Или почему гибридный умный дом — это не «изобретение велосипеда», а апгрейд его до внедорожника.
На главной фотографии статьи сфотографирован дисплей, на который выводится интерфейс с устройствами умного дома. Но это не интерфейс от Яндекса или Сбера, а веб-приложение собственной разработки, в которое через API считаны умные устройства и перенесены их состояния (вкл / выкл).
Если при взаимодействии с Алисой нужно вспоминать, назван ли телевизор телевизором или же надо называть его точно «телевизор в зале»… То на холодильнике в собственном веб-интерфейсе можно четко расписать все названия, но называть их необязательно – достаточно передвинуть ползунок внутри карточки одного из устройств, чтобы им управлять.
Каждый раз, когда на Хабре выходит статья о самодельных датчиках на 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 и готовые экосистемы как врагов. Они идеально дополняют друг друга. Яндекс дает надежное, сертифицированное и безопасное «железо» (розетки, реле), а ваш собственный сервер дает интеллект, гибкость и контроль над данными.
Попробуйте написать такой «мост». Вы удивитесь, насколько мощным может стать ваш умный дом, когда вы перестанете быть просто пользователем и станете его архитектором.
Полезные ссылки:
- Документация Яндекс ID (OAuth)
- Протокол управления устройствами Яндекс.Умного дома
- Статья одного из предшественника на Хабре, которой не хватило
P.S. Если у вас есть вопросы по настройке OAuth или вы хотите увидеть полный код визуального дашборда с поддержкой ползунков яркости и температур — пишите в комментарии, постараюсь ответить!