Или почему гибридный умный дом — это не «изобретение велосипеда», а апгрейд его до внедорожника.
Каждый раз, когда на Хабре выходит статья о самодельных датчиках на 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 или вы хотите увидеть полный код визуального дашборда с поддержкой ползунков яркости и температур — пишите в комментарии, постараюсь ответить!
ссылка на оригинал статьи https://habr.com/ru/articles/1065084/