Локальні API замість вендорської хмари: робочий стіл
Третя, фінальна частина серії за каталогом debloat.dev. Тема — залізо на столі та вдома: клавіатура, миша, підсвітка, синхронізація файлів, домашній NAS. Не «фірмовий комбайн у треї, який стартує з системою і живе фоновим процесом на 300 МБ», а периферія, якою керує скрипт: підсвітка реагує на подію збірки, клавіатура шле команду в прошивку, синхронізація рапортує про кожен долитий файл, NAS налаштовується однією командою в терміналі.
Спільний знаменник із двома попередніми частинами той самий: софт уже вміє все локально, питання лише в тому, чи є до цього програмний вхід повз GUI. Тут він є всюди — але транспорт у кожного інший: D-Bus, сирий USB HID, HTTP REST і JSON-RPC. Чотири різні світи на одному столі.
Рамка: що обрати під задачу
| Що є | Що взяти | Транспорт |
|---|---|---|
| Razer-периферія на Linux, треба підсвітка з коду | OpenRazer | D-Bus org.razer |
| QMK/VIA-клавіатура, треба двосторонній канал хост↔прошивка | QMK raw HID | сирий USB HID |
| Синхронізація файлів між машинами, треба реагувати на зміни | Syncthing REST + events | HTTP REST |
| Домашній NAS, треба конфігурувати його зі скрипта | OpenMediaVault | JSON-RPC / omv-rpc |
OpenRazer (D-Bus)
Що замінює. Razer Synapse — хмарний конфігуратор, який на Linux не працює взагалі, а на Windows тягне акаунт і фоновий сервіс. OpenRazer — це драйвери ядра + демон (openrazer-daemon), який виставляє всю периферію на сесійній шині D-Bus під сервісом org.razer. Підсвітка, DPI, профілі, частота опитування — усе доступне як D-Bus-методи, а поверх них є чиста Python-обгортка. GUI Polychromatic — це теж лише клієнт до цього самого демона, не окремий стек; усе, що вміє Polychromatic, доступно й зі скрипта.
Транспорт і авт. D-Bus, сесійна шина (не системна), сервіс org.razer. Окремого токена нема — доступ дає належність до сесії користувача: демон крутиться від імені юзера, і будь-який його ж процес до нього ходить. Умова одна — користувач у групі plugdev, інакше драйвер не віддасть пристрій. Демон має бути запущений (systemctl --user status openrazer-daemon або автостарт при першому зверненні бібліотеки).
Робочий приклад. pip install openrazer (бібліотека openrazer, поверх драйверів із репозиторію дистрибутива):
from openrazer.client import DeviceManager
dm = DeviceManager()
for device in dm.devices:
print(device.name, device.type) # 'Razer BlackWidow V3', 'keyboard'
kbd = dm.devices[0]
# готові апаратні ефекти — рахуються на самому девайсі
kbd.fx.static(0, 255, 0) # суцільний зелений, канали 0..255
kbd.fx.spectrum() # циклічний перелив
kbd.fx.wave(1) # хвиля; напрямок WAVE_LEFT/WAVE_RIGHT
kbd.fx.reactive(255, 0, 0, 2) # підсвічує натиснуту клавішу; time 1..3
# власний кадр: покласти колір у кожну клавішу матриці й відмалювати
matrix = kbd.fx.advanced.matrix
matrix.reset()
for col in range(kbd.fx.advanced.cols):
matrix[0, col] = (255, 0, 255) # matrix[row, col] = (r, g, b)
kbd.fx.advanced.draw() # шле кадр по D-Bus (setKeyRow + setCustom)
fx.* — це апаратні ефекти, які девайс рахує сам. fx.advanced.matrix[row, col] = (r,g,b) + draw() — покадрове малювання, де кожен піксель задає хост: draw() серіалізує матрицю в setKeyRow і застосовує setCustom через D-Bus. Це і є точка, де пишуться власні анімації.
Сценарій. Демон-скрипт слухає власну подію (успіх/провал CI, вхідний пуш, стан батареї) і кидає fx.static() відповідного кольору на клавіатуру: зелений — білд зелений, червоний — впав. Для біжучих ефектів — цикл, що раз на N мілісекунд оновлює advanced.matrix і викликає draw(). Хмари й акаунта в ланцюгу нема — лише локальна D-Bus-петля.
Граблі.
- Шина сесійна, не системна. Скрипт із-під cron/systemd-system не побачить org.razer, бо в нього нема сесійного D-Bus. Запускати як systemd --user-юніт або з правильним DBUS_SESSION_BUS_ADDRESS.
- fx.advanced є не в кожного пристрою — лише в тих, що мають адресовану матрицю (per-key RGB). На мишці чи килимку з однією зоною advanced.matrix не працює; перевіряти device.has('lighting_led_matrix') перед draw().
- Немає користувача в plugdev → девайс просто не зʼявиться в dm.devices, без явної помилки. Це грабля номер один при першому запуску.
Джерела: openrazer.github.io, github.com/openrazer/openrazer (pylib), polychromatic.app.
QMK / VIA (raw HID)
Що замінює. Пропрієтарний конфігуратор клавіатури (Razer Synapse, Logitech G Hub, Corsair iCUE) — але глибше, ніж решта добірки. Тут заміняється не хмара, а сама межа між хостом і залізом: QMK-прошивка може відкрити довільний двонапрямний канал RAW_ENABLE, і хост шле в мікроконтролер клавіатури власні команди, а прошивка відповідає. Це не ремап. Ремап (у тому числі через VIA) міняє те, який скан-код віддає клавіша — таблицю в прошивці. Raw HID — це окремий канал даних поверх USB, яким їздить що завгодно: «покажи на дисплеї клавіатури поточний трек», «підсвіти шар червоним, бо в докері помилка», «віддай хосту стан енкодера». Ремап відповідає на питання «що робить клавіша», raw HID — «що прошивка й застосунок кажуть одне одному».
Транспорт і авт. Сирий USB HID, окремий інтерфейс. Автентифікації нема взагалі — це локальна USB-шина, фізичний доступ і є доступ. Канал визначається двома константами (config.h): RAW_USAGE_PAGE = 0xFF60, RAW_USAGE_ID = 0x61. Хост знаходить потрібний інтерфейс саме за цією парою, а не лише за VID/PID (клавіатура виставляє кілька HID-інтерфейсів). Усі репорти в обидва боки — рівно 32 байти (RAW_EPSIZE).
Робочий приклад. Бік прошивки — в rules.mk:
RAW_ENABLE = yes
І колбек у keymap.c (викликається на кожен вхідний репорт; length завжди 32):
#include "raw_hid.h"
void raw_hid_receive(uint8_t *data, uint8_t length) {
// data[0] — власний код команди; ехо назад на хост
switch (data[0]) {
case 0x01:
// ... зробити щось: перемкнути шар, оновити екран ...
break;
}
raw_hid_send(data, length); // відповідь мусить бути теж 32 байти
}
Бік хоста — Python, pip install hid (обгортка hidapi):
import hid
VID, PID = 0x4653, 0x0001 # з info.json клавіатури
# фільтрувати САМЕ за usage_page/usage, не лише за VID/PID:
path = next(d["path"] for d in hid.enumerate(VID, PID)
if d["usage_page"] == 0xFF60 and d["usage"] == 0x61)
h = hid.Device(path=path)
report = bytes([0x01] + [0] * 31) # 32 байти корисних даних
h.write(b"\x00" + report) # провідний 0x00 — Report ID (див. граблі)
print(h.read(32, timeout=1000)) # відповідь від raw_hid_send
Сценарій. Демон на хості раз на секунду шле в клавіатуру 32-байтовий пакет зі станом системи (навантаження CPU, чи зелений CI, скільки непрочитаних) — прошивка малює це на OLED або підсвіткою шару. У зворотний бік енкодер шле хосту raw_hid_send, а слухач на хості мапить це на гучність. Один USB-кабель, нуль мережі.
Граблі.
- Провідний Report ID. На більшості ОС write() очікує першим байтом Report ID (0x00), тобто фізично йде 33 байти (1 + 32), хоча прошивка бачить рівно 32. Забути \x00 — репорт не піде або поїде зсунутим.
- Фільтрувати за usage_page/usage, а не за VID/PID. Клавіатура виставляє кілька інтерфейсів (keyboard, mouse, raw); відкриєш не той — write мовчки нікуди.
- VIA займає той самий канал. З VIA_ENABLE = yes функцію raw_hid_receive уже використовує сам VIA-протокол. Щоб додати свої команди, не переписуючи VIA, — хук raw_hid_receive_kb або власний діапазон у VIA custom-командах, інакше свій колбек перебʼє ремап через застосунок VIA.
- Усе строго по 32 байти в обидва боки; більший payload — самому різати на кілька репортів.
Джерела: docs.qmk.fm/features/rawhid, docs.qmk.fm — VIA.
Syncthing (REST + events)
Що замінює. Dropbox, Google Drive, OneDrive — будь-яку хмару синхронізації файлів. Syncthing тримає P2P-синхронізацію без жодного центрального сервера, і поверх неї віддає повний HTTP REST API — той самий, яким користується його ж веб-GUI. Два режими під дві задачі: разові запити (GET /rest/db/status — скільки долити, чи все синхронізовано) і потік подій (GET /rest/events — реагувати на кожен долитий файл без опитування).
Транспорт і авт. HTTP(S), порт 8384 (той самий, що GUI). Ключ — заголовок X-API-Key: <ключ> (або Authorization: Bearer <ключ>). Звідки ключ: GUI → Actions → Settings → поле API Key (кнопка генерує новий), або напряму в config.xml, елемент <gui><apikey>…</apikey></gui>. Ключ дає повний адмінський доступ до інстансу.
Робочий приклад.
KEY=abc123DEFyourapikeyhere
BASE=http://localhost:8384
# стан конкретної папки: скільки байт лишилось долити, скільки файлів
curl -H "X-API-Key: $KEY" "$BASE/rest/db/status?folder=default"
# -> {"globalBytes":..., "needBytes":..., "state":"idle", ...}
# уся конфігурація (сучасний ендпоінт, з 1.12.0; старий /rest/system/config задепрекейчено)
curl -H "X-API-Key: $KEY" "$BASE/rest/config"
# окрема папка / девайс — редагується точково
curl -H "X-API-Key: $KEY" "$BASE/rest/config/folders/default"
Потік подій — довгий полінг: запит блокується, поки не зʼявляться нові події або не спливе timeout (дефолт 60 с), і віддає масив, де кожна подія має id, type, time, data. Наступний запит іде з since=<останній id>:
import requests
BASE = "http://localhost:8384"
H = {"X-API-Key": "abc123DEFyourapikeyhere"}
last = 0
while True:
r = requests.get(f"{BASE}/rest/events",
params={"since": last, "events": "ItemFinished"},
headers=H, timeout=70)
for ev in r.json():
last = ev["id"]
d = ev["data"]
print(d["folder"], d["item"]) # яка папка, який файл долито
Параметри /rest/events: since=<id> (віддати все після цього id), limit=, events=<через кому> (фільтр типів), timeout=. Корисні типи: ItemStarted/ItemFinished (файл), FolderCompletion/FolderSummary (прогрес папки), StateChanged, DownloadProgress, LocalIndexUpdated.
Сценарій. Слухач тримає один запит на /rest/events?events=ItemFinished і на кожен долитий файл запускає обробку: прийшло нове фото в синк-папку — прогнати через тег-скрипт; прийшов новий .md — перебілдити сайт. Замість cron, що раз на хвилину сканує папку, — миттєва реакція на факт долиття, з нульовим полінгом файлової системи.
Граблі.
- GUI/REST за замовчуванням слухає лише 127.0.0.1:8384. Для доступу з іншої машини — або підняти адресу в налаштуваннях GUI, або (краще) реверс-проксі; голий порт у LAN/інтернет = адмінський API назовні.
- API-ключ = повний адмін. Ним можна переписати конфіг і додати чужий девайс у синхронізацію. Не в git, не в публічний порт.
- /rest/events без since віддасть історію від старту й може одразу вивалити пачку старих подій; для «тільки нове» передати since= останнього відомого id (або спершу зробити запит із limit=1, щоб дізнатись поточний id).
- Старий /rest/system/config ще відповідає, але задепрекейчений на користь /rest/config — нове писати на новий.
Джерела: docs.syncthing.net/dev/rest, docs.syncthing.net/rest/config, docs.syncthing.net/rest/events.
OpenMediaVault (JSON-RPC)
Що замінює. Пропрієтарні NAS-прошивки (Synology DSM, QNAP QTS) з їхніми закритими API. OpenMediaVault — Debian-базований NAS, і весь його веб-інтерфейс говорить із бекендом через один JSON-RPC-ендпоінт /rpc.php. Тобто будь-яка дія в UI — це виклик service/method/params, і той самий виклик доступний ззовні: створити спільну папку, підняти SMB-шару, зібрати RAID, глянути змонтовані ФС — усе програмно.
Транспорт і авт. HTTP, веб-порт OMV. Тіло POST /rpc.php — JSON {"service","method","params","options"}; відповідь — {"response": …, "error": …}. Авт сесійна, на кукі: спершу POST /rpc.php з service:"session", method:"login" віддає кукі сесії, і всі подальші виклики йдуть із цим кукі. Це головна незручність сирих викликів — треба вести cookie-jar, а в новіших версіях ще й пробрасувати CSRF-токен сесії, і назва сервісу логіну між версіями плавала (session/Session).
Робочий приклад — сирий JSON-RPC (як зразок, чому так робити не треба):
BASE=http://nas.local
# 1) логін — зберегти кукі сесії
curl -c cookies.txt -X POST "$BASE/rpc.php" \
-H "Content-Type: application/json" \
-d '{"service":"session","method":"login","params":{"username":"admin","password":"<PASS>"}}'
# -> {"response":{"authenticated":true,"username":"admin"},"error":null}
# 2) виклик методу з тим самим кукі
curl -b cookies.txt -X POST "$BASE/rpc.php" \
-H "Content-Type: application/json" \
-d '{"service":"FileSystemMgmt","method":"enumerateMountedFilesystems","params":{"includeroot":true},"options":null}'
Той самий результат через omv-rpc (локально на NAS):
# ані логіну, ані кукі, ані CSRF — авт бере з системного користувача
omv-rpc -u admin 'FileSystemMgmt' 'enumerateMountedFilesystems' '{"includeroot": true}'
omv-rpc -u admin 'RaidMgmt' 'getCandidates' | jq
omv-rpc викликає ті самі service/method/params, що й веб-GUI, але обходить весь HTTP-шар авт: логін, кукі, токен. Для скрипта, що крутиться на самому NAS (cron, деплой, бекап-хук), це єдиний осудний шлях — сирий curl виправданий лише коли керувати треба з іншої машини.
Сценарій. Скрипт розгортання нового шара: omv-rpc … 'ShareMgmt' 'set' '{…}' створює спільну папку, далі 'SMB' 'setShare' піднімає над нею SMB, і 'Config' 'applyChanges' застосовує зміни — увесь провіжининг NAS у version-controlled bash-файлі замість двадцяти кліків у вебі. Перелік доступних service/method — у теці engined/rpc вихідників OMV.
Граблі.
- Сесія на кукі + CSRF. Сирий curl мусить вести cookie-jar (-c/-b); один запит без кукі після логіну → session not authenticated. Саме тому локально — omv-rpc, не curl.
- Назва сервісу логіну й дрібниці протоколу різнилися між мажорами OMV — приклад із чужої версії може не завестись; канон завжди — engined/rpc конкретної інсталяції.
- Історично rpc.php мав RCE-вразливості (напр. CVE-2013-3632) — веб-морда OMV не для публічного інтернету; тримати за VPN/реверс-проксі й свіжою версією.
- applyChanges — окремий крок: багато set-методів лише пишуть у конфіг, зміни не діють, поки не застосовано.
Джерела: docs.openmediavault.org — omv-rpc, docs.openmediavault.org — RPC, github.com/openmediavault/openmediavault.
Спільні граблі всіх чотирьох
- Транспорт у кожного свій — і перша спроба ламається саме на ньому. OpenRazer — сесійний D-Bus (не системний), QMK — сирий HID із провідним Report ID, Syncthing —
X-API-Key, OMV — сесійний кукі. Схема з однієї служби не переноситься на іншу. - «Локальний» ≠ «без доступу». Ключ Syncthing і кукі OMV — це повний адмін. D-Bus і HID захищає лише те, що вони фізично на цій машині. Не світити 8384 і веб-морду OMV у мережу без потреби, ключі — не в git.
- Фільтрувати правильний інтерфейс/шину. HID — за
usage_page+usage, а не VID/PID; D-Bus — сесія, а не система. Половина «нічого не відбувається» — це звернення в правильний сервіс не тим каналом. - Окремий крок «застосувати». OMV має
applyChanges, ESPHome/Homebridge із частини 1 — свої нюанси.setбез застосування — типова причина «команда пройшла, а нічого не змінилось». - Джерело правди — жива інсталяція.
device.has(...),info.jsonклавіатури,/rest/config,engined/rpcконкретної версії OMV — а не стаття. Ця теж.
Порівняльна таблиця
| Проєкт | Транспорт | Авт | Події (push) | Стабільність між мажорами |
|---|---|---|---|---|
| OpenRazer | D-Bus (сесійна шина) | членство в сесії + plugdev |
сигнали D-Bus | помірна; матрична частина залежить від моделі |
| QMK raw HID | сирий USB HID | нема (фізичний доступ) | так — двонапрямний канал | контракт визначає твоя ж прошивка |
| Syncthing | HTTP REST | X-API-Key (повний адмін) |
так — /rest/events long-poll |
висока; старий config-ендпоінт депрекейтнуто плавно |
| OpenMediaVault | JSON-RPC /rpc.php |
сесійний кукі (+CSRF); або omv-rpc |
ні — полінг | назви сервісів плавали між мажорами |
Фінал серії: як відрізнити реальний API від «просто локального»
Три частини серії пройшли по чотирнадцяти проєктах, і закономірність вилазить одна: «локальний» і «програмований» — не синоніми. Купа софту працює без хмари, але керується виключно пальцем по GUI; а частина віддає інтерфейс, на який справді можна покласти автоматизацію. Ознака других — не маркетинг «open & local», а конкретні технічні риси. Чекліст, за яким проєкт перевіряється до того, як вкладати в нього вечір:
- Є документація саме на API, а не лише на UI — з ендпоінтами, полями, кодами помилок. Скріншоти меню в рахунок не йдуть.
- Є версійність протоколу — номер версії API/схеми, окремий від версії застосунку, і видима політика змін (Kodi пінить
JSONRPC.Version, Subsonic —v=). - Авт не завʼязана на їхню хмару — ключ/токен/сесія живуть на твоєму залізі, і доступ не гасне, коли ляже сервер вендора. Хмарний OAuth як єдиний вхід — червоний прапор.
- Є події, а не лише полінг — WebSocket, long-poll, D-Bus-сигнали чи вебхуки. Якщо єдиний спосіб дізнатись про зміну — опитувати щосекунди, автоматика буде або млявою, або ненажерливою.
- Є офіційний клієнт або машинна схема — SDK, OpenAPI/Swagger, D-Bus-інтроспекція. Схема, з якої генерується клієнт, цінніша за приклади в блозі: зміни вилазять на компіляції, а не в проді.
- API не ламається щорелізу — або стабільний контракт, або чесно оголошений «до 1.0 ламаємо» (як Immich). Найгірше — тихі зміни без версійності й без deprecation-політики (як PhotoPrism, pytapo).
Проєкт, що бере 5–6 з 6, — це фундамент, на який можна писати роками. 2–3 з 6 — «локальний», але автоматизувати доведеться реверс-інжинірингом і з ризиком, що наступний реліз усе перепише. 0–1 — це GUI, який просто не ходить у хмару; програмованим його називати не можна.
Серія
- Частина 1 — розумний дім і IoT: ESPHome, Home Assistant, diyHue, Homebridge, pytapo → /claude/debloat-api-home/
- Частина 2 — медіа й контент: Jellyfin, Kodi, Navidrome, Immich, PhotoPrism → /claude/debloat-api-media/
Вендорський конфігуратор периферії любить назватися «фоновою службою», ніби це скромний помічник, що тихо сидить у треї. По факту це процес, який стартує з системою, тримає з'єднання з акаунтом, і без якого не міняється колір підсвітки на клавіатурі, за яку вже заплачено повністю.
D-Bus-виклик, 32-байтовий HID-репорт чи omv-rpc у cron — це те саме керування залізом, тільки без посередника, який має власну думку щодо того, чи варто йому сьогодні запускатись. Клавіатура світиться зеленим, бо скрипт сказав їй світитися зеленим, а не бо хмара дозволила. Ціна питання — розуміння, що шина D-Bus сесійна, а перший байт HID-репорту — це Report ID, який прошивка навіть не побачить.