Умная розетка для умного дома

Привет.

Сразу скажу: я профессиональный разработчик, но при работе с ошибками еще с университета у меня вылетало столько ошибок, что их по количеству было решить невозможно, но и по качеству даже одной ошибки приходилось засиживаться не на один вечер вместо того, чтобы погулять, например.

По основной работе я вообще изначально был далек от IT. И, когда я вошел в эту область, ошибки просто посыпались – мыслил я совсем не стандартно и был очень узконаправлен на железо и электронику. Теперь же все чуть наоборот, у меня есть хобби — я делаю разные железки для умного дома. Ну и как у любого нормального человека, у меня есть розетки Яндекс, лампочки, и куча самодельных датчиков на ESP8266 и ESP32.

И вот недавно мне понадобилось сделать одну вещь: управлять розеткой Яндекса в зависимости от показаний моего самопального датчика влажности почвы. Казалось бы, тривиальная задача. Но когда я попытался сделать для этого нормальный веб-интерфейс, я понял, что PHP — это не только для “сайтов-визиток”. И заодно я собрал такой букет граблей, что решил написать об этом статью, чтобы кто-то не наступал на те же.

Можно, конечно, воспользоваться Home Assistant и ESPHome, а потом развивать Opensource-проекты… Но многие из наc, специалистов по железу, часто кодят в блокнотике в Linux, руками запуская GCC компилятор, так вот… При написании сервера для умного дома на PHP захотелось учесть и осознать нюансы по безопасности и удобному пользовательскому интерфейсу, а не просто запустить готовый код непонятно откуда и потом вместе с другими опенсорсерами наблюдать повсеместное и однотипное восстание машин, как в фильме Робот 2, когда старого профессора прессовал, а затем убил ИИ.

Итак, погнали. Но в статье делаю упор именно на те самые заветные ошибки, которые раньше надо было вечерами гуглить где-то на StackOverflow.

Задача: иметь доступ к управлению устройствами, интегрированными в Умный Дом Яндекса, но при этом в этом же интерфейсе писать собственные алгоритмы и

Конкретный узкий кейс в рамках задачи: при пересечении порогового значения с самодельного датчике влажности нужно включать умную розетку, интегрированную в Яндекс, для полива растения.Для краткости: этот конкретный кейс я уже решил, но….

То есть у меня есть на подоконнике что-то типа «Умного огорода», где растениям часто не хватает воды, когда я на работе.

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

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

Sensorbox как самоделка, которая отправляет значения с датчиков на веб-сервер
Sensorbox как самоделка, которая отправляет значения с датчиков на веб-сервер

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

Дисплей на моем холодильнике
Дисплей на моем холодильнике

Зачем вообще 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>

Данный код после запуска через браузер на моем холодильнике теперь без ошибок показывает следующее:

Управляю устройствами умного дома Яндекса через собственный PHP-интерфейс
Управляю устройствами умного дома Яндекса через собственный PHP-интерфейс

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

Was this article helpful?
YesNo

от Евгений Рычков

Основатель сообщества разработчиков Технофея, фулстек-программист, тим лидер, инженер 1 категории, в прошлом главный специалист по робототехнике в Кванториум Мосгормаш,

0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest
0 комментариев
Старые
Новые Популярные
0
Оставьте комментарий! Напишите, что думаете по поводу статьи.x