Локальні 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).
Спільні граблі всіх п'яти
- Локальний ≠ безпечний. Токен/PSK/username — це діючий доступ до девайса. Не тримати під git, не світити порт у інтернет без потреби.
- Версійність. ESPHome і Homebridge UI ламають дрібниці між релізами; Hue v1 заморожений; pytapo взагалі неофіційний. Пінити версії там, де автоматизація критична.
- Джерело правди — жива інсталяція.
list_entities_services(),/swagger, реальний дискавер — а не стаття річної давнини. Ця теж.
Далі в серії
- Частина 2 — медіа й контент: локальні API медіа-серверів і стрімінгу замість вендорських застосунків → /claude/debloat-api-media/
- Частина 3 — робочий стіл і продуктивність: локальні API десктопних інструментів → /claude/debloat-api-desk/
Кожен вендор називає своє хмарне керування «зручним», і формально не бреше: натиснути кнопку в апці справді простіше, ніж підняти PSK і написати subscribe_states. Різниця вилазить не в день установки, а в день, коли сервіс закривають, задирають ціну на API або просто кладуть дата-центр на обслуговування — і твоя лампа перестає гаснути, бо чекає відповіді з іншого континенту.
Локальний API — це та сама лампа, яка гасне навіть коли інтернет ліг разом з усім бізнес-планом виробника. Ціна питання — один вечір з curl і розуміння, що id у WebSocket мусить бути унікальним.