Или почему гибридный умный дом — это не «изобретение велосипеда», а апгрейд его до внедорожника.
Каждый раз, когда на Хабре выходит статья о самодельных датчиках на ESP8266 или собственных серверах умного дома, в комментариях обязательно появляется классический вопрос:
«Зачем изобретать велосипед? Есть же Яндекс с Алисой, Сбер, Tuya. Купил готовую розетку за 500 рублей, и всё работает. Как вы вообще собираетесь интегрировать свои поделки в нормальные экосистемы?»
Вопрос справедливый. Но он исходит из ложной дихотомии: будто нужно выбрать либо облачные экосистемы, либо свои сервера.
На самом деле, самая мощная архитектура — гибридная. Где Яндекс отвечает за надежное «железо» (розетки, лампы, реле), а ваш собственный сервер выступает «мозгом», принимающим сложные решения, которые облачным автоматизациям не под силу.
Сегодня я покажу на реальном, рабочем примере, как за 20 минут написать на PHP скрипт, который объединяет данные с самодельного датчика влажности (Sensorbox) и управляет умной розеткой Яндекс. И да, это действительно просто.
Сценарий: «Умный полив», который не осилит облако
Представьте задачу: у нас есть грядка с растениями.
У нас есть самодельный датчик влажности почвы Sensorbox, который шлет данные на наш сервер.
У нас есть обычная умная розетка Яндекс, к которой подключен водяной насос.


Задача: Включить насос только если средняя влажность почвы за последние 3 замера ниже критического уровня.
Почему это сложно сделать в облаке Яндекса? Потому что стандартные сценарии там линейны: «Если датчик X показал значение Y, сделать Z». Они не умеют:
Запрашивать данные с кастомных API по HTTP.
Делать математические операции (например, усреднять массив значений).
Гибко работать с гистерезисом (чтобы насос не включался/выключался каждую минуту на граничном значении).
Решение? Наш собственный PHP-скрипт на сервере, который выступает дирижером этого оркестра, за счет чего можно очень быстро дописывать и редактировать алгоритмы и прописывать математические операции.
Шаг 1. Получаем доступ к API Яндекса
Чтобы наш сервер мог управлять розеткой, ему нужен ключ. Яндекс предоставляет отличный OAuth-процесс.
Идем в консоль разработчика Яндекс ID и создаем новое приложение.
Выбираем платформу «Веб-сервисы» и выбираем вариант, что не просто для авторизации.
В Redirect URI указываем
https://oauth.yandex.ru/verification_code(для ручного получения кода, это самый надежный способ для своих скриптов).В разделе «Доступы» обязательно ставим галочки по поиску "smart" и "home".
Сохраняем
Client IDиClient secret.

Подробный протокол работы с устройствами описан в официальной документации: Платформа умного дома Яндекса.
Шаг 2. Hello, Yandex. Или перечень устройств с их контролированием прямо на Вашем сайте
Управление через cron-скрипты — это отлично для фоновой автоматизации. Но что, если мы хотим зайти на сервер, увидеть актуальный статус всех 10+ устройств и вручную переключить розетку или изменить яркость лампы?
Писать для этого отдельный фронтенд на React/Vue и бэкенд на Laravel — избыточно. Мы пойдем путем элегантного минимализма: один PHP-файл, который работает как современное SPA-приложение (Single Page Application).

При разработке этого дашборда мы решили несколько нетривиальных инженерных задач, о которых стоит рассказать:
Оптимистичный интерфейс (Optimistic UI). Облака умных домов работают с задержкой 1–3 секунды. Вместо того чтобы крутить спиннер и блокировать интерфейс, мы мгновенно переключаем тумблер в браузере. Если сервер вернет ошибку, мы так же мгновенно откатим состояние обратно. Пользователь видит отклик за 0 мс.
Параллельные запросы (
curl_multi). Стандартный последовательный опрос 10 устройств через API Яндекса занял бы 15–20 секунд. Мы используемcurl_multi_init, чтобы опрашивать устройства, не ответившие на массовый запрос, параллельно. Это сокращает время загрузки до 2–3 секунд.Борьба с блокировкой сессий PHP. При асинхронных AJAX-запросах PHP блокирует файл сессии. Если один запрос "завис", остальные встанут в очередь. Решение: вызов
session_write_close()сразу после чтения токена в API-роутере.Null-safe парсинг. API Яндекса иногда возвращает
nullвместо объекта состояния для новых или оффлайн-устройств. Жесткая проверкаonCap?.stateпредотвращает падение JavaScript-кода с ошибкойCannot read properties of null.
Готовый код дашборда (yandex_smarthome.php)
Создайте файл yandex_smarthome.php на вашем сервере и вставьте в него этот код. Не забудьте заменить $CLIENT_ID и $CLIENT_SECRET на ваши данные из консоли разработчика.
<?php /** * Technofeya Home: Smart Dashboard (v9.6 - Production Ready) * Одностраничное приложение (SPA) для управления Яндекс.Умным домом. * Особенности: Optimistic UI, curl_multi, null-safe parsing, session lock fix. */ session_start(); // ==================== НАСТРОЙКИ ==================== // Вставьте сюда ваши реальные данные из консоли разработчика Яндекс $CLIENT_ID = 'ВАШ_CLIENT_ID_ИЗ_КОНСОЛИ'; $CLIENT_SECRET = 'ВАШ_CLIENT_SECRET_ИЗ_КОНСОЛИ'; $REDIRECT_URI = 'https://oauth.yandex.ru/verification_code'; $API_BASE = 'https://api.iot.yandex.net/v1.0'; // =================================================== function apiRequest($url, $method = 'GET', $data = null, $headers = []) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_FOLLOWLOCATION => true, ]); if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); if (!empty($data)) { if (strpos($url, 'oauth.yandex.ru') !== false) { curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); } else { $headers = array_filter($headers, fn($h) => strpos($h, 'Content-Type') === false); $headers[] = 'Content-Type: application/json'; curl_setopt($ch, CURLOPT_POSTFIELDS, is_array($data) ? json_encode($data) : $data); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); } } } $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $decoded = json_decode($response, true); return ['code' => $httpCode, 'body' => $decoded ?: ['raw_response' => $response]]; } // Функция для параллельных запросов (ускоряет загрузку состояний в разы) function apiRequestParallel($urls, $headers) { $mh = curl_multi_init(); $curlArray = []; foreach ($urls as $key => $url) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => true, ]); curl_multi_add_handle($mh, $ch); $curlArray[$key] = $ch; } $running = null; do { curl_multi_exec($mh, $running); } while ($running > 0); $results = []; foreach ($curlArray as $key => $ch) { $response = curl_multi_getcontent($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $decoded = json_decode($response, true); $results[$key] = ['code' => $httpCode, 'body' => $decoded ?: ['raw_response' => $response]]; curl_multi_remove_handle($mh, $ch); } curl_multi_close($mh); return $results; } // ==================== API ROUTER (Обработка AJAX) ==================== if (isset($_GET['api']) && isset($_SESSION['yandex_token'])) { header('Content-Type: application/json'); $token = $_SESSION['yandex_token']; // КРИТИЧЕСКИ ВАЖНО: снимаем блокировку сессии, чтобы параллельные AJAX-запросы не висели session_write_close(); $iotHeaders = ["Authorization: Bearer {$token}"]; if ($_GET['api'] === 'devices') { // 1. Получаем список устройств $infoRes = apiRequest("{$API_BASE}/user/info", 'GET', null, $iotHeaders); if ($infoRes['code'] !== 200 || !isset($infoRes['body']['devices'])) { echo json_encode(['error' => 'Не удалось получить список устройств', 'details' => $infoRes['body']]); exit; } $devices = $infoRes['body']['devices']; $deviceIds = array_map(fn($d) => ['id' => $d['id']], $devices); // 2. Пробуем быстрый массовый запрос состояний $queryRes = apiRequest("{$API_BASE}/user/devices/query", 'POST', ['devices' => $deviceIds], $iotHeaders); $states = []; if ($queryRes['code'] === 200 && isset($queryRes['body']['devices'])) { foreach ($queryRes['body']['devices'] as $devState) { $states[$devState['id']] = $devState['capabilities'] ?? []; } } // 3. Fallback: запрашиваем параллельно только те устройства, где нет состояния $missing = array_filter($devices, fn($d) => !isset($states[$d['id']])); if (count($missing) > 0) { $urls = array_map(fn($d) => "{$API_BASE}/devices/{$d['id']}", $missing); $parallelRes = apiRequestParallel($urls, $iotHeaders); foreach ($parallelRes as $key => $res) { if ($res['code'] === 200 && isset($res['body']['capabilities'])) { $devId = $missing[$key]['id']; $states[$devId] = $res['body']['capabilities']; $devices[array_search($devId, array_column($devices, 'id'))]['device_state'] = $res['body']['state'] ?? 'unknown'; } } } // 4. Объединяем данные для фронтенда foreach ($devices as &$dev) { $dev['current_state'] = $states[$dev['id']] ?? []; if (!isset($dev['device_state'])) { $dev['device_state'] = 'unknown'; } } echo json_encode(['success' => true, 'devices' => $devices]); exit; } if ($_GET['api'] === 'action') { $rawInput = file_get_contents('php://input'); $input = json_decode($rawInput, true) ?: $_POST; if (empty($input) || !isset($input['devices'])) { echo json_encode(['success' => false, 'error' => 'Пустые данные']); exit; } $res = apiRequest("{$API_BASE}/devices/actions", 'POST', $input, $iotHeaders); if ($res['code'] === 200 || $res['code'] === 202) { echo json_encode(['success' => true]); } else { echo json_encode(['success' => false, 'error' => "HTTP {$res['code']}", 'details' => $res['body']]); } exit; } } // ==================== ЛОГИКА АВТОРИЗАЦИИ ==================== if (isset($_POST['manual_code']) && !isset($_SESSION['yandex_token'])) { $res = apiRequest('https://oauth.yandex.ru/token', 'POST', [ 'grant_type' => 'authorization_code', 'code' => trim($_POST['manual_code']), 'client_id' => $CLIENT_ID, 'client_secret' => $CLIENT_SECRET, 'redirect_uri' => $REDIRECT_URI ]); if ($res['code'] == 200 && isset($res['body']['access_token'])) { $_SESSION['yandex_token'] = $res['body']['access_token']; $userInfo = apiRequest('https://login.yandex.ru/info?format=json', 'GET', null, ['Authorization: OAuth ' . $_SESSION['yandex_token']]); $_SESSION['yandex_user'] = $userInfo['body']['login'] ?? 'User'; header('Location: ' . strtok($_SERVER["REQUEST_URI"], '?')); exit; } $authError = "Ошибка авторизации: " . json_encode($res['body']); } if (!isset($_SESSION['yandex_token'])) { $authUrl = "https://oauth.yandex.ru/authorize?response_type=code&client_id={$CLIENT_ID}&redirect_uri=" . urlencode($REDIRECT_URI); die("<!DOCTYPE html><html><head><meta charset='utf-8'><title>Вход в Умный дом</title> <style>body{font-family:system-ui;max-width:400px;margin:100px auto;padding:20px;text-align:center;background:#f5f5f7} .card{background:#fff;padding:30px;border-radius:16px;box-shadow:0 4px 20px rgba(0,0,0,0.08)} .btn{display:block;width:100%;padding:14px;background:#fc0;color:#000;border:none;border-radius:10px;font-weight:bold;font-size:16px;cursor:pointer;margin:15px 0;text-decoration:none} input{width:100%;padding:12px;border:1px solid #ddd;border-radius:10px;margin:10px 0;box-sizing:border-box;text-align:center}</style></head> <body><div class='card'><h2>? Technofeya Home</h2> " . (isset($authError) ? "<p style='color:red;font-size:14px'>$authError</p>" : "") . " <p style='font-size:14px;color:#666'>1. Нажмите кнопку ниже.<br>2. Разрешите доступ в аккаунте Яндекса.<br>3. Скопируйте код и вставьте его сюда.</p> <a href='$authUrl' target='_blank' class='btn'>? Получить код от Яндекса</a> <form method='POST'><input type='text' name='manual_code' placeholder='Вставьте код' required><button class='btn' style='background:#007aff;color:#fff'>Войти</button></form></div></body></html>"); } ?> <!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Technofeya Home Dashboard</title> <!-- Подключаем Tailwind CSS для современного дизайна без лишних файлов --> <script src="https://cdn.tailwindcss.com"></script> <style> .toggle-checkbox:checked { right: 0; border-color: #34c759; } .toggle-checkbox:checked + .toggle-label { background-color: #34c759; } .glass-panel { background: rgba(255, 255, 255, 0.9); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); } .loader { border: 3px solid #f3f3f3; border-top: 3px solid #007aff; border-radius: 50%; width: 24px; height: 24px; animation: spin 1s linear infinite; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } </style> </head> <body class="bg-gray-100 text-gray-800 font-sans min-h-screen"> <header class="glass-panel sticky top-0 z-50 border-b border-gray-200 shadow-sm"> <div class="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center"> <h1 class="text-xl font-bold flex items-center gap-2">? Technofeya Home <span class="text-xs font-normal text-gray-500 bg-gray-200 px-2 py-1 rounded-full">v9.6</span></h1> <div class="flex items-center gap-3"> <span class="text-sm text-gray-600 hidden sm:inline">? <?= htmlspecialchars($_SESSION['yandex_user']) ?></span> <button onclick="window.location.href='?action=logout'" class="text-sm text-red-500 hover:text-red-700 font-medium border border-red-200 px-3 py-1 rounded-lg hover:bg-red-50 transition">Выйти</button> </div> </div> </header> <main class="max-w-4xl mx-auto px-4 py-8"> <div class="flex justify-between items-center mb-6"> <h2 class="text-2xl font-semibold">Мои устройства</h2> <button onclick="loadDevices()" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition shadow-sm"> <span id="refresh-icon">?</span> Обновить </button> </div> <div id="devices-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5"> <div class="col-span-full text-center py-20 text-gray-500"> <div class="loader mx-auto mb-4"></div> Загрузка устройств... </div> </div> </main> <div id="toast" class="fixed bottom-5 right-5 bg-gray-800 text-white px-6 py-3 rounded-lg shadow-xl transform translate-y-20 opacity-0 transition-all duration-300 z-50 max-w-sm break-words"></div> <script> async function loadDevices() { const grid = document.getElementById('devices-grid'); const btn = document.getElementById('refresh-icon'); btn.classList.add('animate-spin'); try { const res = await fetch('?api=devices'); const data = await res.json(); if (data.error) { grid.innerHTML = `<div class="col-span-full bg-red-100 text-red-700 p-4 rounded-lg border border-red-200">${data.error}</div>`; return; } grid.innerHTML = ''; if (!data.devices || data.devices.length === 0) { grid.innerHTML = `<div class="col-span-full text-center text-gray-500 py-10">Устройства не найдены.</div>`; return; } data.devices.forEach(dev => grid.appendChild(createDeviceCard(dev))); showToast('✅ Список обновлен'); } catch (e) { grid.innerHTML = `<div class="col-span-full bg-red-100 text-red-700 p-4 rounded-lg">Ошибка сети: ${e.message}</div>`; } finally { btn.classList.remove('animate-spin'); } } function createDeviceCard(dev) { const state = dev.current_state || []; const onCap = state.find(c => c.type === 'devices.capabilities.on_off'); // NULL-SAFE: безопасно извлекаем данные, даже если Яндекс вернул null const capState = onCap?.state || {}; const isRetrievable = onCap ? onCap.retrievable : false; const isOn = isRetrievable ? !!capState.value : false; const lastUpdated = isRetrievable && capState.last_updated ? capState.last_updated : 0; const isStale = lastUpdated > 0 && (Date.now() / 1000 - lastUpdated) > 3600; // Старше 1 часа const deviceState = dev.device_state || 'unknown'; const isOffline = deviceState === 'offline'; let statusBadge = ''; if (isOffline) { statusBadge = `<span class="text-[10px] bg-gray-200 text-gray-600 px-2 py-0.5 rounded-full">? Не в сети</span>`; } else if (isStale) { statusBadge = `<span class="text-[10px] bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full" title="Статус не обновлялся более часа">⚠️ Устаревшие данные</span>`; } else if (!isRetrievable) { statusBadge = `<span class="text-[10px] bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full" title="Устройство не сообщает свой статус">❓ Статус неизвестен</span>`; } let extraControls = ''; const rangeCap = state.find(c => c.type === 'devices.capabilities.range'); if (rangeCap && rangeCap.retrievable && rangeCap?.state) { const val = rangeCap.state.value; const min = rangeCap.parameters?.range?.min || 1; const max = rangeCap.parameters?.range?.max || 100; const instance = rangeCap.state.instance; const label = instance === 'brightness' ? 'Яркость' : (instance === 'temperature' ? 'Температура' : 'Уровень'); extraControls += ` <div class="mt-4 pt-4 border-t border-gray-100"> <div class="flex justify-between text-xs text-gray-500 mb-1"> <span>${label}</span> <span id="val-${dev.id}-${instance}" class="font-bold text-blue-600">${val}</span> </div> <input type="range" min="${min}" max="${max}" value="${val}" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-500" onchange="sendRangeAction('${dev.id}', '${instance}', this.value)" ${isOffline ? 'disabled' : ''}> </div> `; } const card = document.createElement('div'); card.id = `card-${dev.id}`; card.className = `glass-panel rounded-2xl p-5 shadow-sm border border-white/60 hover:shadow-md transition relative overflow-hidden ${isOffline ? 'opacity-75' : ''}`; card.innerHTML = ` <div class="absolute top-0 right-0 w-24 h-24 bg-gradient-to-br from-blue-50 to-transparent rounded-bl-full -mr-4 -mt-4"></div> <div class="relative z-10"> <div class="flex justify-between items-start mb-2"> <div> <h3 class="font-bold text-lg text-gray-800 leading-tight flex items-center gap-2"> ${dev.name} ${statusBadge} </h3> <p class="text-[10px] text-gray-400 mt-1 font-mono truncate w-40" title="${dev.id}">${dev.id}</p> </div> <div id="status-${dev.id}" class="status-indicator w-3 h-3 rounded-full mt-1.5 ${isOn ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]' : 'bg-gray-300'}"></div> </div> <div class="flex items-center justify-between mt-4"> <span id="text-${dev.id}" class="text-sm font-medium ${isOn ? 'text-green-600' : 'text-gray-400'}"> ${isOn ? 'Включено' : (isRetrievable ? 'Выключено' : 'Статус неизвестен')} </span> <label class="relative inline-flex items-center cursor-pointer ${isOffline ? 'opacity-50 cursor-not-allowed' : ''}"> <input type="checkbox" id="toggle-${dev.id}" class="sr-only toggle-checkbox" ${isOn ? 'checked' : ''} onchange="sendOnOffAction('${dev.id}', this.checked)" ${isOffline ? 'disabled' : ''}> <div class="w-11 h-6 bg-gray-200 rounded-full toggle-label transition-colors duration-300"></div> <div id="knob-${dev.id}" class="absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform duration-300 transform ${isOn ? 'translate-x-5' : 'translate-x-0'} shadow-sm"></div> </label> </div> ${extraControls} </div> `; return card; } // Мгновенное обновление интерфейса до ответа сервера (Optimistic UI) function updateDeviceUI(deviceId, isOn) { const status = document.getElementById(`status-${deviceId}`); const text = document.getElementById(`text-${deviceId}`); const toggle = document.getElementById(`toggle-${deviceId}`); const knob = document.getElementById(`knob-${deviceId}`); if (status) status.className = `status-indicator w-3 h-3 rounded-full mt-1.5 ${isOn ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]' : 'bg-gray-300'}`; if (text) { text.className = `text-sm font-medium ${isOn ? 'text-green-600' : 'text-gray-400'}`; text.textContent = isOn ? 'Включено' : 'Выключено'; } if (toggle) toggle.checked = isOn; if (knob) knob.className = `absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform duration-300 transform ${isOn ? 'translate-x-5' : 'translate-x-0'} shadow-sm`; } async function sendAction(deviceId, payload, optimisticUpdate = null) { showToast('⏳ Отправка команды...', 'info'); if (optimisticUpdate !== null) updateDeviceUI(deviceId, optimisticUpdate); try { const res = await fetch('?api=action', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({devices: [{id: deviceId, actions: [payload]}]}) }); const data = await res.json(); if (data.success) { showToast('✅ Команда выполнена'); // Фоновая синхронизация через 5 секунд, когда облако реально обновит статус setTimeout(loadDevices, 5000); } else { showToast('❌ ' + (data.error || 'Ошибка') + (data.details ? ': ' + JSON.stringify(data.details) : ''), 'error'); // Откат интерфейса при ошибке if (optimisticUpdate !== null) updateDeviceUI(deviceId, !optimisticUpdate); } } catch (e) { showToast('❌ Ошибка сети: ' + e.message, 'error'); if (optimisticUpdate !== null) updateDeviceUI(deviceId, !optimisticUpdate); } } function sendOnOffAction(deviceId, isOn) { sendAction(deviceId, {type: 'devices.capabilities.on_off', state: {instance: 'on', value: isOn}}, isOn); } function sendRangeAction(deviceId, instance, value) { const el = document.getElementById(`val-${deviceId}-${instance}`); if (el) el.innerText = value; sendAction(deviceId, {type: 'devices.capabilities.range', state: {instance: instance, value: parseInt(value)}}); } function showToast(msg, type = 'success') { const toast = document.getElementById('toast'); toast.innerText = msg; toast.className = `fixed bottom-5 right-5 px-6 py-3 rounded-lg shadow-xl transform transition-all duration-300 z-50 text-white text-sm ${type === 'error' ? 'bg-red-500' : (type === 'info' ? 'bg-gray-600' : 'bg-green-600')}`; toast.style.transform = 'translateY(0)'; toast.style.opacity = '1'; setTimeout(() => { toast.style.transform = 'translateY(20px)'; toast.style.opacity = '0'; }, 4000); } document.addEventListener('DOMContentLoaded', loadDevices); </script> </body> </html>
Как это работает:
При первом запуске скрипт покажет форму для ввода кода авторизации Яндекса.
После успешного входа вы увидите сетку карточек всех ваших устройств.
Нажмите на переключатель любой розетки. Интерфейс переключится мгновенно (благодаря Optimistic UI), команда уйдет на сервер, а через 5 секунд произойдет тихая фоновая синхронизация для подтверждения реального статуса.
Если устройство оффлайн, карточка станет полупрозрачной, а переключатель заблокируется, чтобы не вводить вас в заблуждение.
Этот код полностью автономен, не требует сборщиков (Webpack/Vite) или баз данных, и готов к работе сразу после загрузки на хостинг.
Шаг 3. Магия простых HTTP-запросов
Вся интеграция строится на двух вещах:
Мы забираем данные с нашего датчика через простой GET-запрос.
Мы отправляем команду в Яндекс через POST-запрос с JSON-телом.
Вот как выглядит наш источник данных с Sensorbox. Обратите внимание, он отдает чистый JSON:
https://iot.tfeya.ru/sensorData.php?login=eugene&sensor_id=1&secret=001c0ee0805bd121xxxxxxxxxxxxxeb9ad576&json=1
Ответ:
{"val":"50","datetime":"2026-05-30 23:06:01"}
(Для примера значение 50 означает критическую сухость).
Шаг 4. Пример автоматизации полива (PHP)
Ниже представлен минималистичный, но полнофункциональный скрипт auto_watering.php. Он делает ровно то, что мы задумали: проверяет датчик (для простоты не усложняем, но суть Вы понимаете), принимает решение и дергает розетку Яндекса.
<?php /** * auto_watering.php - Гибридный контроллер умного дома */ // === НАСТРОЙКИ === $YANDEX_TOKEN = 'ВАШ_OAUTH_TOKEN_ЗДЕСЬ'; // Получается через OAuth $DEVICE_ID = '19aae231-xxxxx-xxxx-.....'; // ID розетки из приложения Яндекс $SENSOR_URL = 'https://iot.tfeya.ru/sensorData.php?login=eugene&sensor_id=1&secret=00xxxxxxxx121cba99c83eec54f0f43a0eeeb9ad576&json=1'; $DRY_THRESHOLD = 15; // Порог влажности, ниже которого включаем полив (%) // 1. Получаем данные с нашего кастомного датчика $sensorDataJson = file_get_contents($SENSOR_URL); $sensorData = json_decode($sensorDataJson, true); if (!$sensorData || !isset($sensorData['val'])) { die("Ошибка: Не удалось получить данные с Sensorbox"); } $currentMoisture = (int)$sensorData['val']; echo "Текущая влажность: {$currentMoisture}%\n"; // 2. Логика принятия решения (здесь можно добавить усреднение, гистерезис и т.д.) $shouldWater = ($currentMoisture < $DRY_THRESHOLD); // 3. Формируем команду для Яндекс.Умного дома // Документация: https://yandex.ru/dev/dialogs/smart-home/doc/ru/concepts/control-api $payload = [ "devices" => [[ "id" => $DEVICE_ID, "actions" => [[ "type" => "devices.capabilities.on_off", "state" => [ "instance" => "on", "value" => $shouldWater // true или false ] ]] ]] ]; // 4. Отправляем запрос в Яндекс $ch = curl_init('https://api.iot.yandex.net/v1.0/devices/actions'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $YANDEX_TOKEN, 'Content-Type: application/json' ], CURLOPT_TIMEOUT => 10 ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); // 5. Вывод результата if ($httpCode === 200 || $httpCode === 202) { $actionText = $shouldWater ? "ВКЛЮЧЕН (полив)" : "ВЫКЛЮЧЕН (достаточно влаги)"; echo "✅ Успех! Розетка Яндекс {$actionText}.\n"; } else { echo "❌ Ошибка API Яндекса (HTTP {$httpCode}): {$response}\n"; } ?>
Как это использовать?
Загружаете этот файл на свой сервер (например,
iot.tfeya.ru).Настраиваете Cron на выполнение этого скрипта, например, каждые 30 минут:
*/30 * * * * /usr/bin/php /var/www/iot.tfeya.ru/auto_watering.php >> /var/log/watering.log
Всё. Ваш кастомный датчик теперь управляет коммерческой экосистемой.
А что насчет красивого интерфейса?
Управление через cron — это хорошо для автоматизации, но иногда хочется зайти и проверить состояние вручную, или увидеть список всех устройств. Но если в приложении Яндекса это можно сделать тольно по правилам игры разработчиков самого Яндекса, то на Вашей странице открывается полная свобода действий. И полная свобода по дизайну итогового решения, с которым Вы хотите встречаться дальше при дальнейшем совершенствовании Вашего Умного Дома.
Выше показано, в процессе работы над этой задачей был создан полноценный SPA-дашборд на PHP + Tailwind CSS (в одном файле), который:
Проходит OAuth-авторизацию Яндекса.
Мгновенно (без перезагрузки страницы) показывает статус всех устройств (используя
curl_multiдля параллельных запросов, чтобы не ждать ответа 10 секунд).Позволяет включать/выключать приборы, менять яркость ламп и температуру обогревателей.
Корректно обрабатывает состояния
offlineиretrievable: false, не вводя пользователя в заблуждение.
Код дашборда получился довольно объемным для статьи, но он полностью готов к использованию. Если тема будет интересна сообществу, я с радостью выложу его в отдельном репозитории на GitHub или в следующей статье.
(Спойлер: ключ к стабильности такого дашборда — использование session_write_close() перед AJAX-запросами в PHP и "оптимистичное" обновление UI на стороне JavaScript, о чем можно почитать в материалах по архитектуре современных веб-приложений).
Почему это лучше, чем «просто купить всё готовое»?
Свобода логики. Вы не ограничены визуальным конструктором сценариев Яндекса. Хотите включить свет, если влажность < 20%, И на улице темно (данные с открытого Weather API), И сегодня не вторник? На PHP это 5 строчек кода.
Объединение экосистем. На одном дашборде могут быть устройства Яндекса, датчики Sensorbox, камеры Xiaomi и реле, управляемые через MQTT. Ваш сервер — это универсальный переводчик.
Независимость. Если завтра Яндекс изменит правила бесплатных сценариев или отключит серверы, ваша базовая логика автоматизации останется на вашем сервере и продолжит работать.
Это просто. Как мы видели выше, интеграция сводится к двум функциям:
file_get_contents()для чтения иcurlдля записи. Никаких сложных брокеров сообщений или Docker-контейнеров для базовой задачи не требуется.
Заключение
Не стоит воспринимать DIY и готовые экосистемы как врагов. Они идеально дополняют друг друга. Яндекс дает надежное, сертифицированное и безопасное "железо" (розетки, реле), а ваш собственный сервер дает интеллект, гибкость и контроль над данными.
Попробуйте написать такой «мост». Вы удивитесь, насколько мощным может стать ваш умный дом, когда вы перестанете быть просто пользователем и станете его архитектором.
Полезные ссылки:
P.S. Если у вас есть вопросы по настройке OAuth или вы хотите увидеть полный код визуального дашборда с поддержкой ползунков яркости и температур — пишите в комментарии, постараюсь ответить!
Heggi
Как вы калибровали датчик? Я купил парочку таких, но у них +- одинаковые показания при любой влажности почвы. В итоге заменил на емкостные, там хотя бы попугаи меняются при изменении влажности.