Hardware Integration Layer¶
Парний документ: planning/gatehouse-plugin.md — перший споживач цього шару. Status: accepted 2026-04-28, реалізація — Sprint 2 Gatehouse.
Контекст¶
DOP має комунікувати з фізичним обладнанням клієнта:
- промислові ваги (RS-232 / USB-Serial / TCP / file-share),
- ANPR-камери (HTTP push / RTSP),
- RFID/NFC-зчитувачі карт (Wiegand-26/34, USB-HID),
- label printers (Zebra ZPL, Epson ESC/POS),
- фіскальні принтери (UA-сертифікація),
- стороннє СКД-ПЗ (Bolid, Sigur, Honeywell, FortNet, ...),
- шлагбауми (relay-driven).
Кожне підприємство має різний набір моделей, перебільшувати code-base specifics окремої моделі — bottleneck для масштабування. Потрібен уніфікований шар, що:
- Декаплює DOP-backend від transport-протоколу пристрою.
- Дозволяє додавати нові моделі без міграцій бази.
- Працює офлайн (втрата звʼязку з backend не зупиняє реєстрацію подій локально).
- Дистрибутується як standalone-installer, не вимагає DOP-розробника на місці.
Альтернативи, які відкидаємо:
- Web Serial / File System Access API в браузері — Chromium-only, не працює у Firefox/Safari, втрачає зʼєднання при reload, не дає persistent connection. Лишаємо як "light-mode" для одноразових dev-кейсів.
- Browser-only ANPR через WebRTC — підходить для testing-flow (Sprint 2 використовує webcam + EasyOCR на бекенді), але не масштабується на production-камеру з RTSP-стрімом.
- Бекенд-вбудовані драйвери (всередині Django app) — ламається multi-instance setup (deployment з кількома gunicorn-воркерами тримає на одному з них persistent serial-зʼєднання, інші не бачать).
Рішення¶
1. Driver-as-plugin registry¶
GateEquipment.driver — рядкове ім'я зареєстрованого драйвера, не enum в БД. Реєстр живе у device-bridge/drivers/__init__.py. Додавання нової моделі ваги = новий клас на ~50-100 рядків без міграцій.
# device-bridge/drivers/base.py
from typing import AsyncIterator, Literal
from dataclasses import dataclass
@dataclass
class DeviceEvent:
kind: Literal['scale', 'anpr_camera', 'card_reader', 'label_printer', 'barrier']
payload: dict # weight_kg / plate / card_uid — driver-specific
timestamp_iso: str
raw: str = '' # raw bytes-as-text, debug
class DeviceDriver:
code: str = '' # унікальний код у реєстрі ('file_scale_generic')
kind: str = '' # 'scale' | 'anpr_camera' | 'card_reader' | ...
def __init__(self, config: dict): ...
async def connect(self) -> None: ...
async def stream(self) -> AsyncIterator[DeviceEvent]: ... # event-driven або polling
async def disconnect(self) -> None: ...
Backend endpoint GET /api/v1/gatehouse/equipment-drivers/ віддає список зареєстрованих кодів — frontend підставляє у GateEquipment.driver як choice.
MVP — 6 драйверів:
| Code | Kind | Транспорт | Призначення |
|---|---|---|---|
file_scale_generic |
scale | watch файла | Будь-яка вага, що пише число у файл |
serial_scale_ascii |
scale | pyserial + regex | Більшість недорогих ваг RS-232/USB-Serial |
serial_scale_cas_ci |
scale | pyserial CAS CI | Reference-впровадження протоколу CAS |
anpr_emulator_http |
anpr_camera | HTTP webhook (CLI emulator) | Stub для dev і user-testing |
anpr_webcam_browser |
anpr_camera | Browser → backend EasyOCR | User-testing з фото зі смартфона |
card_reader_emulator |
card_reader | watch файла | Stub для dev |
Решта моделей (Mettler SICS, Avery, KPZ, Hikvision/Axis ANPR, Zebra/Epson printers, Bolid/Sigur SCUD) додаються як окремі класи.
2. Device Bridge — standalone Python service¶
Окремий процес на робочому місці оператора, що:
- Тримає persistent зʼєднання з пристроєм через відповідний driver.
- Стрімить події у DOP backend через WebSocket
/ws/gatehouse/{tenant}/{gate_point_id}/. - Heartbeat'ом оновлює
GateEquipment.last_seen_at. - SQLite outbox локально буферизує події при втраті звʼязку → ресинхронізує при відновленні мережі.
Обрано Python над Node:
| Критерій | Python | Node |
|---|---|---|
| Code reuse моделей з DOP backend | + import serializers/validators | − дубль |
| Serial/USB бібліотека | + pyserial (de-facto для промислових ваг, готові приклади CAS/Mettler/Avery) |
± serialport, прикладів менше |
| File watching | + watchdog |
+ chokidar |
| Distribution як EXE | + PyInstaller, single-file, перевірений | ± pkg, образ великий |
| Tray icon + Windows service | + pystray + NSSM |
± Electron-обгортка |
| Команда у проєкті | + Python вже є у backend | − Node лише у frontend tooling |
| Майбутні інтеграції (фіскалі, сканери, каси) | + більше готових бібліотек | − писати з нуля |
Розгортання у клієнта:
device-bridge.exe(PyInstaller, single-file) уC:\Program Files\DOP\device-bridge\,device-bridge.tomlпоруч (URL DOP backend, JWT для bridge-користувача, gate_point_id, equipment driver-mapping),- зареєстровано як Windows service через NSSM (auto-start, auto-restart),
- логи у
%LOCALAPPDATA%\DOP\device-bridge\, - tray-icon (
pystray) для індикації стану (зелений / червоний / нема звʼязку).
3. Transport: WebSocket-first, REST fallback¶
Primary — WebSocket /ws/gatehouse/{tenant}/{gate_point_id}/ з JWT-auth у query-string (?token=...). Той самий патерн, що GPSConsumer. Multi-tenant ізоляція — обовʼязкова: bridge може писати лише у свій tenant і свій gate_point. Канал перевіряє user.tenant_id == path_tenant_id і відкидає мисмач з close-code 4003.
Fallback — POST /api/v1/gatehouse/equipment-events/ для випадків, коли WS неможливий (мобільні мережі з пасивним NAT, розгортання за корпоративним проксі без WS-upgrade). Той самий payload, та ж валідація, той самий side-effect — створення GateEvent (через існуючий serializer + tenant-injection).
4. SQLite outbox у bridge¶
Для backpressure при втраті звʼязку bridge → DOP. База у %LOCALAPPDATA%\DOP\device-bridge\outbox.db, схема:
CREATE TABLE outbox_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
kind TEXT NOT NULL,
payload_json TEXT NOT NULL,
sent_at TEXT,
error TEXT,
retry_count INTEGER DEFAULT 0
);
CREATE INDEX idx_outbox_pending ON outbox_events (sent_at) WHERE sent_at IS NULL;
При успішному надсиланні — UPDATE sent_at = now(). Cleanup-thread видаляє надіслані події старші за 24 години. Невдалі — error + retry_count++, retry з експоненційною паузою.
Граничний випадок: outbox переповнений (>10K записів = 7+ днів повного простою мережі) → bridge переходить у "degraded mode", відкидає нові події з warning у tray. Це сигнал оператору викликати IT.
5. Webcam ANPR — окремий flow поза bridge¶
anpr_webcam_browser — frontend-driven driver, не device-bridge driven. Архітектурно:
[Browser оператора] [Backend] [DOP gatehouse]
getUserMedia → <video>
capture frame → JPEG ──POST /api/v1/gatehouse/ocr/license-plate/──→
EasyOCR.readtext(img)
regex UA post-process
←── {plate, confidence}
якщо conf > 0.8:
create GateEvent ──→ WS push
Чому окрема архітектура: frame захоплюється у браузері користувача (webcam), не на робочому місці bridge. Реальна network-камера у production шле frame на той самий endpoint через push-API (HTTP POST) — frontend-захоплення замінюється, інше без змін.
Backend side:
pip install easyocr(~600MB pyTorch CPU + ~80MB initial model download). Lazy-import всередині endpoint — якщо бібліотека не встановлена → 503 з instructive message замість 500 startup-failure.reader = easyocr.Reader(['uk', 'en'], gpu=False). Singleton, ініціалізується при першому зверненні.- Regex post-processing: фільтр кандидатів за UA-форматами
AA9999AA,AA999999,AAA9999. - Endpoint повертає
{plate, confidence, bbox, all_candidates}.
Наслідки¶
Позитивні¶
- Драйвер-as-plugin: додавання нової моделі ваги/камери = окремий клас на 50-100 рядків. Жодних міграцій.
- DOP-backend нічого не знає про
pyserialчиeasyocr— лише про event-payload contract. Зміна моделі ваги → ніяких backend-змін. - Bridge можна розгортати незалежно від DOP-релізу (стабільний REST/WS contract).
- Webcam-ANPR без купівлі камери на MVP — реальний user-testing з фото зі смартфона.
Негативні / Ризики¶
- Bridge — окремий артефакт у дистрибутиві. Свій PyInstaller-pipeline, свій upgrade-flow. Mitigation: Sprint 2 робить лише dev-mode runs (без exe). PyInstaller — Sprint 3+.
- WS multi-tenant ізоляція — easy-to-miss. Тестування у Sprint 2 явно прописано як обовʼязковий чекпойнт.
- EasyOCR — heavy dependency. Lazy-import + 503 fallback. Production deploy документує інструкцію встановлення.
- SQLite outbox локальний — не реплікується. Якщо диск bridge-host'а вмирає, події втрачаються. Mitigation: outbox-flush при кожній успішній доставці, плюс daily-snapshot до DOP як backup-job (Sprint 5+).
Альтернативи розглянуті, відкинуті¶
| Альтернатива | Чому ні |
|---|---|
| Web Serial API (Chromium-only) | Не Firefox/Safari, втрачає при reload, не persistent. |
| Browser-only ANPR (WebRTC peer) | Не масштабується на network-камеру, проблеми з RTSP. |
| Бекенд-вбудовані драйвери | Multi-instance setup ламається (один воркер тримає serial). |
| Node.js bridge | Менше готових промислових бібліотек, розширення команди на ще одну мову. |
| MQTT broker замість WS | Потребує брокер (Mosquitto/EMQX). Зайва ланка. WS вистачає. |
| gRPC замість REST/WS | Бінарний contract складніше debug'ати; для 100 events/min WS+JSON надлишковий. |
Sprint plan для Gatehouse¶
| Sprint | Зона | Status |
|---|---|---|
| S1 | Universal core, manual flow | ✅ shipped 2026-04-28 |
| S2 | Hardware bridge layer (цей ADR) — 6 драйверів + emulators + EasyOCR + WS | 🔧 in progress |
| S3 | Vehicle gate scenario (ANPR + QR labels + checkpoints) | pending |
| S4 | Weighing correlator + GoodsReceipt bridge | pending |
| S5 | Personnel turnstile + FortNet integration + cross-module bridges | pending |