Привет.
Сразу скажу: я профессиональный разработчик, но при работе с ошибками еще с университета у меня вылетало столько ошибок, что их по количеству было решить невозможно, но и по качеству даже одной ошибки приходилось засиживаться не на один вечер вместо того, чтобы погулять, например.
По основной работе я вообще изначально был далек от IT. И, когда я вошел в эту область, ошибки просто посыпались – мыслил я совсем не стандартно и был очень узконаправлен на железо и электронику. Теперь же все чуть наоборот, у меня есть хобби — я делаю разные железки для умного дома. Ну и как у любого нормального человека, у меня есть розетки Яндекс, лампочки, и куча самодельных датчиков на ESP8266 и ESP32.
И вот недавно мне понадобилось сделать одну вещь: управлять розеткой Яндекса в зависимости от показаний моего самопального датчика влажности почвы. Казалось бы, тривиальная задача. Но когда я попытался сделать для этого нормальный веб-интерфейс, я понял, что PHP — это не только для “сайтов-визиток”. И заодно я собрал такой букет граблей, что решил написать об этом статью, чтобы кто-то не наступал на те же.
Можно, конечно, воспользоваться Home Assistant и ESPHome, а потом развивать Opensource-проекты… Но многие из наc, специалистов по железу, часто кодят в блокнотике в Linux, руками запуская GCC компилятор, так вот… При написании сервера для умного дома на PHP захотелось учесть и осознать нюансы по безопасности и удобному пользовательскому интерфейсу, а не просто запустить готовый код непонятно откуда и потом вместе с другими опенсорсерами наблюдать повсеместное и однотипное восстание машин, как в фильме Робот 2, когда старого профессора прессовал, а затем убил ИИ.
Итак, погнали. Но в статье делаю упор именно на те самые заветные ошибки, которые раньше надо было вечерами гуглить где-то на StackOverflow.
Задача: иметь доступ к управлению устройствами, интегрированными в Умный Дом Яндекса, но при этом в этом же интерфейсе писать собственные алгоритмы и
Конкретный узкий кейс в рамках задачи: при пересечении порогового значения с самодельного датчике влажности нужно включать умную розетку, интегрированную в Яндекс, для полива растения.Для краткости: этот конкретный кейс я уже решил, но….
То есть у меня есть на подоконнике что-то типа «Умного огорода», где растениям часто не хватает воды, когда я на работе.


Есть куча самоделок на ESP32 и есть умная розетка за 370 рублей.

Я не хочу выходить в магазин за дополнительными компонентами или устройствами, а хочу написать простой однофайловый серверный скрипт на своем ноуте на холодильнике, который и так все время показывает рацион моего питания.

Зачем вообще PHP для железа?
Сначала объясню, почему я не пошел стандартным путем.
Вариант первый: Home Assistant. Мощная штука, но для моей задачи это как из пушки по воробьям. Мне не нужен MQTT, Node-RED, Docker-контейнеры и база данных. У меня есть один сервер, один датчик и одна розетка. Разворачивать ради этого целый зоопарк — overkill.
Вариант второй: Python-скрипт. Тоже вариант, но у меня на сервере уже крутится PHP (пет-сайт моей кошки (шучу)).
Вариант третий: написать на PHP. Да, да, том самом PHP, который “только для веб-сайтов”. Но ведь PHP умеет делать HTTP-запросы через curl, умеет работать с JSON, умеет запускаться по cron. Чего еще надо? И вот я сел писать. И тут началось…
Грабля первая: “Почему оно виснет?!”
Первое, что я сделал — написал простенький скрипт, который опрашивает API Яндекса и выводит список устройств. Запускаю — работает. Открываю в браузере — грузится секунды три. Нормально, думаю.
Потом я решил сделать AJAX-запросы, чтобы страница не перезагружалась при каждом клике. И тут началось веселье. Открываю две вкладки — вторая виснет. Нажимаю “Обновить” — виснет на 10 секунд. Что за хрень? Час гугления, и я нахожу ответ: блокировка сессий в PHP.
Оказывается, когда ты вызываешь session_start(), PHP ставит эксклюзивный лок на файл сессии. И пока этот скрипт не завершится (или не закроет сессию вручную), любой другой запрос к тому же скрипту будет стоять в очереди и ждать. У меня же AJAX делает несколько запросов одновременно: один тянет список устройств, другой — их статусы. И они ждут друг друга, как на узкой тропинке. Решение: одна строчка кода.
$token = $_SESSION['yandex_token'];
session_write_close(); // Всё, освободили сессию
Грабля вторая: “Почему так долго?!”
Ладно, сессии починил. Теперь хочу показать статусы всех устройств. У меня их 11 штук.
Смотрю документацию Яндекса: есть массовый запрос /user/devices/query, который должен вернуть статусы всех устройств сразу. Отлично, делаю один запрос. Но!
Яндекс иногда не возвращает статусы для некоторых устройств. Особенно если они недавно добавлены или ушли в оффлайн. Документация говорит: “Запрашивайте такие устройства отдельно через /devices/{id}”. Окей, делаю цикл:
foreach ($devices as $dev) {
$status = apiRequest("/devices/{$dev['id']}");
}
Запускаю. Жду. Жду. Жду. 15 секунд грузится страница. Бесит. Потому что я делаю 11 запросов последовательно. Каждый ждет, пока закончится предыдущий.
Решение: curl_multi_init. Это такая штука в PHP, которая позволяет делать несколько HTTP-запросов параллельно. Написал функцию:
function apiRequestParallel($urls, $headers) {
$mh = curl_multi_init();
foreach ($urls as $url) {
$ch = curl_init($url); // ... настройки ...
curl_multi_add_handle($mh, $ch);
}
$running = null;
do { curl_multi_exec($mh, $running); } while ($running > 0); // Собираем результаты
}
Вместо 15 секунд — 2 секунды. Потому что все запросы летят одновременно.
Грабля третья: “Cannot read properties of null”
Сделал красивый интерфейс с карточками устройств. Всё работает, радуюсь.
Потом добавляю новое устройство. Открываю страницу — белый экран. В консоли браузера ошибка:
Cannot read properties of null (reading ‘value’).
Смотрю код JavaScript:
const isOn = onCap.state.value;
Что не так-то?
А то, что API Яндекса иногда возвращает null вместо объекта state. Особенно для новых устройств или тех, что ушли в оффлайн.
Я-то думал, что API всегда возвращает нормальные данные. А он, оказывается, может и null подкинуть.
Решение: Optional Chaining в JavaScript.
const capState = onCap?.state || {};
const isOn = capState.value || false;
Теперь если state равен null, код не падает, а просто показывает “Статус неизвестен”. И интерфейс не ломается.
Заодно добавил красивые бейджи: “Не в сети”, “Устаревшие данные”, “Статус неизвестен”. Пользователь сразу видит, что происходит, а не гадает, почему розетка не включается.
Грабля четвертая: “Я нажал кнопку, а оно не работает!”
Самая бесячая проблема. Пользователь (то есть я) нажимает “Включить”. Видит спиннер загрузки. Ждет 2 секунды.
Переключатель возвращается в положение “Выключено”. Что за? Я же включил!
Смотрю логи. Команда ушла на сервер Яндекса. Сервер ответил 202 Accepted (то есть “принял, сейчас обработаем”). Но когда я сразу после этого запрашиваю статус устройства, Яндекс возвращает старое состояние — “выключено”.
Потому что облаку нужно время, чтобы:
1) Отправить команду на физическую розетку;
2) Розетка должна щелкнуть реле;
3) Розетка должна отчитаться в облако об изменении статуса;
4) Облако должно обновить свой кэш.
А я запрашиваю статус через секунду после отправки команды.
Естественно, облако еще не в курсе.
Решение: Optimistic UI (оптимистичный интерфейс).
Не ждем ответа сервера, чтобы обновить картинку.
Делаем так:
1) Пользователь нажал “Включить”;
2) JavaScript мгновенно переключает тумблер в положение “Включено”;
3) Показываем тост “Отправка команды…”;
4) Отправляем запрос на сервер;
5) Если сервер ответил 200/202 — показываем “Выполнено” и ставим setTimeout на обновление реальных данных через 5 секунд (когда облако точно проснется);
6) Если сервер вернул ошибку — программно откатываем переключатель обратно и показываем текст ошибки.
Для пользователя это выглядит как мгновенный отклик. Как в нативных приложениях. А “под капотом” мы просто хитро управляем состоянием.
Что в итоге получилось
В результате всех этих итераций у меня получился один файл, который:
1) Делает OAuth-авторизацию Яндекса
2) Показывает красивый дашборд с карточками устройств
3) Позволяет включать/выключать розетки, менять яркость ламп, температуру обогревателей
4) Корректно обрабатывает оффлайн-устройства и кривые ответы API Работает быстро благодаря параллельным запросам
5) Не требует ни Webpack, ни Node.js, ни баз данных Всё это на чистом PHP + немного JavaScript + Tailwind CSS через CDN.
Код получился около 400 строк. Для кого-то это много, для кого-то мало. Но главное — он работает и решает реальную задачу, а не просто “дергает эндпоинт”.
<?php
/**
* Technofeya Home: Smart Dashboard (v9.6 - Null-Safe)
* Исправлена ошибка "Cannot read properties of null" при парсинге ответа API
*/
session_start();
// ==================== НАСТРОЙКИ ====================
$CLIENT_ID = 'sgsjhjdhj';
$CLIENT_SECRET = 'tyjdtuksykdtuk';
$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 ====================
if (isset($_GET['api']) && isset($_SESSION['yandex_token'])) {
header('Content-Type: application/json');
$token = $_SESSION['yandex_token'];
session_write_close();
$iotHeaders = ["Authorization: Bearer {$token}"];
if ($_GET['api'] === 'devices') {
$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);
$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'] ?? [];
}
}
$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';
}
}
}
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>
<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) ===
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;
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');
// === ИСПРАВЛЕНИЕ: Проверка rangeCap?.state перед использованием ===
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;
}
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('Команда выполнена');
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>
Данный код после запуска через браузер на моем холодильнике теперь без ошибок показывает следующее:

И позволяет управлять устройствами, при этом любую логику вне линейности сценариев Яндекса я могу прописать парой строчек кода. Спасибо за внимание!