Перейти до змісту

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 для масштабування. Потрібен уніфікований шар, що:

  1. Декаплює DOP-backend від transport-протоколу пристрою.
  2. Дозволяє додавати нові моделі без міграцій бази.
  3. Працює офлайн (втрата звʼязку з backend не зупиняє реєстрацію подій локально).
  4. Дистрибутується як 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

Окремий процес на робочому місці оператора, що:

  1. Тримає persistent зʼєднання з пристроєм через відповідний driver.
  2. Стрімить події у DOP backend через WebSocket /ws/gatehouse/{tenant}/{gate_point_id}/.
  3. Heartbeat'ом оновлює GateEquipment.last_seen_at.
  4. 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.

FallbackPOST /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_browserfrontend-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