mandrock-files / tools

debloat-api-home

Розумний дім без вендорської хмари: ESPHome native API, Home Assistant REST+WebSocket, diyHue, Homebridge UI API і pytapo. По кожному — транспорт, автентифікація, робочий приклад, сценарій і граблі. Частина 1 серії про локальні API.

Локальні API замість вендорської хмари: розумний дім

Ця стаття — перша з серії за каталогом debloat.dev: опенсорсні заміни вендорському софту, відібрані за одним критерієм — щоб давали не «ще одну апку», а програмний інтерфейс, на який можна повісити власну автоматизацію.

Тут — розумний дім і IoT. П'ять способів забрати керування з чужого сервера собі в код: коли лампа гасне через API у твоєму скрипті, а не через round-trip до дата-центру, який може лягти, задерти латенсі або одного дня закрити продукт.


Рамка: що обрати під задачу

Що є Що взяти Рівень
ESP-девайс на ESPHome, треба стани і команди з Python ESPHome native API пряме залізо
Уже стоїть Home Assistant, треба керувати ним ззовні HA REST + WebSocket хаб
Купа несумісного світла, хочу один Hue-протокол на все diyHue шлюз-емулятор
HomeKit-аксесуари з Homebridge, а клієнт не Apple Homebridge UI API міст
Камера Tapo, треба PTZ і стрім без апки TP-Link pytapo обхід апки

ESPHome native API

Що замінює. MQTT-брокер і хмару виробника прошивки. ESPHome вже піднімає на девайсі власний бінарний protobuf-сервер — окремий брокер тут зайвий проміжний хоп. Клієнт aioesphomeapi (той самий, що використовує Home Assistant всередині) конектиться прямо до чипа по TCP, тримає персистентне зʼєднання і отримує стани push-ом, а не polling-ом.

Транспорт і авт. TCP, порт 6053. Шифрування — Noise protocol з pre-shared key. Ключ генерується один раз і кладеться в конфіг девайса:

# у YAML прошивки ESPHome
api:
  encryption:
    key: "base64-32-байтний-ключ"   # openssl rand -base64 32

Той самий ключ передається клієнту як noise_psk. Старий плейнтекст-password: у свіжих релізах ESPHome (2026.x) прибрано — noise-ключ фактично обовʼязковий.

Робочий приклад. pip install aioesphomeapi:

import asyncio
import aioesphomeapi

async def main():
    cli = aioesphomeapi.APIClient(
        "device.local", 6053, password=None,
        noise_psk="ТВІЙ_BASE64_КЛЮЧ",
    )
    await cli.connect(login=True)

    # сутності мають числовий key — його треба дістати один раз
    entities, services = await cli.list_entities_services()
    for e in entities:
        print(e.object_id, e.key)

    def on_state(state):          # push при кожній зміні
        print(state)
    cli.subscribe_states(on_state)

    # команда по key з list_entities_services()
    cli.switch_command(key=12345678, state=True)
    # cli.light_command(key=..., state=True, brightness=0.8)  # brightness 0..1

    await asyncio.sleep(3600)     # тримати конект живим

asyncio.run(main())

Сценарій. Локальний хаб-скрипт, який підписаний на subscribe_states з датчика руху і напряму кидає light_command на реле в тій самій кімнаті — без Home Assistant у ланцюгу взагалі. Латенсі — одна LAN-петля.

Граблі. - key сутності — числовий per-device ID, який зʼясовується в рантаймі через list_entities_services(). Він не стабільний рядок: після зміни набору сутностей чи перепрошивки — перечитати. - reboot_timeout за замовчуванням 15 хв: якщо жоден API-клієнт не тримає конект, девайс сам ребутнеться. Для скрипта, який відвалюється надовго, — виставити більше або тримати конект. - Документованого rate limit нема; вузьке місце — сам чип.

Джерела: esphome.io/components/api, github.com/esphome/aioesphomeapi.


Home Assistant: REST + WebSocket

Що замінює. Хмарні містки виробників (SmartThings cloud, Tuya cloud тощо) — HA дає єдиний локальний API поверх усіх інтеграцій. Два транспорти під дві різні задачі: REST — для «зроби разово» зі скрипта чи cron; WebSocket — для «реагуй на кожну зміну» в реальному часі.

Транспорт і авт. HTTP(S) + WS, порт 8123. Токен — Long-Lived Access Token: профіль користувача (/profile) → внизу «Long-Lived Access Tokens» → Create Token. Далі скрізь заголовок Authorization: Bearer <token>.

REST — приклад.

# усі стани
curl -H "Authorization: Bearer TOKEN" \
     http://localhost:8123/api/states

# один entity
curl -H "Authorization: Bearer TOKEN" \
     http://localhost:8123/api/states/light.kitchen

# виклик сервісу
curl -X POST -H "Authorization: Bearer TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"entity_id":"light.kitchen"}' \
     http://localhost:8123/api/services/light/turn_on

POST /api/services/<domain>/<service> віддає список змінених станів, а не просто 200 — це очікувано, парсити відповідно.

WebSocket — приклад. Конект на ws://host:8123/api/websocket, далі messages-хендшейк:

import asyncio, json, websockets

async def main():
    async with websockets.connect("ws://localhost:8123/api/websocket") as ws:
        await ws.recv()                                   # {"type":"auth_required"}
        await ws.send(json.dumps({"type": "auth", "access_token": "TOKEN"}))
        await ws.recv()                                   # {"type":"auth_ok"}

        await ws.send(json.dumps({
            "id": 1, "type": "subscribe_events",
            "event_type": "state_changed",
        }))
        async for raw in ws:
            msg = json.loads(raw)
            if msg.get("type") == "event":
                print(msg["event"]["data"]["entity_id"])

asyncio.run(main())

Сценарій. Зовнішній дашборд або телеграм-бот тримає одне WS-зʼєднання з subscribe_events на state_changed і показує зміни без опитування; а разові дії («вимкни все») шле REST-ом з того ж cron.

Граблі. - Кожне WS-повідомлення після auth потребує унікального id — на ньому матчиться відповідь. Переплутав нумерацію — зламав відповідність запит/відповідь. - Токен живе довго, але прибирається в тому ж профілі; при відкликанні — миттєвий 401. - Явних цифр rate limit у доці нема; зловживання → стандартний 401/403.

Джерела: developers.home-assistant.io/docs/api/rest, /docs/api/websocket.


diyHue

Що замінює. Фізичний Philips Hue Bridge і хмару Signify. diyHue емулює Hue Bridge API v1 локально, тож будь-що, що вміє говорити з Hue-мостом (додатки, інтеграції, старі скрипти), керує через нього різномастим світлом — ESP-стрічками WS2812, лампами з Home Assistant, WLED — як єдиним Hue. Це найдешевший спосіб дати «Hue-сумісність» залізу, яке Hue ніколи не бачило.

Транспорт і авт. HTTP/HTTPS, порти 80/443 (плюс UDP 1900 для UPnP-дискаверу). Порти мінятися не можуть — Hue-екосистема очікує саме їх, реверс-проксі на інший порт ламає дискавер стокових додатків. Автентифікація — та сама пейрінг-схема Hue v1: POST /api з devicetype, натиснути link button, повторити POST, отримати username (він же API key).

Робочий приклад.

# 1) запит — перед цим натиснути link button у diyHue
curl -X POST http://<diyhue-ip>/api \
     -d '{"devicetype":"my_app#my_script"}'
# -> [{"success":{"username":"1028d66426293e821ecfd9ef1a0731df"}}]

# 2) керування лампою
curl -X PUT \
     http://<diyhue-ip>/api/1028d66426293e821ecfd9ef1a0731df/lights/1/state \
     -H "Content-Type: application/json" \
     -d '{"on":true,"bri":254}'

Поля стану з Hue v1: on (bool), bri (0–254), sat (0–254), hue (0–65535).

Сценарій. Один локальний endpoint для всього світла: скрипт вечірнього режиму шле PUT .../lights/<id>/state {"on":true,"bri":80} на diyHue, а той уже розводить команду на фізично різні лампи (ESP, HA, WLED) — без жодного знання, якого вони бренду.

Граблі. - Hue API v1 офіційно задепрекейчений Signify на користь CLIP v2. Публічна дока meethue.com по v1 — заморожений історичний референс, не жива специфікація. diyHue емулює саме v1; чи покриває він також v2 — по доці не підтверджено, звіряти з diyhue.readthedocs.io під конкретну потребу. - 80/443 захардкоджені — див. вище.

Джерела: github.com/diyhue/diyHue, developers.meethue.com/develop/get-started-2.


Homebridge (UI API)

Що замінює. Потребу в Apple-пристрої, щоб чіпати HomeKit-аксесуари. Homebridge вже експонує залізо в HomeKit; плагін homebridge-config-ui-x додає поверх звичайний HTTP API, і будь-який не-Apple клієнт (веб-дашборд, Node-RED, cron) керує тими самими аксесуарами без HAP-пейрінгу. Питання «навіщо, коли є HA» чесне: якщо HA вже стоїть — бери HA. Homebridge UI API виправданий, коли Homebridge — це і є твій єдиний міст у HomeKit, і другий хаб городити не хочеться.

Транспорт і авт. HTTP, порт 8581. Swagger — на http://<host>:8581/swagger (він і є джерело правди по ендпоінтах на твоїй версії). Токен — POST /api/auth/login з {"username","password"}, у відповіді access_token (JWT), далі Authorization: Bearer <jwt>.

Робочий приклад.

# логін
curl -X POST http://homebridge.local:8581/api/auth/login \
     -H "Content-Type: application/json" \
     -d '{"username":"admin","password":"admin"}'
# -> {"access_token":"<JWT>", ...}

# список аксесуарів
curl -H "Authorization: Bearer <JWT>" \
     http://homebridge.local:8581/api/accessories

# керування одним аксесуаром за uniqueId
curl -X PUT \
     http://homebridge.local:8581/api/accessories/<uniqueId> \
     -H "Authorization: Bearer <JWT>" \
     -H "Content-Type: application/json" \
     -d '{"characteristicType":"On","value":true}'

Тіло PUT: characteristicType (напр. On, Brightness, Hue — case-sensitive) і value. Приклади: {"characteristicType":"Brightness","value":50}, {"characteristicType":"Hue","value":240}.

Сценарій. Скрипт логіниться раз, кешує JWT, тягне GET /api/accessories і масово гасить усе через PUT — з машини без жодного HomeKit.

Граблі. - JWT протухає; є refresh-флоу через HttpOnly-cookie (/auth/refresh, /auth/session). Голий curl cookie-jar сам не веде — або перелогінюйся, або веди cookie вручну. - Точні поля тіла й набір ендпоінтів різняться між версіями плагіна — канон завжди живий /swagger на конкретній інсталяції, не стаття і не вікі. - Дефолтні креди першого запуску admin/admin — змінити до того, як відкриєш порт кудись далі за localhost.

Джерела: github.com/homebridge/homebridge-config-ui-x, Swagger інсталяції на /swagger.


pytapo

Що замінює. Апку TP-Link Tapo і її хмару для керування камерою. pytapo — Python-клієнт до локального інтерфейсу камери: PTZ, інфо, дискавер стріму — все по LAN, без round-trip до серверів TP-Link.

Транспорт і авт. HTTPS до камери, контрол-порт 443. Креди — Camera Account, який заводиться в апці Tapo: Settings → Advanced Settings → Camera Account (це локальний акаунт, окремий від хмарного TP-Link ID). Відео — окремий протокол: RTSP на порту 554 з тими самими кредами.

Робочий приклад. pip install pytapo:

from pytapo import Tapo

host = "192.168.1.52"
user = "camera_account_user"      # Advanced Settings -> Camera Account
password = "camera_account_pass"

tapo = Tapo(host, user, password)          # controlPort=443 за замовчуванням
print(tapo.getBasicInfo())

tapo.moveMotor(x=10, y=0)                  # PTZ: motorMove x_coord/y_coord
# tapo.moveMotorStep(angle)                # крок; 0 <= angle < 360

RTSP-потік — напряму, повз pytapo:

rtsp://camera_account_user:camera_account_pass@192.168.1.52:554/stream1   # HD
rtsp://camera_account_user:camera_account_pass@192.168.1.52:554/stream2   # SD

(getStreamURL() у pytapo повертає host:streamPort, а не RTSP-рядок — це внутрішній стрім-порт, не плутати з 554.)

Сценарій. Локальний скрипт крутить moveMotor() за розкладом чи по тригеру, а окремий Frigate/ffmpeg тягне RTSP тими ж кредами на запис і motion-детект — усе офлайн, хмара TP-Link у ланцюгу відсутня.

Граблі. - Camera Account треба явно створити в апці до першого стороннього доступу — це не хмарний логін. - pytapo сам заявляє себе неофіційним: усі запити — реверс-інжиніринг, не документований TP-Link API. Сигнатури методів можуть ламатися між прошивками камери без попередження й без політики версійності. - ONVIF (яким ходить частина NVR замість pytapo) — це ще інший порт, 2020. Не плутати з RTSP 554 і контрол-портом 443.

Джерела: github.com/JurajNyiri/pytapo, tp-link.com FAQ 2680 (RTSP).


Спільні граблі всіх п'яти


Далі в серії

P.S. — про слово «локальний»

Кожен вендор називає своє хмарне керування «зручним», і формально не бреше: натиснути кнопку в апці справді простіше, ніж підняти PSK і написати subscribe_states. Різниця вилазить не в день установки, а в день, коли сервіс закривають, задирають ціну на API або просто кладуть дата-центр на обслуговування — і твоя лампа перестає гаснути, бо чекає відповіді з іншого континенту.

Локальний API — це та сама лампа, яка гасне навіть коли інтернет ліг разом з усім бізнес-планом виробника. Ціна питання — один вечір з curl і розуміння, що id у WebSocket мусить бути унікальним.

MANDROCK.DEV · TELEGRAMЩе більше про код, агентів і те, що ламається красиво.Зазирнути в канал →