27 Commits

Author SHA1 Message Date
julian 6d32e0fc0f fix(i18n): correct translation for 'addAnother' in Albanian
Build desktop / desktop (push) Successful in 4m29s
Build & push images / images (push) Successful in 2m41s
CI / check (push) Successful in 42s
2026-06-27 14:37:02 +02:00
julian 3a60367232 feat(setup): print a real test slip from the printer "Test connection" modal
healthCheck only opens the transport (TCP connect / USB open) — it proves the
printer is REACHABLE, not that paper feeds and the head fires. Add a "Print test
slip" action so the admin can physically confirm a printer is live (the new
host-net USB /dev/usb/lpN path, or a network printer).

- server: POST /api/setup/test-print — printer-only, re-merges stored secrets like
  /test (so an edited network printer authenticates), creates the device, and pushes
  a short slip via the device-agnostic printReport(). Fail-soft: a print error
  (paper out, head fault, transport drop) is reported, never a 500. Mirrors the
  test-anpr pattern.
- web: testPrint() client + PrintTestResult; a button in the device modal shown for
  category=printer, with ok/fail rendering. i18n keys in sq + en (parity holds).

Server 168 tests pass; web + server typecheck clean.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 14:36:39 +02:00
julian 045892bc94 fix(deploy): group_add lp (gid 7) so the server can write the USB printer node
Build & push images / images (push) Successful in 2m41s
CI / check (push) Successful in 42s
USB passthrough (1ea1aa4) made /dev/usb/lp1 visible in the container, but the node
is `crw-rw---- root:lp` (660) and the server runs as the non-root `app` user, not in
`lp` — so open(O_WRONLY) → EACCES → printer still "offline". Add the host's `lp` GID
(7 on this Ubuntu booth, verified `getent group lp` → lp:x:7:) via group_add, so the
app process gains the supplementary group that owns the node. Least-privilege: no
world-writable device, no root, no image rebuild. (If a future booth's lp GID differs,
update the number.)

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 14:23:02 +02:00
julian 916c147b4d fix(camera): drop debug console.log(c) leaking the camera password to logs
Build desktop / desktop (push) Successful in 4m39s
Build & push images / images (push) Successful in 2m49s
CI / check (push) Successful in 41s
The Hikvision driver's create() had a leftover `console.log(c)` that dumped the
ENTIRE camera config — including the plaintext `password` — to stdout every time
the adapter was built, on every request that resolves a camera. That puts a device
credential in the logs (which get shipped/cached/read — the booth operator is the
adversary). Removed. Swept the rest of the shipped source: no other console.* leaks.
2026-06-27 14:13:45 +02:00
julian 1ea1aa4189 fix(deploy): pass the USB printer (usblp) into the host-net server container
Build & push images / images (push) Successful in 2m45s
CI / check (push) Successful in 39s
The USB ESC/POS printer is the host's /dev/usb/lpN (usblp char device, major 180),
but the container has its own /dev — `docker exec server ls /dev/usb` → "No such
file or directory", so probeUsb's open() ENOENTs and the printer is always offline
regardless of the path set in setup. Containerization isolates host hardware (same
root cause as the network fix); USB needs explicit passthrough:

- volumes: /dev/usb:/dev/usb  → the lpN NODES appear inside the container
- device_cgroup_rules: 'c 180:* rmw'  → permit the usblp char major (180), and the
  `:*` minor wildcard survives lp0/lp1/lp2 renumbering across replug/boot-order.

Binding the /dev/usb DIR (not a single `devices:` node) is what survives renumber.
Merge verified: parking-data ledger volume preserved (lists append), host net intact,
config valid. Booth prereq: `usblp` loaded at boot + printer attached before start,
else /dev/usb is absent.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 14:11:51 +02:00
julian 9c20faf8de fix(deploy): drop server sysctls under host net (runc rejects per-netns sysctl)
Build & push images / images (push) Successful in 2m37s
CI / check (push) Successful in 35s
network_mode: host + sysctls: net.ipv4.ping_group_range fails at container create:
"sysctl not allowed in host network namespace" — runc refuses a per-netns sysctl
when there's no separate netns. Remove it; under host net the server uses the HOST's
ping_group_range (set on the booth via /etc/sysctl.d). Fixes the park-buzi-server-1
start failure introduced by c87dcb2.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 12:59:10 +02:00
julian a68dc23393 fix(i18n): update placeholder text for park name in English and Albanian translations
Build desktop / desktop (push) Successful in 4m14s
Build & push images / images (push) Successful in 2m38s
CI / check (push) Successful in 37s
2026-06-27 12:48:51 +02:00
julian c87dcb2253 fix(deploy): server on host network so it sees the booth LAN / device VLAN
In prod the containerized server sat on the Docker bridge (172.18.0.x) and could
only see eth0 — so the setup backend-IP picker (net.ts networkInterfaces) showed
just the Docker IP, the server couldn't reach the relay or fetch Hikvision ISAPI
snapshots, and push devices (readers/cameras) couldn't reach it. The server is the
ONLY container doing device I/O, so put it on the HOST network namespace.

- docker-compose.prod.yml: server + proxy → network_mode: host (server detaches the
  base `parking` network via `networks: !reset []`). server VISION_URL=127.0.0.1:8089.
  vision stays BRIDGED (it never touches a device — the server hands it JPEG bytes)
  but publishes 8089 on 127.0.0.1 only, so the host-net server reaches it over
  loopback while the ANPR service stays off the LAN.
- docker-compose.yml: VISION_URL is now ${VISION_URL:-http://vision:8089} so dev keeps
  compose-DNS service-name routing; prod overrides to loopback.
- Caddyfile: reverse_proxy 127.0.0.1:3000 (was server:3000 — service DNS doesn't
  resolve on host net). Dev doesn't use Caddy, so unaffected.

Merge validated for both envs (booth.sh config, exit 0). Host-net side effect: the
container ping_group_range sysctl is a no-op — the HOST must set it for reader ICMP
liveness (see appliance-provisioning).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 12:48:27 +02:00
julian 7eadf71a0b docs(wiki): appliance-provisioning — Komodo deploy is now the primary flow
§6 split: §6 = Docker engine only; new §7 = the Komodo Periphery deploy (PRIMARY,
verified end-to-end on park-buzi 2026-06-27):
- 7a install Periphery (onboarding key, user-mode/outbound, runs as admin, no
  inbound port; core_address = Core's proxy URL)
- 7b deploy the Stack in Core (registry+git accounts, per-booth [[..]] secrets,
  env incl. COOKIE_SECURE=0; seed admin via Komodo's container terminal — no SSH)
- 7b-bis fleet-as-code via komodo/resources.toml + ResourceSync (empty diff =
  in sync)
- 7c break-glass: manual booth.sh when mesh/Core is down

Added Komodo deploy gotchas 7-11 (core_address is the proxy URL not :9120;
git-auth ≠ registry-auth; user-mode vs /etc/komodo root_directory; core_address
singular; empty-diff/disabled-Execute = success). §5b SSH TODO reframed (Komodo
removes SSH from routine ops). Header + date updated; log entry added. Fixed a
stale [[atecc608-secure-element]] alias in the prior log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 12:30:08 +02:00
julian 9918f278b2 feat(deploy): Komodo fleet deployment — resources.toml + decision
CI / check (push) Successful in 36s
Adopt Komodo Periphery (over the NetBird mesh) as the booth fleet control plane,
superseding SSH-and-booth.sh. The booth runs the SAME compose files; Komodo Core
drives them remotely. booth.sh is demoted to a break-glass local fallback.

- komodo/resources.toml mirrors the working park-buzi Stack (built by hand in the
  Core UI, then exported to TOML — field names match the running v2.2). Stack-only:
  servers are created by the agent onboarding OUTBOUND (one-time onboarding key →
  Periphery self-registers, auto-rotating keys, booth opens no inbound port), so
  there is no [[server]] block. Per-booth secrets via [[...]] refs to Core's store.
- komodo/README.md + .env.komodo.example document the flow and the hard rules
  (no webhook; onboarding/outbound/mesh-only; per-booth unique secrets; never
  down -v the ledger volume).
- wiki/decisions/fleet-deployment-komodo.md records the decision + threat-model
  analysis (Periphery is a root agent → mesh-bound; EVENT_SIGNING_KEY-in-Core is a
  fraud-root blast radius until ATECC608 signs; Core is now Tier-0; GPL-3.0 is fine
  as external ops tooling). container-deployment reframed (booth.sh = fallback);
  index + log updated.

Verified end-to-end against a real booth (park-buzi): onboarded OK, Stack deployed,
all containers green, admin seeded.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 12:14:04 +02:00
julian 83298bc0c5 fix(deploy): booth.sh works in the flat /opt layout; .env TAG=dev default
Build desktop / desktop (push) Successful in 4m37s
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 37s
The booth deploys the compose files FLAT (e.g. /opt/parking_systems/) with
booth.sh next to them, but the script assumed it lived in <repo>/scripts/ and
blindly did `cd ..` — so REPO_DIR resolved to the parent, where there are no
compose files, and every subcommand operated on the wrong dir. `usage()` then
sed-read a relative $0 that no longer existed after the cd ("can't read
booth.sh"). Discover the compose files instead: check the script's own dir,
then ../, then $PWD, and cd to whichever has docker-compose.yml. usage() reads
an absolute $SELF so it survives the cd.

Also: .env.example defaulted TAG=main, but the registry only has dev-* tags
(no main build yet), so `compose pull` 404s. Default to TAG=dev and document
the moving-vs-immutable (dev / dev-<sha>) tag scheme.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 08:42:11 +02:00
julian 898cf1953a docs(wiki): camera 503/stream, alarm URL helper, reader ICMP liveness
- lpr-camera.md: "503 Device Busy" can be PERSISTENT (main-stream saturation on
  the G3H) — the real fix is sub-stream selection, not just retry.
- device-status-monitoring.md: QR reader health was false-healthy (hardcoded
  "ready") until the ICMP-ping fix; document the push-device monitoring model.
- log entries for both 2026-06-26 sessions.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-26 16:47:19 +02:00
julian dd0f6e483a fix(reader): real ICMP liveness — QR reader status was a hardcoded "ready"
Two genuinely-offline QR readers showed GREEN: the adapter's healthCheck was
hardcoded to { ready, "stub" } and never probed. These are PUSH devices (scan →
GET our backend, resolve by serial) with NO TCP port, so a connect probe has
nothing to hit — the stub "solved" that by lying. False-healthy is the worst
failure for a status bar.

- Optional reader IP field (monitor-ONLY; scans still resolve by serial,
  operation unchanged).
- Unprivileged ICMP ping (drivers/icmp.ts): shells /bin/ping -c1, exit-0 = reply.
  No native dep, no CAP_NET_RAW. docker-compose.prod.yml sets
  net.ipv4.ping_group_range so it works for the non-root container user.
- healthCheck: replies → ready, no reply → offline, NO IP → degraded
  ("set IP to monitor") — never a false green.

Verified on hardware: readers (10.0.10.7/.8) answer ICMP on the device VLAN;
UI Test connection → "● ready — ping 10.0.10.7". Tests: reader.test.ts (4).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-26 16:47:12 +02:00
julian 40de8a7467 feat(setup): generate the camera's Alarm Server settings to paste
When a camera has Alarm Server push enabled, the setup form now shows the
camera's Alarm Settings (Destination IP / URL / Protocol / Port) ready to copy,
so the operator never hunts the deviceId or memorises the endpoint.

CRUCIAL: host/port come from the BACKEND address on the camera's subnet
(backendIpForDevice + the server's listen port — the same probe the push-IP
picker uses), NOT window.location.origin (the SPA's dev/proxy origin, which
would wrongly say localhost:5173). Verified live: matches the on-camera config
field-for-field (10.0.10.203 / …/event / HTTP / 3000). Shows a "save first"
(needs a deviceId) then "test first" (needs the resolved backend IP) hint.
i18n keys added to sq + en (parity enforced).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-26 16:47:02 +02:00
julian f0fd15bb88 fix(camera): selectable snapshot stream + retry transient 503 Device Busy
A Hikvision DS-2CD1047G3H-LIU returned HTTP 503 (statusCode 2 / deviceBusy)
on EVERY main-stream snapshot — its main encoder is persistently saturated.
Probed on hardware: channels/101/picture → 503 on 5 consecutive tries, while
channels/102/picture (sub stream) → 200 clean JPEG every time. A retry loop
can't fix a persistent busy; the real fix is stream selection.

- Add a `stream` config field to the Hikvision driver (1=main, default for
  back-compat; 2=sub). ISAPI channel id is <channel><stream> (101 main, 102 sub).
  Verified live: setting the G3H to Sub flips its status degraded→ready (14.7KB
  JPEG in ~87ms).
- captureSnapshot also retries the TRANSIENT case (503/500, linear backoff
  250/500/750ms ×4) then fails naming it "(device busy)"; does NOT retry 401/404
  (config errors won't self-heal). Complements captureSnapshotShared (concurrent
  de-dup). healthCheck still reports a live 503 as degraded (surfaces a saturated
  main stream rather than hiding it).

Tests: camera.test.ts (10) — retry behaviour + main/sub path selection.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-26 16:46:46 +02:00
julian 40ffa90dac fix(vision): self-heal local real ANPR — dev scripts sync the alpr extra
The dev box runs vision as bare `uv run uvicorn`, and a plain uv run/uv sync
re-resolves the venv to the lockfile DEFAULTS, stripping fast-alpr/onnxruntime.
So after any `pnpm dev` real ANPR silently degraded to "snapshot, no plate"
(diagnosed 2026-06-25: real reads through 06-22, venv frozen lean since 06-19,
no other env with fast_alpr). The BOOTH was never affected — it runs the Docker
image, which bakes `uv sync --frozen --extra alpr` at build (immutable, weights
pre-warmed); a booth ModuleNotFoundError is a STALE image (fix: booth.sh update).

Vision package.json dev/start/recognize now run `uv sync --extra alpr &&` first
so pnpm dev is self-healing; added a dev:stub escape hatch for a lean run.
Documented in wiki/decisions/vision-service-packaging.md ("Two runtimes, one
fragile") + a log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-26 08:11:17 +02:00
julian b3cb67188e fix(anpr): share one camera snapshot across bridge + advisory paths
On a vehicle entry, two paths captured the SAME Hikvision camera within ~1s —
the ANPR bridge (barrier-driving) and the advisory snapshotAsync (evidence/
telemetry) — each from a separate adapter instance. Hikvision serves snapshots
single-threaded, so the second concurrent GET returned HTTP 503; the bridge
then fail-softed and burned its 12s debounce, producing a ~74s "slow" subscriber
entry (observed 2026-06-25, Qazim Mulleti / AB816NN — plate read was instant at
conf 1.000; the delay was the 503/debounce churn, not recognition).

Add captureSnapshotShared() in snapshot.ts: a module-level, deviceId-keyed cache
that both paths call. It coalesces in-flight captures (the 2nd caller awaits the
1st's pull → no concurrent 503), serves a brief freshness window (1500ms) so the
bridge→advisory sequence for one vehicle reuses one frame, never caches a failure
(next caller retries), and keys by deviceId (no cross-camera/stale-vehicle reuse).
Wired into anpr-entry.ts (bridge) and snapshot.ts (advisory).

Tests: snapshot.test.ts (concurrent coalescing, TTL reuse, TTL-lapse re-pull,
failure-not-cached, per-camera keying); anpr-entry.test.ts mock updated. 168
server tests green.

NOTE: this removes the latency (the 503 collision). The separate double-entry
(two signed vehicle_entry for one car) — debounce-too-short / stamp-before-
success — is still open; less likely now but not eliminated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-26 08:10:56 +02:00
julian b1c4109045 docs(wiki): document scripts/booth.sh in container-deployment
Add a "Booth operator wrapper" section (commands, the update flow, env
handling, the volume/ledger safety notes) + a log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 20:38:44 +02:00
julian 50dd554b43 feat(deploy): booth.sh wrapper over the compose files + update flow
The booth PC (Ubuntu) needs one command instead of the long
`docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env …`
over the three compose files.

scripts/booth.sh — prod by default (ENV=dev for the dev override):
up/down/restart/status/logs/pull/config/exec, plus the requested `update` =
pull the moving branch tag → up -d --remove-orphans (recreates only
digest-changed services; named volumes / the SQLite ledger are preserved) →
docker image prune. Prod refuses to run without .env (no safe JWT_SECRET
default); dev with no .env injects the documented benign local secret (the
base file makes JWT_SECRET shell-required via ${JWT_SECRET:?}). down never
passes -v (would wipe the signed-ledger volume); help/unknown-command
short-circuit before any Docker/.env requirement.

.env.example — the vars the compose files consume (REGISTRY, TAG, JWT_SECRET,
EVENT_SIGNING_KEY, COOKIE_SECURE=0, WS_ALLOWED_ORIGINS). .env stays gitignored.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 20:38:44 +02:00
julian 6d7682ab4a docs(wiki): printer USB transport + open-question for the provisioning
New concepts/printer-usb-transport.md (the seam, usblp char device,
reachability-only status, threat model). open-questions #14: confirm the
on-site printer is USB and bake the usblp + udev write-access rule into the
appliance image (provisioning, not app code; unverified on hardware). Updated
rongta-printer.md (USB transport note), index.md, log.md.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 20:32:24 +02:00
julian 793b8d83ee fix(setup): hide transport-irrelevant printer fields (USB vs Network)
The wizard rendered every configField in a flat loop, so the USB device path
showed under a Network printer (and host/port would show under USB) — the
form could mislead. Add a transport-aware filter (mirroring the existing
pulseMs/inputRestingHigh skip): when Connection=USB hide host/port/httpPort,
otherwise hide devicePath. Verified live (Playwright): each transport shows
only its own fields and toggling swaps them.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 20:32:17 +02:00
julian 7366ad19cb feat(printer): USB transport behind the ESC/POS render layer
The ESC/POS printer drivers were TCP-only — every path went through
sendRaw/probe to a raw socket on port 9100. Add a USB transport behind
the existing render layer without touching a single render*() function.

- printer-escpos.ts: sendRawUsb/probeUsb write the same ESC/POS bytes to a
  kernel usblp char device (/dev/usb/lp0) via a plain fs write — no
  libusb/CUPS/native dep (keeps MIT-only + minimal-deps appliance). A
  discriminated Transport + transportFromConfig/sendTo/probeTo dispatch the
  wire; anything not transport:"usb" is TCP, so existing host-only configs
  need no migration. Shared transportField/devicePathField config fields.
- cashino + rongta resolve a Transport once; both are reachability-only over
  USB, and the Rongta's HTTP status page degrades to the open-the-node probe
  over USB (no guessed paper/cover — the standing honesty rule). host/port
  made not-required so a USB printer needs neither.
- Tests: printer-escpos.test.ts (USB writes the exact rendered bytes; probe
  present/absent; transportFromConfig TCP back-compat) + printer-cashino.test.ts
  (USB-configured driver prints to the node, ready/offline).

USB itself is unverified on hardware (the on-site printers are networked);
the appliance-side usblp + udev provisioning is tracked as open-questions #14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 20:32:10 +02:00
julian 5a5fedf4f4 docs(wiki): booth bring-up fixes — relay password, secret re-merge, lamp concurrency
- dingtian-relay: the "offline despite ping" gotcha (relay_pw in every binary frame,
  missing form field → Test connection sent 0 → timeout) + the identity-gated secret
  re-merge that stops a redirected probe exfiltrating the password.
- button-light-indicator: serialized desired-state worker (UDP is unordered → the lamp
  stuck on/off) and hot-reload of the lamp config (no restart).
- log entry for the three fixes (commits 420542c / fd15988 / 830993b).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 19:11:49 +02:00
julian 830993bcb8 fix(button-light): serialize relay sends + hot-reload the lamp config
Build desktop / desktop (push) Successful in 4m20s
Build & push images / images (push) Successful in 2m45s
CI / check (push) Successful in 37s
Two bugs in the button-light controller:

1. Stuck relay (random on/off). The blink fired fire-and-forget setAux every 500ms over
   UNORDERED UDP with no serialization — concurrent on/off packets reordered/overlapped,
   so the relay latched on whichever packet the device processed last. Replace with a
   desired-state + serialized worker (#pump): the blink timer only flips desiredOn; a
   single in-flight send per lamp is guaranteed, and on completion it re-converges to the
   latest desired state — so the final state is always authoritative and a lost/stale
   packet self-corrects.

2. Lamp ignored until restart. The lamp map was built once at start(); a button light
   added/changed via the UI never took effect without a server restart. #reconcile now
   re-reads the device config (at start and before each event, like DeviceMonitor),
   adding/updating/dropping lamps live — so a just-saved lamp blinks on the next radar
   edge.

Tests assert confirmedOf() (the device's latched state); +1 reconcile-after-start case.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 19:04:18 +02:00
julian fd15988a73 refactor(setup): split the controller form into Outputs and Inputs sections
The controller editor mixed outputs and inputs in one flat "Relays" block — relay
direction, the entry-button terminal, and the presence/radar terminal all on the same
row, with the lamp orphaned below. Reorganize into two labelled sections:

- Outputs — relays (barriers + lamp): relay # + direction, the button-light relay, and
  "Pulse open (ms)" (a relay hold-time, NOT an input setting — answers a recurring
  confusion).
- Inputs — terminals (button, sensor): per entry relay, the button + presence/radar
  terminals (kind, active-low) and cooldown, each labelled "For relay N", plus the
  board-wide "Inputs idle HIGH".

UI-only: storage stays config.relays[] (+ config.buttonLight), so saved booth configs
keep working with no migration. pulseMs/inputRestingHigh are pulled out of the generic
field loop and rendered in their section. i18n parity (sq + en).

Also passes the device id to testDevice() so an edited device's stored relay password
re-merges on Test connection (pairs with the secure-merge server change).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 19:04:05 +02:00
julian 420542ce10 fix(setup): add Dingtian relay-password field + secure secret re-merge on test
The relay control password (relay_pw) was read by the driver but had NO form field,
so Test connection sent it as 0 → the device ignored the probe → a controller showed
"offline" even though it pinged. Add a "Relay control password" config field (secret;
blank keeps the stored value).

Because relayPassword is redacted from the client, the edit form can't resend it — so
the test endpoint now re-merges the stored secret by device id (mirroring save). It is
re-merged ONLY when the submitted config addresses the SAME device: matching driverId
and every connection-identity field it sets (host/port/binaryPort/httpPort/serial). A
redirected host/port or mismatched driver yields NO secret, so a probe can't exfiltrate
the password to an attacker host (the booth operator is the threat-model adversary).
testDevice() now passes the device id; setup-secrets.test.ts covers the identity guard.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 19:03:53 +02:00
julian 2915d141aa feat(devices): radar presence input + button-light output on the controller
Model the entry button (I1) and a Hikvision radar (I2) as named children of the
access controller, and drive the button's 12V lamp on a spare relay.

- Radar = the existing relays[].presenceInput one-car-one-ticket gate, now labelled
  presenceKind: loop|radar. A radar may idle opposite the button, so add a per-input
  active-level override: relays[].presenceActiveLow -> driver inputActiveLow set,
  inverting just that terminal (pure helper inputActive()). The Dingtian has one
  board-wide resting level otherwise.
- AuxOutputDevice.setAux(channel,on) capability on the device interface (Dingtian
  latch) so business logic drives a NON-barrier lamp through the interface. Barriers
  still only pulseOpen — barrier-not-a-door preserved.
- ButtonLightController: subscribes to the radar input edge + the camera lane status
  and drives a 3-state lamp — radar+car=solid, radar-only=blink (~1Hz), else off.
  Fails OFF on host loss/error; de-duped. A radar detection never opens a barrier on
  its own (advisory; threat model).
- SetupWizard: presence kind + active-low + a button-light relay picker; sq+en i18n.

Tests: button-light.test.ts (truth table + blink + fail-OFF + de-dupe),
access-dingtian.test.ts (active-level inversion). Workspace build+lint+test green
(158 server tests). Wiki: hikvision-radar, button-light-indicator + updates.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 11:45:22 +02:00
52 changed files with 3819 additions and 219 deletions
+37
View File
@@ -0,0 +1,37 @@
# Booth deploy env — copy to `.env` and fill in, then run ./scripts/booth.sh up
# (prod). Consumed by docker-compose.yml + the prod override via --env-file.
# See wiki/decisions/container-deployment.md. Do NOT commit the filled-in .env.
# --- image source (prod pulls from the house Gitea registry) ------------------
# The registry namespace; combined with the image name + TAG below.
REGISTRY=git.infra.msai.al/mca/parking_solution
# Image tag to deploy. CI publishes TWO tags per build: a MOVING branch tag
# (`dev`, and `main` once that branch is built) republished on every push, and an
# IMMUTABLE per-commit `dev-<sha>` (e.g. dev-830993b). Use the moving tag for a
# self-updating booth (`booth.sh update` pulls the latest); pin the `<branch>-<sha>`
# form for a reproducible, deterministic deploy. NOTE: `main` images only exist once
# something is built on main — until then deploy from `dev`.
TAG=dev
# --- secrets (NO safe defaults — the server refuses to boot without a real one) -
# JWT signing secret. Generate yourself, never share it: openssl rand -hex 32
# Must be 32+ chars and must NOT contain change-me / insecure / dev-only.
JWT_SECRET=
# Ledger-signing key for the append-only signed event chain. Set a DISTINCT value
# in prod (don't reuse JWT_SECRET). openssl rand -hex 32
EVENT_SIGNING_KEY=
# --- booth LAN specifics ------------------------------------------------------
# Auth cookie is HTTPS-only by default; the booth is plain HTTP behind Caddy on
# :80, so this MUST stay 0 or operators cannot log in. Set to 1 only behind TLS.
COOKIE_SECURE=0
# Remote origins the live WS feed must accept (same-origin always passes). Add any
# address admins hit the UI from beyond the booth itself, comma-separated, e.g.
# http://parksystems.msai.al (leave blank if only the local booth URL is used).
WS_ALLOWED_ORIGINS=
# Vision/ANPR. Prod override already forces the fast_alpr engine; leave VISION_ENABLED=1
# unless you are running without the camera. (Set 0 to disable the vision call entirely.)
VISION_ENABLED=1
+3 -1
View File
@@ -9,5 +9,7 @@
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`. # CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
:80 { :80 {
encode gzip encode gzip
reverse_proxy server:3000 # Host network (prod): the server runs on the host's net namespace (to reach the booth LAN /
# device VLAN), so reach it over loopback, not the compose service name `server`.
reverse_proxy 127.0.0.1:3000
} }
+5
View File
@@ -15,8 +15,13 @@ import type { SubscriptionFlow, SubscriptionMatch } from "./subscription-flow.js
// Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub // Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub
// (no registry, no network). The factory returns a fresh shot each call. // (no registry, no network). The factory returns a fresh shot each call.
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" })); const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
// The bridge now goes through captureSnapshotShared (the dedup wrapper, exercised in
// snapshot.test.ts); here it just delegates to the fake camera's captureSnapshot so this
// suite stays focused on the bridge's own match/debounce/emit logic.
vi.mock("./snapshot.js", () => ({ vi.mock("./snapshot.js", () => ({
buildCamera: () => ({ captureSnapshot }), buildCamera: () => ({ captureSnapshot }),
captureSnapshotShared: (_id: string, camera: { captureSnapshot: typeof captureSnapshot }, ctx: unknown) =>
camera.captureSnapshot(ctx as never),
})); }));
// Import AFTER the mock is registered. // Import AFTER the mock is registered.
+4 -2
View File
@@ -3,7 +3,7 @@ import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db, ty
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import { deviceEvents, type DeviceReadEvent } from "./device-events.js"; import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
import { directionOf, type FlowDirection } from "./device-resolve.js"; import { directionOf, type FlowDirection } from "./device-resolve.js";
import { buildCamera } from "./snapshot.js"; import { buildCamera, captureSnapshotShared } from "./snapshot.js";
import type { SubscriptionFlow } from "./subscription-flow.js"; import type { SubscriptionFlow } from "./subscription-flow.js";
import type { VisionClient } from "./vision-client.js"; import type { VisionClient } from "./vision-client.js";
@@ -100,7 +100,9 @@ export class AnprBridge {
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane — // "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
// the gated flow infers the verb from the camera's bound relay direction). // the gated flow infers the verb from the camera's bound relay direction).
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry"; const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
const shot = await camera.captureSnapshot({ direction }); // Shared capture (deviceId-keyed): coalesces with the advisory snapshotAsync for
// the SAME vehicle so the single-threaded camera isn't hit twice (→ HTTP 503).
const shot = await captureSnapshotShared(deviceId, camera, { direction });
const result = await this.#vision.analyze(shot.bytes, shot.contentType); const result = await this.#vision.analyze(shot.bytes, shot.contentType);
if (!result || !result.plate) return; // nothing read if (!result || !result.plate) return; // nothing read
+262
View File
@@ -0,0 +1,262 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { eq, devices, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import type { AuxOutputDevice } from "@parking/devices";
import { ButtonLightController } from "./button-light.js";
import { deviceEvents } from "./device-events.js";
import { silentLogger } from "./test-helpers.js";
// ButtonLightController: the entry-button lamp on a spare relay, driven by the RADAR
// input vs. the camera lane status. Truth table:
// radar present + lane busy -> SOLID on
// radar present + lane free -> BLINK (~1 Hz)
// otherwise -> OFF
// Lamp is a non-barrier aux output; fails OFF; de-dupes redundant writes.
let db: Db;
const CONTROLLER = "ctl-1";
const RADAR_INPUT = 2; // I2
const LAMP_RELAY = 3; // spare relay R3
/** A fake aux device recording setAux calls (channel,on). Optionally throws. */
function fakeAux(record: Array<{ ch: number; on: boolean }>, throwOnce = { v: false }): AuxOutputDevice {
return {
async setAux(channel: number, on: boolean): Promise<void> {
if (throwOnce.v) {
throwOnce.v = false;
throw new Error("UDP down");
}
record.push({ ch: channel, on });
},
};
}
beforeEach(() => {
({ db } = createTestDb());
vi.useFakeTimers();
// One controller: entry relay 1 with radar on I2; lamp on spare relay 3.
db.insert(devices).values({
id: CONTROLLER,
category: "access",
driverId: "dingtian",
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" },
{ relay: 2, direction: "exit" },
],
buttonLight: { relay: LAMP_RELAY, blinkOnMs: 500, blinkOffMs: 500 },
},
enabled: true,
}).run();
});
afterEach(() => {
vi.useRealTimers();
});
/** Emit a radar (presence input) edge for the controller. */
function radar(present: boolean): void {
deviceEvents.emitInput({
driverId: "dingtian",
deviceId: CONTROLLER,
input: RADAR_INPUT,
edge: present ? "on" : "off",
at: new Date().toISOString(),
source: "poll",
});
}
/** Emit a lane status (entry busy/free). */
function lane(entryBusy: boolean): void {
deviceEvents.emitLaneStatus({ entry: entryBusy, exit: false });
}
/** Flush the microtask queue so serialized setAux promises (and their re-pump on
* completion) settle. The lamp worker sends ONE UDP at a time and re-pumps on resolve;
* a few turns drain a burst. Needed because sends are now async (was synchronous). */
async function flush(): Promise<void> {
for (let i = 0; i < 6; i++) await Promise.resolve();
}
describe("ButtonLightController truth table", () => {
it("OFF at start (no radar, no car)", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
ctl.start();
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("off");
// confirmedOn starts null; OFF de-dupes (null !== false → one off write), so the
// device is confirmed OFF and at most one call was made.
expect(ctl.confirmedOf(CONTROLLER)).toBe(false);
ctl.stop();
});
it("radar present + lane busy -> SOLID on", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
lane(true);
radar(true);
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // device latched ON
// Solid = no blinking: advancing time produces no further sends.
const n = calls.length;
vi.advanceTimersByTime(2000);
await flush();
expect(calls.length).toBe(n);
ctl.stop();
});
it("radar present + lane free -> BLINK (toggles the device over time)", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
radar(true); // lane still free
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // on now
vi.advanceTimersByTime(500);
await flush();
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // toggled off
vi.advanceTimersByTime(500);
await flush();
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // toggled on
ctl.stop();
});
it("blink -> solid when the camera confirms a car (lane busy)", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
radar(true); // blink
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
lane(true); // camera confirms
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
// No more toggles (blink torn down) — the device stays ON over time.
vi.advanceTimersByTime(2000);
await flush();
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
ctl.stop();
});
it("radar clears -> OFF", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
lane(true);
radar(true); // solid
await flush();
radar(false); // car gone
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("off");
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // device latched OFF
ctl.stop();
});
it("de-dupes redundant writes (no spam on repeat events)", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
lane(true);
radar(true); // solid, on
await flush();
const n = calls.length;
radar(true); // same state — no new edge (present unchanged)
lane(true); // same lane — no change
await flush();
expect(calls.length).toBe(n);
ctl.stop();
});
it("fails OFF: a setAux error does not throw or escalate", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const throwOnce = { v: true };
const aux = fakeAux(calls, throwOnce);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
// First write (initial off) throws — must be swallowed.
expect(() => ctl.start()).not.toThrow();
await flush();
// Subsequent writes work; driving to solid still converges to ON.
lane(true);
radar(true);
await flush();
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
ctl.stop();
});
it("ignores controllers without a buttonLight config", () => {
// A second controller, no lamp.
db.insert(devices).values({
id: "ctl-2",
category: "access",
driverId: "dingtian",
config: { host: "10.0.0.6", relays: [{ relay: 1, direction: "entry", presenceInput: 2 }] },
enabled: true,
}).run();
const calls: Array<{ ch: number; on: boolean }> = [];
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
ctl.start();
expect(ctl.stateOf("ctl-2")).toBeNull();
ctl.stop();
});
it("picks up a button light ADDED after start() (no restart needed)", async () => {
// Fresh controller with a radar input but NO buttonLight yet.
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
// Replace the seeded controller with one that has the radar but no lamp.
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }],
},
})
.where(eq(devices.id, CONTROLLER))
.run();
ctl.start();
await flush();
// No lamp configured → an input does nothing.
radar(true);
await flush();
expect(ctl.stateOf(CONTROLLER)).toBeNull();
expect(calls.length).toBe(0);
radar(false);
await flush();
// Admin saves a button light (relay 3) — without restarting the server.
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }],
buttonLight: { relay: LAMP_RELAY, blinkOnMs: 500, blinkOffMs: 500 },
},
})
.where(eq(devices.id, CONTROLLER))
.run();
// The very next radar edge reconciles + blinks (lane still free).
radar(true);
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
ctl.stop();
});
});
+285
View File
@@ -0,0 +1,285 @@
import { eq, devices, type Db, type DeviceRow } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { hasAuxOutput, registry, type AuxOutputDevice } from "@parking/devices";
import { deviceEvents, type DeviceInputEvent, type LaneStatusEvent } from "./device-events.js";
import { buttonLightOf, relayForPresence, type ButtonLightSpec } from "./device-resolve.js";
// The entry button's 12 V light, driven by the RADAR input vs. the camera "car in
// zone" signal (the existing advisory lane-status). A disagreement indicator:
// radar present + lane busy (camera confirms a car) → SOLID on
// radar present + lane free (radar sees something, no car) → BLINK (~1 Hz)
// otherwise → OFF
// The lamp is a NON-barrier aux output (setAux latch), so holding/blinking it is fine
// — barrier-not-a-door applies only to barriers, which still only pulseOpen. The lamp
// FAILS OFF: any error / shutdown leaves it off, so a dead lamp is "no hint", never a
// misleading solid "go". See wiki/concepts/button-light-indicator.md.
type LightState = "off" | "solid" | "blink";
const DEFAULT_BLINK_MS = 500;
/** Per-controller live state for the lamp rule. */
interface LampState {
/** Lamp config (relay #, blink ms). Mutable: #reconcile updates it in place when the
* admin changes the button-light config without a restart. */
spec: ButtonLightSpec;
/** Is the radar (presence input on an entry relay) currently active? */
present: boolean;
/** The high-level state we're rendering (to avoid restarting a running blink). */
rendered: LightState | null;
/** Active blink timer, if blinking. */
blink: ReturnType<typeof setInterval> | null;
/** Blink phase (true = currently on). */
blinkOn: boolean;
/** The output we WANT the relay to be in. The serialized worker drives the device
* toward this. The blink timer only flips this flag — it never sends directly. */
desiredOn: boolean;
/** The output we last CONFIRMED on the device (after a successful send). null = unknown. */
confirmedOn: boolean | null;
/** True while a send is in flight for this lamp — serializes UDP so on/off can't
* overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */
sending: boolean;
}
/** Resolves a controller's live aux-output adapter. The default goes through the
* driver registry; tests inject a spy. Returns null when the controller has no
* aux-output capability (or won't build). */
export type AuxResolver = (controllerId: string) => AuxOutputDevice | null;
export class ButtonLightController {
readonly #db: Db;
readonly #logger: FastifyBaseLogger;
readonly #resolveAux: AuxResolver;
/** Per-controller state, keyed by controller deviceId. */
readonly #lamps = new Map<string, LampState>();
/** Latest lane status (entry busy = a camera-confirmed car in the entry zone). */
#entryBusy = false;
/** Controllers we've already warned lack the aux-output capability (warn once). */
readonly #warned = new Set<string>();
#unsubInput: (() => void) | null = null;
#unsubLane: (() => void) | null = null;
constructor(db: Db, logger: FastifyBaseLogger, resolveAux?: AuxResolver) {
this.#db = db;
this.#logger = logger;
this.#resolveAux = resolveAux ?? ((id) => this.#auxFromRegistry(id));
}
/** Subscribe to radar input edges + lane status, and initialise every lamp OFF. */
start(): void {
this.#reconcile();
// All lamps start OFF (known-safe baseline) regardless of prior device state.
for (const [controllerId, lamp] of this.#lamps) this.#apply(controllerId, lamp);
this.#unsubInput = deviceEvents.onInput((e) => this.#onInput(e));
this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s));
}
/** Reconcile the lamp map with the CURRENT device config (the booth can add/change a
* button light without a server restart). Mirrors DeviceMonitor, which re-reads the
* device set each tick. Adds lamps for newly-configured controllers, updates the spec
* (relay #, blink ms) in place — preserving live `present`/blink state — and drops
* lamps whose controller lost its buttonLight or was disabled. Called at start() and
* before handling each event, so a just-saved lamp takes effect immediately. */
#reconcile(): void {
const rows = this.#db.select().from(devices).where(eq(devices.category, "access")).all();
const seen = new Set<string>();
for (const row of rows) {
if (!row.enabled) continue;
const spec = buttonLightOf(row);
if (!spec) continue;
seen.add(row.id);
const existing = this.#lamps.get(row.id);
if (existing) {
existing.spec = spec; // pick up a changed relay # / blink cadence
} else {
this.#lamps.set(row.id, {
spec,
present: false,
rendered: null,
blink: null,
blinkOn: false,
desiredOn: false,
confirmedOn: null,
sending: false,
});
}
}
// Drop lamps whose controller no longer declares one (or was disabled/removed).
for (const [id, lamp] of this.#lamps) {
if (seen.has(id)) continue;
if (lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = null;
}
this.#finalOff(id, lamp); // best-effort fail-OFF before forgetting it
this.#lamps.delete(id);
}
}
/** A radar (presence) edge updates that controller's `present` flag. We resolve the
* edge the SAME way the entry flow does (relayForPresence on an entry/both relay),
* so the lamp and the one-car-one-ticket gate always agree on "a car is here". */
#onInput(e: DeviceInputEvent): void {
// Reconcile first so a lamp added/changed since boot (no restart) is picked up.
this.#reconcile();
const lamp = this.#lamps.get(e.deviceId);
if (!lamp) return; // no lamp on this controller
const presence = relayForPresence(this.#db, e.deviceId, e.input);
if (!presence) return; // not the presence/radar terminal
const present = e.edge === "on";
if (present === lamp.present) return;
lamp.present = present;
this.#apply(e.deviceId, lamp);
}
/** Lane status changed: entry busy = a camera-confirmed car in the entry zone. */
#onLane(s: LaneStatusEvent): void {
if (s.entry === this.#entryBusy) return;
this.#entryBusy = s.entry;
// Re-render every lamp (the camera signal is site-wide entry status).
for (const [controllerId, lamp] of this.#lamps) this.#apply(controllerId, lamp);
}
/** Compute + render the target state for one lamp. Drives are fire-and-forget (the
* timer/state machine is synchronous; the UDP write resolves on its own). */
#apply(controllerId: string, lamp: LampState): void {
const target: LightState = !lamp.present ? "off" : this.#entryBusy ? "solid" : "blink";
if (target === lamp.rendered) return; // already rendering this state
// Tear down any running blink before switching states.
if (lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = null;
}
lamp.rendered = target;
if (target === "off") {
lamp.desiredOn = false;
this.#pump(controllerId, lamp);
} else if (target === "solid") {
lamp.desiredOn = true;
this.#pump(controllerId, lamp);
} else {
// BLINK: a wall-clock timer flips ONLY the desired flag; #pump does the actual
// (serialized) UDP send. A symmetric cadence uses one interval; an asymmetric one
// re-arms each phase with its own duration. Sends never overlap or reorder, so the
// relay can't get stuck on a stale packet.
const onMs = lamp.spec.blinkOnMs && lamp.spec.blinkOnMs > 0 ? lamp.spec.blinkOnMs : DEFAULT_BLINK_MS;
const offMs = lamp.spec.blinkOffMs && lamp.spec.blinkOffMs > 0 ? lamp.spec.blinkOffMs : DEFAULT_BLINK_MS;
lamp.blinkOn = true;
lamp.desiredOn = true;
const tick = () => {
lamp.blinkOn = !lamp.blinkOn;
lamp.desiredOn = lamp.blinkOn;
this.#pump(controllerId, lamp);
if (onMs !== offMs && lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs);
lamp.blink.unref?.();
}
};
lamp.blink = setInterval(tick, onMs);
lamp.blink.unref?.();
this.#pump(controllerId, lamp);
}
}
/** Serialized per-lamp worker: drive the relay toward `desiredOn`, one UDP send at a
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
* the relay stuck on a stale packet. Here a single in-flight send is guaranteed
* (`sending` guard); when it resolves, if the desired state moved on we send again —
* so the LAST desired state is always the one finally asserted on the device. */
#pump(controllerId: string, lamp: LampState): void {
if (lamp.sending) return; // a send is already in flight; it'll re-check on completion
if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP
const aux = this.#resolveAux(controllerId);
if (!aux) return;
const target = lamp.desiredOn;
lamp.sending = true;
void aux
.setAux(lamp.spec.relay, target)
.then(() => {
lamp.confirmedOn = target;
})
.catch((err: unknown) => {
// Leave confirmedOn unchanged so the next pump retries this state. Never escalates.
this.#logger.error(`button-light setAux failed (${controllerId} R${lamp.spec.relay}): ${(err as Error).message}`);
})
.finally(() => {
lamp.sending = false;
// Desired state may have changed (or the send failed) while we were busy —
// re-pump to converge. This is what makes the final state authoritative.
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(controllerId, lamp);
});
}
/** Build the live aux-output adapter for a controller, or null (logged once). */
#auxFromRegistry(controllerId: string): AuxOutputDevice | null {
const row = this.#db.select().from(devices).where(eq(devices.id, controllerId)).get();
if (!row) return null;
const driver = registry.get(row.driverId);
if (!driver) return null;
let device: unknown;
try {
device = driver.create(row.config as never);
} catch {
return null;
}
if (!hasAuxOutput(device)) {
if (!this.#warned.has(controllerId)) {
this.#warned.add(controllerId);
this.#logger.warn(`button-light: controller ${controllerId} (${row.driverId}) has no aux-output — lamp ignored`);
}
return null;
}
return device;
}
/** Unsubscribe, stop all blink timers, and best-effort drive every lamp OFF. */
stop(): void {
this.#unsubInput?.();
this.#unsubLane?.();
this.#unsubInput = null;
this.#unsubLane = null;
for (const [controllerId, lamp] of this.#lamps) {
if (lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = null;
}
// Best-effort fail-OFF on shutdown.
this.#finalOff(controllerId, lamp);
}
}
/** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired
* OFF and pump. The serialized worker still applies, so this can't collide with an
* in-flight send — it converges to OFF. */
#finalOff(controllerId: string, lamp: LampState): void {
lamp.desiredOn = false;
this.#pump(controllerId, lamp);
}
/** Test seam: current high-level state being rendered for a controller. */
stateOf(controllerId: string): LightState | null {
return this.#lamps.get(controllerId)?.rendered ?? null;
}
/** Test seam: the state last CONFIRMED on the device for a controller (after a
* successful send). null = unknown / nothing sent yet. */
confirmedOf(controllerId: string): boolean | null {
return this.#lamps.get(controllerId)?.confirmedOn ?? null;
}
}
/** Build a controller row's live aux device (exported for reuse/tests). */
export function buildAux(db: Db, row: DeviceRow): AuxOutputDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
const device = driver.create(row.config as never);
return hasAuxOutput(device) ? device : null;
} catch {
return null;
}
}
+39 -3
View File
@@ -33,12 +33,32 @@ export interface RelaySpec {
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md. * Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
*/ */
readonly presenceInput?: number; readonly presenceInput?: number;
/** What kind of sensor is on `presenceInput` — an induction LOOP or a RADAR. Label
* only (the gate behaviour is identical); drives UI copy + telemetry. Default loop. */
readonly presenceKind?: "loop" | "radar";
/** The presence terminal's ACTIVE level is LOW (idles HIGH). Maps to the driver's
* per-input `inputActiveLow` override so a radar wired opposite the button reads
* right. See wiki/entities/hikvision-radar.md. */
readonly presenceActiveLow?: boolean;
readonly entryCooldownSec?: number; readonly entryCooldownSec?: number;
} }
/** A non-barrier indicator lamp wired to a spare relay (e.g. the entry button's
* 12 V light). Driven by the server LightController off the radar + lane status —
* NOT a barrier. See wiki/concepts/button-light-indicator.md. */
export interface ButtonLightSpec {
/** 1-based spare relay channel the lamp is wired to. */
readonly relay: number;
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
readonly blinkOnMs?: number;
readonly blinkOffMs?: number;
}
/** Access controller config (the `relays[]` map + connection fields). */ /** Access controller config (the `relays[]` map + connection fields). */
interface AccessConfig { interface AccessConfig {
readonly relays?: RelaySpec[]; readonly relays?: RelaySpec[];
/** Optional button-lamp output on a spare relay. */
readonly buttonLight?: ButtonLightSpec;
readonly [k: string]: unknown; readonly [k: string]: unknown;
} }
@@ -60,9 +80,11 @@ export interface ResolvedRelay {
readonly controller: DeviceRow; readonly controller: DeviceRow;
readonly relay: number; readonly relay: number;
readonly direction: Direction; readonly direction: Direction;
/** 1-based presence-loop input gating this relay's entry (when wired). */ /** 1-based presence input gating this relay's entry (loop or radar, when wired). */
readonly presenceInput?: number; readonly presenceInput?: number;
/** Cooldown seconds suppressing repeat presses (fallback when no presence loop). */ /** Sensor kind on the presence input (loop|radar) — telemetry/label only. */
readonly presenceKind?: "loop" | "radar";
/** Cooldown seconds suppressing repeat presses (fallback when no presence input). */
readonly entryCooldownSec?: number; readonly entryCooldownSec?: number;
} }
@@ -102,6 +124,7 @@ export function relayForButton(db: Db, controllerId: string, terminal: number):
relay: spec.relay, relay: spec.relay,
direction: spec.direction, direction: spec.direction,
presenceInput: spec.presenceInput, presenceInput: spec.presenceInput,
presenceKind: spec.presenceKind ?? "loop",
entryCooldownSec: spec.entryCooldownSec, entryCooldownSec: spec.entryCooldownSec,
}; };
} }
@@ -122,7 +145,20 @@ export function relayForPresence(db: Db, controllerId: string, terminal: number)
const spec = relaysOf(row).find((r) => r.presenceInput === terminal); const spec = relaysOf(row).find((r) => r.presenceInput === terminal);
if (!spec) return null; if (!spec) return null;
if (spec.direction !== "entry" && spec.direction !== "both") return null; if (spec.direction !== "entry" && spec.direction !== "both") return null;
return { controller: row, relay: spec.relay, direction: spec.direction }; return {
controller: row,
relay: spec.relay,
direction: spec.direction,
presenceInput: spec.presenceInput,
presenceKind: spec.presenceKind ?? "loop",
};
}
/** The button-lamp output declared on an access controller, or null. */
export function buttonLightOf(row: DeviceRow): ButtonLightSpec | null {
const cfg = row.config as AccessConfig;
const bl = cfg.buttonLight;
return bl && typeof bl.relay === "number" ? bl : null;
} }
/** /**
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it } from "vitest";
import { randomUUID } from "node:crypto";
import { devices, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { storedSecrets } from "./setup.js";
// storedSecrets re-merges a device's machine-only secrets (relayPassword/pushPassword)
// into a test/save — but ONLY when the submitted config addresses the SAME device at the
// SAME host/port. This guards against a redirected probe exfiltrating the secret to an
// attacker host (an admin keeps a real device id but swaps the host). The booth operator
// is the threat-model adversary, so an authenticated-admin redirect must NOT leak.
let db: Db;
const ID = "ctl-secret";
const HOST = "10.0.10.5";
beforeEach(() => {
({ db } = createTestDb());
db.insert(devices).values({
id: ID,
category: "access",
driverId: "dingtian",
config: { host: HOST, binaryPort: 60000, relayPassword: 1996, pushPassword: "p-secret" },
enabled: true,
}).run();
});
describe("storedSecrets identity guard", () => {
it("re-merges secrets when host/port/driver match the stored device", () => {
const out = storedSecrets(db, ID, "dingtian", { host: HOST, binaryPort: 60000 });
expect(out.relayPassword).toBe(1996);
expect(out.pushPassword).toBe("p-secret");
});
it("re-merges when identity fields are OMITTED (fall back to the stored device)", () => {
const out = storedSecrets(db, ID, "dingtian", {});
expect(out.relayPassword).toBe(1996);
});
it("REFUSES secrets when the host is redirected (exfiltration attempt)", () => {
const out = storedSecrets(db, ID, "dingtian", { host: "10.66.66.66", binaryPort: 60000 });
expect(out).toEqual({});
});
it("REFUSES secrets when a control port is changed", () => {
const out = storedSecrets(db, ID, "dingtian", { host: HOST, binaryPort: 9999 });
expect(out).toEqual({});
});
it("REFUSES secrets when the driver doesn't match the stored row", () => {
const out = storedSecrets(db, ID, "stub-access", { host: HOST });
expect(out).toEqual({});
});
it("returns nothing for an unknown device id", () => {
expect(storedSecrets(db, randomUUID(), "dingtian", { host: HOST })).toEqual({});
});
});
+124 -2
View File
@@ -7,6 +7,7 @@ import {
isCamera, isCamera,
isDiscoverable, isDiscoverable,
isHardenable, isHardenable,
isPrinter,
registerBuiltinDrivers, registerBuiltinDrivers,
registry, registry,
setDeviceLogSink, setDeviceLogSink,
@@ -36,6 +37,11 @@ interface AssignBody {
interface TestBody { interface TestBody {
driverId: string; driverId: string;
config: Record<string, string | number | boolean>; config: Record<string, string | number | boolean>;
/** When editing an EXISTING device, its id — so the test re-merges the stored
* machine secrets (relayPassword/pushPassword) the client never received. Without
* this, testing an edited device would send no relay password → the device ignores
* the probe → a false "offline". Omitted when testing a brand-new device. */
id?: string;
} }
// Config keys that hold MACHINE-ONLY secrets — never sent back to the client. // Config keys that hold MACHINE-ONLY secrets — never sent back to the client.
@@ -54,6 +60,41 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
return out; return out;
} }
// Connection-identity keys: the fields that decide WHERE a probe is sent. A stored
// secret may only be re-merged when these match the stored row — otherwise an admin
// could point a test at an attacker host while keeping a real device id and have the
// secret sent there (exfiltration). host/port/binaryPort/httpPort cover the Dingtian's
// UDP + CGI targets; serial covers serial-bound readers.
const IDENTITY_KEYS = ["host", "port", "binaryPort", "httpPort", "serial"] as const;
/** Stored machine-only secrets (relayPassword/pushPassword) for a device `id`, but ONLY
* when the submitted config addresses the SAME device — same driver, and every
* connection-identity field (host/port/…) that the submitted config sets equals the
* stored value. If the admin redirected the probe (different host/port) or the driver
* doesn't match, NO secret is returned: they must re-enter it explicitly. This stops a
* redirected test from exfiltrating the secret to an attacker host. */
export function storedSecrets(
db: Db,
id: string,
driverId: string,
submitted: Record<string, unknown>,
): Record<string, unknown> {
const row = db.select().from(devices).where(eq(devices.id, id)).get();
if (!row || row.driverId !== driverId) return {};
const cfg = row.config as Record<string, unknown>;
// Any identity field the client SENT must equal the stored value. (A field the client
// omits falls back to the stored device, so it can't be used to redirect.)
for (const k of IDENTITY_KEYS) {
const sent = submitted[k];
if (sent !== undefined && sent !== "" && String(sent) !== String(cfg[k] ?? "")) {
return {};
}
}
const out: Record<string, unknown> = {};
for (const k of SECRET_CONFIG_KEYS) if (cfg[k] !== undefined) out[k] = cfg[k];
return out;
}
/** Result of the device configure pipeline: a ready-to-persist config, or an /** Result of the device configure pipeline: a ready-to-persist config, or an
* HTTP error to send back. Shared by assign (create) and patch (edit). */ * HTTP error to send back. Shared by assign (create) and patch (edit). */
type ConfigureOutcome = type ConfigureOutcome =
@@ -249,13 +290,30 @@ export async function setupRoutes(
"/api/setup/test", "/api/setup/test",
{ preHandler: adminGuard }, { preHandler: adminGuard },
async (req, reply) => { async (req, reply) => {
const { driverId, config } = req.body; const { driverId, config, id } = req.body;
const driver = registry.get(driverId); const driver = registry.get(driverId);
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` }); if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
// When editing an existing device, re-merge its stored machine secrets (e.g.
// relayPassword) — redacted from the client, so the submitted config omits them.
// Submitted values win (an admin can override), but a blank/0 field falls back to
// the stored secret so the probe authenticates. Without this, an edited Dingtian
// tests with no relay password → false "offline". The submitted-value-wins rule:
// only fill a secret from the store when the form didn't send a real one.
// Re-merge stored secrets ONLY when this addresses the same device at the same
// host/port (storedSecrets enforces identity) — so a redirected probe can't leak
// the secret to an attacker host. Submitted values still win.
const merged: Record<string, string | number | boolean | undefined> = { ...config };
if (id) {
for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) {
const sent = merged[k];
if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number;
}
}
let device; let device;
try { try {
device = registry.create(driverId, config); device = registry.create(driverId, merged as Record<string, string | number | boolean>);
} catch (err) { } catch (err) {
return reply.code(400).send({ error: (err as Error).message }); return reply.code(400).send({ error: (err as Error).message });
} }
@@ -334,6 +392,70 @@ export async function setupRoutes(
}, },
); );
// Print a TEST SLIP on a printer config WITHOUT saving. healthCheck only opens the
// transport (TCP connect / USB open) — it proves reachability, NOT that paper feeds
// and the head fires. This pushes a real short slip through the device-agnostic
// printReport(), so the admin can physically confirm the printer is live (the USB
// /dev/usb/lpN path or the network printer). Fail-soft like test-anpr: a print error
// is reported, never a 500. Mirrors /test's stored-secret re-merge so an edited
// network printer still authenticates.
app.post<{ Body: TestBody }>(
"/api/setup/test-print",
{ preHandler: adminGuard },
async (req, reply) => {
const { driverId, config, id } = req.body;
const driver = registry.get(driverId);
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
if (driver.category !== "printer") {
return reply.code(400).send({ error: `driver ${driverId} is not a printer` });
}
const merged: Record<string, string | number | boolean | undefined> = { ...config };
if (id) {
for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) {
const sent = merged[k];
if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number;
}
}
let device;
try {
device = registry.create(driverId, merged as Record<string, string | number | boolean>);
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
if (!isPrinter(device)) {
return reply.code(400).send({ error: `driver ${driverId} cannot print` });
}
const startedAt = Date.now();
try {
await device.printReport({
title: "TEST PRINT",
lines: [
"Parking System",
"Printer test slip",
new Date().toLocaleString("sv"), // YYYY-MM-DD HH:MM:SS, locale-stable
"",
"If you can read this, the",
"printer is connected and",
"printing correctly.",
],
});
} catch (err) {
// The failure we're testing for (paper out, head fault, transport drop) —
// report it, don't 500.
return reply.send({
ok: false,
reason: "print-failed",
detail: (err as Error).message,
tookMs: Date.now() - startedAt,
});
}
return reply.send({ ok: true, tookMs: Date.now() - startedAt });
},
);
// Candidate backend IPs the device can push to, for a given device host. The // Candidate backend IPs the device can push to, for a given device host. The
// wizard pre-fills with the on-subnet one and lets the admin override (matters // wizard pre-fills with the on-subnet one and lets the admin override (matters
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md. // on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
+8
View File
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db"; import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js"; import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
import { deviceEvents } from "./device-events.js"; import { deviceEvents } from "./device-events.js";
import { ButtonLightController } from "./button-light.js";
import { EntryFlow } from "./entry-flow.js"; import { EntryFlow } from "./entry-flow.js";
import { EventLog } from "./event-log.js"; import { EventLog } from "./event-log.js";
import { ExitFlow } from "./exit-flow.js"; import { ExitFlow } from "./exit-flow.js";
@@ -188,6 +189,13 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
}); });
app.addHook("onClose", async () => unsubscribeEntry()); app.addHook("onClose", async () => unsubscribeEntry());
// Button-light indicator: drives the entry button's lamp on a spare relay from the
// RADAR input vs. the camera lane status (blink = radar-only, solid = radar+camera,
// off otherwise). A non-barrier aux output; fails OFF. See button-light.ts.
const buttonLight = new ButtonLightController(db, app.log);
buttonLight.start();
app.addHook("onClose", async () => buttonLight.stop());
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the // Read-driven flows: a credential read (ticket scan / plate / card) routes via the
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the // dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
// transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts, // transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it, vi } from "vitest";
import type { CameraDevice, Snapshot } from "@parking/devices";
import { captureSnapshotShared } from "./snapshot.js";
// captureSnapshotShared: one HTTP pull per camera per vehicle. A Hikvision unit serves
// snapshots SINGLE-THREADED (a 2nd concurrent GET → HTTP 503). On an entry the ANPR
// bridge AND the advisory snapshotAsync both capture the same camera within ~1s, each
// from a SEPARATE adapter instance — so this deviceId-keyed cache coalesces in-flight
// captures and serves a brief freshness window, collapsing the two into one real pull.
// (Root cause of the slow 2026-06-25 subscriber entry.)
/** A fake camera whose captureSnapshot is controllable (count calls, delay, fail). */
function fakeCamera(opts: { delayMs?: number; fail?: boolean; tag?: string } = {}): {
camera: CameraDevice;
calls: () => number;
} {
let calls = 0;
const tag = opts.tag ?? "x";
const camera = {
async captureSnapshot(): Promise<Snapshot> {
calls++;
if (opts.delayMs) await new Promise((r) => setTimeout(r, opts.delayMs));
if (opts.fail) throw new Error("HTTP 503");
// Tag distinguishes frames from different cameras (the per-camera keying test).
return { bytes: Buffer.from(`shot-${tag}-${calls}`), contentType: "image/jpeg", capturedAt: new Date().toISOString() };
},
} as unknown as CameraDevice;
return { camera, calls: () => calls };
}
/** A unique deviceId per test so the module-level cache never bleeds across cases. */
function id(): string {
return `cam-${Math.random().toString(36).slice(2)}`;
}
describe("captureSnapshotShared", () => {
it("coalesces CONCURRENT captures into a single hardware pull (the 503 fix)", async () => {
const { camera, calls } = fakeCamera({ delayMs: 20 });
const dev = id();
// The bridge and the advisory path fire at nearly the same instant.
const [a, b] = await Promise.all([
captureSnapshotShared(dev, camera, { direction: "entry" }),
captureSnapshotShared(dev, camera, { direction: "entry" }),
]);
expect(calls()).toBe(1); // ONE GET, not two — no concurrent 503
expect(a.bytes.equals(b.bytes)).toBe(true); // both got the same frame
});
it("reuses a fresh capture within the TTL (sequential, same vehicle)", async () => {
const { camera, calls } = fakeCamera();
const dev = id();
const a = await captureSnapshotShared(dev, camera, { direction: "entry" });
const b = await captureSnapshotShared(dev, camera, { direction: "entry" }); // ~0ms later
expect(calls()).toBe(1); // 2nd call served from the freshness cache
expect(a.bytes.equals(b.bytes)).toBe(true);
});
it("pulls AGAIN after the TTL lapses (a later, different vehicle)", async () => {
vi.useFakeTimers();
try {
const { camera, calls } = fakeCamera();
const dev = id();
await captureSnapshotShared(dev, camera, { direction: "entry" });
expect(calls()).toBe(1);
await vi.advanceTimersByTimeAsync(2000); // past SNAPSHOT_TTL_MS (1500)
await captureSnapshotShared(dev, camera, { direction: "entry" });
expect(calls()).toBe(2); // stale → a real new pull (never a stale frame for a new car)
} finally {
vi.useRealTimers();
}
});
it("does NOT cache a failure — the next caller retries", async () => {
const dev = id();
const failing = fakeCamera({ fail: true });
await expect(captureSnapshotShared(dev, failing.camera, { direction: "entry" })).rejects.toThrow("503");
// A subsequent capture (camera recovered) must actually pull, not inherit the error.
const ok = fakeCamera();
const shot = await captureSnapshotShared(dev, ok.camera, { direction: "entry" });
expect(shot.bytes.toString()).toBe("shot-x-1");
expect(ok.calls()).toBe(1);
});
it("keys by deviceId — different cameras never share a frame", async () => {
const c1 = fakeCamera({ tag: "A" });
const c2 = fakeCamera({ tag: "B" });
const s1 = await captureSnapshotShared("cam-A", c1.camera, { direction: "entry" });
const s2 = await captureSnapshotShared("cam-B", c2.camera, { direction: "entry" });
expect(c1.calls()).toBe(1);
expect(c2.calls()).toBe(1);
expect(s1.bytes.equals(s2.bytes)).toBe(false);
});
});
+68 -2
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db"; import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
import { registry, type CameraDevice } from "@parking/devices"; import { registry, type CameraDevice, type Snapshot } from "@parking/devices";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import { devicesByDirection, type FlowDirection } from "./device-resolve.js"; import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
import type { VisionClient } from "./vision-client.js"; import type { VisionClient } from "./vision-client.js";
@@ -62,7 +62,9 @@ export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
return null; return null;
} }
try { try {
const shot = await camera.captureSnapshot({ direction }); // Shared capture: if the ANPR bridge just pulled this camera's frame for the
// same vehicle, reuse it instead of a 2nd concurrent GET (which 503s).
const shot = await captureSnapshotShared(row.id, camera, { direction });
const id: string = randomUUID(); const id: string = randomUUID();
db.insert(snapshots) db.insert(snapshots)
.values({ .values({
@@ -153,6 +155,70 @@ export function buildCamera(row: { driverId: string; config: unknown }): CameraD
} }
} }
// --- shared snapshot capture (one HTTP pull per camera per vehicle) -----------
// A Hikvision camera serves /ISAPI/.../picture SINGLE-THREADED: two concurrent
// snapshot GETs to the same unit return HTTP 503 "service busy". On a vehicle entry
// TWO paths capture the SAME camera within ~1s — the ANPR bridge (barrier-driving,
// anpr-entry.ts) and the advisory snapshotAsync (evidence + telemetry, below). They
// each `buildCamera()` a SEPARATE adapter instance, so a per-instance cache can't
// dedupe them. This module-level, deviceId-keyed cache does: it coalesces in-flight
// captures (the 2nd caller awaits the 1st's pull) AND serves a result captured within
// SNAPSHOT_TTL_MS, so the bridge + advisory share ONE frame instead of colliding into
// a 503 (which then burned the bridge's 12s debounce → the slow entry observed
// 2026-06-25; see wiki/concepts/lane-presence-and-anpr-entry.md).
/** How long a fresh capture is reused for the same camera. A car is one event for a
* couple of seconds; 1.5s comfortably spans the bridge→advisory gap without ever
* serving a stale frame for a *different* vehicle (entries are seconds apart). */
const SNAPSHOT_TTL_MS = 1500;
interface CacheEntry {
/** A capture in flight — concurrent callers await this instead of issuing a 2nd GET. */
inflight?: Promise<Snapshot>;
/** The last SUCCESSFUL capture + when it resolved, for the freshness window. */
last?: { shot: Snapshot; at: number };
}
const snapshotCache = new Map<string, CacheEntry>();
/**
* Capture a snapshot for a camera, sharing ONE HTTP pull across concurrent/near-
* simultaneous callers (the ANPR bridge and the advisory snapshot). Same contract as
* `camera.captureSnapshot` (throws on failure) — a failed pull is NOT cached, so the
* next caller retries rather than inheriting the error. Key by the stable `deviceId`.
*/
export function captureSnapshotShared(
deviceId: string,
camera: CameraDevice,
ctx: { direction: FlowDirection },
): Promise<Snapshot> {
const now = Date.now();
let entry = snapshotCache.get(deviceId);
if (!entry) {
entry = {};
snapshotCache.set(deviceId, entry);
}
// Fresh enough → reuse the last frame (same vehicle, no second hardware hit).
if (entry.last && now - entry.last.at < SNAPSHOT_TTL_MS) {
return Promise.resolve(entry.last.shot);
}
// A capture is already running → join it (this is what prevents the 503 collision).
if (entry.inflight) return entry.inflight;
// Otherwise issue the single real pull; record it as the in-flight promise.
const pull = camera
.captureSnapshot(ctx)
.then((shot) => {
entry.last = { shot, at: Date.now() };
return shot;
})
.finally(() => {
// Clear the in-flight slot whether it resolved or threw; a failure is never cached.
if (entry.inflight === pull) entry.inflight = undefined;
});
entry.inflight = pull;
return pull;
}
function recordFailure( function recordFailure(
db: Db, db: Db,
direction: FlowDirection, direction: FlowDirection,
+5 -3
View File
@@ -3,14 +3,16 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"//": "Thin shim so this Python service is a first-class node in the Turbo task graph (it is NOT a JS package — deps are managed by uv/pyproject.toml). Each script shells to Python tooling. See wiki/decisions/vision-service-packaging.md.", "//": "Thin shim so this Python service is a first-class node in the Turbo task graph (it is NOT a JS package — deps are managed by uv/pyproject.toml). Each script shells to Python tooling. See wiki/decisions/vision-service-packaging.md.",
"//alpr": "DEV self-heals real ANPR: `dev`/`start` run `uv sync --extra alpr` FIRST, because a plain `uv run` re-resolves the venv to the lockfile DEFAULTS and STRIPS fast-alpr (the cause of silent 'snapshot but no plate' after a prior pnpm dev). Syncing the extra here guarantees the recognizer survives every run. Use `dev:stub` for a lean, model-free local run. The BOOTH is unaffected — it runs the Docker image, which bakes `--extra alpr` at build (see Dockerfile + docker-compose.prod.yml).",
"scripts": { "scripts": {
"dev": "uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089", "dev": "uv sync --extra alpr && uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
"start": "uv run uvicorn vision_service.app:app --host 0.0.0.0 --port 8089", "dev:stub": "uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
"start": "uv sync --extra alpr && uv run uvicorn vision_service.app:app --host 0.0.0.0 --port 8089",
"lint": "uv run ruff check .", "lint": "uv run ruff check .",
"format": "uv run ruff format .", "format": "uv run ruff format .",
"typecheck": "uv run mypy vision_service", "typecheck": "uv run mypy vision_service",
"test": "uv run pytest -q", "test": "uv run pytest -q",
"recognize": "uv run python -m vision_service.cli", "recognize": "uv sync --extra alpr && uv run python -m vision_service.cli",
"build": "echo 'no build step (Python service; models fetched at deploy)'" "build": "echo 'no build step (Python service; models fetched at deploy)'"
} }
} }
+389 -35
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, Fragment } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
assignDevice, assignDevice,
@@ -9,10 +9,13 @@ import {
fetchState, fetchState,
testAnpr, testAnpr,
testDevice, testDevice,
testPrint,
unassignDevice, unassignDevice,
type AnprTestResult, type AnprTestResult,
type PrintTestResult,
type Assignment, type Assignment,
type BackendIpCandidate, type BackendIpCandidate,
type ButtonLightSpec,
type Catalog, type Catalog,
type CatalogEntry, type CatalogEntry,
type DeviceCategory, type DeviceCategory,
@@ -270,11 +273,24 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
if (assignment.category === "access") { if (assignment.category === "access") {
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : []; const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>; if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
const bl = cfg.buttonLight as ButtonLightSpec | undefined;
return ( return (
<span className="flex gap-1.5"> <span className="flex flex-wrap gap-1.5">
{relays.map((r) => ( {relays.map((r) => {
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} /> const presence = r.presenceInput
))} ? `·${r.presenceKind === "radar" ? "radar" : "loop"}${r.presenceInput}`
: "";
return (
<DirectionBadge
key={r.relay}
direction={r.direction}
label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}${presence}`}
/>
);
})}
{bl?.relay != null && (
<DirectionBadge direction="both" label={`lamp·R${bl.relay}`} />
)}
</span> </span>
); );
} }
@@ -325,6 +341,7 @@ function DeviceForm({
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id); const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
const isController = category === "access"; const isController = category === "access";
const isCamera = category === "camera"; const isCamera = category === "camera";
const isPrinter = category === "printer";
// ANPR opt-in for a camera: when true, the VisionReader polls this camera for plates // ANPR opt-in for a camera: when true, the VisionReader polls this camera for plates
// (config.anpr). Off by default. See wiki/entities/opencv-anpr-service.md. // (config.anpr). Off by default. See wiki/entities/opencv-anpr-service.md.
const [anpr, setAnpr] = useState<boolean>(editCfg?.anpr === true); const [anpr, setAnpr] = useState<boolean>(editCfg?.anpr === true);
@@ -345,6 +362,11 @@ function DeviceForm({
const [relays, setRelays] = useState<RelaySpec[]>(() => const [relays, setRelays] = useState<RelaySpec[]>(() =>
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }], Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
); );
// Controller-level button-lamp output (a spare relay), driven by the radar + camera.
const [buttonLight, setButtonLight] = useState<ButtonLightSpec | null>(() => {
const bl = editCfg?.buttonLight as ButtonLightSpec | undefined;
return bl && typeof bl.relay === "number" ? bl : null;
});
// Bound devices: which controller + relay this device sits at. // Bound devices: which controller + relay this device sits at.
const [controllerId, setControllerId] = useState<string>( const [controllerId, setControllerId] = useState<string>(
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "", typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
@@ -357,9 +379,14 @@ function DeviceForm({
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null); const [testError, setTestError] = useState<string | null>(null);
// ANPR probe (camera + anpr on): snapshot → vision analyze, reported below. // ANPR probe (camera + anpr on): snapshot → vision analyze, reported below.
const [alarmUrlCopied, setAlarmUrlCopied] = useState(false);
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null); const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
const [anprTesting, setAnprTesting] = useState(false); const [anprTesting, setAnprTesting] = useState(false);
const [anprError, setAnprError] = useState<string | null>(null); const [anprError, setAnprError] = useState<string | null>(null);
const [printResult, setPrintResult] = useState<PrintTestResult | null>(null);
const [printTesting, setPrintTesting] = useState(false);
const [printError, setPrintError] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [found, setFound] = useState<DiscoveredDevice[] | null>(null); const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
@@ -368,22 +395,31 @@ function DeviceForm({
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null); const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
const [backendIp, setBackendIp] = useState<string>(""); const [backendIp, setBackendIp] = useState<string>("");
// The server's listen port (e.g. 3000) the device must POST to — NOT the page's
// port (the SPA may be served by Vite on :5173 in dev, or behind a proxy on :80).
// Comes from the same /api/setup/backend-ips probe as the IPs.
const [backendPort, setBackendPort] = useState<number | null>(null);
const testedHost = tested ? String(mergedScalarConfig().host ?? "") : ""; const testedHost = tested ? String(mergedScalarConfig().host ?? "") : "";
useEffect(() => { useEffect(() => {
if (!testedHost || !pushesToBackend) { if (!testedHost || !pushesToBackend) {
setBackendIps(null); setBackendIps(null);
setBackendPort(null);
return; return;
} }
let live = true; let live = true;
fetchBackendIps(testedHost) fetchBackendIps(testedHost)
.then(({ candidates }) => { .then(({ candidates, port }) => {
if (!live) return; if (!live) return;
setBackendIps(candidates); setBackendIps(candidates);
setBackendPort(port);
setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || ""); setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || "");
}) })
.catch(() => { .catch(() => {
if (live) setBackendIps(null); if (live) {
setBackendIps(null);
setBackendPort(null);
}
}); });
return () => { return () => {
live = false; live = false;
@@ -442,8 +478,18 @@ function DeviceForm({
direction: r.direction, direction: r.direction,
...(r.button ? { button: r.button } : {}), ...(r.button ? { button: r.button } : {}),
...(r.presenceInput ? { presenceInput: r.presenceInput } : {}), ...(r.presenceInput ? { presenceInput: r.presenceInput } : {}),
...(r.presenceInput && r.presenceKind ? { presenceKind: r.presenceKind } : {}),
...(r.presenceInput && r.presenceActiveLow ? { presenceActiveLow: true } : {}),
...(r.entryCooldownSec ? { entryCooldownSec: r.entryCooldownSec } : {}), ...(r.entryCooldownSec ? { entryCooldownSec: r.entryCooldownSec } : {}),
})); }));
// Button-lamp output (a spare relay), persisted only when a relay is chosen.
if (buttonLight && buttonLight.relay) {
out.buttonLight = {
relay: buttonLight.relay,
...(buttonLight.blinkOnMs ? { blinkOnMs: buttonLight.blinkOnMs } : {}),
...(buttonLight.blinkOffMs ? { blinkOffMs: buttonLight.blinkOffMs } : {}),
};
}
} else if (controllerId && boundRelay !== "") { } else if (controllerId && boundRelay !== "") {
out.controllerId = controllerId; out.controllerId = controllerId;
out.relay = boundRelay; out.relay = boundRelay;
@@ -467,7 +513,7 @@ function DeviceForm({
setTestError(null); setTestError(null);
setTested(null); setTested(null);
try { try {
setTested(await testDevice(selected.id, mergedScalarConfig())); setTested(await testDevice(selected.id, mergedScalarConfig(), editing?.id));
} catch (e) { } catch (e) {
setTestError((e as Error).message); setTestError((e as Error).message);
} finally { } finally {
@@ -492,6 +538,23 @@ function DeviceForm({
} }
} }
// Push a real test slip to the printer — proves it physically prints (healthCheck
// only opens the transport). Passes editing?.id so an edited network printer's
// stored secrets re-merge. Never blocks save.
async function testPrintNow() {
if (!selected) return;
setPrintTesting(true);
setPrintError(null);
setPrintResult(null);
try {
setPrintResult(await testPrint(selected.id, mergedScalarConfig(), editing?.id));
} catch (e) {
setPrintError((e as Error).message);
} finally {
setPrintTesting(false);
}
}
async function save() { async function save() {
if (!selected) return; if (!selected) return;
// Bound devices must point at a controller relay (binding is optional in the // Bound devices must point at a controller relay (binding is optional in the
@@ -569,7 +632,21 @@ function DeviceForm({
</div> </div>
)} )}
{selected.configFields.map((f) => {selected.configFields
// pulseMs + inputRestingHigh are surfaced in the Outputs / Inputs model
// sections below (a relay setting and an input setting, respectively), so
// skip them here to avoid rendering them twice. See OutputEditor/InputEditor.
.filter((f) => !(isController && (f.key === "pulseMs" || f.key === "inputRestingHigh")))
// Printer transport is exclusive: when Connection = USB the network fields
// (host/port/status-page) don't apply, and vice-versa the USB device path
// doesn't. Hide the irrelevant side so the form can't mislead (e.g. a USB
// path lingering under a Network printer). Driven by config.transport.
.filter((f) => {
const transport = String(config.transport ?? "tcp-ip");
if (transport === "usb") return !["host", "port", "httpPort"].includes(f.key);
return f.key !== "devicePath";
})
.map((f) =>
f.type === "boolean" ? ( f.type === "boolean" ? (
// Boolean config field → a real checkbox (stores a true/false boolean, not // Boolean config field → a real checkbox (stores a true/false boolean, not
// the string "true"). The label sits beside the box, with the help below. // the string "true"). The label sits beside the box, with the help below.
@@ -628,8 +705,34 @@ function DeviceForm({
), ),
)} )}
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */} {/* CONTROLLER — OUTPUTS: the relays (barriers + the button lamp) + pulse time. */}
{isController && <RelayEditor relays={relays} onChange={setRelays} />} {isController && (
<OutputEditor
relays={relays}
onChange={setRelays}
buttonLight={buttonLight}
onButtonLightChange={setButtonLight}
pulseMs={config.pulseMs as number | undefined}
onPulseMsChange={(v) => {
setConfig((c) => ({ ...c, pulseMs: v }));
resetStatus();
}}
/>
)}
{/* CONTROLLER — INPUTS: the terminals (entry button, presence/radar), each bound
to the output relay it drives. Separated from the outputs above. */}
{isController && (
<InputEditor
relays={relays}
onChange={setRelays}
inputsIdleHigh={config.inputRestingHigh as boolean | undefined}
onInputsIdleHighChange={(v) => {
setConfig((c) => ({ ...c, inputRestingHigh: v }));
resetStatus();
}}
/>
)}
{/* BOUND device: which controller + relay it sits at. */} {/* BOUND device: which controller + relay it sits at. */}
{!isController && ( {!isController && (
@@ -661,6 +764,64 @@ function DeviceForm({
</label> </label>
)} )}
{/* CAMERA + Alarm Server push ON: show the camera's Alarm Server settings,
ready to copy, so the operator never has to find the deviceId or memorise the
endpoint. The CAMERA reaches us over the device VLAN, NOT via the browser's
origin — so host/port are the BACKEND address (backendIp on the camera's
subnet + the server's listen port), resolved by the same probe the push-IP
picker uses, NOT window.location (which is the SPA's dev/proxy origin). The
URL embeds the deviceId, so it needs a SAVED camera; and the backend IP needs
a Test connection first. We surface each field separately, matching the
camera's Alarm Settings form (Destination IP / URL / Protocol / Port). */}
{isCamera && Boolean(config.alarmPushEnabled) && (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
<div className="font-semibold text-term-text">{t("setup.alarmUrlTitle")}</div>
{!editing?.id ? (
<p className="hint mt-1">{t("setup.alarmUrlSaveFirst")}</p>
) : !backendIp || backendPort == null ? (
<p className="hint mt-1">{t("setup.alarmUrlTestFirst")}</p>
) : (
(() => {
const path = `/api/devices/hikvision/${editing.id}/event`;
// What the operator pastes into the camera's Alarm Settings form.
const fields: [string, string][] = [
[t("setup.alarmFieldHost"), backendIp],
[t("setup.alarmFieldUrl"), path],
[t("setup.alarmFieldProtocol"), "HTTP"],
[t("setup.alarmFieldPort"), String(backendPort)],
];
const copyText = fields.map(([k, v]) => `${k}: ${v}`).join("\n");
return (
<>
<div className="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
{fields.map(([k, v]) => (
<Fragment key={k}>
<span className="text-term-muted">{k}</span>
<code className="break-all rounded bg-term-panel px-2 py-0.5 text-term-green">{v}</code>
</Fragment>
))}
</div>
<div className="mt-2 flex items-center gap-2">
<button
type="button"
className="btn btn-sm"
onClick={() => {
void navigator.clipboard?.writeText(copyText);
setAlarmUrlCopied(true);
setTimeout(() => setAlarmUrlCopied(false), 2000);
}}
>
{alarmUrlCopied ? t("setup.alarmUrlCopied") : t("setup.alarmUrlCopy")}
</button>
</div>
<p className="hint mt-1">{t("setup.alarmUrlHint")}</p>
</>
);
})()
)}
</div>
)}
{/* Test (no save/no device change) then Save (configures + persists). */} {/* Test (no save/no device change) then Save (configures + persists). */}
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
<button type="button" className="btn btn-sm" onClick={test} disabled={testing}> <button type="button" className="btn btn-sm" onClick={test} disabled={testing}>
@@ -730,6 +891,30 @@ function DeviceForm({
</div> </div>
)} )}
{/* PRINTER: push a real test slip so the admin can confirm it physically
prints (healthCheck only opens the transport / USB node). */}
{isPrinter && (
<div className="mt-3 rounded-term border border-term-border bg-term-bg p-2">
<button type="button" className="btn btn-sm" onClick={testPrintNow} disabled={printTesting}>
{printTesting ? t("setup.printTesting") : t("setup.testPrint")}
</button>
<p className="hint mt-1">{t("setup.testPrintHint")}</p>
{printError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: printError })}</p>}
{printResult &&
(printResult.ok ? (
<div className="mt-2 text-[12px] text-term-green">
{t("setup.printOk", { ms: printResult.tookMs })}
</div>
) : (
<div className="mt-2 text-[12px] text-term-amber">
⚠ {t(`setup.printFail.${printResult.reason}`, { defaultValue: printResult.reason })}
{printResult.detail && <span className="text-term-muted"> — {printResult.detail}</span>}
</div>
))}
</div>
)}
{backendIps && backendIps.length > 0 && ( {backendIps && backendIps.length > 0 && (
<div className="mt-3"> <div className="mt-3">
<div className="field max-w-md"> <div className="field max-w-md">
@@ -760,9 +945,28 @@ function DeviceForm({
); );
} }
/** Controller relay map editor: each row = a relay + its direction + (optional) // ── Controller OUTPUTS (relays) ────────────────────────────────────────────
* the input terminal its entry button is wired to. */ // A relay is an OUTPUT: it opens a barrier (or drives the button lamp). This section
function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) { // owns relay number + direction, the pulse-open time (relay hold ms), and the lamp
// relay. The INPUT terminals wired to these relays live in InputEditor below — the two
// are deliberately separated (a controller's inputs and outputs are distinct things).
/** Relays = outputs (barriers + lamp) + the pulse-open hold time. */
function OutputEditor({
relays,
onChange,
buttonLight,
onButtonLightChange,
pulseMs,
onPulseMsChange,
}: {
relays: RelaySpec[];
onChange: (r: RelaySpec[]) => void;
buttonLight: ButtonLightSpec | null;
onButtonLightChange: (v: ButtonLightSpec | null) => void;
pulseMs: number | undefined;
onPulseMsChange: (v: number) => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
function update(i: number, patch: Partial<RelaySpec>) { function update(i: number, patch: Partial<RelaySpec>) {
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
@@ -774,11 +978,27 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
function remove(i: number) { function remove(i: number) {
onChange(relays.filter((_, idx) => idx !== i)); onChange(relays.filter((_, idx) => idx !== i));
} }
const barrierRelays = new Set(relays.map((r) => r.relay));
return ( return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2"> <div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.relaysTitle")}</strong> <strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.outputsTitle")}</strong>
<p className="hint mt-0.5 mb-2">{t("setup.relaysHint")}</p> <p className="hint mt-0.5 mb-2">{t("setup.outputsHint")}</p>
{/* Pulse-open time applies to every barrier relay (how long it's held open). */}
<label className="my-1 inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.pulseOpenHint")}>
{t("setup.pulseOpenMs")}
<input
type="number"
min={100}
value={pulseMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) => onPulseMsChange(Number(e.target.value))}
/>
</label>
{/* Barrier relays: number + direction. (Input terminals are in the Inputs section.) */}
{relays.map((r, i) => ( {relays.map((r, i) => (
<div key={i} className="my-1 flex flex-wrap items-center gap-2"> <div key={i} className="my-1 flex flex-wrap items-center gap-2">
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted"> <label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
@@ -798,7 +1018,130 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
</option> </option>
))} ))}
</select> </select>
{(r.direction === "entry" || r.direction === "both") && ( {relays.length > 1 && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
✕
</button>
)}
</div>
))}
<button type="button" className="btn btn-sm mt-1" onClick={add}>
{t("setup.addRelay")}
</button>
{/* Button-lamp output (a spare relay) — an OUTPUT, so it lives here. Driven by the
radar + camera (blink = radar-only, solid = car confirmed, off otherwise). */}
<div className="mt-3 flex flex-wrap items-center gap-3 border-t border-term-border pt-2">
<span className="text-[12px] text-term-muted" title={t("setup.buttonLightHint")}>
{t("setup.buttonLight")}
</span>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.buttonLightRelay")}
<input
type="number"
min={1}
value={buttonLight?.relay ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) =>
onButtonLightChange(e.target.value === "" ? null : { ...buttonLight, relay: Number(e.target.value) })
}
/>
</label>
{buttonLight?.relay != null && barrierRelays.has(buttonLight.relay) && (
<span className="text-[11px] text-term-amber">{t("setup.buttonLightBarrierWarn")}</span>
)}
{buttonLight?.relay != null && (
<>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.blinkOnMs")}
<input
type="number"
min={50}
value={buttonLight.blinkOnMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) =>
onButtonLightChange({ ...buttonLight, blinkOnMs: e.target.value === "" ? undefined : Number(e.target.value) })
}
/>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.blinkOffMs")}
<input
type="number"
min={50}
value={buttonLight.blinkOffMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) =>
onButtonLightChange({ ...buttonLight, blinkOffMs: e.target.value === "" ? undefined : Number(e.target.value) })
}
/>
</label>
</>
)}
</div>
</div>
);
}
// ── Controller INPUTS (terminals) ──────────────────────────────────────────
// An input is a TERMINAL the host READS: the entry button, the presence/radar sensor.
// Each input belongs to an entry barrier (it triggers/gates that relay's entry), so we
// render one block per entry/both relay, labelled with the output relay it drives. The
// button never SETS a pulse — its electrical pulse is the device's to report — so no
// timing field lives here (pulse-open is an OUTPUT setting, in OutputEditor).
/** Per-entry-relay input terminals: the entry button + the presence/radar sensor. */
function InputEditor({
relays,
onChange,
inputsIdleHigh,
onInputsIdleHighChange,
}: {
relays: RelaySpec[];
onChange: (r: RelaySpec[]) => void;
inputsIdleHigh: boolean | undefined;
onInputsIdleHighChange: (v: boolean) => void;
}) {
const { t } = useTranslation();
function update(i: number, patch: Partial<RelaySpec>) {
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
}
// Inputs only matter for entry/both relays (transient entry). Keep each row's real
// index so updates target the right relay.
const entryRelays = relays
.map((r, i) => ({ r, i }))
.filter(({ r }) => r.direction === "entry" || r.direction === "both");
return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.inputsTitle")}</strong>
<p className="hint mt-0.5 mb-2">{t("setup.inputsHint")}</p>
{/* Board-wide resting level (idle HIGH vs LOW) — an input property. */}
<label className="my-1 inline-flex items-start gap-2 text-[12px] text-term-muted">
<input
type="checkbox"
className="mt-0.5"
checked={inputsIdleHigh ?? true}
onChange={(e) => onInputsIdleHighChange(e.target.checked)}
/>
<span>
<span className="font-semibold text-term-text">{t("setup.inputsIdleHigh")}</span>
<span className="hint mt-0.5 block">{t("setup.inputsIdleHighHint")}</span>
</span>
</label>
{entryRelays.length === 0 ? (
<p className="hint">{t("setup.inputsNoEntryRelay")}</p>
) : (
entryRelays.map(({ r, i }) => (
<div key={i} className="my-1 flex flex-wrap items-center gap-2 border-t border-term-border pt-2">
<span className="text-[11px] uppercase tracking-wider text-term-amber">
{t("setup.inputsForRelay", { relay: r.relay })}
</span>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted"> <label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.entryButtonTerminal")} {t("setup.entryButtonTerminal")}
<input <input
@@ -810,8 +1153,6 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })} onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
/> />
</label> </label>
)}
{(r.direction === "entry" || r.direction === "both") && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceInputHint")}> <label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceInputHint")}>
{t("setup.presenceInput")} {t("setup.presenceInput")}
<input <input
@@ -820,13 +1161,35 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
value={r.presenceInput ?? ""} value={r.presenceInput ?? ""}
placeholder="—" placeholder="—"
className="input input-sm w-16" className="input input-sm w-16"
onChange={(e) => onChange={(e) => update(i, { presenceInput: e.target.value === "" ? undefined : Number(e.target.value) })}
update(i, { presenceInput: e.target.value === "" ? undefined : Number(e.target.value) })
}
/> />
</label> </label>
{/* Sensor kind + active-level — only once a presence terminal is set. */}
{!!r.presenceInput && (
<>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.presenceKind")}
<select
value={r.presenceKind ?? "loop"}
className="input input-sm w-24"
onChange={(e) => update(i, { presenceKind: e.target.value as "loop" | "radar" })}
>
<option value="loop">{t("setup.presenceKindLoop")}</option>
<option value="radar">{t("setup.presenceKindRadar")}</option>
</select>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceActiveLowHint")}>
<input
type="checkbox"
checked={!!r.presenceActiveLow}
onChange={(e) => update(i, { presenceActiveLow: e.target.checked || undefined })}
/>
{t("setup.presenceActiveLow")}
</label>
</>
)} )}
{(r.direction === "entry" || r.direction === "both") && !r.presenceInput && ( {/* Cooldown fallback only when no presence sensor is wired. */}
{!r.presenceInput && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}> <label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
{t("setup.entryCooldown")} {t("setup.entryCooldown")}
<input <input
@@ -835,22 +1198,13 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
value={r.entryCooldownSec ?? ""} value={r.entryCooldownSec ?? ""}
placeholder="—" placeholder="—"
className="input input-sm w-16" className="input input-sm w-16"
onChange={(e) => onChange={(e) => update(i, { entryCooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })}
update(i, { entryCooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })
}
/> />
</label> </label>
)} )}
{relays.length > 1 && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
✕
</button>
)}
</div> </div>
))} ))
<button type="button" className="btn btn-sm mt-1" onClick={add}> )}
{t("setup.addRelay")}
</button>
</div> </div>
); );
} }
+38 -3
View File
@@ -286,9 +286,24 @@ export interface RelaySpec {
* loop/barrier-feedback signal; a press prints only with a car present + re-arms when * loop/barrier-feedback signal; a press prints only with a car present + re-arms when
* it clears. COOLDOWN (fallback, no feedback): suppress repeat presses for N seconds. */ * it clears. COOLDOWN (fallback, no feedback): suppress repeat presses for N seconds. */
presenceInput?: number; presenceInput?: number;
/** Sensor on the presence input: induction LOOP or a RADAR (label only). */
presenceKind?: "loop" | "radar";
/** The presence terminal is active-LOW (idles HIGH) — e.g. a radar wired opposite
* the button. Maps to the driver's per-input active-level override. */
presenceActiveLow?: boolean;
entryCooldownSec?: number; entryCooldownSec?: number;
} }
/** A non-barrier indicator lamp wired to a spare relay (e.g. the entry button light),
* driven by the radar input vs. the camera lane status. */
export interface ButtonLightSpec {
/** 1-based spare relay the lamp is on. */
relay: number;
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
blinkOnMs?: number;
blinkOffMs?: number;
}
export interface TestResult { export interface TestResult {
health: { status: string; detail?: string }; health: { status: string; detail?: string };
preconditions: { preconditions: {
@@ -297,11 +312,13 @@ export interface TestResult {
}; };
} }
/** Test a device config (reachability + preconditions) without saving. */ /** Test a device config (reachability + preconditions) without saving. Pass the
export function testDevice(driverId: string, config: DeviceConfig): Promise<TestResult> { * device `id` when editing an existing one so the server re-merges its stored
* machine secrets (e.g. the relay password redacted from the client). */
export function testDevice(driverId: string, config: DeviceConfig, id?: string): Promise<TestResult> {
return apiFetch<TestResult>("/api/setup/test", { return apiFetch<TestResult>("/api/setup/test", {
method: "POST", method: "POST",
body: JSON.stringify({ driverId, config }), body: JSON.stringify({ driverId, config, ...(id ? { id } : {}) }),
}); });
} }
@@ -333,6 +350,24 @@ export function testAnpr(driverId: string, config: DeviceConfig): Promise<AnprTe
}); });
} }
/** Result of a physical print test: a real test slip is pushed to the printer. */
export type PrintTestResult =
| { ok: true; tookMs: number }
| { ok: false; reason: string; detail?: string; tookMs?: number };
/** Print a real test slip on the printer — without saving. Confirms the printer
* actually feeds paper + fires the head (healthCheck only opens the transport). */
export function testPrint(
driverId: string,
config: DeviceConfig,
id?: string,
): Promise<PrintTestResult> {
return apiFetch<PrintTestResult>("/api/setup/test-print", {
method: "POST",
body: JSON.stringify({ driverId, config, id }),
});
}
// --- Admin reports ------------------------------------------------------- // --- Admin reports -------------------------------------------------------
export type ReportBucket = "hour" | "day" | "month"; export type ReportBucket = "hour" | "day" | "month";
+49 -4
View File
@@ -359,14 +359,39 @@ export const en: Catalog = {
relaysTitle: "Relays on this controller", relaysTitle: "Relays on this controller",
relaysHint: relaysHint:
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.", "Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
outputsTitle: "Outputs — relays (barriers + lamp)",
outputsHint:
"Relays are OUTPUTS: each opens a barrier (or drives the button lamp). Set the relay number and direction. The input terminals (button, sensor) are in the Inputs section below.",
pulseOpenMs: "Pulse open (ms)",
pulseOpenHint: "How long a barrier relay is held open (jog). Applies to all barrier relays.",
inputsTitle: "Inputs — terminals (button, sensor)",
inputsHint:
"Inputs are TERMINALS the host READS: the entry button and the presence/radar sensor. Each belongs to an entry barrier — it triggers or gates that relay.",
inputsIdleHigh: "Inputs idle HIGH",
inputsIdleHighHint: "This board idles inputs HIGH (status 1111); a press pulls LOW.",
inputsForRelay: "For relay {{relay}}",
inputsNoEntryRelay: "No entry relay — add an 'Entry' or 'Entry + exit' relay in Outputs to assign terminals.",
relay: "Relay", relay: "Relay",
entryButtonTerminal: "Entry button on terminal", entryButtonTerminal: "Entry button on terminal",
presenceInput: "Presence loop (terminal)", presenceInput: "Presence sensor (terminal)",
presenceInputHint: presenceInputHint:
"Input terminal the vehicle-presence loop / barrier feedback is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the loop clears (the car drove in) and a new car re-occupies it. Preferred mode.", "Input terminal the vehicle-presence sensor (induction loop or radar) is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the sensor clears (the car drove in) and a new car re-occupies it. Preferred mode.",
entryCooldown: "Cooldown after ticket (s)", entryCooldown: "Cooldown after ticket (s)",
entryCooldownHint: entryCooldownHint:
"When there's no presence loop: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.", "When there's no presence sensor: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
presenceKind: "Kind",
presenceKindLoop: "Loop",
presenceKindRadar: "Radar",
presenceActiveLow: "Active-low",
presenceActiveLowHint:
"Tick if the presence sensor (e.g. a radar) idles HIGH and goes LOW on detection — the opposite of the button. This inverts that terminal's reading so 'present' is read correctly.",
buttonLight: "Button light (spare relay)",
buttonLightRelay: "Relay",
buttonLightHint:
"The button's 12 V light on a spare relay. Blinks when the radar detects but the camera doesn't confirm a car; solid on when both confirm; off otherwise.",
buttonLightBarrierWarn: "This relay is used by a barrier — pick a spare relay.",
blinkOnMs: "Blink on (ms)",
blinkOffMs: "Blink off (ms)",
addRelay: "+ Add relay", addRelay: "+ Add relay",
anpr: "Plate recognition (ANPR)", anpr: "Plate recognition (ANPR)",
anprHint: anprHint:
@@ -381,6 +406,26 @@ export const en: Catalog = {
"anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.", "anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.",
"anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).", "anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).",
"anprFail.no-plate": "No plate found in the snapshot.", "anprFail.no-plate": "No plate found in the snapshot.",
// Printer test slip — pushes a real slip so the admin can confirm it physically prints.
testPrint: "Print test slip",
printTesting: "Printing…",
testPrintHint:
"Sends a test slip to the printer now. ‘Connected’ only opens the link — this confirms the printer actually feeds paper.",
printOk: "✓ Test slip sent ({{ms}} ms). Check the printer.",
"printFail.print-failed": "The printer rejected the job (out of paper, cover open, or the link dropped).",
alarmUrlTitle: "Alarm Server settings (enter these in the camera)",
alarmUrlHint:
"Enter these in the camera at Configuration → Event → … → Alarm Settings (or Notify Surveillance Center). The camera POSTs every event here — no polling.",
alarmUrlCopy: "Copy all",
alarmUrlCopied: "Copied ✓",
alarmUrlSaveFirst:
"Save the camera first — the address is generated once the device has an ID. Re-open it for editing to see it.",
alarmUrlTestFirst:
"Click “Test connection” first — that resolves this host's IP on the camera's network (so the camera can reach it).",
alarmFieldHost: "Destination IP / Host",
alarmFieldUrl: "URL",
alarmFieldProtocol: "Protocol",
alarmFieldPort: "Port",
whichBarrier: "Which barrier does this device serve?", whichBarrier: "Which barrier does this device serve?",
controller: "Controller", controller: "Controller",
choose: "Choose…", choose: "Choose…",
@@ -561,7 +606,7 @@ export const en: Catalog = {
save: "Save", save: "Save",
saved: "Saved.", saved: "Saved.",
fieldParkName: "Park name", fieldParkName: "Park name",
fieldParkNamePh: "e.g. Acme Parking", fieldParkNamePh: "e.g. Airport Parking",
fieldOperator: "Operator (legal name)", fieldOperator: "Operator (legal name)",
fieldOperatorPh: "operating company", fieldOperatorPh: "operating company",
fieldNius: "NIUS", fieldNius: "NIUS",
+57 -11
View File
@@ -308,21 +308,21 @@ export const sq = {
setup: { setup: {
title: "Konfigurimi", title: "Konfigurimi",
intro: intro:
"Shto fillimisht kontrolluesit e barrierave — cakto cili rele është hyrje/dalje dhe në cilin terminal është lidhur butoni i hyrjes. Pastaj shto lexues, kamera dhe printera dhe drejto secilin te barriera që shërben.", "Shto fillimisht kontrollerat e barrierave — cakto cili rele është hyrje/dalje dhe në cilin terminal është lidhur butoni i hyrjes. Pastaj shto lexues, kamera dhe printera dhe drejto secilin te barriera që shërben.",
// Category titles + the singular noun used in buttons/modal titles. // Category titles + the singular noun used in buttons/modal titles.
catControllers: "Kontrolluesit (barrierat + butoni i hyrjes)", catControllers: "Kontrollerat (barrierat + butoni i hyrjes)",
catReaders: "Lexuesit (QR / RFID)", catReaders: "Lexuesit (QR / RFID)",
catCameras: "Kamerat (foto + targë)", catCameras: "Kamerat (foto + targë)",
catPrinters: "Printerat (bileta / vouchera)", catPrinters: "Printerat (bileta / vouchera)",
nounController: "kontrollues", nounController: "kontroller",
nounReader: "lexues", nounReader: "lexues",
nounCamera: "kamerë", nounCamera: "kamerë",
nounPrinter: "printer", nounPrinter: "printer",
add: "+ Shto {{noun}}", add: "+ Shto {{noun}}",
addAnother: "+ Shto edhe një {{noun}}", addAnother: "+ Shto {{noun}}",
addTitle: "Shto {{noun}}", addTitle: "Shto {{noun}}",
editTitle: "Ndrysho {{noun}}", editTitle: "Ndrysho {{noun}}",
needControllerFirst: "Shto fillimisht një kontrollues — {{noun}} drejtohet te një prej releve të tij.", needControllerFirst: "Shto fillimisht një kontroller — {{noun}} drejtohet te një prej releve të tij.",
failedToLoad: "Ngarkimi i konfigurimit dështoi: {{error}}", failedToLoad: "Ngarkimi i konfigurimit dështoi: {{error}}",
loadingCatalog: "Duke ngarkuar katalogun e pajisjeve…", loadingCatalog: "Duke ngarkuar katalogun e pajisjeve…",
// Direction labels (relay direction + inherited binding). // Direction labels (relay direction + inherited binding).
@@ -344,9 +344,9 @@ export const sq = {
// Device form. // Device form.
noDrivers: "Asnjë drejtues i regjistruar.", noDrivers: "Asnjë drejtues i regjistruar.",
chooseDevice: "Zgjidh një pajisje…", chooseDevice: "Zgjidh një pajisje…",
scan: "Skano për kontrollues", scan: "Skano për kontroller",
scanning: "Duke skanuar…", scanning: "Duke skanuar…",
noControllersFound: "Asnjë kontrollues në LAN.", noControllersFound: "Asnjë kontroller në LAN.",
use: "Përdor", use: "Përdor",
test: "Testo lidhjen", test: "Testo lidhjen",
testing: "Duke testuar…", testing: "Duke testuar…",
@@ -365,9 +365,21 @@ export const sq = {
noNicOnSubnet: "⚠ asnjë NIC në subnetin e pajisjes — pajisja mund të mos arrijë backend-in", noNicOnSubnet: "⚠ asnjë NIC në subnetin e pajisjes — pajisja mund të mos arrijë backend-in",
backendIpHint: "Adresa te e cila kjo pajisje do të dërgojë eventet e hyrjes.", backendIpHint: "Adresa te e cila kjo pajisje do të dërgojë eventet e hyrjes.",
// Relay editor. // Relay editor.
relaysTitle: "Relet në këtë kontrollues", relaysTitle: "Relet në këtë kontroller",
relaysHint: relaysHint:
"Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.", "Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.",
outputsTitle: "Daljet — relet (barrierat + drita)",
outputsHint:
"Relet janë DALJE: secila hap një barrierë (ose ndez dritën e butonit). Cakto numrin e relesë dhe drejtimin. Terminalet hyrëse (butoni, sensori) janë te seksioni Hyrjet më poshtë.",
pulseOpenMs: "Kohëzgjatja e hapjes (ms)",
pulseOpenHint: "Sa kohë mbahet rele e barrierës e hapur (jog). Vlen për të gjitha relet e barrierave.",
inputsTitle: "Hyrjet — terminalet (buton, sensor)",
inputsHint:
"Hyrjet janë TERMINALE që hosti i LEXON: butoni i hyrjes dhe sensori i pranisë/radari. Secila i përket një barriere hyrëse — e gateron ose e nis atë rele.",
inputsIdleHigh: "Hyrjet në pushim HIGH",
inputsIdleHighHint: "Kjo pllakë i mban hyrjet HIGH në pushim (statusi 1111); një shtypje e ul në LOW.",
inputsForRelay: "Për rele {{relay}}",
inputsNoEntryRelay: "Asnjë rele hyrëse — shto një rele 'Hyrje' ose 'Hyrje + dalje' te Daljet që të caktosh terminalet.",
relay: "Rele", relay: "Rele",
entryButtonTerminal: "Butoni i hyrjes në terminalin", entryButtonTerminal: "Butoni i hyrjes në terminalin",
presenceInput: "Sensori i pranisë (terminali)", presenceInput: "Sensori i pranisë (terminali)",
@@ -376,6 +388,19 @@ export const sq = {
entryCooldown: "Pritje pas biletës (sek)", entryCooldown: "Pritje pas biletës (sek)",
entryCooldownHint: entryCooldownHint:
"Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.", "Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.",
presenceKind: "Lloji",
presenceKindLoop: "Lak",
presenceKindRadar: "Radar",
presenceActiveLow: "Aktiv-ulët",
presenceActiveLowHint:
"Shëno nëse sensori i pranisë (p.sh. radari) qëndron HIGH në pushim dhe shkon LOW kur detekton — e kundërta e butonit. Kjo përmbys leximin e atij terminali që 'prania' të lexohet saktë.",
buttonLight: "Drita e butonit (rele rezervë)",
buttonLightRelay: "Rele",
buttonLightHint:
"Drita 12V e butonit e lidhur në një rele rezervë. Pulson kur radari detekton por kamera s'konfirmon makinë; ndizet fiks kur të dy konfirmojnë; përndryshe fiket.",
buttonLightBarrierWarn: "Kjo rele përdoret nga një barrierë — zgjidh një rele rezervë.",
blinkOnMs: "Pulsim ndezur (ms)",
blinkOffMs: "Pulsim fikur (ms)",
addRelay: "+ Shto rele", addRelay: "+ Shto rele",
// Camera ANPR opt-in. // Camera ANPR opt-in.
anpr: "Njohja e targave (ANPR)", anpr: "Njohja e targave (ANPR)",
@@ -391,12 +416,33 @@ export const sq = {
"anprFail.vision-disabled": "Shërbimi i vizionit është çaktivizuar — aktivizoje (VISION_ENABLED) për ta testuar ANPR.", "anprFail.vision-disabled": "Shërbimi i vizionit është çaktivizuar — aktivizoje (VISION_ENABLED) për ta testuar ANPR.",
"anprFail.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).", "anprFail.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).",
"anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.", "anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.",
// Printer test slip — pushes a real slip so the admin can confirm it physically prints.
testPrint: "Printo provë",
printTesting: "Duke printuar…",
testPrintHint:
"Dërgon një fletë prove te printeri tani. ‘I lidhur’ vetëm hap lidhjen — kjo konfirmon se printeri vërtet nxjerr letër.",
printOk: "✓ Fleta e provës u dërgua ({{ms}} ms). Kontrollo printerin.",
"printFail.print-failed": "Printeri nuk pranoi punën (pa letër, kapaku hapur, ose lidhja ra).",
// Alarm Server push settings — generated for the camera's Event → Alarm Server form.
alarmUrlTitle: "Cilësimet e Alarm Server (vendosi te kamera)",
alarmUrlHint:
"Vendosi këto te kamera: Configuration → Event → … → Alarm Settings (ose Notify Surveillance Center). Kamera do të dërgojë çdo ngjarje këtu — pa polling.",
alarmUrlCopy: "Kopjo të gjitha",
alarmUrlCopied: "U kopjua ✓",
alarmUrlSaveFirst:
"Ruaje kamerën më parë — adresa gjenerohet pasi pajisja të marrë një ID. Hape sërish për editim që ta shohësh.",
alarmUrlTestFirst:
"Kliko “Testo lidhjen” më parë — kështu përcaktohet IP-ja e këtij hosti në rrjetin e kamerës (që kamera ta thërrasë).",
alarmFieldHost: "Destination IP / Host",
alarmFieldUrl: "URL",
alarmFieldProtocol: "Protokolli",
alarmFieldPort: "Porta",
// Binding picker. // Binding picker.
whichBarrier: "Cilën barrierë shërben kjo pajisje?", whichBarrier: "Cilën barrierë shërben kjo pajisje?",
controller: "Kontrolluesi", controller: "Kontrolleri",
choose: "Zgjidh…", choose: "Zgjidh…",
relayLabel: "Rele {{relay}} ({{direction}})", relayLabel: "Rele {{relay}} ({{direction}})",
noRelaysConfigured: "Ky kontrollues nuk ka rele të konfiguruar.", noRelaysConfigured: "Ky kontroller nuk ka rele të konfiguruar.",
}, },
lab: { lab: {
title: "Lab Tarife", title: "Lab Tarife",
@@ -572,7 +618,7 @@ export const sq = {
save: "Ruaj", save: "Ruaj",
saved: "U ruajt.", saved: "U ruajt.",
fieldParkName: "Emri i parkimit", fieldParkName: "Emri i parkimit",
fieldParkNamePh: "p.sh. Acme Parking", fieldParkNamePh: "p.sh. Parking Aeroport",
fieldOperator: "Operatori (emri ligjor)", fieldOperator: "Operatori (emri ligjor)",
fieldOperatorPh: "kompania operuese", fieldOperatorPh: "kompania operuese",
fieldNius: "NIUS", fieldNius: "NIUS",
+54 -13
View File
@@ -7,24 +7,25 @@
# See wiki/decisions/container-deployment.md. # See wiki/decisions/container-deployment.md.
services: services:
# Reverse proxy: :80 → server:3000 (WebSocket /api/ws upgrades pass through natively). # Reverse proxy: :80 → server (127.0.0.1:3000). On the HOST network (see the server note),
# Caddy is a single static binary with a one-line proxy config; swapping http:// for the # so it reaches the host-net server over loopback and publishes :80 directly on the host.
# site's real hostname later enables automatic HTTPS. The booth is reached at # WebSocket /api/ws upgrades pass through natively. Swapping http:// for the site's real
# http://<name-or-ip>/ (the name set via hosts/DNS on-site — NOT baked into any image). # hostname later enables automatic HTTPS. Reached at http://<name-or-ip>/ (name via hosts/DNS
# on-site — NOT baked into any image).
proxy: proxy:
image: caddy:2-alpine image: caddy:2-alpine
restart: always restart: always
ports: # Host network: Caddy listens on the host's :80 and proxies the host-net server on
- "80:80" # 127.0.0.1:3000. (No `ports:` mapping — host mode publishes directly.)
# - "443:443" # uncomment when moving to TLS (and set a real hostname in Caddyfile) network_mode: host
# host mode is mutually exclusive with a named network; the base file doesn't attach proxy,
# so nothing to null here (server does — see below).
volumes: volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro - ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data - caddy-data:/data
- caddy-config:/config - caddy-config:/config
depends_on: depends_on:
- server - server
networks:
- parking
logging: logging:
driver: json-file driver: json-file
options: options:
@@ -33,9 +34,45 @@ services:
server: server:
restart: always restart: always
# No published port — only the proxy reaches the server, over the private network. # HOST NETWORK — the crux of the appliance. The server is the ONLY container doing device
expose: # I/O (camera ISAPI snapshots, relay control, receiving reader/alarm pushes), all on the
- "3000" # booth's LAN / isolated device VLAN (10.0.10.x). On a bridge network it sees only the Docker
# subnet (172.18.0.x) — it can't reach the relay, can't be reached by push devices, and the
# backend-IP picker (net.ts networkInterfaces) only sees eth0. Host mode puts it on the real
# NICs. Vision stays bridged (it never touches a device — the server hands it JPEG bytes).
network_mode: host
# host mode is mutually exclusive with a named network — detach the base file's `parking`
# attachment (compose errors otherwise: "network_mode and networks cannot both be set").
networks: !reset []
# Listens on :3000 directly on the host (Caddy proxies it). Loopback to vision:
environment:
VISION_URL: http://127.0.0.1:8089
# NB: NO `sysctls:` here. net.ipv4.ping_group_range is a per-netns sysctl; under host net
# there is no separate namespace, and runc REFUSES it ("not allowed in host network
# namespace"). Reader liveness ping uses the HOST's setting instead — the booth host must
# set net.ipv4.ping_group_range (see appliance-provisioning §7 / disk-os-hardening).
#
# USB PRINTER PASSTHROUGH. A USB ESC/POS printer (Rongta/Cashino) is the kernel `usblp` char
# device /dev/usb/lpN on the HOST — the container has its own /dev and can't see it (probeUsb
# open() → ENOENT → printer always "offline"). Two parts, both needed:
# - bind-mount /dev/usb so the lpN NODES appear inside the container, and
# - a device-cgroup rule permitting the usblp char major (180) so the kernel allows the
# open(). `180:*` covers lp0/lp1/lp2… so a USB replug/boot-order renumber still works
# (the printer's path can move; set Connection=USB + the matching /dev/usb/lpN in setup).
# (Bind-mounting the dir, not a single `devices:` node, is what survives renumbering.)
#
# ...AND access: the lpN node is `crw-rw---- root:lp` (mode 660). The server runs as the
# non-root `app` user, which is NOT in `lp`, so open(O_WRONLY) → EACCES → still "offline".
# group_add the HOST's `lp` GID (numeric — `getent group lp`, typically 7 on Debian/Ubuntu)
# so the app process gains that supplementary group and can write the 660 node. Least-
# privilege (no world-writable device, no root, no rebuild). VERIFY the GID on the booth;
# if the host's lp GID differs, change the number here.
group_add:
- "7"
volumes:
- /dev/usb:/dev/usb
device_cgroup_rules:
- "c 180:* rmw"
logging: logging:
driver: json-file driver: json-file
options: options:
@@ -45,9 +82,13 @@ services:
vision: vision:
restart: always restart: always
# The real ANPR engine. The image baked the model weights at build (offline-first). # The real ANPR engine. The image baked the model weights at build (offline-first).
# Stays on the bridge network (isolated — it makes NO outbound device calls), but PUBLISHES
# 8089 on the host LOOPBACK ONLY so the host-net server can reach it. 127.0.0.1 binding keeps
# it off the booth LAN — nothing on the network can hit the ANPR service.
environment: environment:
VISION_RECOGNIZER: fast_alpr VISION_RECOGNIZER: fast_alpr
# No published ports — vision is reached only by the server over the private network. ports:
- "127.0.0.1:8089:8089"
logging: logging:
driver: json-file driver: json-file
options: options:
+5 -2
View File
@@ -13,8 +13,11 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
DATABASE_URL: /data/parking.sqlite DATABASE_URL: /data/parking.sqlite
# Reach the vision service over the private compose network by service name. # Reach the vision service. DEV: the private compose-network service name (`vision`).
VISION_URL: http://vision:8089 # PROD: the server runs on the HOST network (to see the booth LAN / device VLAN — it's the
# only container doing device I/O), where compose DNS doesn't resolve, so the prod override
# sets VISION_URL=http://127.0.0.1:8089 and vision publishes 8089 on the host loopback.
VISION_URL: ${VISION_URL:-http://vision:8089}
VISION_ENABLED: ${VISION_ENABLED:-1} VISION_ENABLED: ${VISION_ENABLED:-1}
# JWT signing secret MUST be provided at deploy (no insecure default — see auth.ts). # JWT signing secret MUST be provided at deploy (no insecure default — see auth.ts).
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in the env/.env} JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in the env/.env}
+238
View File
@@ -0,0 +1,238 @@
# i18n long-sentence review — EN vs SQ
Generated 2026-06-24. Source: `apps/web/src/lib/i18n/en.ts` + `sq.ts`. Threshold: strings ≥ 80 chars in either language (35 keys).
Review each pair for MEANING (does SQ say the same as EN?). Mark your decision in the **Verdict** line: `OK` / `FIX: <new text>` / `?`.
Keys with an automated note are tagged **⚑ NOTE**.
---
## 1. `badgeOverstayTitle` ⚑
- **EN** (en.ts:138, 86 ch): Paid session. The customer failed to exit during the grace period. A new period began.
- **SQ** (sq.ts:140, 93 ch): Sesion i paguar. Klienti nuk doli brenda afatit kohor. Ka filluar një periudhë e re tarifimi.
- **⚑ NOTE:** Spelling: "brënda" → standard is "brenda" (no ë).
- **Verdict:**
## 2. `activeSince`
- **EN** (en.ts:254, 133 ch): Active since {{date}} · {{count}} version(s) in history. Publishing creates a new version; past sessions keep their original pricing.
- **SQ** (sq.ts:257, 137 ch): Aktive që nga {{date}} · {{count}} version(e) në histori. Publikimi krijon një version të ri; sesionet e kaluara ruajnë çmimin origjinal.
- **Verdict:**
## 3. `rateBlocksHint`
- **EN** (en.ts:263, 256 ch): Each band lasts a number of hours and bills at its own price; bands are consumed in order (the first hours, then the next hours). The last band is \"thereafter\" (open-ended) — its price applies once the ladder is exhausted. Price is per billing increment.
- **SQ** (sq.ts:266, 251 ch): Çdo brez zgjat një numër orësh dhe faturohet me çmimin e tij; brezat konsumohen me radhë (orët e para, pastaj orët në vijim). Brezi i fundit është \"më pas\" (i hapur) — çmimi i tij zbatohet pas mbarimit të shkallës. Çmimi është për interval faturimi.
- **Verdict:**
## 4. `defaultCardHint`
- **EN** (en.ts:275, 98 ch): The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.
- **SQ** (sq.ts:278, 116 ch): Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.
- **Verdict:**
## 5. `steppedHint`
- **EN** (en.ts:280, 210 ch): Set the TOTAL price for a stay up to a given time (e.g. up to 3h = 500). The first row whose limit ≥ the duration wins (the limit is inclusive). The last row's total repeats as a per-day price for longer stays.
- **SQ** (sq.ts:283, 233 ch): Vendos çmimin TOTAL për një qëndrim deri në një kohë të caktuar (p.sh. deri 3 orë = 500). Fiton rreshti i parë me kufi ≥ kohëzgjatjes (kufiri përfshihet). Totali i rreshtit të fundit përsëritet si çmim ditor për qëndrime më të gjata.
- **Verdict:**
## 6. `steppedTiersConflict`
- **EN** (en.ts:285, 235 ch): ⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price'. Publishing is blocked until this is fixed.
- **SQ** (sq.ts:288, 244 ch): ⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks'. Publikimi bllokohet derisa kjo të rregullohet.
- **Verdict:**
## 7. `tiersHint`
- **EN** (en.ts:287, 174 ch): Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.
- **SQ** (sq.ts:290, 182 ch): Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.
- **Verdict:**
## 8. `intro`
- **EN** (en.ts:526, 160 ch): Admin-defined plans the operator sells from — the price is looked up, never typed. Editing a plan publishes a new version; past sales keep their recorded price.
- **SQ** (sq.ts:537, 193 ch): Planet i përcakton admini; operatori vetëm shet prej tyre — çmimi merret automatikisht, nuk shkruhet. Ndryshimi i një plani publikon një version të ri; shitjet e mëparshme ruajnë çmimin e tyre.
- **Verdict:**
## 9. `relaysHint`
- **EN** (en.ts:361, 124 ch): Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.
- **SQ** (sq.ts:370, 130 ch): Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.
- **Verdict:**
## 10. `outputsHint`
- **EN** (en.ts:364, 175 ch): Relays are OUTPUTS: each opens a barrier (or drives the button lamp). Set the relay number and direction. The input terminals (button, sensor) are in the Inputs section below.
- **SQ** (sq.ts:373, 178 ch): Relet janë DALJE: secila hap një barrierë (ose ndez dritën e butonit). Cakto numrin e relesë dhe drejtimin. Terminalet hyrëse (butoni, sensori) janë te seksioni Hyrjet më poshtë.
- **Verdict:**
## 11. `pulseOpenHint`
- **EN** (en.ts:366, 75 ch): How long a barrier relay is held open (jog). Applies to all barrier relays.
- **SQ** (sq.ts:375, 85 ch): Sa kohë mbahet rele e barrierës e hapur (jog). Vlen për të gjitha relet e barrierave.
- **Verdict:**
## 12. `inputsHint` ⚑
- **EN** (en.ts:369, 152 ch): Inputs are TERMINALS the host READS: the entry button and the presence/radar sensor. Each belongs to an entry barrier — it triggers or gates that relay.
- **SQ** (sq.ts:378, 153 ch): Hyrjet janë TERMINALE që hosti i LEXON: butoni i hyrjes dhe sensori i pranisë/radari. Secila i përket një barriere hyrëse — e gateron ose e nis atë rele.
- **⚑ NOTE:** "e gateron" is an anglicism ("gates it"). Native: "e kushtëzon" / "e lejon".
- **Verdict:**
## 13. `inputsIdleHighHint`
- **EN** (en.ts:371, 62 ch): This board idles inputs HIGH (status 1111); a press pulls LOW.
- **SQ** (sq.ts:380, 80 ch): Kjo pllakë i mban hyrjet HIGH në pushim (statusi 1111); një shtypje e ul në LOW.
- **Verdict:**
## 14. `inputsNoEntryRelay`
- **EN** (en.ts:373, 87 ch): No entry relay — add an 'Entry' or 'Entry + exit' relay in Outputs to assign terminals.
- **SQ** (sq.ts:382, 97 ch): Asnjë rele hyrëse — shto një rele 'Hyrje' ose 'Hyrje + dalje' te Daljet që të caktosh terminalet.
- **Verdict:**
## 15. `presenceInputHint` ⚑
- **EN** (en.ts:378, 290 ch): Input terminal the vehicle-presence sensor (induction loop or radar) is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the sensor clears (the car drove in) and a new car re-occupies it. Preferred mode.
- **SQ** (sq.ts:387, 275 ch): Terminali hyrës ku është lidhur sensori/laku i pranisë së automjetit. Kur vendoset, lëshohet vetëm NJË biletë për automjet: butoni printon vetëm kur ka makinë, dhe nuk lëshon biletë të dytë derisa laku të lirohet (makina hyri) dhe një makinë e re ta zërë. Mënyra e preferuar.
- **⚑ NOTE:** EN names "induction loop OR radar"; SQ "sensori/laku i pranisë" omits radar explicitly.
- **Verdict:**
## 16. `entryCooldownHint`
- **EN** (en.ts:381, 175 ch): When there's no presence sensor: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.
- **SQ** (sq.ts:390, 165 ch): Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.
- **Verdict:**
## 17. `presenceActiveLowHint`
- **EN** (en.ts:387, 178 ch): Tick if the presence sensor (e.g. a radar) idles HIGH and goes LOW on detection — the opposite of the button. This inverts that terminal's reading so 'present' is read correctly.
- **SQ** (sq.ts:396, 184 ch): Shëno nëse sensori i pranisë (p.sh. radari) qëndron HIGH në pushim dhe shkon LOW kur detekton — e kundërta e butonit. Kjo përmbys leximin e atij terminali që 'prania' të lexohet saktë.
- **Verdict:**
## 18. `buttonLightHint`
- **EN** (en.ts:391, 152 ch): The button's 12 V light on a spare relay. Blinks when the radar detects but the camera doesn't confirm a car; solid on when both confirm; off otherwise.
- **SQ** (sq.ts:400, 160 ch): Drita 12V e butonit e lidhur në një rele rezervë. Pulson kur radari detekton por kamera s'konfirmon makinë; ndizet fiks kur të dy konfirmojnë; përndryshe fiket.
- **Verdict:**
## 19. `anprHint` ⚑
- **EN** (en.ts:398, 203 ch): Enable to scan plates on this camera: the vision service reads the plate from a snapshot and feeds it as a read (advisory only — it never opens a barrier on its own). Requires the vision service running.
- **SQ** (sq.ts:408, 185 ch): Aktivizo që ky aparat të skanojë targat: shërbimi i vizionit lexon targën nga pamja dhe e dërgon si lexim (vetëm këshillues — nuk hap vetë barrierën). Kërkon shërbimin e vizionit aktiv.
- **⚑ NOTE:** SQ uses "aparat" for camera; elsewhere camera = "kamerë" (see testAnprHint). Inconsistent.
- **Verdict:**
## 20. `testAnprHint`
- **EN** (en.ts:402, 143 ch): Takes a live snapshot from this camera and tries to read a plate, reporting the result and the time it took. Point a plate at the camera first.
- **SQ** (sq.ts:412, 169 ch): Merr një pamje të drejtpërdrejtë nga kjo kamerë dhe përpiqet të lexojë një targë, duke raportuar rezultatin dhe kohën e nevojshme. Vendos një targë para kamerës më parë.
- **Verdict:**
## 21. `curveHint`
- **EN** (en.ts:443, 88 ch): Fee from entry at several durations — see where the daily cap flattens or windows shift.
- **SQ** (sq.ts:454, 96 ch): Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.
- **Verdict:**
## 22. `versionHint`
- **EN** (en.ts:465, 128 ch): Move this subscriber to another version of the same plan. The price stays as billed; only the access hours change going forward.
- **SQ** (sq.ts:476, 139 ch): Zhvendos këtë abonent në një version tjetër të të njëjtit plan. Çmimi mbetet siç u faturua; ndryshon vetëm orari i lejuar nga këtu e tutje.
- **Verdict:**
## 23. `deleteInUse`
- **EN** (en.ts:550, 68 ch): Can't delete — subscriptions still use this plan. Retire it instead.
- **SQ** (sq.ts:561, 80 ch): S'mund të fshihet — abonime ende e përdorin këtë plan. Tërhiqe në vend të kësaj.
- **Verdict:**
## 24. `newVersionHint`
- **EN** (en.ts:553, 84 ch): This publishes a NEW version of the plan — existing sales keep their original price.
- **SQ** (sq.ts:564, 86 ch): Kjo publikon një version TË RI të planit — shitjet ekzistuese ruajnë çmimin origjinal.
- **Verdict:**
## 25. `timeframesHint`
- **EN** (en.ts:570, 168 ch): A scan outside the allowed window is charged the normal transient tariff for the out-of-window minutes (early entry is deferred to exit; late exit is gated until paid).
- **SQ** (sq.ts:581, 184 ch): Një skanim jashtë intervalit të lejuar tarifohet me tarifën normale kalimtare për minutat jashtë intervalit (hyrja e hershme shtyhet në dalje; dalja e vonuar bllokohet derisa paguhet).
- **Verdict:**
## 26. `reserveSubsHint` ⚑
- **EN** (en.ts:582, 164 ch): Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).
- **SQ** (sq.ts:593, 198 ch): Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).
- **⚑ NOTE:** EN "handle overflow by valet" → SQ "parkim manual" (manual parking) — meaning shift; valet ≠ manual parking.
- **Verdict:**
## 27. `anprEntryHint`
- **EN** (en.ts:584, 195 ch): When on, a subscriber's plate read by a lane camera opens the barrier through the normal subscription gate. Off: subscribers must use their card/QR. Plate snapshots are still recorded either way.
- **SQ** (sq.ts:595, 207 ch): Kur është aktiv, targa e një abonenti e lexuar nga kamera e korsisë hap barrierën përmes portës normale të abonimit. Joaktiv: abonentët duhet të përdorin kartën/QR-në. Fotot e targave regjistrohen gjithsesi.
- **Verdict:**
## 28. `voucherHint`
- **EN** (en.ts:659, 128 ch): A receipt (Mandat Arkëtimi) adds cash; a disbursement (Mandat Pagese) removes it. The float only moves with an admin's sign-off.
- **SQ** (sq.ts:671, 97 ch): Mandat Arkëtimi shton para; Mandat Pagese heq para. Arka lëviz vetëm me autorizimin e një admini.
- **Verdict:**
## 29. `xReportHint`
- **EN** (en.ts:667, 83 ch): View only — nothing is recorded. These figures are signed when the shift is closed.
- **SQ** (sq.ts:679, 85 ch): Vetëm për shikim — asgjë nuk regjistrohet. Këto shifra nënshkruhen kur mbyllet turni.
- **Verdict:**
## 30. `gateBody`
- **EN** (en.ts:691, 80 ch): No shift is open. Open your shift so payments and exits are recorded against it.
- **SQ** (sq.ts:703, 95 ch): Asnjë turn nuk është i hapur. Hap turnin tënd që pagesat dhe daljet të regjistrohen te ky turn.
- **Verdict:**
## 31. `gateOtherBody`
- **EN** (en.ts:694, 120 ch): {{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.
- **SQ** (sq.ts:706, 140 ch): {{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.
- **Verdict:**
## 32. `overstayHint` ⚑
- **EN** (en.ts:813, 155 ch): Earlier session paid. The customer failed to exit during the grace period. Payment for the new period is required. The total below is the new period's fee.
- **SQ** (sq.ts:827, 151 ch): Sesion i mëparshëm i paguar. Klienti nuk doli brënda afatit kohor. Kërkohet pagesë për periudhën e re. Totali më poshtë është tarifa e periudhës së re.
- **⚑ NOTE:** Spelling: "brënda" → standard is "brenda" (no ë).
- **Verdict:**
## 33. `subAssistHint`
- **EN** (en.ts:837, 101 ch): Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.
- **SQ** (sq.ts:851, 108 ch): Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.
- **Verdict:**
## 34. `windowChargeHint`
- **EN** (en.ts:842, 154 ch): This subscriber parked outside their plan's allowed hours. They owe the transient tariff for the out-of-window time — take payment, then open the barrier.
- **SQ** (sq.ts:856, 153 ch): Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën, pastaj hap barrierën.
- **Verdict:**
## 35. `cancelTicketHint`
- **EN** (en.ts:852, 115 ch): Cancels a wrongly-printed ticket. A signed record is kept (operator + reason); the original entry is never deleted.
- **SQ** (sq.ts:866, 129 ch): Anulon një biletë të printuar gabimisht. Ruhet një gjurmë e nënshkruar (operatori + arsyeja); hyrja origjinale nuk fshihet kurrë.
- **Verdict:**
---
## Summary of flagged items
- `badgeOverstayTitle` — Spelling: "brënda" → standard is "brenda" (no ë).
- `inputsHint` — "e gateron" is an anglicism ("gates it"). Native: "e kushtëzon" / "e lejon".
- `presenceInputHint` — EN names "induction loop OR radar"; SQ "sensori/laku i pranisë" omits radar explicitly.
- `anprHint` — SQ uses "aparat" for camera; elsewhere camera = "kamerë" (see testAnprHint). Inconsistent.
- `reserveSubsHint` — EN "handle overflow by valet" → SQ "parkim manual" (manual parking) — meaning shift; valet ≠ manual parking.
- `overstayHint` — Spelling: "brënda" → standard is "brenda" (no ë).
_All other pairs: meaning judged faithful in automated review; confirm during your read._
+24
View File
@@ -0,0 +1,24 @@
# Komodo Stack environment — reference of what a booth Stack needs and WHERE it comes
# from. Under Komodo, plain env lives in the Stack definition (komodo/resources.toml);
# the two SECRETS come from Komodo Core's secret store, PER BOOTH and UNIQUE. This file
# is documentation only — do NOT fill in real secrets here (it would be the same leak
# we're avoiding). See wiki/decisions/fleet-deployment-komodo.md.
# --- plain Stack env (lives in resources.toml; safe in git) -------------------
REGISTRY=git.infra.msai.al/mca/parking_solution
# IMMUTABLE per-commit tag. Manual + pinned. Bump per deploy. Never the moving `dev`
# on a production booth.
TAG=dev-830993b
COOKIE_SECURE=0
VISION_ENABLED=1
WS_ALLOWED_ORIGINS=
# --- secrets (Core secret store, referenced by name in resources.toml) --------
# Generated PER BOOTH (openssl rand -hex 32), registered in Core under booth-scoped
# names, never reused across sites. The user generates these; they are never typed on
# a CLI or committed.
# JWT_SECRET -> [[booth_<name>_jwt_secret]] (login)
# EVENT_SIGNING_KEY -> [[booth_<name>_event_signing_key]] (ledger signing — fraud root)
# Periphery/registry auth also live in Core:
# periphery passkey -> [[periphery_passkey_booth_<name>]]
# registry account -> [[gitea_registry_account]]
+60
View File
@@ -0,0 +1,60 @@
# `komodo/` — fleet deployment as code
Infra-as-code for the **Komodo Core** control plane that deploys the parking appliance to the
booth fleet over the **NetBird** mesh. See `wiki/decisions/fleet-deployment-komodo.md` for the
rationale, threat-model analysis, and the three settled choices (many/growing fleet · deploys
are **manual + pinned** · secrets are **Komodo-managed, per-booth**).
This directory does **not** change how images are built or how the app runs — it's only the
control plane. The booth still runs the same `docker-compose.yml` + `docker-compose.prod.yml`
([[container-deployment]]); Komodo just drives them remotely instead of someone SSH-ing in to
run `booth.sh`.
## Files
- **`resources.toml`** — the Komodo resource definitions (Servers, Stacks, optional Builders/
Procedures), synced into Core via a **ResourceSync**. This is the reviewable, version-
controlled source of truth for *which booth runs what*.
- **`.env.komodo.example`** — the variables a Stack expects, documenting what comes from Core's
**secret store** (per-booth `JWT_SECRET` / `EVENT_SIGNING_KEY`) vs. plain Stack env.
## How Core consumes this (one-time)
In Komodo Core, create a **ResourceSync** pointing at this repo + path (`komodo/resources.toml`),
on the branch you manage from (e.g. `main`). Core reads the file and reconciles Servers/Stacks to
match. Thereafter, a PR to this directory + a sync is how you change the fleet — no clicking.
> Komodo's TOML schema evolves across releases. Treat `resources.toml` as a **starting sketch**:
> `resources.toml` mirrors the **working `park-buzi` Stack** (built by hand in the Core UI, then
> exported to TOML — so field names match the running Komodo version, v2.2). Import it into the
> sync **Unmanaged** first and review the diff; it should be ~empty against the live Stack.
## How servers get created — NOT here
There is **no `[[server]]` block** in `resources.toml`. Servers are created by the **Periphery
agent onboarding outbound**: in Core, create a one-time **Onboarding Key** (Settings →
Onboarding), then install Periphery on the booth passing `--onboarding-key` + `--core-address`
(Core's reverse-proxy URL, reached over the NetBird mesh) + `--connect-as=<booth-name>`. The
agent self-registers, generates its own auto-rotating key pair (private key never leaves the
booth), and connects **outbound** — the booth opens **no inbound port**. The sync owns only the
**Stack**, which references the server by the name it onboarded as (`server = "park-buzi"`). See
`wiki/decisions/fleet-deployment-komodo.md`.
## Adding booth N
Copy the `[[stack]]` block, change `name`, `server` (its onboarded name), and the per-booth
secret references (`[[park_<site>_jwt_secret]]`, `[[park_<site>_event_signing_key]]`). Create
those secrets in Core's store first.
## Hard rules encoded here (do not relax without updating the decision page)
1. **No deploy webhook on a booth Stack.** Deploys are a human action; pin `TAG=dev-<sha>` before
a production booth goes live. A moving `:dev` on a production booth is the non-determinism we
rejected. (`TAG=dev` here is fine while staging.)
2. **Onboarding, outbound, mesh-only.** Servers self-register via an onboarding key; Periphery
connects outbound to Core's mesh URL and exposes no inbound port. Never a LAN/WAN address.
3. **Secrets are per-booth and unique.** `EVENT_SIGNING_KEY` signs the anti-fraud ledger — one
leak must taint one booth, never the fleet. Reference Core secrets by name; never inline a
real value in this file (it's in git).
4. **Volumes preserved.** The Stack must never run `compose down -v` — that would wipe the
`parking-data` volume (the signed ledger). Komodo's "destroy" is gated for the same reason.
+51
View File
@@ -0,0 +1,51 @@
# Komodo resources — parking appliance fleet (control plane as code)
#
# Synced into Komodo Core via a ResourceSync pointing at this file. Drives the SAME
# compose files the booth runs locally (docker-compose.yml + docker-compose.prod.yml);
# Komodo Periphery on each booth executes them. See:
# wiki/decisions/fleet-deployment-komodo.md (rationale + threat model)
# wiki/decisions/container-deployment.md (image build/tag/registry — unchanged)
#
# This file mirrors the WORKING park-buzi Stack (built by hand in the Core UI, then
# exported to TOML). Field names match the running Komodo version (v2.2).
#
# NO [[server]] block: servers are created by the AGENT onboarding outbound (a one-time
# onboarding key → Periphery self-registers with auto-rotating key pairs). The sync owns
# only the Stack; it references the server by the name it onboarded as (`connect_as`).
#
# Secrets ([[park_buzi_jwt_secret]] etc.) are REFERENCES to Komodo Core's secret store —
# per-booth + unique, never inlined here (this file is in git). JWT_SECRET gates login;
# EVENT_SIGNING_KEY signs the append-only anti-fraud ledger.
#
# Deploys are MANUAL + PINNED in spirit: bump TAG to an immutable dev-<sha> before a
# production booth goes live (TAG=dev here is the moving tag, fine while staging). NO
# deploy webhook is attached to a booth Stack.
##############################################################################
# Stack — the deployable unit for booth "park-buzi". One Stack per booth; add a
# new [[stack]] block per site (unique name, its own per-booth secret refs).
##############################################################################
[[stack]]
name = "park-buzi"
[stack.config]
server = "park-buzi"
git_provider = "git.infra.msai.al"
git_account = "komodo"
repo = "mca/parking_solution"
branch = "dev"
file_paths = [
"docker-compose.yml",
"docker-compose.prod.yml"
]
registry_provider = "git.infra.msai.al"
registry_account = "komodo"
environment = """
REGISTRY=git.infra.msai.al/mca/parking_solution
TAG=dev
COOKIE_SECURE=0
VISION_ENABLED=1
WS_ALLOWED_ORIGINS=
JWT_SECRET=[[park_buzi_jwt_secret]]
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
"""
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { inputActive } from "./access-dingtian.js";
// Per-input active-level normalisation. The board has ONE resting level, but a radar
// can idle opposite the button — listing its terminal in `activeLow` inverts just that
// input so "present" reads correctly. See wiki/entities/hikvision-radar.md.
describe("inputActive (per-input active-level)", () => {
const none = new Set<number>();
const radarOnI2 = new Set<number>([2]);
it("default board (resting HIGH): a pull LOW is active, HIGH is rest", () => {
// Button on I1, board idles HIGH → active when LOW.
expect(inputActive(false, 1, true, none)).toBe(true); // LOW = pressed
expect(inputActive(true, 1, true, none)).toBe(false); // HIGH = rest
});
it("resting LOW board: a pull HIGH is active", () => {
expect(inputActive(true, 1, false, none)).toBe(true);
expect(inputActive(false, 1, false, none)).toBe(false);
});
it("active-low override inverts ONLY the listed input", () => {
// Board idles HIGH (button on I1), radar on I2 idles HIGH and goes LOW on detect →
// mark I2 active-low so detection (LOW) reads active.
// I1 (button) keeps the board default:
expect(inputActive(false, 1, true, radarOnI2)).toBe(true); // button LOW = active
expect(inputActive(true, 1, true, radarOnI2)).toBe(false);
// I2 (radar) overridden to active-low: active when LOW.
expect(inputActive(false, 2, true, radarOnI2)).toBe(true); // radar LOW = detecting
expect(inputActive(true, 2, true, radarOnI2)).toBe(false); // radar HIGH = clear
});
});
@@ -3,6 +3,7 @@ import { createSocket } from "node:dgram";
import { request as httpRequest } from "node:http"; import { request as httpRequest } from "node:http";
import type { import type {
AccessControlDevice, AccessControlDevice,
AuxOutputDevice,
DeviceHealth, DeviceHealth,
HardenableDevice, HardenableDevice,
HardenResult, HardenResult,
@@ -166,6 +167,22 @@ interface DingtianStatus {
channels: number; channels: number;
} }
/**
* Normalise one input line to "active". `high` = the line is currently HIGH. An input
* whose 1-based channel is in `activeLow` is active when LOW (idles HIGH), overriding
* the board-wide `restingHigh`; otherwise active = differs from the resting level. This
* is the seam that lets a radar (wired opposite the button) read correctly. Exported for
* unit testing the bit logic without a UDP socket. See wiki/entities/hikvision-radar.md.
*/
export function inputActive(
high: boolean,
channel1Based: number,
restingHigh: boolean,
activeLow: ReadonlySet<number>,
): boolean {
return activeLow.has(channel1Based) ? !high : high !== restingHigh;
}
const INPUT_LINK_ISSUE = { const INPUT_LINK_ISSUE = {
key: "input_link_relay", key: "input_link_relay",
message: message:
@@ -223,6 +240,7 @@ function configApi(
class DingtianController class DingtianController
implements implements
AccessControlDevice, AccessControlDevice,
AuxOutputDevice,
InputDevice, InputDevice,
PreconditionDevice, PreconditionDevice,
PushConfigurableDevice, PushConfigurableDevice,
@@ -242,6 +260,13 @@ class DingtianController
readonly #channels: number; readonly #channels: number;
/** Input level at rest; an input is "active" when it differs from this. */ /** Input level at rest; an input is "active" when it differs from this. */
readonly #restingHigh: boolean; readonly #restingHigh: boolean;
/** 1-based input terminals whose ACTIVE level is LOW, overriding the board-wide
* #restingHigh for just those inputs. A button and a radar can idle oppositely:
* the button (NO-to-GND) pulls LOW on press while the board idles HIGH, but a
* radar's dry contact may idle LOW and go HIGH on detection. Listing the radar's
* terminal here flips its edge so "active" still means "detecting". See
* wiki/entities/hikvision-radar.md. */
readonly #inputActiveLow: Set<number>;
readonly #pulseMs: number; readonly #pulseMs: number;
/** Device web-UI login user (gates the browser UI only, not the CGI API). */ /** Device web-UI login user (gates the browser UI only, not the CGI API). */
readonly #webUser: string; readonly #webUser: string;
@@ -269,6 +294,22 @@ class DingtianController
this.#channels = config.channels ? Number(config.channels) : 4; this.#channels = config.channels ? Number(config.channels) : 4;
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW. // This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
this.#restingHigh = config.inputRestingHigh !== false; this.#restingHigh = config.inputRestingHigh !== false;
// Per-input active-LOW overrides (1-based). Source of truth is each entry relay's
// `presenceActiveLow` flag (a radar terminal wired opposite the button); an explicit
// top-level `inputActiveLow` array is also honoured as an escape hatch. Both merged.
this.#inputActiveLow = new Set<number>();
if (Array.isArray(config.inputActiveLow)) {
for (const n of (config.inputActiveLow as unknown[]).map(Number)) {
if (Number.isInteger(n) && n > 0) this.#inputActiveLow.add(n);
}
}
if (Array.isArray(config.relays)) {
for (const r of config.relays as Array<Record<string, unknown>>) {
if (r?.presenceActiveLow === true && Number.isInteger(Number(r.presenceInput))) {
this.#inputActiveLow.add(Number(r.presenceInput));
}
}
}
this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500; this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
this.#webUser = config.webUser ? String(config.webUser) : "admin"; this.#webUser = config.webUser ? String(config.webUser) : "admin";
// webPassword = the DESIRED login (admin's choice; blank → harden generates). // webPassword = the DESIRED login (admin's choice; blank → harden generates).
@@ -319,6 +360,14 @@ class DingtianController
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress); await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
} }
/** AuxOutputDevice: latch a NON-barrier output (e.g. a button lamp) on a spare
* relay. Same wire op as setRelay — separated so business logic drives indicators
* through the aux capability, never the barrier relay methods. Holding/blinking an
* aux output is allowed (it is not a barrier). See button-light-indicator.md. */
async setAux(channel: number, on: boolean): Promise<void> {
await this.setRelay(channel, on);
}
async getDoorStatus(doorId: number): Promise<"open" | "closed"> { async getDoorStatus(doorId: number): Promise<"open" | "closed"> {
this.#assertChannel(doorId); this.#assertChannel(doorId);
const { relays } = await this.#status(); const { relays } = await this.#status();
@@ -672,8 +721,10 @@ class DingtianController
for (let i = 0; i < this.#channels; i++) { for (let i = 0; i < this.#channels; i++) {
const high = (inputVal & (1 << i)) !== 0; const high = (inputVal & (1 << i)) !== 0;
relays.push((relayVal & (1 << i)) !== 0); relays.push((relayVal & (1 << i)) !== 0);
// active = differs from the resting level (a press pulls the line). // active = differs from the resting level (a press pulls the line); a terminal in
inputs.push(high !== this.#restingHigh); // inputActiveLow is read inverted (active when LOW) — so a radar wired opposite the
// button reads right. See inputActive().
inputs.push(inputActive(high, i + 1, this.#restingHigh, this.#inputActiveLow));
} }
return { relays, inputs, channels: this.#channels }; return { relays, inputs, channels: this.#channels };
} }
@@ -728,6 +779,19 @@ export const dingtianDriver: AccessDriver = {
{ key: "binaryPort", label: "Binary protocol port", type: "port", required: false, default: 60000, help: "Dingtian binary protocol UDP port — authenticated relay control (default 60000)." }, { key: "binaryPort", label: "Binary protocol port", type: "port", required: false, default: 60000, help: "Dingtian binary protocol UDP port — authenticated relay control (default 60000)." },
{ key: "httpPort", label: "HTTP config port", type: "port", required: false, default: 80, help: "Device web/config-API port (default 80)." }, { key: "httpPort", label: "HTTP config port", type: "port", required: false, default: 80, help: "Device web/config-API port (default 80)." },
{ key: "channels", label: "Channels (relays/inputs)", type: "number", required: true, default: 4 }, { key: "channels", label: "Channels (relays/inputs)", type: "number", required: true, default: 4 },
{
// relay_pw — the BINARY-protocol control/status password (NOT the web-UI login
// below). Every relay command + the status read embeds it; with the wrong/no
// value the device silently ignores the packet → healthCheck times out → the
// controller shows "offline" even though it pings. Redacted from the client
// (SECRET_CONFIG_KEYS), so it renders as a secret: blank KEEPS the stored value
// (the server re-merges it on test/save); type a value to set/change it.
key: "relayPassword",
label: "Relay control password",
type: "secret",
required: false,
help: "Binary-protocol relay password (relay_pw). Leave blank to keep the current one; a wrong/missing value makes the device ignore commands (Test connection times out).",
},
{ {
key: "pulseMs", key: "pulseMs",
label: "Pulse open (ms)", label: "Pulse open (ms)",
+115
View File
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DigestGetResult } from "./http-digest.js";
// The HTTP layer is mocked so the camera driver's RETRY logic is tested without a
// network. Hikvision returns 503 "Device Busy" (sometimes 500) transiently when its
// snapshot encoder is occupied — captureSnapshot must retry those and succeed, but
// fail FAST on a config error (401 auth / 404 path). See camera.ts.
const digestGet = vi.fn<(...a: unknown[]) => Promise<DigestGetResult>>();
vi.mock("./http-digest.js", () => ({ digestGet: (...a: unknown[]) => digestGet(...a) }));
// Import the driver AFTER the mock is registered.
const { hikvisionDriver } = await import("./camera.js");
function reply(status: number, body = "jpeg-bytes"): DigestGetResult {
return { status, contentType: "image/jpeg", body: Buffer.from(body) };
}
function makeCamera() {
return hikvisionDriver.create({ host: "10.0.10.12", port: 80, username: "admin", password: "x", channel: 1 });
}
beforeEach(() => {
digestGet.mockReset();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("hikvision captureSnapshot — 503 Device Busy retry", () => {
it("retries a transient 503 and succeeds", async () => {
digestGet
.mockResolvedValueOnce(reply(503))
.mockResolvedValueOnce(reply(503))
.mockResolvedValueOnce(reply(200, "the-frame"));
const cam = makeCamera();
const p = cam.captureSnapshot({ direction: "entry" });
await vi.runAllTimersAsync(); // let the backoff sleeps fire
const shot = await p;
expect(shot.bytes.toString()).toBe("the-frame");
expect(digestGet).toHaveBeenCalledTimes(3); // 503, 503, 200
});
it("also retries a transient 500", async () => {
digestGet.mockResolvedValueOnce(reply(500)).mockResolvedValueOnce(reply(200));
const cam = makeCamera();
const p = cam.captureSnapshot({ direction: "entry" });
await vi.runAllTimersAsync();
await p;
expect(digestGet).toHaveBeenCalledTimes(2);
});
it("gives up after the attempt cap, naming it 'device busy'", async () => {
digestGet.mockResolvedValue(reply(503)); // always busy
const cam = makeCamera();
// Attach the rejection assertion BEFORE flushing timers so the rejection always
// has a handler (no unhandled-rejection noise), then drive the backoff sleeps.
const assertion = expect(cam.captureSnapshot({ direction: "entry" })).rejects.toThrow(/HTTP 503 \(device busy\)/);
await vi.runAllTimersAsync();
await assertion;
expect(digestGet).toHaveBeenCalledTimes(4); // SNAPSHOT_MAX_ATTEMPTS
});
it("does NOT retry a 401 (auth error self-won't-heal) — fails fast", async () => {
digestGet.mockResolvedValue(reply(401));
const cam = makeCamera();
const assertion = expect(cam.captureSnapshot({ direction: "entry" })).rejects.toThrow(/HTTP 401/);
await vi.runAllTimersAsync();
await assertion;
expect(digestGet).toHaveBeenCalledTimes(1); // no retry
});
it("does NOT retry a 404 (wrong path/channel) — fails fast", async () => {
digestGet.mockResolvedValue(reply(404));
const cam = makeCamera();
const assertion = expect(cam.captureSnapshot({ direction: "entry" })).rejects.toThrow(/HTTP 404/);
await vi.runAllTimersAsync();
await assertion;
expect(digestGet).toHaveBeenCalledTimes(1);
});
it("succeeds first try with no retry on a clean 200", async () => {
digestGet.mockResolvedValue(reply(200));
const cam = makeCamera();
const shot = await cam.captureSnapshot({ direction: "entry" });
expect(shot.contentType).toBe("image/jpeg");
expect(digestGet).toHaveBeenCalledTimes(1);
});
});
describe("hikvision snapshot stream selection (main vs sub)", () => {
function pathFor(config: Record<string, unknown>): string {
digestGet.mockReset();
digestGet.mockResolvedValue(reply(200));
hikvisionDriver.create(config as never).captureSnapshot({ direction: "entry" });
return String((digestGet.mock.calls[0]![0] as { path: string }).path);
}
it("defaults to the MAIN stream (…/channels/101/picture) — back-compat", () => {
expect(pathFor({ host: "1.2.3.4", channel: 1 })).toBe("/ISAPI/Streaming/channels/101/picture");
});
it("stream=2 selects the SUB stream (…/channels/102/picture) — the G3H 503 fix", () => {
expect(pathFor({ host: "1.2.3.4", channel: 1, stream: 2 })).toBe("/ISAPI/Streaming/channels/102/picture");
});
it("honours the channel number with the stream (ch2 sub = 202)", () => {
expect(pathFor({ host: "1.2.3.4", channel: 2, stream: 2 })).toBe("/ISAPI/Streaming/channels/202/picture");
});
it("an invalid stream falls back to main (1)", () => {
expect(pathFor({ host: "1.2.3.4", channel: 1, stream: 9 })).toBe("/ISAPI/Streaming/channels/101/picture");
});
});
+118 -18
View File
@@ -1,6 +1,17 @@
import type { CameraDevice, DeviceHealth, Snapshot, SnapshotContext } from "../interfaces.js"; import type {
CameraDevice,
DeviceHealth,
Snapshot,
SnapshotContext,
} from "../interfaces.js";
import type { CameraDriver, ConfigField, DeviceConfig } from "../registry.js"; import type { CameraDriver, ConfigField, DeviceConfig } from "../registry.js";
import { hostField, passwordField, portField, usernameField, stubLog } from "./common.js"; import {
hostField,
passwordField,
portField,
usernameField,
stubLog,
} from "./common.js";
import { digestGet } from "./http-digest.js"; import { digestGet } from "./http-digest.js";
// Camera drivers — entry/exit snapshot-on-event. The host pulls a still over // Camera drivers — entry/exit snapshot-on-event. The host pulls a still over
@@ -15,12 +26,32 @@ import { digestGet } from "./http-digest.js";
const DEFAULT_TIMEOUT_MS = 8000; const DEFAULT_TIMEOUT_MS = 8000;
// Hikvision returns HTTP 503 (statusCode 2 / "Device Busy" / subStatus deviceBusy) —
// and occasionally 500 — when its snapshot encoder is momentarily occupied (another
// snapshot in flight, a stream starting, on-camera VCA). It is TRANSIENT: a retry a
// few hundred ms later succeeds. The newer G3H sensors (e.g. DS-2CD1047G3H) hit it
// more readily. So a standalone capture retries a few times before giving up; we do
// NOT retry config errors (401 auth, 404 path/channel) — those won't self-heal.
// (Concurrent same-camera hits are separately de-duped by captureSnapshotShared in
// the server.) See wiki/entities/lpr-camera.md ("503 Device Busy").
const SNAPSHOT_RETRY_STATUSES = new Set([500, 503]);
const SNAPSHOT_MAX_ATTEMPTS = 4;
const SNAPSHOT_RETRY_BASE_MS = 250;
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
class HttpCamera implements CameraDevice { class HttpCamera implements CameraDevice {
readonly #host: string; readonly #host: string;
readonly #port: number; readonly #port: number;
readonly #user: string; readonly #user: string;
readonly #password: string; readonly #password: string;
readonly #channel: number; readonly #channel: number;
/** Hikvision stream within the channel: 1 = main (high-res), 2 = sub (lighter).
* Some models (e.g. the G3H) keep the MAIN encoder saturated and return a
* persistent 503 deviceBusy on the main-stream snapshot, while the sub-stream
* serves fine — so this is selectable. Ignored by drivers (Dahua) that don't
* encode a stream in the path. See wiki/entities/lpr-camera.md ("503 Device Busy"). */
readonly #stream: number;
readonly #timeout: number; readonly #timeout: number;
// Source outbound from the device-facing NIC on a multi-homed host (the // Source outbound from the device-facing NIC on a multi-homed host (the
// multi-subnet source-address trap — see wiki/concepts/wsl-dev-networking.md). // multi-subnet source-address trap — see wiki/concepts/wsl-dev-networking.md).
@@ -29,16 +60,20 @@ class HttpCamera implements CameraDevice {
constructor( constructor(
readonly driverId: string, readonly driverId: string,
config: DeviceConfig, config: DeviceConfig,
/** Builds the snapshot path from the configured channel. */ /** Builds the snapshot path from the configured channel + stream (1=main, 2=sub). */
private readonly snapshotPath: (channel: number) => string, private readonly snapshotPath: (channel: number, stream: number) => string,
) { ) {
this.#host = String(config.host); this.#host = String(config.host);
this.#port = Number(config.port ?? 80); this.#port = Number(config.port ?? 80);
this.#user = String(config.username ?? ""); this.#user = String(config.username ?? "");
this.#password = String(config.password ?? ""); this.#password = String(config.password ?? "");
this.#channel = Number(config.channel ?? 1); this.#channel = Number(config.channel ?? 1);
// 1 = main, 2 = sub. Clamp to those two; default main for back-compat.
this.#stream = Number(config.stream) === 2 ? 2 : 1;
this.#timeout = Number(config.timeoutMs ?? DEFAULT_TIMEOUT_MS); this.#timeout = Number(config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
this.#localAddress = config.localAddress ? String(config.localAddress) : undefined; this.#localAddress = config.localAddress
? String(config.localAddress)
: undefined;
} }
async connect(): Promise<void> {} async connect(): Promise<void> {}
@@ -49,8 +84,13 @@ class HttpCamera implements CameraDevice {
// frame: it exercises reachability + auth + the path/channel in one shot. // frame: it exercises reachability + auth + the path/channel in one shot.
try { try {
const res = await this.#get(); const res = await this.#get();
if (res.status === 200) return { status: "ready", detail: `${res.body.length} bytes` }; if (res.status === 200)
if (res.status === 401) return { status: "degraded", detail: "auth rejected (check username/password)" }; return { status: "ready", detail: `${res.body.length} bytes` };
if (res.status === 401)
return {
status: "degraded",
detail: "auth rejected (check username/password)",
};
return { status: "degraded", detail: `HTTP ${res.status}` }; return { status: "degraded", detail: `HTTP ${res.status}` };
} catch (err) { } catch (err) {
return { status: "offline", detail: (err as Error).message }; return { status: "offline", detail: (err as Error).message };
@@ -58,13 +98,37 @@ class HttpCamera implements CameraDevice {
} }
async captureSnapshot(ctx: SnapshotContext): Promise<Snapshot> { async captureSnapshot(ctx: SnapshotContext): Promise<Snapshot> {
const res = await this.#get(); // Retry transient "Device Busy" (503/500); a config error (401/404) fails fast.
let res = await this.#get();
for (
let attempt = 1;
res.status !== 200 &&
SNAPSHOT_RETRY_STATUSES.has(res.status) &&
attempt < SNAPSHOT_MAX_ATTEMPTS;
attempt++
) {
// Linear backoff (250/500/750ms) — the encoder frees within a frame or two.
await sleep(SNAPSHOT_RETRY_BASE_MS * attempt);
stubLog(
this.driverId,
`captureSnapshot ${ctx.direction} retry ${attempt} (was HTTP ${res.status})`,
);
res = await this.#get();
}
if (res.status !== 200) { if (res.status !== 200) {
// Name the busy case so the operator/telemetry can tell "camera busy" from a
// real fault (offline / auth / wrong path).
const busy = SNAPSHOT_RETRY_STATUSES.has(res.status)
? " (device busy)"
: "";
throw new Error( throw new Error(
`${this.driverId} snapshot failed (${ctx.direction}): HTTP ${res.status}`, `${this.driverId} snapshot failed (${ctx.direction}): HTTP ${res.status}${busy}`,
); );
} }
stubLog(this.driverId, `captureSnapshot ${ctx.direction} (${res.body.length} bytes)`); stubLog(
this.driverId,
`captureSnapshot ${ctx.direction} (${res.body.length} bytes)`,
);
return { return {
bytes: res.body, bytes: res.body,
contentType: res.contentType || "image/jpeg", contentType: res.contentType || "image/jpeg",
@@ -76,7 +140,7 @@ class HttpCamera implements CameraDevice {
return digestGet({ return digestGet({
host: this.#host, host: this.#host,
port: this.#port, port: this.#port,
path: this.snapshotPath(this.#channel), path: this.snapshotPath(this.#channel, this.#stream),
user: this.#user, user: this.#user,
password: this.#password, password: this.#password,
timeoutMs: this.#timeout, timeoutMs: this.#timeout,
@@ -93,7 +157,32 @@ const channelField: ConfigField = {
default: 1, default: 1,
}; };
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, channelField]; // Hikvision stream-within-channel for the snapshot: main (01) is full-res; sub (02)
// is lighter. Default MAIN (back-compat). Switch to SUB when the main encoder is
// saturated and returns a persistent 503 deviceBusy (seen on DS-2CD1047G3H-LIU) —
// the sub-stream is also the better fit for snapshot/ANPR (smaller, faster, doesn't
// contend with live-view/recording). See wiki/entities/lpr-camera.md.
const streamField: ConfigField = {
key: "stream",
label: "Snapshot stream",
type: "select",
required: false,
default: "1",
options: [
{ value: "1", label: "Main (01)" },
{ value: "2", label: "Sub (02)" },
],
};
// Dahua has no stream selector (its CGI snapshot isn't stream-encoded in the path).
const cameraConfigFields = [
hostField,
portField(80),
usernameField,
passwordField,
channelField,
];
const hikvisionConfigFields = [...cameraConfigFields, streamField];
// Hikvision "Alarm Server" PUSH config. The newer firmware (Event → Smart/VCA → // Hikvision "Alarm Server" PUSH config. The newer firmware (Event → Smart/VCA →
// "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm Settings → // "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm Settings →
@@ -138,15 +227,21 @@ export const hikvisionDriver: CameraDriver = {
id: "hikvision", id: "hikvision",
category: "camera", category: "camera",
label: "Hikvision camera", label: "Hikvision camera",
description: "Hikvision snapshot via ISAPI (HTTP Digest) + optional Alarm Server event push.", description:
"Hikvision snapshot via ISAPI (HTTP Digest) + optional Alarm Server event push.",
transports: ["tcp-ip"], transports: ["tcp-ip"],
// The camera PULLS snapshots, but with Alarm Server on it ALSO pushes events to us — // The camera PULLS snapshots, but with Alarm Server on it ALSO pushes events to us —
// so it may need the backend push IP at assign time (like the Dingtian). // so it may need the backend push IP at assign time (like the Dingtian).
pushesToBackend: true, pushesToBackend: true,
configFields: [...cameraConfigFields, ...alarmPushFields], configFields: [...hikvisionConfigFields, ...alarmPushFields],
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch2 main = 201. // ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch1 sub = 102, ch2 main = 201.
// stream 1 → "01" (main), 2 → "02" (sub).
create: (c) => create: (c) =>
new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`), new HttpCamera(
"hikvision",
c,
(ch, stream) => `/ISAPI/Streaming/channels/${ch}0${stream}/picture`,
),
}; };
export const dahuaDriver: CameraDriver = { export const dahuaDriver: CameraDriver = {
@@ -156,7 +251,12 @@ export const dahuaDriver: CameraDriver = {
description: "Dahua snapshot via CGI (HTTP Digest).", description: "Dahua snapshot via CGI (HTTP Digest).",
transports: ["tcp-ip"], transports: ["tcp-ip"],
configFields: cameraConfigFields, configFields: cameraConfigFields,
// Dahua channels are 0-based on the CGI; the admin enters 1-based. // Dahua channels are 0-based on the CGI; the admin enters 1-based. No stream in the
// path (the second arg is ignored — Dahua has no main/sub snapshot distinction here).
create: (c) => create: (c) =>
new HttpCamera("dahua", c, (ch) => `/cgi-bin/snapshot.cgi?channel=${Math.max(0, ch - 1)}`), new HttpCamera(
"dahua",
c,
(ch) => `/cgi-bin/snapshot.cgi?channel=${Math.max(0, ch - 1)}`,
),
}; };
+36
View File
@@ -0,0 +1,36 @@
import { execFile } from "node:child_process";
// Unprivileged ICMP liveness check for PUSH-only devices that expose no TCP port —
// e.g. the Dingtian/GEE QR readers, which GET our backend on each scan but listen on
// nothing. For those a TCP connect probe (what cameras/printers use) has nothing to
// connect to; ICMP echo is the only honest "powered + on-network" signal.
//
// We shell to the system `ping` rather than open a raw socket: Node's `dgram` is
// UDP-only (no IPPROTO_ICMP), and a raw socket needs CAP_NET_RAW. `/bin/ping` in
// SOCK_DGRAM mode runs WITHOUT NET_RAW when the kernel's `net.ipv4.ping_group_range`
// includes the runtime user's gid — which the booth compose sets as a sysctl (see
// docker-compose.prod.yml). So: no native dep, no NET_RAW. A ping only proves the box
// answers ICMP (not that the scan head works) — but it correctly flips red when the
// reader is unplugged/dead, which the old hardcoded "ready" never did.
// See wiki/entities/dingtian-qr-reader.md / device-status-monitoring.md.
/**
* Send ONE ICMP echo to `host` and resolve true if it replied within `timeoutMs`.
* Never throws — any spawn/permission/timeout failure resolves false (treated as
* "not reachable"). Linux `ping` flags: `-n` numeric (no DNS), `-c 1` one packet,
* `-w`/`-W` deadline. We pass the host as a fixed arg (execFile, not a shell) so a
* crafted "host" can't inject a command.
*/
export function icmpPing(host: string, timeoutMs = 2000): Promise<boolean> {
const deadlineSec = Math.max(1, Math.ceil(timeoutMs / 1000));
return new Promise((resolve) => {
const child = execFile(
"ping",
["-n", "-c", "1", "-w", String(deadlineSec), "-W", String(deadlineSec), host],
{ timeout: timeoutMs + 500 },
(err) => resolve(err == null), // exit 0 = a reply; anything else = no reply
);
// If the binary is missing entirely, execFile emits 'error' (callback also fires).
child.on("error", () => resolve(false));
});
}
@@ -0,0 +1,50 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { cashinoDriver } from "./printer-cashino.js";
import { renderTicket } from "./printer-escpos.js";
// End-to-end transport routing through the real driver: a USB-configured Cashino must
// resolve to the char-device transport and write the SAME ESC/POS bytes the TCP path
// would. (The TCP path is exercised by the routing/escpos suites and on hardware.)
describe("cashinoDriver — USB transport", () => {
let dir: string;
let devicePath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "cashino-usb-"));
devicePath = join(dir, "lp0");
// Stand in for an enumerated usblp node (the kernel creates it; we only open it).
writeFileSync(devicePath, "");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("prints a ticket to the configured USB device path", async () => {
const printer = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
const data = { ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" };
await printer.printTicket(data);
const written = readFileSync(devicePath);
expect(written.equals(renderTicket(data))).toBe(true);
});
it("healthCheck reports ready when the node exists, offline when it doesn't", async () => {
const present = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
expect((await present.healthCheck()).status).toBe("ready");
// An absent device node (printer unplugged / not enumerated) → offline.
const absent = cashinoDriver.create({
transport: "usb",
devicePath: join(dir, "absent-lp0"),
timeoutMs: 1000,
});
expect((await absent.healthCheck()).status).toBe("offline");
});
it("advertises both transports", () => {
expect(cashinoDriver.transports).toContain("usb");
expect(cashinoDriver.transports).toContain("tcp-ip");
});
});
+38 -31
View File
@@ -10,20 +10,31 @@ import type {
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js"; import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js"; import { hostField, portField, stubLog } from "./common.js";
import { import {
probe, devicePathField,
probeTo,
renderReceipt, renderReceipt,
renderReport, renderReport,
renderSubscriptionCard, renderSubscriptionCard,
renderTicket, renderTicket,
renderWindowChargeNotice, renderWindowChargeNotice,
sendRaw, sendTo,
transportField,
transportFromConfig,
type Transport,
} from "./printer-escpos.js"; } from "./printer-escpos.js";
// Cashino 80mm network thermal printer driver. The Cashino is an ESC/POS clone: // Cashino 80mm thermal printer driver (network OR USB). The Cashino is an ESC/POS
// it PRINTS identically to the Rongta (same byte stream — see ./printer-escpos.ts), // clone: it PRINTS identically to the Rongta (same byte stream — see
// so tickets, reports and subscription cards render the same. What it does NOT // ./printer-escpos.ts), so tickets, reports and subscription cards render the same,
// have is the Rongta board's decoded status web page (/prn_stat.htm). It cannot // over either transport. What it does NOT have is the Rongta board's decoded status
// report paper-out / cover-open / cutter faults in a form we trust. // web page (/prn_stat.htm). It cannot report paper-out / cover-open / cutter faults
// in a form we trust.
//
// TRANSPORT: a single `config.transport` ("tcp-ip" | "usb") picks the wire; the
// driver resolves it ONCE into a Transport and every print/probe stays transport-
// blind (see transportFromConfig/sendTo/probeTo). USB writes the same bytes to a
// local usblp char device (/dev/usb/lp0); TCP writes to the raw print socket. This
// clone is the natural USB candidate — reachability-only, no status page to lose.
// //
// Therefore this driver deliberately does NOT implement MonitorableDevice // Therefore this driver deliberately does NOT implement MonitorableDevice
// (no readStatus). The device monitor then falls back to the generic // (no readStatus). The device monitor then falls back to the generic
@@ -40,13 +51,11 @@ import {
class CashinoPrinter implements PrinterDevice { class CashinoPrinter implements PrinterDevice {
readonly driverId = "cashino"; readonly driverId = "cashino";
readonly #host: string; readonly #transport: Transport;
readonly #port: number;
readonly #timeout: number; readonly #timeout: number;
constructor(config: DeviceConfig) { constructor(config: DeviceConfig) {
this.#host = String(config.host); this.#transport = transportFromConfig(config);
this.#port = config.port ? Number(config.port) : 9100;
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000; this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
} }
@@ -59,15 +68,15 @@ class CashinoPrinter implements PrinterDevice {
} }
/** /**
* Reachability only — a TCP connect probe of the raw print socket. The Cashino * Reachability only — a connect probe (TCP) or char-device open probe (USB) of
* has no trustworthy status protocol, so this is the floor and the ceiling of * the print path. The Cashino has no trustworthy status protocol, so this is the
* what we report: reachable → ready, unreachable → offline. Deliberately NO * floor and the ceiling of what we report: reachable → ready, unreachable →
* readStatus(): the monitor uses this for the traffic-light, never a guessed * offline. Deliberately NO readStatus(): the monitor uses this for the
* paper/cover state. * traffic-light, never a guessed paper/cover state.
*/ */
async healthCheck(): Promise<DeviceHealth> { async healthCheck(): Promise<DeviceHealth> {
try { try {
await probe(this.#host, this.#port, this.#timeout); await probeTo(this.#transport, this.#timeout);
return { status: "ready" }; return { status: "ready" };
} catch (err) { } catch (err) {
return { status: "offline", detail: (err as Error).message }; return { status: "offline", detail: (err as Error).message };
@@ -75,12 +84,12 @@ class CashinoPrinter implements PrinterDevice {
} }
async printTicket(data: TicketData): Promise<void> { async printTicket(data: TicketData): Promise<void> {
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout); await sendTo(this.#transport, renderTicket(data), this.#timeout);
stubLog(this.driverId, `printed ticket ${data.ticketId}`); stubLog(this.driverId, `printed ticket ${data.ticketId}`);
} }
async printReport(report: PrintReport): Promise<void> { async printReport(report: PrintReport): Promise<void> {
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout); await sendTo(this.#transport, renderReport(report), this.#timeout);
stubLog( stubLog(
this.driverId, this.driverId,
`printed report "${report.title}" (${report.lines.length} lines)`, `printed report "${report.title}" (${report.lines.length} lines)`,
@@ -88,17 +97,12 @@ class CashinoPrinter implements PrinterDevice {
} }
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> { async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
await sendRaw( await sendTo(this.#transport, renderSubscriptionCard(data), this.#timeout);
this.#host,
this.#port,
renderSubscriptionCard(data),
this.#timeout,
);
stubLog(this.driverId, `printed subscription card ${data.code}`); stubLog(this.driverId, `printed subscription card ${data.code}`);
} }
async printReceipt(data: ReceiptData): Promise<void> { async printReceipt(data: ReceiptData): Promise<void> {
await sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout); await sendTo(this.#transport, renderReceipt(data), this.#timeout);
stubLog( stubLog(
this.driverId, this.driverId,
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`, `printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
@@ -106,7 +110,7 @@ class CashinoPrinter implements PrinterDevice {
} }
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> { async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
await sendRaw(this.#host, this.#port, renderWindowChargeNotice(data), this.#timeout); await sendTo(this.#transport, renderWindowChargeNotice(data), this.#timeout);
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`); stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
} }
} }
@@ -141,14 +145,17 @@ export const cashinoDriver: PrinterDriver = {
category: "printer", category: "printer",
label: "Cashino 80mm thermal printer", label: "Cashino 80mm thermal printer",
description: description:
"Cashino 80mm thermal printer (ESC/POS over raw TCP, port 9100). Prints like the Rongta but has no status page — monitored by reachability ping only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.", "Cashino 80mm thermal printer (ESC/POS over raw TCP port 9100, OR local USB /dev/usb/lp0). Prints like the Rongta but has no status page — monitored by reachability only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
transports: ["tcp-ip"], transports: ["tcp-ip", "usb"],
configFields: [ configFields: [
hostField, transportField,
devicePathField,
// host/port are TCP-only; not required because a USB printer needs neither.
{ ...hostField, required: false, help: `${hostField.help} Leave blank for a USB printer.` },
{ {
...portField(9100), ...portField(9100),
required: false, required: false,
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).", help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100). TCP only.",
}, },
roleField, roleField,
rankField, rankField,
@@ -1,9 +1,15 @@
import { describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { import {
renderTicket, renderTicket,
renderReceipt, renderReceipt,
renderWindowChargeNotice, renderWindowChargeNotice,
renderSubscriptionCard, renderSubscriptionCard,
probeUsb,
sendRawUsb,
transportFromConfig,
stamp, stamp,
} from "./printer-escpos.js"; } from "./printer-escpos.js";
@@ -103,6 +109,69 @@ describe("CP852 character mapping (the misprint fixes)", () => {
}); });
}); });
describe("USB transport (sendRawUsb / probeUsb / transportFromConfig)", () => {
// A regular file stands in for the usblp character device: open(O_WRONLY) + write
// is the same syscall path. This proves the transport is byte-blind — the EXACT
// ESC/POS stream renderTicket produces lands at the device path, with no transport
// touching a rendered byte (the whole point of the seam).
let dir: string;
let devicePath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "escpos-usb-"));
devicePath = join(dir, "lp0");
// A real usblp node already EXISTS (created by the kernel on enumeration); we open
// it O_WRONLY without O_CREAT, never create it. Pre-create the stand-in file so the
// test mirrors that — opening an ABSENT path means "printer not present" (offline).
writeFileSync(devicePath, "");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("writes the exact rendered ESC/POS bytes to the device path", async () => {
const payload = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" });
await sendRawUsb(devicePath, payload, 1000);
const written = readFileSync(devicePath);
expect(written.equals(payload)).toBe(true);
});
it("rejects when the device path can't be opened (printer not present)", async () => {
await expect(
sendRawUsb(join(dir, "absent-lp0"), Buffer.from([0x1b, 0x40]), 1000),
).rejects.toThrow();
});
it("probeUsb resolves for an existing node, rejects for a missing one", async () => {
await expect(probeUsb(devicePath, 1000)).resolves.toBeUndefined();
await expect(probeUsb(join(dir, "nope"), 1000)).rejects.toThrow();
});
it("transportFromConfig: transport=usb selects the char device (default /dev/usb/lp0)", () => {
expect(transportFromConfig({ transport: "usb", devicePath: "/dev/usb/lp1" })).toEqual({
kind: "usb",
devicePath: "/dev/usb/lp1",
});
expect(transportFromConfig({ transport: "usb" })).toEqual({
kind: "usb",
devicePath: "/dev/usb/lp0",
});
});
it("transportFromConfig: anything else is TCP (back-compat with host-only configs)", () => {
expect(transportFromConfig({ host: "10.0.0.9" })).toEqual({
kind: "tcp",
host: "10.0.0.9",
port: 9100,
});
expect(transportFromConfig({ host: "10.0.0.9", port: 9101 })).toEqual({
kind: "tcp",
host: "10.0.0.9",
port: 9101,
});
});
});
describe("stamp (Albanian date format)", () => { describe("stamp (Albanian date format)", () => {
it("formats an ISO time as '<day> <Month> <year> HH:MM:SS'", () => { it("formats an ISO time as '<day> <Month> <year> HH:MM:SS'", () => {
// Local-time dependent, so assert the structure + the Albanian month name. // Local-time dependent, so assert the structure + the Albanian month name.
@@ -1,4 +1,6 @@
import { Socket } from "node:net"; import { Socket } from "node:net";
import { open } from "node:fs/promises";
import { constants as FS } from "node:fs";
import type { import type {
PrintReport, PrintReport,
ReceiptData, ReceiptData,
@@ -567,7 +569,150 @@ export function probe(
}); });
} }
// --- USB transport (kernel usblp character device) ----------------------------
// An ESC/POS USB printer plugged into the appliance enumerates as a character
// device (e.g. /dev/usb/lp0) via the in-box `usblp` kernel driver. We deliver the
// SAME ESC/POS byte stream there as over TCP — only the transport differs, not a
// single rendered byte. No libusb / CUPS / native addon: a plain file write keeps
// the MIT-only + offline-first, minimal-deps appliance constraints, and the path is
// a LOCAL char device the booth operator (the threat model's adversary) can't reach
// over the network. Paper/cover is NOT sensed here — same honesty floor as the
// Cashino TCP probe. usblp + a udev rule granting the server write access to the
// node are a provisioning dependency. See wiki/concepts/printer-usb-transport.md.
/** Bound a promise with a timeout — a wedged USB printer can block a write (or even
* the open) indefinitely, and a stuck print must surface as a failure rather than
* hang the entry flow. The underlying handle leaks on timeout, but the process is
* the appliance server; a failed print is logged and retried/failed-over upstream. */
function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error(msg)), ms);
p.then(
(v) => {
clearTimeout(t);
resolve(v);
},
(e) => {
clearTimeout(t);
reject(e as Error);
},
);
});
}
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
* is a RAW character device: a single open + write delivers the job — there is no
* FIN/half-close dance (that was a TCP concern, where an early destroy() could
* truncate the stream). We always close the handle (even on a failed write). */
export async function sendRawUsb(
devicePath: string,
payload: Buffer,
timeoutMs: number,
): Promise<void> {
const handle = await withTimeout(
open(devicePath, FS.O_WRONLY | FS.O_NONBLOCK),
timeoutMs,
"usb open timeout",
);
try {
await withTimeout(handle.write(payload), timeoutMs, "usb write timeout");
} finally {
await handle.close();
}
}
/** Reachability for a USB printer: the floor is "does the char device exist and
* open writable". A present, openable /dev/usb/lp0 means usblp bound a powered,
* enumerated printer — the USB analogue of the TCP connect probe. (Like the Cashino
* TCP probe, this reports reachability only, never a guessed paper/cover state.) */
export async function probeUsb(devicePath: string, timeoutMs: number): Promise<void> {
const handle = await withTimeout(
open(devicePath, FS.O_WRONLY | FS.O_NONBLOCK),
timeoutMs,
"usb open timeout",
);
await handle.close();
}
// --- transport dispatch -------------------------------------------------------
// A discriminated transport so each driver resolves the wire ONCE (from config) and
// every print/probe call site stays transport-blind. Adding a transport = one more
// arm here + the render layer is untouched.
/** Where a printer's bytes go: a TCP raw-print socket, or a local USB char device. */
export type Transport =
| { kind: "tcp"; host: string; port: number }
| { kind: "usb"; devicePath: string };
/** Build a Transport from a driver's flat config. `transport: "usb"` selects the
* USB char device (`devicePath`, default /dev/usb/lp0); anything else is TCP
* (host + port, default 9100) — so existing network configs with no `transport`
* key keep working unchanged. */
export function transportFromConfig(config: {
transport?: unknown;
host?: unknown;
port?: unknown;
devicePath?: unknown;
}): Transport {
if (config.transport === "usb") {
return { kind: "usb", devicePath: String(config.devicePath ?? "/dev/usb/lp0") };
}
return {
kind: "tcp",
host: String(config.host),
port: config.port ? Number(config.port) : 9100,
};
}
/** Send an ESC/POS payload over whichever transport the printer is configured for. */
export function sendTo(t: Transport, payload: Buffer, timeoutMs: number): Promise<void> {
return t.kind === "usb"
? sendRawUsb(t.devicePath, payload, timeoutMs)
: sendRaw(t.host, t.port, payload, timeoutMs);
}
/** Reachability probe over whichever transport the printer is configured for. */
export function probeTo(t: Transport, timeoutMs: number): Promise<void> {
return t.kind === "usb"
? probeUsb(t.devicePath, timeoutMs)
: probe(t.host, t.port, timeoutMs);
}
/** Human label for a transport, for status detail / logs. */
export function transportLabel(t: Transport): string {
return t.kind === "usb" ? t.devicePath : `${t.host}:${t.port}`;
}
// --- shared driver config fields ---------------------------------------------- // --- shared driver config fields ----------------------------------------------
// Role + failover are identical across ESC/POS printers; defined here so each // Role + failover are identical across ESC/POS printers; defined here so each
// driver shares them. See wiki/concepts/printer-roles-failover.md. // driver shares them. See wiki/concepts/printer-roles-failover.md.
export type PrinterRole = "entry-dispenser" | "booth-receipt"; export type PrinterRole = "entry-dispenser" | "booth-receipt";
// --- shared printer config fields (transport) ---------------------------------
// TCP-or-USB is offered identically across the ESC/POS drivers; defined here so each
// shares the exact field set. The setup wizard renders these generically.
import type { ConfigField } from "../registry.js";
/** Connection-transport select: network (raw TCP 9100) or local USB char device. */
export const transportField: ConfigField = {
key: "transport",
label: "Connection",
type: "select",
required: true,
default: "tcp-ip",
options: [
{ value: "tcp-ip", label: "Network (raw TCP, port 9100)" },
{ value: "usb", label: "USB (local /dev/usb/lp0)" },
],
help: "USB drives a printer plugged into the appliance (usblp); Network drives one on the isolated device VLAN.",
};
/** USB character-device path; used only when transport=usb (ignored for TCP). */
export const devicePathField: ConfigField = {
key: "devicePath",
label: "USB device",
type: "string",
required: false,
default: "/dev/usb/lp0",
help: "Character device for a USB printer (usblp), e.g. /dev/usb/lp0. Only used when Connection is USB.",
};
+45 -28
View File
@@ -13,22 +13,28 @@ import type {
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js"; import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js"; import { hostField, portField, stubLog } from "./common.js";
import { import {
probe, devicePathField,
probeTo,
renderReceipt, renderReceipt,
renderReport, renderReport,
renderSubscriptionCard, renderSubscriptionCard,
renderTicket, renderTicket,
renderWindowChargeNotice, renderWindowChargeNotice,
sendRaw, sendTo,
transportField,
transportFromConfig,
type Transport,
} from "./printer-escpos.js"; } from "./printer-escpos.js";
// Rongta 80mm network thermal printer driver. Rongta RP-series printers (and the // Rongta 80mm thermal printer driver (network OR USB). Rongta RP-series printers
// many OEM clones that share their firmware) speak ESC/POS over a raw TCP socket // (and the many OEM clones that share their firmware) speak ESC/POS over a raw TCP
// on port 9100 — the JetDirect/RAW convention. The ESC/POS rendering + transport // socket on port 9100 — the JetDirect/RAW convention — or over a local USB usblp
// are shared with the other ESC/POS clones in ./printer-escpos.ts; what is unique // char device. The ESC/POS rendering + transport are shared with the other ESC/POS
// to Rongta — and lives here — is LIVE STATUS via the board's own status web page. // clones in ./printer-escpos.ts (config.transport picks the wire); what is unique to
// There is no auth on the print socket; like the other field devices it lives on // Rongta — and lives here — is LIVE STATUS via the board's own status web page. That
// the isolated device VLAN. // page is a NETWORK feature: a USB Rongta degrades to reachability-only monitoring
// (see readStatus). There is no auth on the print socket; like the other field
// devices a networked unit lives on the isolated device VLAN.
// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md. // See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md.
// //
// ROLES + FAILOVER: a lane has more than one printer. Each instance declares a // ROLES + FAILOVER: a lane has more than one printer. Each instance declares a
@@ -128,14 +134,15 @@ function parseStatusPage(html: string): StatusFlags {
class RongtaPrinter implements PrinterDevice, MonitorableDevice { class RongtaPrinter implements PrinterDevice, MonitorableDevice {
readonly driverId = "rongta"; readonly driverId = "rongta";
readonly #transport: Transport;
readonly #host: string; readonly #host: string;
readonly #port: number;
readonly #httpPort: number; readonly #httpPort: number;
readonly #timeout: number; readonly #timeout: number;
constructor(config: DeviceConfig) { constructor(config: DeviceConfig) {
this.#host = String(config.host); this.#transport = transportFromConfig(config);
this.#port = config.port ? Number(config.port) : 9100; // Kept for the HTTP status page (TCP only); empty on a USB printer.
this.#host = config.host ? String(config.host) : "";
this.#httpPort = config.httpPort ? Number(config.httpPort) : 80; this.#httpPort = config.httpPort ? Number(config.httpPort) : 80;
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000; this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
} }
@@ -150,7 +157,7 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
async healthCheck(): Promise<DeviceHealth> { async healthCheck(): Promise<DeviceHealth> {
try { try {
await probe(this.#host, this.#port, this.#timeout); await probeTo(this.#transport, this.#timeout);
return { status: "ready" }; return { status: "ready" };
} catch (err) { } catch (err) {
return { status: "offline", detail: (err as Error).message }; return { status: "offline", detail: (err as Error).message };
@@ -158,12 +165,12 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
} }
async printTicket(data: TicketData): Promise<void> { async printTicket(data: TicketData): Promise<void> {
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout); await sendTo(this.#transport, renderTicket(data), this.#timeout);
stubLog(this.driverId, `printed ticket ${data.ticketId}`); stubLog(this.driverId, `printed ticket ${data.ticketId}`);
} }
async printReport(report: PrintReport): Promise<void> { async printReport(report: PrintReport): Promise<void> {
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout); await sendTo(this.#transport, renderReport(report), this.#timeout);
stubLog( stubLog(
this.driverId, this.driverId,
`printed report "${report.title}" (${report.lines.length} lines)`, `printed report "${report.title}" (${report.lines.length} lines)`,
@@ -171,17 +178,12 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
} }
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> { async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
await sendRaw( await sendTo(this.#transport, renderSubscriptionCard(data), this.#timeout);
this.#host,
this.#port,
renderSubscriptionCard(data),
this.#timeout,
);
stubLog(this.driverId, `printed subscription card ${data.code}`); stubLog(this.driverId, `printed subscription card ${data.code}`);
} }
async printReceipt(data: import("../interfaces.js").ReceiptData): Promise<void> { async printReceipt(data: import("../interfaces.js").ReceiptData): Promise<void> {
await sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout); await sendTo(this.#transport, renderReceipt(data), this.#timeout);
stubLog( stubLog(
this.driverId, this.driverId,
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`, `printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
@@ -189,7 +191,7 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
} }
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> { async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
await sendRaw(this.#host, this.#port, renderWindowChargeNotice(data), this.#timeout); await sendTo(this.#transport, renderWindowChargeNotice(data), this.#timeout);
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`); stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
} }
@@ -206,6 +208,18 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
*/ */
async readStatus(): Promise<PrinterStatus> { async readStatus(): Promise<PrinterStatus> {
const checkedAt = new Date().toISOString(); const checkedAt = new Date().toISOString();
// The status page is an HTTP feature of the network board; a USB printer has no
// such page. Degrade to the reachability floor (open the char device) and report
// ready/offline only — never a guessed paper/cover state, same honesty rule as
// the Cashino. (A USB Rongta is effectively a Cashino for monitoring purposes.)
if (this.#transport.kind === "usb") {
try {
await probeTo(this.#transport, this.#timeout);
return { status: "ready", checkedAt };
} catch (err) {
return { status: "offline", detail: (err as Error).message, checkedAt };
}
}
let html: string; let html: string;
try { try {
html = await fetchStatusPage(this.#host, this.#httpPort, this.#timeout); html = await fetchStatusPage(this.#host, this.#httpPort, this.#timeout);
@@ -281,14 +295,17 @@ export const rongtaDriver: PrinterDriver = {
category: "printer", category: "printer",
label: "Rongta 80mm thermal printer", label: "Rongta 80mm thermal printer",
description: description:
"Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones that serve the /prn_stat.htm status page) over raw TCP (port 9100). No auth on the print socket — isolate the VLAN.", "Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones that serve the /prn_stat.htm status page) over raw TCP (port 9100), OR local USB /dev/usb/lp0. The decoded status page is a network feature — a USB Rongta is monitored by reachability only. No auth on the print socket — isolate the VLAN.",
transports: ["tcp-ip"], transports: ["tcp-ip", "usb"],
configFields: [ configFields: [
hostField, transportField,
devicePathField,
// host/port/status-page are TCP-only; not required for a USB printer.
{ ...hostField, required: false, help: `${hostField.help} Leave blank for a USB printer.` },
{ {
...portField(9100), ...portField(9100),
required: false, required: false,
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).", help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100). TCP only.",
}, },
{ {
key: "httpPort", key: "httpPort",
@@ -296,7 +313,7 @@ export const rongtaDriver: PrinterDriver = {
type: "port", type: "port",
required: false, required: false,
default: 80, default: 80,
help: "Device status page (/prn_stat.htm) port for live monitoring (default 80).", help: "Device status page (/prn_stat.htm) port for live monitoring (default 80). TCP only.",
}, },
roleField, roleField,
rankField, rankField,
@@ -0,0 +1,43 @@
import { afterEach, describe, expect, it, vi } from "vitest";
// Reader health: push-only QR readers expose no TCP port, so liveness is an ICMP
// ping of the (optional) configured IP. With no IP we must NOT claim "ready" (the old
// stub did, hiding offline readers behind a green dot) — we report degraded instead.
// icmpPing is mocked so the test is deterministic + offline.
const icmpPing = vi.fn<(host: string, timeoutMs?: number) => Promise<boolean>>();
vi.mock("./icmp.js", () => ({ icmpPing: (...a: [string, number?]) => icmpPing(...a) }));
const { geeQrReaderDriver } = await import("./reader.js");
afterEach(() => {
icmpPing.mockReset();
});
describe("QR reader healthCheck (ICMP liveness)", () => {
it("with an IP that replies → ready", async () => {
icmpPing.mockResolvedValue(true);
const r = geeQrReaderDriver.create({ serial: "H05M2AFA", host: "10.0.10.7" });
expect(await r.healthCheck()).toEqual({ status: "ready", detail: "ping 10.0.10.7" });
expect(icmpPing).toHaveBeenCalledWith("10.0.10.7");
});
it("with an IP that does NOT reply → offline (this is the bug fix)", async () => {
icmpPing.mockResolvedValue(false);
const r = geeQrReaderDriver.create({ serial: "H05M2AFA", host: "10.0.10.7" });
expect(await r.healthCheck()).toEqual({ status: "offline", detail: "no ping reply from 10.0.10.7" });
});
it("with NO IP → degraded (never a false 'ready')", async () => {
const r = geeQrReaderDriver.create({ serial: "H05M2AFA" });
const h = await r.healthCheck();
expect(h.status).toBe("degraded");
expect(icmpPing).not.toHaveBeenCalled(); // nothing to ping
});
it("exposes an optional host field for monitoring", () => {
const hostField = geeQrReaderDriver.configFields.find((f) => f.key === "host");
expect(hostField).toBeDefined();
expect(hostField!.required).toBe(false); // operation is push-by-serial; IP is monitor-only
});
});
+25 -1
View File
@@ -1,6 +1,7 @@
import type { DeviceHealth, ReaderDevice, ReaderEvent } from "../interfaces.js"; import type { DeviceHealth, ReaderDevice, ReaderEvent } from "../interfaces.js";
import type { DeviceConfig, ReaderDriver } from "../registry.js"; import type { DeviceConfig, ReaderDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js"; import { hostField, portField, stubLog } from "./common.js";
import { icmpPing } from "./icmp.js";
// Reader drivers (RF / optical). Two integration paths: Wiegand reads reach the // Reader drivers (RF / optical). Two integration paths: Wiegand reads reach the
// access controller directly (autonomous); TCP-IP readers are seen host-side. // access controller directly (autonomous); TCP-IP readers are seen host-side.
@@ -18,8 +19,23 @@ class StubReader implements ReaderDevice {
async disconnect(): Promise<void> { async disconnect(): Promise<void> {
stubLog(this.driverId, "disconnect"); stubLog(this.driverId, "disconnect");
} }
/**
* Liveness. These readers PUSH (scan → GET our backend) and expose no TCP port, so
* there's nothing to connect-probe. If the admin gave the reader's IP we ICMP-ping
* it (powered + on-network); a reply → ready, no reply → offline. With NO IP we
* report `degraded` ("set IP to monitor") rather than a false `ready` — a push
* device that's silent is indistinguishable from a dead one, so claiming `ready`
* unconditionally (the old behaviour) hid offline readers behind a green dot.
*/
async healthCheck(): Promise<DeviceHealth> { async healthCheck(): Promise<DeviceHealth> {
return { status: "ready", detail: "stub" }; const host = this.config.host ? String(this.config.host) : "";
if (!host) {
return { status: "degraded", detail: "push device — set IP to monitor" };
}
const alive = await icmpPing(host);
return alive
? { status: "ready", detail: `ping ${host}` }
: { status: "offline", detail: `no ping reply from ${host}` };
} }
onRead(cb: (r: ReaderEvent) => void): void { onRead(cb: (r: ReaderEvent) => void): void {
this.#cb = cb; this.#cb = cb;
@@ -80,6 +96,14 @@ export const geeQrReaderDriver: ReaderDriver = {
required: true, required: true,
help: "The reader's serial as it reports in each scan (the `cjihao` field). Used to map scans to this lane.", help: "The reader's serial as it reports in each scan (the `cjihao` field). Used to map scans to this lane.",
}, },
{
// OPTIONAL: the reader pushes by serial (operation needs no IP), but giving its
// IP lets the status monitor ICMP-ping it for a real online/offline dot instead
// of an always-green stub. Leave blank to skip monitoring (shows "set IP").
...hostField,
required: false,
help: "Optional: the reader's IP, used ONLY to monitor it (ping). Scans still resolve by serial. Leave blank to skip liveness monitoring.",
},
], ],
create: (c) => new StubReader("gee-qr-reader", c), create: (c) => new StubReader("gee-qr-reader", c),
}; };
+17
View File
@@ -35,6 +35,23 @@ export interface AccessControlDevice extends Device {
getDoorStatus(doorId: number): Promise<"open" | "closed">; getDoorStatus(doorId: number): Promise<"open" | "closed">;
} }
// --- Auxiliary outputs (non-barrier latched signals) ---------------------
// Optional capability for controllers with SPARE relays wired to something that
// is NOT a barrier — a button lamp, a "wait"/"go" sign. setAux LATCHES the output
// on or off and holds it (unlike pulseOpen, which is momentary). The
// barrier-not-a-door rule does NOT apply here: this output never gates a vehicle,
// so holding/blinking it is fine. Business logic drives indicators through THIS,
// never the driver's own relay methods. See wiki/concepts/button-light-indicator.md.
export interface AuxOutputDevice {
/** Latch an auxiliary output on/off. 1-based channel (a spare relay). */
setAux(channel: number, on: boolean): Promise<void>;
}
/** Feature-detect the aux-output capability on a built device adapter. */
export function hasAuxOutput(d: unknown): d is AuxOutputDevice {
return typeof (d as Partial<AuxOutputDevice>)?.setAux === "function";
}
// --- Inputs (buttons / dry contacts) ------------------------------------- // --- Inputs (buttons / dry contacts) -------------------------------------
// Optional capability for controllers that expose host-readable inputs SEPARATE // Optional capability for controllers that expose host-readable inputs SEPARATE
// from their relays — e.g. the Dingtian board. This is what enables host-in-the- // from their relays — e.g. the Dingtian board. This is what enables host-in-the-
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
#
# booth.sh — operate the parking stack on the booth PC (Ubuntu).
#
# Wraps the three compose files (base + a dev/prod override) so the operator runs
# one command instead of a long `docker compose -f … -f … --env-file …` line.
#
# ./booth.sh up # start the stack (detached)
# ./booth.sh update # pull newer images + recreate (the "there are new
# # images" case) — see `update` below
# ./booth.sh down # stop the stack
# ./booth.sh restart # restart without pulling
# ./booth.sh status # what's running
# ./booth.sh logs # follow logs (Ctrl-C to stop)
# ./booth.sh ps|pull|config|exec …
#
# Runs from wherever it sits next to the compose files (the booth deploys them
# flat, e.g. /opt/parking_systems/) or from the repo at scripts/booth.sh.
#
# Environment is PROD by default (the booth runs prod: pull pinned registry images,
# Caddy on :80, fast_alpr). Override with ENV=dev for a local build/dev run:
# ENV=dev ./booth.sh up
#
# Config comes from an .env file next to the compose files (REGISTRY, TAG,
# JWT_SECRET, …). Copy .env.example → .env and fill it in. See
# wiki/decisions/container-deployment.md.
set -euo pipefail
# --- locate the compose files -------------------------------------------------
# The script must work in BOTH layouts: in the repo at <repo>/scripts/booth.sh
# (files one level up), AND deployed flat on the booth (booth.sh sits next to the
# compose files, e.g. /opt/parking_systems/). So we don't assume a `scripts/`
# parent — we look for docker-compose.yml in the script's own dir, then ../,
# then $PWD, and cd there. (An absolute SELF is also kept for usage()/sed.)
SELF="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/$(basename -- "${BASH_SOURCE[0]}")"
SCRIPT_DIR="$(dirname -- "$SELF")"
REPO_DIR=""
for d in "$SCRIPT_DIR" "$SCRIPT_DIR/.." "$PWD"; do
if [ -f "$d/docker-compose.yml" ]; then REPO_DIR="$(cd -- "$d" && pwd)"; break; fi
done
[ -n "$REPO_DIR" ] || {
printf 'ERROR: docker-compose.yml not found (looked in %s, its parent, and %s).\n' \
"$SCRIPT_DIR" "$PWD" >&2
exit 1
}
cd "$REPO_DIR"
# --- environment selection (prod by default; the booth is prod) ---------------
ENV="${ENV:-prod}"
case "$ENV" in
prod|production) ENV=prod; OVERRIDE="docker-compose.prod.yml" ;;
dev|development) ENV=dev; OVERRIDE="docker-compose.dev.yml" ;;
*) echo "ERROR: ENV must be 'prod' or 'dev' (got '$ENV')." >&2; exit 2 ;;
esac
BASE="docker-compose.yml"
ENV_FILE="${ENV_FILE:-.env}"
# --- colours (only when attached to a terminal) -------------------------------
if [ -t 1 ]; then
R="$(printf '\033[31m')"; G="$(printf '\033[32m')"; Y="$(printf '\033[33m')"
B="$(printf '\033[1m')"; N="$(printf '\033[0m')"
else
R=""; G=""; Y=""; B=""; N=""
fi
info() { printf '%s==>%s %s\n' "$B" "$N" "$*"; }
warn() { printf '%s!! %s%s\n' "$Y" "$*" "$N" >&2; }
die() { printf '%sERROR:%s %s\n' "$R" "$N" "$*" >&2; exit 1; }
usage() {
sed -n '3,26p' "$SELF" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
# --- preflight (only for commands that actually talk to Docker) ---------------
# Deferred into a function so `help`/usage works with no Docker and no .env.
ENV_ARGS=()
DC=()
preflight() {
command -v docker >/dev/null 2>&1 || die "docker is not installed or not on PATH."
# Prefer the v2 plugin (`docker compose`); fall back to legacy `docker-compose`.
if docker compose version >/dev/null 2>&1; then
DC=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
DC=(docker-compose)
else
die "Docker Compose v2 plugin not found ('docker compose'). Install docker-compose-plugin."
fi
[ -f "$BASE" ] || die "missing $BASE in $REPO_DIR"
[ -f "$OVERRIDE" ] || die "missing $OVERRIDE in $REPO_DIR"
# An .env is required for prod (JWT_SECRET et al. have no safe default); optional
# for dev (we inject a benign local secret below). Pass --env-file only when it
# exists so dev works without one.
if [ -f "$ENV_FILE" ]; then
ENV_ARGS=(--env-file "$ENV_FILE")
elif [ "$ENV" = "prod" ]; then
die "no $ENV_FILE found. Copy .env.example to $ENV_FILE and set JWT_SECRET/REGISTRY/TAG. (prod has no safe defaults.)"
else
# The BASE compose file makes JWT_SECRET shell-required (${JWT_SECRET:?}), which
# the dev override's service-level default can't satisfy. For a dev run with no
# .env, inject the same benign 32-char local secret the dev override documents so
# `up`/`config` work out of the box. NEVER do this for prod (the die above).
warn "no $ENV_FILE found — injecting the documented local-dev JWT_SECRET (dev only)."
: "${JWT_SECRET:=localdevsecret0123456789abcdef0123}"
export JWT_SECRET
fi
}
# The assembled compose invocation every subcommand builds on (runs preflight once).
compose() { "${DC[@]}" -f "$BASE" -f "$OVERRIDE" "${ENV_ARGS[@]}" "$@"; }
# --- subcommands --------------------------------------------------------------
cmd="${1:-}"; [ "$#" -gt 0 ] && shift || true
# Help/usage short-circuits before any Docker or .env requirement.
case "$cmd" in ""|-h|--help|help) usage 0 ;; esac
# Reject an unknown command up front (before preflight) so a typo gets a clear
# "unknown command" rather than a confusing "no .env" from the prod env check.
case "$cmd" in
up|start|update|upgrade|down|stop|restart|pull|status|ps|logs|config|exec) ;;
*) warn "unknown command: $cmd"; usage 1 ;;
esac
preflight
case "$cmd" in
up|start)
info "Starting the parking stack ($B$ENV$N) …"
compose up -d "$@"
info "Up. ${G}$(compose ps --services 2>/dev/null | tr '\n' ' ')${N}"
info "Booth UI: prod → http://<booth-ip>/ · dev → http://<booth-ip>:3000/"
;;
update|upgrade)
# The "I know there are new images" path: pull the moving branch tag, then
# recreate only what changed. Compose recreates a service whose image digest
# moved; unchanged services (and the named volumes — the SQLite DB!) are left
# alone. Old image layers are pruned afterwards to reclaim disk.
[ "$ENV" = "prod" ] || warn "update on ENV=$ENV: dev builds locally, so 'pull' may be a no-op. Use 'up --build' to rebuild dev."
info "Pulling newer images for the ${B}$ENV_FILE${N} TAG …"
compose pull
info "Recreating changed services (volumes/DB preserved) …"
compose up -d --remove-orphans
info "Pruning dangling image layers …"
docker image prune -f >/dev/null || true
info "${G}Update complete.${N} Running:"
compose ps
;;
down|stop)
info "Stopping the parking stack ($ENV) …"
# NOTE: never pass -v here — that would delete the parking-data volume (the
# signed event ledger). Volumes are intentionally preserved across down/up.
compose down "$@"
;;
restart)
info "Restarting (no pull) …"
compose restart "$@"
;;
pull)
info "Pulling images only (no recreate) …"
compose pull "$@"
;;
status|ps)
compose ps "$@"
;;
logs)
# Follow by default; pass a service name to scope, e.g. `logs server`.
compose logs -f --tail=200 "$@"
;;
config)
# Render the merged, variable-substituted compose config (debugging).
compose config "$@"
;;
exec)
[ "$#" -ge 1 ] || die "usage: $0 exec <service> [cmd…] (e.g. exec server sh)"
compose exec "$@"
;;
esac
+75
View File
@@ -0,0 +1,75 @@
---
type: concept
tags: [parking, device, indicator, radar, camera, aux-output, barrier-not-a-door]
sources: []
updated: 2026-06-24
status: settled
---
# Button-light indicator (radar × camera disagreement lamp)
The entry button has a **12 V light**. It is driven by the host on a **spare relay** of the
[[dingtian-relay|Dingtian]] controller as a 3-state indicator that combines the **[[hikvision-radar|
radar]]** input with the **camera "car in zone"** signal:
| Radar input | Camera (lane entry busy) | Button light |
| --- | --- | --- |
| detecting | **free** — no car confirmed | **BLINK** (~1 Hz) |
| detecting | **busy** — camera confirms a car | **SOLID on** |
| clear | — | **OFF** |
It is a **disagreement indicator**: the radar sees *something* but the camera hasn't confirmed a
real vehicle → blink (attention / "pull forward"); both agree → solid; nothing there → off.
## Signals
- **Radar** = the presence input edge on the entry relay (`relays[].presenceInput`, the same edge
the [[entry-double-press|one-car-one-ticket]] gate observes — so the lamp and the gate always
agree on "a car is here").
- **Camera "car in zone"** = the existing **[[lpr-camera|lane status]]** (`LaneStatusEvent` entry
busy/free, from camera vehicle detection). Already advisory; already drives the booth's barrier
lights. No new camera plumbing.
## Config
A controller-level `config.buttonLight = { relay, blinkOnMs?, blinkOffMs? }` (the operator picks a
**spare** relay — not a barrier relay; the setup UI warns if it overlaps one). Blink defaults to
500 ms / 500 ms.
## Implementation
`apps/server/src/button-light.ts` — `ButtonLightController` subscribes to `deviceEvents.onInput`
(radar) + `onLaneStatus` (camera), computes the target state per controller, and drives the lamp via
a **device-agnostic aux-output** capability.
- **Aux-output capability.** `AuxOutputDevice { setAux(channel, on) }` on the device interface (the
Dingtian driver implements it as a latch). Business logic drives the lamp through this — **never**
the driver's barrier methods.
- **Barrier-not-a-door is preserved.** The lamp is **not a barrier**, so holding / blinking it on a
timer is fine — the [[barrier-not-a-door]] rule forbids timing a *barrier* closed, and barriers
still only ever `pulseOpen`. The lamp uses the separate `setAux` latch.
- **Fails OFF.** On host loss, shutdown, or a `setAux` error the lamp defaults OFF — a dead lamp is
"no hint", never a misleading solid "go". SOLID is only ever held while busy + present is actively
true (never latched on through a crash path).
- **Serialized sends (must — UDP is unordered).** The first cut fired fire-and-forget `setAux` every
500 ms; over **unordered UDP** the on/off packets reordered/overlapped and the relay **latched on
whichever packet the device processed last** — the lamp got stuck on/off at random (observed on
hardware). Fix: a **desired-state + serialized worker** (`#pump`). The blink timer only flips a
`desiredOn` flag; the worker guarantees **one in-flight send per lamp** and, on completion,
re-converges to the latest desired state. So the **final state is always authoritative** and a
lost/stale packet self-corrects. This also de-dupes (it skips a send when `confirmedOn === desiredOn`),
so the input stream never spams the controller.
- **Hot-reloads the config (no restart).** The lamp map is reconciled against the live device config
at start AND before each event (mirroring [[device-status-monitoring|DeviceMonitor]], which re-reads
the device set each tick) — adding/updating/dropping lamps. So a button light added or re-pointed in
the setup UI takes effect on the **next radar edge**, not after a server restart. (The first cut
loaded the map once at boot, so a just-saved lamp silently did nothing until restart.)
## Status
Built 2026-06-24 for the first booth (button I1, radar I2, lamp on a spare relay); the serialized-send
+ hot-reload fixes landed the same day after the lamp stuck on/off on hardware. Covered by
`apps/server/src/button-light.test.ts` (the truth table, blink toggling asserted on the device's
*confirmed* state, fail-OFF, de-dupe, and a lamp-added-after-start reconcile case).
Related: [[hikvision-radar]], [[entry-double-press]], [[lpr-camera]], [[dingtian-relay]],
[[barrier-not-a-door]].
+10 -1
View File
@@ -2,7 +2,7 @@
type: concept type: concept
tags: [parking, device, monitoring, reliability, ui] tags: [parking, device, monitoring, reliability, ui]
sources: [] sources: []
updated: 2026-06-18 updated: 2026-06-26
status: open status: open
--- ---
@@ -25,6 +25,15 @@ talks only to the adapter interfaces ([[device-adapter-pattern]]), never a drive
- **Relays / readers / cameras** → the generic `Device.healthCheck()` **reachability** probe every - **Relays / readers / cameras** → the generic `Device.healthCheck()` **reachability** probe every
adapter implements (`ready | degraded | offline`). This is presence/up-ness, not a deep fault adapter implements (`ready | degraded | offline`). This is presence/up-ness, not a deep fault
model — a relay either answers or it doesn't. model — a relay either answers or it doesn't.
> **Reader health was a LIE until 2026-06-26.** The QR-reader adapter (a PUSH device: it GETs our
> backend on each scan and exposes **no TCP port**) had a hardcoded `healthCheck → { ready, "stub" }`,
> so two genuinely-offline readers still showed **green**. A push device that's silent is
> indistinguishable from a dead one — so claiming `ready` unconditionally is the worst failure
> (false-healthy). Fix: an **optional reader IP** (monitor-only; scans still resolve by serial) +
> an **unprivileged ICMP ping** (`drivers/icmp.ts`, shells `/bin/ping` in SOCK_DGRAM mode — no
> CAP_NET_RAW, no native dep; the booth compose sets `net.ipv4.ping_group_range`). Reply → `ready`,
> no reply → `offline`; **no IP set → `degraded` ("set IP to monitor")**, never a false green.
> Verified on hardware: pinged the real readers on the device VLAN. See [[gee-qr-er80]].
Both collapse to one **traffic-light**: `ready | degraded | offline`, plus a `detail` string. Fail Both collapse to one **traffic-light**: `ready | degraded | offline`, plus a `detail` string. Fail
**toward "there's a problem"**, never false-healthy: a probe that throws or times out reads **toward "there's a problem"**, never false-healthy: a probe that throws or times out reads
+6 -4
View File
@@ -26,10 +26,12 @@ press → print → press again issued a second ticket immediately. That is not
The guard lives on the entry relay's spec (`config.relays[]` — see [[entry-exit-points]]), because The guard lives on the entry relay's spec (`config.relays[]` — see [[entry-exit-points]]), because
whether real one-car-one-ticket is *possible* depends on the hardware at that lane. Two modes: whether real one-car-one-ticket is *possible* depends on the hardware at that lane. Two modes:
### PRESENCE mode (preferred — when a vehicle loop is wired) ### PRESENCE mode (preferred — when a vehicle-presence sensor is wired)
`relays[].presenceInput` = the 1-based input terminal of an **induction loop / barrier presence `relays[].presenceInput` = the 1-based input terminal of a **vehicle-presence sensor** on the same
signal** on the same [[dingtian-relay|controller]] (the Dingtian's inputs are decoupled from its [[dingtian-relay|controller]] (the Dingtian's inputs are decoupled from its relays). The sensor may
relays, and loops are already in the [[bom]]). The rule makes one-car-one-ticket **physical**: be an **induction loop** OR a **[[hikvision-radar|radar]]** (`relays[].presenceKind: "loop"|"radar"`
— a label; the gate behaviour is identical). A radar wired to idle opposite the button needs
`presenceActiveLow: true` so its edge reads correctly. The rule makes one-car-one-ticket **physical**:
- A press prints **only while a car is present** on the loop. - A press prints **only while a car is present** on the loop.
- After a ticket prints, the relay is **disarmed** — no second ticket — **until the loop CLEARS** - After a ticket prints, the relay is **disarmed** — no second ticket — **until the loop CLEARS**
+91
View File
@@ -0,0 +1,91 @@
---
type: concept
tags: [parking, device, printer, transport, usb, escpos, provisioning]
sources: []
updated: 2026-06-24
status: settled
---
# Printer USB transport (kernel usblp, behind the ESC/POS layer)
The ESC/POS printer drivers ([[rongta-printer|rongta]], `cashino`) can deliver their byte stream
over **either a raw TCP socket (port 9100)** or a **local USB character device** (`/dev/usb/lp0`),
selected per device by `config.transport` (`"tcp-ip" | "usb"`). The original architecture always
intended one ESC/POS adapter to cover "USB **or** network" (parking-system-architecture §BOM); the
first implementation shipped TCP-only, and this closes that gap.
## The seam — render once, dispatch the transport
Every `render*()` function in `packages/devices/src/drivers/printer-escpos.ts` produces a
**transport-independent ESC/POS `Buffer`**. Only delivery differs. The transport is resolved **once**
per driver from config and every print/probe call site stays transport-blind:
- `transportFromConfig(config)` → a discriminated `Transport` (`{ kind: "tcp", host, port }` or
`{ kind: "usb", devicePath }`). Anything other than `transport: "usb"` is TCP, so **existing
host-only configs keep working unchanged** (no migration).
- `sendTo(t, payload, timeoutMs)` / `probeTo(t, timeoutMs)` dispatch to the TCP pair
(`sendRaw`/`probe`) or the USB pair (`sendRawUsb`/`probeUsb`).
Adding a transport = one more arm in the dispatcher; **not a single rendered byte changes**. This is
why the CP852 map, the Code128/QR builders, roles/failover, and the receipt/ticket/voucher layouts
are all untouched by USB support.
## USB transport = the in-box `usblp` char device
A USB ESC/POS printer plugged into the appliance enumerates as a **character device** (e.g.
`/dev/usb/lp0`) via the kernel's in-box **`usblp`** driver. We just **open it `O_WRONLY` and write
the same bytes**:
- **No native dependency.** A plain `fs` write — no libusb, no CUPS, no native addon. This keeps the
**MIT/Apache/BSD-only** dependency constraint and the **offline-first, minimal-deps appliance**
posture (see [[technology-stack]], [[offline-first]]).
- **`usblp` is raw.** Unlike the TCP path there is **no FIN/half-close dance** (the graceful-close
fix was a *TCP* concern — an early `destroy()` could RST-truncate the stream; see
[[rongta-printer]]). A single open + write delivers the job; we always close the handle.
- **Bounded by a timeout.** A wedged USB printer can block the write (or the open) indefinitely; a
stuck print must surface as a failure, not hang the entry flow. `withTimeout` rejects after
`timeoutMs`.
## Status over USB — reachability only (honesty rule)
`probeUsb` is "does the char device exist and open writable" — the **USB analogue of the TCP connect
probe**. A present, openable `/dev/usb/lp0` means `usblp` bound a powered, enumerated printer.
- The `cashino` driver is reachability-only on **both** transports (it never had a status page).
- The `rongta` driver's rich `readStatus()` scrapes the board's **HTTP** `/prn_stat.htm` — a
**network feature**. Over USB there is no such page, so `readStatus()` **degrades to the
reachability floor** (ready/offline only, never a guessed paper/cover state). A USB Rongta is
effectively a Cashino for monitoring. This preserves the standing honesty rule from
[[printer-status-monitoring]]: never report a paper/cover verdict the transport can't actually sense.
## Threat model
The USB path is a **local character device** the booth operator (the threat model's adversary)
cannot reach over the network — narrower attack surface than the unauthenticated TCP print socket on
the VLAN. Printers are advisory output; nothing about the signed [[append-only-event-chain|ledger]]
or barrier control is touched.
## Provisioning dependency (NOT app code) — see open-questions #14
Driving a USB printer depends on the appliance image:
1. the **`usblp`** kernel module is loaded (it is in-box on Ubuntu 26.04; CUPS can claim the
interface first — may need `usblp` to win, or CUPS masked for that device), and
2. a **udev rule** grants the server process write access to the node (e.g. a group on
`/dev/usb/lp*`), since the appliance server does not run as root.
This is a [[appliance-provisioning]] concern, recorded as **open-questions #14** until the on-site
printer is confirmed USB and the rule is baked into the image and verified on hardware.
## Status
Built 2026-06-24 behind the existing render layer. `sendRawUsb`/`probeUsb`/`transportFromConfig`/
`sendTo`/`probeTo` in `printer-escpos.ts`; `cashino` + `rongta` resolve a `Transport` and dispatch.
The setup UI offers a **Connection** select (Network / USB) + a **USB device** path field (default
`/dev/usb/lp0`); host/port are not-required so a USB printer needs neither. Covered by
`printer-escpos.test.ts` (USB writes the exact rendered bytes; probe present/absent;
`transportFromConfig` TCP back-compat) and `printer-cashino.test.ts` (a USB-configured driver prints
to the node and reports ready/offline). The on-hardware confirmation + the udev/usblp provisioning
are pending (open-questions #14).
Related: [[rongta-printer]], [[printer-status-monitoring]], [[printer-roles-failover]],
[[appliance-provisioning]], [[network-isolation]], [[technology-stack]].
+135 -28
View File
@@ -2,17 +2,18 @@
type: reference type: reference
tags: [parking, deployment, appliance, hardening, runbook, offline-first] tags: [parking, deployment, appliance, hardening, runbook, offline-first]
sources: [] sources: []
updated: 2026-06-23 updated: 2026-06-27
status: settled status: settled
--- ---
# Appliance provisioning runbook (booth PC) # Appliance provisioning runbook (booth PC)
Step-by-step to take a booth PC from factory Windows to a hardened, encrypted, container-running Step-by-step to take a booth PC from factory Windows to a hardened, encrypted, container-running
parking appliance. Written from the **first real provisioning, 2026-06-23**, on the actual hardware parking appliance. Written from the **first real provisioning, 2026-06-23** (hardening) +
below — every command here was run and verified on that machine, including the firmware-specific **first Komodo deploy, 2026-06-27** (the runtime) — every command here was run and verified on the
workaround. Companion to [[disk-os-hardening]] (the *why*), [[tpm]] (TPM analysis), and actual hardware, including the firmware-specific workaround. Companion to [[disk-os-hardening]] (the
[[container-deployment]] (the images this runs). *why*), [[tpm]] (TPM analysis), [[container-deployment]] (the images), and
[[fleet-deployment-komodo]] (the deploy control plane this runbook's §7 uses).
> ⚠ This box is the [[threat-model|outsider-with-the-box]] defence. The load-bearing anti-fraud > ⚠ This box is the [[threat-model|outsider-with-the-box]] defence. The load-bearing anti-fraud
> control is still [[reconciliation]] over the [[append-only-event-chain|signed chain]] — disk > control is still [[reconciliation]] over the [[append-only-event-chain|signed chain]] — disk
@@ -171,50 +172,140 @@ default) — `admin`+sudo IS the root path; enabling root adds risk, no gain.
## 5b. Further hardening (TODO — not yet done) ## 5b. Further hardening (TODO — not yet done)
- **Key-based SSH only** (disable password auth) if SSH is enabled at all. - **Key-based SSH only** (disable password auth) if SSH is enabled at all. Routine ops no longer
need SSH — Komodo Periphery (§7) drives deploys + gives a container terminal over the mesh — so
SSH can be locked down hard or disabled, leaving the mesh + Komodo as the management path.
- **No/locked-down desktop + kiosk autostart** — single-purpose; the operator never reaches a shell - **No/locked-down desktop + kiosk autostart** — single-purpose; the operator never reaches a shell
([[desktop-shell-tauri]]). ([[desktop-shell-tauri]]).
- Consider moving the host **event-signing key into the TPM** (non-extractable) — [[tpm]], [[open-questions]] #12. - Consider moving the host **event-signing key into the TPM** (non-extractable) — [[tpm]], [[open-questions]] #12.
- `sudo apt autoremove` the leftover old kernel once the new one is proven. - `sudo apt autoremove` the leftover old kernel once the new one is proven.
## 6. Runtime — Docker stack (VERIFIED 2026-06-23) ## 6. Runtime — Docker engine (VERIFIED 2026-06-23)
Install Docker Engine + compose (as `admin`). NB Ubuntu 26.04 codename is **`resolute`**, which Install Docker Engine + compose (as `admin`). NB Ubuntu 26.04 codename is **`resolute`**, which
download.docker.com may not yet publish — pin the repo line to `noble`, OR use Ubuntu's `docker.io`. download.docker.com may not yet publish — pin the repo line to `noble`, OR use Ubuntu's `docker.io`.
Add only `admin` to the `docker` group (root-equivalent — NEVER the operator). Add only `admin` to the `docker` group (root-equivalent — NEVER the operator).
Deploy from a standalone dir (hand-copied; no repo on the appliance), e.g. `/opt/parking_solution`: This gives the appliance the engine. **How the stack gets ONTO it is step 7** — and as of
`docker-compose.yml` + `docker-compose.prod.yml` (the Caddy/prod override) + `Caddyfile` + a `.env` 2026-06-27 the primary path is **Komodo (remote, no-SSH)**, not a hand-copied dir. The manual
(chmod 600). The `.env` (driven into the containers by the base compose): `docker compose` flow survives as a **break-glass fallback** (§7c).
``` ## 7. Deploy the stack — Komodo Periphery (PRIMARY, 2026-06-27)
JWT_SECRET=<openssl rand -hex 32> # server REFUSES to boot without (>=32, no insecure default)
EVENT_SIGNING_KEY=<a DIFFERENT openssl rand -hex 32> The booth is driven by a central **Komodo Core** over the **NetBird** mesh. The appliance runs a
COOKIE_SECURE=0 # CRITICAL on plain-http or the auth cookie never sends → no login small **Periphery** agent that *dials out* to Core; Core then deploys the same compose files. No
WS_ALLOWED_ORIGINS=http://<name-or-ip> # any REMOTE origin admins use (same-origin always passes) inbound port on the booth, no SSH for routine ops. Full rationale + threat model:
VISION_ENABLED=1 [[fleet-deployment-komodo]]. Verified end-to-end on the first booth (`park-buzi`) 2026-06-27.
# REGISTRY/TAG default to git.infra.msai.al/mca/parking_solution + dev; set TAG=main to pin.
``` ### 7a. Install Periphery (on the booth, as `admin`)
Prereq: the booth is on the **NetBird** mesh and can reach Core's reverse-proxy URL
(`https://komodo.infra.msai.al`).
1. In Core: **Settings → Onboarding → + New Onboarding Key** (Name = the booth, e.g. `park-buzi`;
Expiry ~1 day; Pre-Existing Key empty). Copy the one-time `O-…` key. **Single-use** — delete it
after the agent connects.
2. On the booth, install Periphery in **user mode** (runs as `admin`, who is in `docker`; NO root
daemon; **outbound** → opens no inbound port):
```bash ```bash
docker login git.infra.msai.al # a read-only package token, not the account password curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/setup-periphery.py | python3 - --user \
docker compose -f docker-compose.yml -f docker-compose.prod.yml config # dry-run: verify the merged env --core-address="https://komodo.infra.msai.al" \
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull --connect-as="park-buzi" \
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --onboarding-key="O-…"
# Seed the FIRST admin (DB starts empty → nobody can log in until this runs; idempotent): sudo loginctl enable-linger admin # so the user service starts at boot without a login
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec \
-e ADMIN_USER=admin -e ADMIN_PASS='<strong-pw>' server node scripts/seed-admin.mjs
``` ```
Healthy startup logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked - `--connect-as` is the **Server name in Core** — unique, stable, site-meaningful (the fleet's
weights), server `[migrate] done` → `SPA static serving enabled` → `Server listening`. The transient primary key). Booth #2 = a different name (e.g. `park-durres`); never reuse one.
- `--core-address` is Core's **reverse-proxy URL** (the URL you load the Core UI at over the mesh),
NOT `:9120` — Core's container port `9120` is exposed-not-published; the agent reaches it through
the proxy. (Gotcha #7 below.)
- Config lands at `~/.config/komodo/periphery.config.toml`. The key field is **`core_address`**
(singular); `root_directory` must be a path `admin` can write (user-mode default is fine — a
`/etc/komodo` default from a system install would `Permission denied` for the user service).
Verify: `systemctl --user status periphery` → active; the server **`park-buzi`** appears and goes
**OK/green** in Core → Servers. Then **delete the onboarding key**.
### 7b. Deploy the Stack (in Core — by hand once, then code)
Add **Registry Account** + **Git Account** for `git.infra.msai.al` (user `komodo`, tokens) in Core
so Periphery can clone the repo AND pull the private images. Two distinct credential types — the
git clone working does NOT imply the image pull is authed (gotcha #8). Per-booth secrets
(`park_<booth>_jwt_secret`, `park_<booth>_event_signing_key` — distinct values, `openssl rand -hex
32`) live in Core's **Variables/Secrets** store, referenced from the Stack as `[[…]]`.
Create a **Stack** (UI → Stacks → New), name = the booth (`park-buzi`):
- **Server:** `park-buzi` · **Source:** repo `mca/parking_solution`, branch `dev`, files
`docker-compose.yml` + `docker-compose.prod.yml` · **Registry account:** `komodo` (else the pull
is anonymous → `no basic auth credentials`).
- **Environment** (Komodo writes this to a `.env` on the booth at deploy, substituting `[[…]]`):
```
REGISTRY=git.infra.msai.al/mca/parking_solution
TAG=dev # moving tag (staging). PIN to dev-<sha> for a live booth.
COOKIE_SECURE=0 # CRITICAL on plain-http or the auth cookie never sends → no login
VISION_ENABLED=1
WS_ALLOWED_ORIGINS= # browser at the booth URL is same-origin; leave empty (the
# Tauri desktop app needs its origin here — separate task)
JWT_SECRET=[[park_buzi_jwt_secret]]
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
```
Deploy → Periphery pulls + `compose up`s. All containers (`proxy`/Caddy, `server`, `vision`) green.
Seed the FIRST admin (DB starts empty → nobody can log in until this runs; idempotent) **via
Komodo's terminal on the `server` container** (no SSH):
```bash
docker exec -it -e ADMIN_USER=admin -e ADMIN_PASS='<strong-pw>' \
park-buzi-server-1 node scripts/seed-admin.mjs
```
> **Secrets-on-disk note.** The generated `.env` lands on the booth with **cleartext** secrets
> (compose needs real values). That's why the disk is LUKS-encrypted (§3–4) and keys are per-booth
> — the encryption is the control, and a single-booth compromise leaks only that booth's key. See
> [[fleet-deployment-komodo]] (the `EVENT_SIGNING_KEY`-in-Core blast-radius caveat; ATECC608 is the
> intended long-term signer).
### 7b-bis. Fleet-as-code (`resources.toml`) — optional but recommended
The repo's `komodo/resources.toml` mirrors the working Stack. Pointing a Core **ResourceSync** at
it makes the fleet **git-managed**: booth #N is a copy-pasted `[[stack]]` block; an image bump is a
one-line `TAG=` edit + push + Execute; every change is an auditable commit; a rebuilt Core
re-creates everything from the file. Keep the sync **Unmanaged** + **Delete-Unmatched OFF** until
trusted. An **empty diff / disabled Execute = the file already matches the live Stack** (success,
not an error). See `komodo/README.md` and [[fleet-deployment-komodo]].
### 7c. Break-glass — manual compose (mesh/Core down)
When the mesh or Core is unreachable, the same compose files run locally via `scripts/booth.sh`
(or raw `docker compose`). Needs a local `.env` and a `docker login git.infra.msai.al` (a
read-only package token). This is the FALLBACK, not the routine path:
```bash
docker login git.infra.msai.al
ENV=prod ./booth.sh config # dry-run the merged env
ENV=prod ./booth.sh up
```
`booth.sh` runs from wherever it sits next to the compose files (the booth deploys them flat, e.g.
`/opt/parking_systems/`). See [[container-deployment]].
### Healthy startup + web-access
Healthy logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked weights),
server `[migrate] done` → `SPA static serving enabled` → `Server listening`. The transient
`vision-service -> offline` at boot then `-> ready (fast_alpr)` ~8s later is normal (monitor polls `vision-service -> offline` at boot then `-> ready (fast_alpr)` ~8s later is normal (monitor polls
before vision finishes loading). Reach the UI at **`http://<name-or-ip>/`** (Caddy on :80). before vision finishes loading). Reach the UI at **`http://<name-or-ip>/`** (Caddy on :80).
**Web-access gotchas (all fixed in the images/compose — see [[container-deployment]] "Web access"):** **Web-access gotchas (all fixed in the images/compose — see [[container-deployment]] "Web access"):**
the SPA uses a RELATIVE `/api` base (works from any host; do NOT bake a domain) + a Caddy proxy gives the SPA uses a RELATIVE `/api` base (works from any host; do NOT bake a domain) + a Caddy proxy gives
the clean port-80 URL; the domain (`parksystems.msai.al`) is pointed at the booth's LAN IP via the clean port-80 URL; the domain (`parksystems.msai.al`) is pointed at the booth's LAN IP via
`hosts`/DNS ON-SITE, never an image rebuild. `hosts`/DNS ON-SITE, never an image rebuild. The **Tauri desktop app** is hardcoded to
`localhost:3000` (CSP + endpoints) and can't reach a remote booth without code changes — a browser
works; the desktop app is a separate workstream.
## Quick-reference: the gotchas, in order they bit us ## Quick-reference: the gotchas, in order they bit us
@@ -225,3 +316,19 @@ the clean port-80 URL; the domain (`parksystems.msai.al`) is pointed at the boot
5. Always keep the **password slot** + an off-machine copy of the passphrase (TPM is never the only key). 5. Always keep the **password slot** + an off-machine copy of the passphrase (TPM is never the only key).
6. GRUB password MUST be **edit-only** (`--unrestricted` on entries) or it prompts on EVERY boot → 6. GRUB password MUST be **edit-only** (`--unrestricted` on entries) or it prompts on EVERY boot →
breaks unattended reboot. Verify `grep -c unrestricted /boot/grub/grub.cfg` ≥1 before rebooting. breaks unattended reboot. Verify `grep -c unrestricted /boot/grub/grub.cfg` ≥1 before rebooting.
### Komodo deploy gotchas (2026-06-27)
7. Periphery `core_address` is **Core's reverse-proxy URL** (`https://komodo.infra.msai.al`), NOT
`100.x:9120`. Core's `9120` is exposed-not-published (`docker ps` shows `9120/tcp` with no `->`)
→ a direct dial gets `Connection refused`. Ping/SSH working over the mesh does NOT mean `:9120`
is reachable.
8. **Git auth ≠ registry auth.** The repo cloning fine does not mean image pull is authed — they're
separate Komodo credentials. A blank registry account on the Stack → anonymous pull →
`no basic auth credentials`. Set the Stack's **Registry Account** (`komodo`).
9. **User-mode Periphery + `/etc/komodo` `root_directory` = `Permission denied`** writing the agent
key. User-mode (runs as `admin`, no root daemon) must keep `root_directory` under `$HOME`.
10. The config key is **`core_address`** (singular). And `--core-address` derives `wss://` from
`https://` — if Core were plain-HTTP you'd need `http://` (→ `ws://`).
11. ResourceSync **Execute disabled + file shown clean in Info = empty diff = already in sync**
(success). Execute only enables when the file and Core diverge (e.g. you edit `TAG`).
+30 -1
View File
@@ -2,7 +2,7 @@
type: decision type: decision
tags: [parking, deployment, docker, ci, offline-first] tags: [parking, deployment, docker, ci, offline-first]
sources: [] sources: []
updated: 2026-06-22 updated: 2026-06-24
status: settled status: settled
--- ---
@@ -12,6 +12,12 @@ How the parking system's runtime apps are packaged as containers, tagged, and pu
Settled 2026-06-22. Companion to [[vision-service-packaging]] (which scopes the vision service Settled 2026-06-22. Companion to [[vision-service-packaging]] (which scopes the vision service
into the monorepo) and the desktop [[desktop-shell-tauri]] (a separate, tag-only bundle). into the monorepo) and the desktop [[desktop-shell-tauri]] (a separate, tag-only bundle).
> **The build/tag/registry pipeline below is current.** What changed (2026-06-27): the
> *deploy mechanism* is no longer "SSH in and run `booth.sh`". At fleet scale that's superseded
> by [[fleet-deployment-komodo]] (Komodo Periphery over a NetBird mesh, driving these same
> compose files). `scripts/booth.sh` is now a **break-glass local fallback**, not the primary
> deploy path.
## Two images (the desktop app is NOT containerized) ## Two images (the desktop app is NOT containerized)
- **`parking-server`** — the Fastify API **plus the built React SPA**. One container serves both: - **`parking-server`** — the Fastify API **plus the built React SPA**. One container serves both:
@@ -36,6 +42,29 @@ The **desktop** app stays on its own tag-only `release.yml` (Tauri installers),
`restart: always`, `fast_alpr`, vision kept internal). `REGISTRY`/`TAG` come from env, so a deploy `restart: always`, `fast_alpr`, vision kept internal). `REGISTRY`/`TAG` come from env, so a deploy
on a branch pulls that branch's image — the branch→environment mapping IS the override file. on a branch pulls that branch's image — the branch→environment mapping IS the override file.
## Booth operator wrapper — `scripts/booth.sh`
So the on-site operator runs one command instead of the long `docker compose -f … -f … --env-file …`
line, **`scripts/booth.sh`** wraps the base + override + env-file. **Prod by default** (the booth is
prod); `ENV=dev` switches to the dev override.
- `./scripts/booth.sh up` — start (detached). `down` / `restart` / `status` / `logs [service]` /
`pull` / `config` / `exec <svc> …` as expected.
- **`./scripts/booth.sh update`** — the "**I know there are new images**" path: `compose pull` the
moving branch tag, then `up -d --remove-orphans` (recreates only services whose image digest moved;
**named volumes — the SQLite ledger — are preserved**), then `docker image prune -f` to reclaim the
old layers. This is the routine update after a `dev`/`main` push republishes the branch tag.
- **Env handling.** Reads **`.env`** (copy from `.env.example`: `REGISTRY`, `TAG`, `JWT_SECRET`,
`EVENT_SIGNING_KEY`, `COOKIE_SECURE=0`, `WS_ALLOWED_ORIGINS`). Prod **refuses to run without
`.env`** (no safe `JWT_SECRET` default — `auth.ts` rejects weak ones). Dev with no `.env` injects
the documented benign local secret so `up` works out of the box. The base file makes `JWT_SECRET`
shell-required (`${JWT_SECRET:?}`), so the env-file is mandatory for both — the script surfaces that
early with a clear message rather than a raw compose interpolation error.
- **Safety:** `down` never passes `-v` (deleting `parking-data` would wipe the signed
[[append-only-event-chain|ledger]]); `help`/unknown-command short-circuit before any Docker/.env
requirement. The operator never types `JWT_SECRET` on the CLI — it lives in `.env` (the user
generates it with `openssl rand -hex 32`).
## Registry + CI ## Registry + CI
- Published to the house **Gitea registry** `git.infra.msai.al/mca/parking_solution/{parking-server, - Published to the house **Gitea registry** `git.infra.msai.al/mca/parking_solution/{parking-server,
+151
View File
@@ -0,0 +1,151 @@
---
type: decision
tags: [parking, deployment, fleet, komodo, netbird, offline-first, threat-model]
sources: []
updated: 2026-06-27
status: settled
---
# Fleet deployment — Komodo Periphery over a NetBird mesh
How the parking appliance is deployed and managed **at fleet scale**, superseding the
single-box, SSH-and-`booth.sh` model. The image build/tag/registry pipeline
([[container-deployment]]) is unchanged — this decides only the *control plane* that drives
those same compose files onto many booths. Settled 2026-06-27.
## The problem `booth.sh` couldn't solve
[[container-deployment|`scripts/booth.sh`]] is a thin wrapper over `docker compose -f base -f
prod --env-file .env`. It works for **one** appliance you can get a shell on, but as the fleet
grows (the stated direction is **many/growing** sites) it gives us none of:
- **Remote, no-SSH operation** — an update means someone gets a root shell on the booth.
- **A fleet view** — which booth runs which `dev-<sha>`, which is healthy/offline.
- **A deploy audit trail** — who deployed what, when.
- **One-click rollback** to a previous immutable `dev-<sha>`.
These are exactly the gaps a deployment controller fills. We already run every prerequisite
(a **Komodo Core**, a **NetBird** zero-trust mesh, the **Gitea registry**), so the marginal
cost is low.
## Decision
Adopt **Komodo Periphery** on each appliance, driven by the existing **Komodo Core** over the
**NetBird** mesh. Keep the compose files and the [[container-deployment|image pipeline]]
verbatim — Komodo consumes them as a *Stack*; it does not replace them. `booth.sh` is demoted
to a **break-glass local fallback** for when the mesh/Core is unreachable.
```
Gitea push ─▶ build-images.yml ─▶ registry (parking-server:dev-<sha>, parking-vision:dev-<sha>)
│
Komodo Core (off-site) ──── NetBird mesh ─┼─▶ Periphery @ booth-A ─▶ docker compose up (pinned sha)
• fleet table / history / rollback ├─▶ Periphery @ booth-B
• per-booth secret injection └─▶ Periphery @ booth-C …
• NO deploy webhook (manual + pinned)
```
### The three load-bearing choices (settled with the user 2026-06-27)
1. **Fleet size: many/growing.** Komodo is treated as load-bearing infrastructure, not a
convenience. This is what tips the decision away from "SSH-over-NetBird + a playbook".
2. **Deploy trigger: always manual + pinned.** **No deploy webhook on a booth Stack.** A human
deploys a specific immutable `TAG=dev-<sha>` from Core. This preserves the determinism we
chose when pinning the booth tag (a moving `:dev` auto-redeploying a production booth is the
surprise we explicitly rejected). A *staging* booth MAY track `:dev`; a production booth
never does.
3. **Secrets: Komodo-managed (per-booth, unique).** Core's secret store injects `JWT_SECRET`
and `EVENT_SIGNING_KEY` into the Stack at deploy. This scales (no SSH-to-N-booths to rotate
a key) — but see the threat-model tension below; the keys MUST be **distinct per booth**.
## Why this is safe (against the project's two forces)
### Offline-first ([[offline-first]]) — Core is orchestration, never a runtime dependency
The booth must run **fully when the mesh is down**. Komodo's agent model satisfies this:
Periphery + the local containers keep operating if Core is unreachable; we lose *remote
management* until the mesh returns, **not operation**. There must be **no runtime path** from
booth operation to Core — Core only deploys. (Periphery's own liveness is irrelevant to entry/
exit; the Fastify server and SQLite ledger run independently of it.)
### Threat model — the adversary is the booth operator ([[threat-model]])
This is the sharp edge, and the reason this page is explicit rather than a footnote.
- **Periphery is a root-capable remote-exec agent on the appliance.** If the operator
compromises the box, the agent is a lever. Mitigations: bind Periphery **only to the NetBird
interface** (never `0.0.0.0`), enforce its **passkey + TLS**, and fold the agent into the
[[disk-os-hardening]] surface. It is part of the trusted computing base now.
- **`EVENT_SIGNING_KEY` is the anti-fraud root.** It signs the [[append-only-event-chain|
append-only ledger]] — the control between us and a booth operator forging entry/exit events.
Holding it in Core means **a Core compromise can forge any booth's ledger that shares a key**.
Two mitigations make central management acceptable:
- **Per-booth, unique keys.** Never reuse a signing key across sites, so a single leak taints
one booth, not the fleet.
- **The [[atecc608|ATECC608]] is the real long-term signer.** The
`EVENT_SIGNING_KEY` HMAC is the *interim* mechanism; once the secure element signs the
chain, the key in Core stops being the fraud root. Tracked in [[open-questions]].
- **Core becomes a Tier-0 asset.** It now holds login + ledger keys for the whole fleet, so it
must be hardened to the booths' bar: Komodo API bound to the NetBird mesh only, never a public
interface; access-controlled; backed up.
### Licensing — Komodo is GPL-3.0, and that's fine here
The hard MIT/Apache/BSD constraint ([[technology-stack]]) is about **shipped app dependencies**
(code we distribute/link). Komodo is **external ops tooling we self-host and don't distribute**,
so its GPL-3.0 does not taint the product — exactly like the [[vision-service|AGPL ANPR
exception]] reasoning (a separate process / external boundary, not a linked dependency). Noted
here so it isn't re-litigated.
## What lives where
| Concern | Where | Notes |
| --- | --- | --- |
| Image build + tags | Gitea CI ([[container-deployment]]) | unchanged: `:dev` moving + `:dev-<sha>` immutable |
| Compose files | the repo + on the booth | unchanged base + `docker-compose.prod.yml` |
| Stack / deploy definition | **Komodo Core** | git-synced from `komodo/` (infra-as-code) |
| Which sha is deployed | **Komodo Core**, manual | `TAG=dev-<sha>`, pinned, no webhook |
| `JWT_SECRET`, `EVENT_SIGNING_KEY` | **Komodo Core** secret store | **per-booth, unique** |
| `COOKIE_SECURE=0`, `TAG`, `REGISTRY` | Komodo Stack env | per-environment |
| Registry pull creds | **Komodo Core** | so Periphery can pull from Gitea |
| Local break-glass | `booth.sh` + a local `.env` | mesh-down fallback only |
## Setup outline
**On each appliance** (after [[appliance-provisioning]]):
1. Install **Komodo Periphery** (binary or container), bound **only** to the NetBird interface;
set its passkey/TLS.
2. Point its compose/stack dir at `/opt/parking_systems/` (the existing files).
3. Keep `booth.sh` + a minimal local `.env` (no real secrets) as break-glass.
**In Komodo Core:**
1. Add the booth as a **Server**, address = its **NetBird IP** (mesh, not LAN/WAN).
2. Define the **Stack** = base + `docker-compose.prod.yml`, env from Core's secret store, secrets
**per booth**.
3. **No deploy webhook** on the booth Stack — deploys are manual; set `TAG=dev-<sha>` explicitly.
4. Add Gitea registry creds so Periphery can pull.
5. Sync the Stack/Server definitions from the repo's `komodo/` directory (infra-as-code:
`komodo/resources.toml` + README) so the control plane is itself reviewable +
version-controlled.
## Open / not yet done
- **Per-booth secret generation + rotation flow** — how a new site's unique `EVENT_SIGNING_KEY`
is generated and registered in Core (vs. on-site `openssl rand`). Tie-in: [[open-questions]]
JWT-key item.
- **ATECC608 as the signer** supersedes `EVENT_SIGNING_KEY`-in-Core as the fraud root — until
then central secrets carry the blast-radius noted above.
- **Periphery hardening checklist** folded into [[disk-os-hardening]] (interface binding, passkey,
TLS, agent as TCB).
- **Staging vs production booth split** (a staging booth on `:dev` with a webhook; production
manual+pinned) — not yet modelled in `komodo/`.
- **Core backup / DR** — Core is now Tier-0; its loss = no fleet management (operation
unaffected, per offline-first). Backup story TBD.
## Supersedes / relates
- **Supersedes** the "SSH + `booth.sh` is the deploy mechanism" assumption in
[[container-deployment]] (that page's *build/tag/registry* content stands; its `booth.sh`-as-
primary-deploy framing is now the fallback). Cross-linked there.
- Companion: the `komodo/` infra-as-code sketch (in the repo, not the wiki),
[[appliance-provisioning]] (what runs *before* Periphery), [[disk-os-hardening]] (the
appliance's hardening surface).
+12
View File
@@ -95,3 +95,15 @@ procurement. (See [[parking-system-architecture]] §10.)
vs. serve-degraded — lean **serve-degraded + loud alarm** (fail-open on exit still governs; vs. serve-degraded — lean **serve-degraded + loud alarm** (fail-open on exit still governs;
refusing to boot could strand a lane). Software-only, independent of the TPM/[[atecc608]] hardware. refusing to boot could strand a lane). Software-only, independent of the TPM/[[atecc608]] hardware.
See [[append-only-event-chain]]. See [[append-only-event-chain]].
14. **Printer USB transport — confirm the on-site printer + bake the provisioning.** _(Recorded
2026-06-24; the transport code is built — see [[printer-usb-transport]].)_ The ESC/POS drivers
now drive **TCP (port 9100) OR local USB (`/dev/usb/lp0`)** behind one render layer, selectable
per device. **Open:** is the actual booth printer USB or network? (The site's verified units are
*networked* — Cashino `10.0.10.9`, Rongta `10.0.10.10` — so USB may be unused here; the original
BOM listed "Epson TM / Citizen (USB **or** network)", so a future site may need it.) If USB is
used, the **appliance image** must (a) load/keep the **`usblp`** kernel module bound to the
printer (CUPS can claim the interface first), and (b) ship a **udev rule** giving the non-root
server process write access to `/dev/usb/lp*`. Both are [[appliance-provisioning]] steps, **not
app code**, and are **unverified on hardware**. Close this once the printer transport per site is
fixed and (if USB) the udev/usblp rule is in the image and a real USB print is verified. Relates
to #1 (lane topology / image standardization). See [[printer-usb-transport]], [[rongta-printer]].
+27 -1
View File
@@ -2,7 +2,7 @@
type: decision type: decision
tags: [parking, decisions, vision, anpr, monorepo, packaging] tags: [parking, decisions, vision, anpr, monorepo, packaging]
sources: [] sources: []
updated: 2026-06-19 updated: 2026-06-25
status: settled status: settled
--- ---
@@ -114,3 +114,29 @@ The skeleton is **built and wired** (no recognizer models yet):
> **Resolved 2026-06-22 → [[container-deployment]]:** the vision service now ships as the > **Resolved 2026-06-22 → [[container-deployment]]:** the vision service now ships as the
> `parking-vision` Docker image (uv base, `--extra alpr`), model weights **pre-warmed into the image > `parking-vision` Docker image (uv base, `--extra alpr`), model weights **pre-warmed into the image
> layer** at build (offline-first), and runs under **docker-compose** (base + per-env override). > layer** at build (offline-first), and runs under **docker-compose** (base + per-env override).
## Two runtimes, one fragile (the `uv run` strips-the-extra trap) — 2026-06-25
Real ANPR runs **completely differently on the two machines**, and only the dev path was fragile:
- **Booth (deployment) = the Docker image.** The `Dockerfile` runs `uv sync --frozen --extra alpr`
at build, so fast-alpr/onnxruntime are **baked into an immutable image layer** and the weights are
pre-warmed in. `docker-compose.prod.yml` forces `VISION_RECOGNIZER=fast_alpr`. Nothing at runtime
re-resolves the venv → **the booth's real ANPR cannot silently degrade.** (A booth
`ModuleNotFoundError: fast_alpr` is a STALE image, not this bug — fix with `booth.sh update` to pull
the current image.)
- **Dev machine = bare `uv run uvicorn …`** against `apps/vision/.venv`. **This is the trap:** a plain
`uv run` (or `uv sync` with no `--extra alpr`) re-resolves the venv to the lockfile **defaults** and
**REMOVES** the alpr stack — leaving the model weights orphaned in `~/.cache/open-image-models` but
no recognizer in the venv. So a dev box that ran real ANPR (weights downloaded, plate reads
recorded) silently degrades to "**snapshot captured but no plate**" after the next `pnpm dev`. This
exactly explains a gap observed 2026-06-25: real reads on 06-22, then nothing — the venv (frozen
since 06-19, lean) had been stripped, while the Docker/compose work (06-23) was an innocent
coincidence, not the cause.
**Fix (2026-06-25):** the vision `package.json` `dev`/`start`/`recognize` scripts now run
`uv sync --extra alpr &&` FIRST, so `pnpm dev` is **self-healing** — the recognizer survives every
run. A `dev:stub` script is the lean, model-free escape hatch. The booth (Docker) is untouched.
**Implication:** local real-ANPR and booth real-ANPR are now both reliable; CI/light contributors who
don't want the heavy stack use `dev:stub` or run the suite (tests are stub-mode, offline). See
[[opencv-anpr-service]].
+44 -5
View File
@@ -46,11 +46,28 @@ see [[dingtian-vs-mqtt]].
## Driver & config API ## Driver & config API
The `dingtian` driver ([[device-registry]]) implements three capabilities: The `dingtian` driver ([[device-registry]]) implements:
`AccessControlDevice` (relay pulse/latch over UDP), `InputDevice` (read inputs + poll-based `AccessControlDevice` (relay pulse/latch over UDP), `AuxOutputDevice` (latch a NON-barrier output —
press/release events ~50 ms), and `PreconditionDevice` (below). Config fields include a separate see below), `InputDevice` (read inputs + poll-based press/release events ~50 ms), and
**`httpPort`** — the device's web/config API is on a configurable HTTP port (default **80**), `PreconditionDevice` (below). Config fields include a separate **`httpPort`** — the device's
distinct from the UDP control port 60001. web/config API is on a configurable HTTP port (default **80**), distinct from the UDP control port
60001.
### Spare relays + aux outputs (`setAux`)
A 4-input board typically has spare relays once the entry/exit barriers are wired. These drive
**non-barrier indicators** — e.g. the entry button's 12 V lamp (see [[button-light-indicator]]).
Business logic drives them through the device-agnostic `AuxOutputDevice.setAux(channel, on)` (a
latch), **never** the barrier `pulseOpen`. The [[barrier-not-a-door]] rule doesn't apply to an aux
output (it never gates a vehicle), so holding/blinking it is fine.
### Per-input active level (`presenceActiveLow` / `inputActiveLow`)
Inputs are normalised against ONE board-wide resting level (`inputRestingHigh`). When a sensor (e.g.
a [[hikvision-radar|radar]]) idles **opposite** the button, list its terminal as active-LOW —
sourced from each relay's `presenceActiveLow`, merged into the driver's `inputActiveLow` set — so
that one input is read inverted while the button keeps the board default. (`inputActive()` is the
pure helper; push-mode uses the device's own `ilu.active_level` instead.)
### Precondition: input_link_relay must be OFF ### Precondition: input_link_relay must be OFF
@@ -140,6 +157,28 @@ On assign the driver runs `harden()` (the [[device-registry|HardenableDevice]] c
> drop connections (ECONNRESET), locking out the API the driver depends on — recoverable only by > drop connections (ECONNRESET), locking out the API the driver depends on — recoverable only by
> factory reset. `harden()` deliberately never touches it. > factory reset. `harden()` deliberately never touches it.
## `relayPassword` field + the "offline despite ping" gotcha (2026-06-24)
`relay_pw` is in **every** binary frame — control AND the status read `healthCheck()` uses. With a
wrong/missing value the device **silently drops the packet** (no NAK), so the probe **times out →
the controller shows "offline" even though it pings** (ping is ICMP and never touches the binary
protocol). This bit a real bring-up: the driver read `config.relayPassword` but there was **no form
field** for it, so Test connection sent `0` → timeout → "offline", while `relay_pw` was actually a
non-zero value the harden flow had set. Diagnostic: a raw UDP status frame
(`FF AA <s> 00 <pwLo> <pwHi>`) replies *only* with the right password — `pw=N` → `ffaa…`, `pw=0` →
timeout — and binding the WSL socket to the device-facing NIC (`localAddress`) also broke the reply
(leave it unbound on WSL). Fix: a **"Relay control password"** config field (a **secret**; blank =
keep the stored value).
> 🔒 **Secret re-merge is identity-gated (don't let a redirected probe exfiltrate it).** Because
> `relayPassword`/`pushPassword` are redacted from the client ([[first-run-setup]]), the edit form
> can't resend them, so `/api/setup/test` re-merges the stored secret by device **id** — but ONLY
> when the submitted config addresses the **same device**: matching `driverId` and every
> connection-identity field it sets (`host`/`port`/`binaryPort`/`httpPort`/`serial`). A redirected
> host/port or mismatched driver returns NO secret, so an authenticated admin can't point a test at
> an attacker host and have the password sent there (the booth operator is the [[threat-model]]
> adversary). Save already merged from the stored row; this closes the same gap on test.
## Status — VERIFIED on hardware (DT-R004, sw V3.1.5461A, 10.0.10.172) ## Status — VERIFIED on hardware (DT-R004, sw V3.1.5461A, 10.0.10.172)
- ✅ status read (`0000:1111:4`), relay pulse, input press/release events (active-LOW, idle HIGH). - ✅ status read (`0000:1111:4`), relay pulse, input press/release events (active-LOW, idle HIGH).
+63
View File
@@ -0,0 +1,63 @@
---
type: entity
tags: [parking, device, sensor, radar, entry, presence]
sources: []
updated: 2026-06-24
status: settled
---
# Hikvision Radar (vehicle-presence sensor)
A radar mounted at an entry barrier that **closes a dry-contact relay when it detects something in
its vicinity** (a vehicle approaching the barrier). Wired to a **[[dingtian-relay|Dingtian]] input
terminal**, it acts as the vehicle-**presence** signal for the entry flow — functionally the same
role as an induction loop, just a different sensor.
## Where it sits in the model
The radar is a **child of the access controller config**, not a standalone device. On the entry
relay's spec (`config.relays[]`):
- `presenceInput` = the 1-based input terminal the radar's contact is wired to (e.g. **I2**).
- `presenceKind: "radar"` = a label (vs. `"loop"`) for the UI + telemetry; the **gate behaviour is
identical** either way.
- `presenceActiveLow` = set when the radar idles HIGH and pulls LOW on detection (see below).
The booth's wiring (first install): **button on I1, radar on I2**, both on the same 4-input Dingtian.
## Its job: the one-car-one-ticket gate (advisory, never opens a barrier)
The radar feeds the **[[entry-double-press|one car = one ticket]]** gate exactly as a loop does: the
entry button prints a ticket **only while the radar shows a vehicle present**, and **no second
ticket** issues until the radar **clears** (the car drove in) and a new car re-occupies the zone.
> The radar is **advisory**. A detection NEVER opens a barrier on its own — it only *gates* the
> button press. Entry still requires the physical press (and the capacity gate). This is the
> [[threat-model]] rule: a sensor reading is never the sole reason a barrier opens. (Distinct from
> the [[lane-presence-and-anpr-entry|ANPR bridge]], which admits *subscribers* through the gated
> subscription flow — also never a transient open.)
## The active-level gotcha (why `presenceActiveLow` exists)
The Dingtian normalises **all** inputs against one board-wide resting level (`inputRestingHigh`).
The booth's **button** (NO contact to GND) idles HIGH and pulls LOW on press. A **radar's dry
contact may idle the opposite way** — and if it does, the controller would read "vehicle present"
exactly when the zone is *clear*, inverting the gate (and the [[button-light-indicator|button
lamp]]).
Fix: mark the radar's terminal **active-LOW** (`presenceActiveLow: true` on the relay spec). The
driver then reads just that input inverted (active when LOW), leaving the button on the board
default. Implemented as a per-input override in `access-dingtian.ts` (`inputActive()` +
`inputActiveLow` set, derived from each relay's `presenceActiveLow`). Push-mode (the
`/input/:n/:edge` HTTP path) relies instead on the device's own `ilu.active_level`; the override is
the **poll-mode** equivalent.
## Also drives the button light
The same radar present/clear signal, combined with the camera's lane status, drives the entry
button's 12 V lamp on a spare relay — see [[button-light-indicator]].
## Status
Modelled 2026-06-24 (button I1 + radar I2 on the first booth's Dingtian). Gate behaviour reuses the
existing presence path; only the label + active-level override were added. Related:
[[dingtian-relay]], [[entry-double-press]], [[lpr-camera]], [[entry-exit-points]].
+37 -1
View File
@@ -2,7 +2,7 @@
type: entity type: entity
tags: [parking, hardware, readers, offline-first] tags: [parking, hardware, readers, offline-first]
sources: [parking-system-architecture] sources: [parking-system-architecture]
updated: 2026-06-15 updated: 2026-06-26
--- ---
# LPR Camera # LPR Camera
@@ -59,6 +59,42 @@ A **Hikvision** unit ("Camera 20", MAC `94:e1:ac:…`, Hikvision OUI) at `10.0.1
- Reaching it from the WSL dev box required forcing the source address (`config.localAddress`, - Reaching it from the WSL dev box required forcing the source address (`config.localAddress`,
threaded into the driver) — see [[wsl-dev-networking]] (multi-subnet source-selection trap). threaded into the driver) — see [[wsl-dev-networking]] (multi-subnet source-selection trap).
### HTTP 503 "Device Busy" — can be PERSISTENT; the real fix is stream selection (2026-06-26)
The snapshot endpoint returns **HTTP 503** with the ISAPI body `statusCode 2` / `"Device Busy"` /
`subStatusCode deviceBusy` (occasionally **500**). It comes in two flavours, and they need different
fixes — **don't assume it's a momentary blip**:
- **Transient** — the encoder is briefly occupied (another snapshot in flight, a stream starting).
Clears on retry within a frame or two.
- **Persistent** — the **MAIN-stream encoder is saturated** and 503s on EVERY main-stream snapshot.
Confirmed on hardware (**DS-2CD1047G3H-LIU**, 2026-06-26): `channels/101/picture` → 503 on five
consecutive probes 800 ms apart, while **`channels/102/picture` (the SUB stream) → 200 every time**,
a clean ~15 KB JPEG. So the path/API was correct (the camera answered with a structured Hikvision
status); the main encoder was simply never free. A retry loop **cannot** fix this — it just delays
the failure.
**The fix that actually works: snapshot from the SUB stream.** The Hikvision ISAPI channel id is
`<channel><stream>` (e.g. ch1 main = `101`, ch1 **sub = `102`**). The driver now has a **`stream`
config field** (`1` = main, default for back-compat; `2` = sub). Set the G3H camera to **Sub (02)** in
the setup form → its status flips `degraded → ready` (verified live: pulled a 14.7 KB JPEG in ~87 ms).
The sub-stream is also the better fit for snapshot/ANPR anyway (smaller/faster; doesn't contend with
live-view/recording for the main encoder).
Two more complementary mitigations (both BUILT, for the *transient* case):
1. **Don't cause concurrent busy.** On a vehicle entry two server paths used to snapshot the same
camera at once (the ANPR bridge + the advisory `snapshotAsync`); the 2nd concurrent GET drew a 503.
They now share ONE pull via `captureSnapshotShared` (deviceId-keyed, `apps/server/src/snapshot.ts`)
— the main cause of the slow 2026-06-25 subscriber entry. See [[lane-presence-and-anpr-entry]].
2. **Retry a transient one.** `HttpCamera.captureSnapshot` retries 503/500 with a short linear backoff
(250/500/750 ms, ≤4 attempts), then fails naming it `(device busy)`; it does NOT retry 401/404
(config errors won't self-heal). This recovers a momentary blip but, by design, still fails a
PERSISTENTLY-busy main stream — the cue to switch that camera to the sub-stream.
Covered by `packages/devices/src/drivers/camera.test.ts` (retry behaviour + the main/sub path
selection). `healthCheck()` deliberately reports a live 503 as `degraded` (it surfaces a genuinely
saturated main stream rather than hiding it behind a retry).
## Camera PUSH — "Alarm Server" event notifications (2026-06-22) ## Camera PUSH — "Alarm Server" event notifications (2026-06-22)
Separate from the **pull** snapshot path above: newer Hikvision firmware can **push** an event to Separate from the **pull** snapshot path above: newer Hikvision firmware can **push** an event to
+7
View File
@@ -34,6 +34,13 @@ many ESC/POS-compatible OEM clones that share its firmware). Driver `rongta` in
We scrape that rather than hand-decode `DLE EOT` — this clone's DLE EOT reply bytes do **not** We scrape that rather than hand-decode `DLE EOT` — this clone's DLE EOT reply bytes do **not**
match the canonical ESC/POS bit layout (verified on hardware), so trusting the device's own match the canonical ESC/POS bit layout (verified on hardware), so trusting the device's own
decode avoids a false-healthy. Implemented as `readStatus()`; see [[printer-status-monitoring]]. decode avoids a false-healthy. Implemented as `readStatus()`; see [[printer-status-monitoring]].
- **USB transport (added 2026-06-24).** The same driver can instead drive a printer over a local
USB `usblp` char device (`/dev/usb/lp0`) — `config.transport` (`tcp-ip` | `usb`) picks the wire
behind one render layer (the ESC/POS bytes are identical). The status web page is a **network**
feature, so a **USB Rongta degrades to reachability-only** monitoring (open-the-node probe, no
paper/cover verdict — the same honesty floor as the Cashino). Driving USB depends on the appliance
image (`usblp` bound + a udev write-access rule) — a provisioning step, open-questions #14. Full
rationale in [[printer-usb-transport]].
## Deployment (this site) ## Deployment (this site)
+7 -3
View File
@@ -42,8 +42,9 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source. - [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
- [[gee-qr-er80]] — QR access reader on hand; host-side serial → `read` bus (the QR-ticket scanner). - [[gee-qr-er80]] — QR access reader on hand; host-side serial → `read` bus (the QR-ticket scanner).
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued. - [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware). - [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware); spare relays drive aux outputs (`setAux`).
- [[rongta-printer]] — ✅ CHOSEN 80mm thermal printer; ESC/POS over raw TCP 9100; driver written, one unit reachable at 10.0.10.6. - [[hikvision-radar]] — vehicle-presence radar on a Dingtian input; the entry presence gate (per-input active-level caveat).
- [[rongta-printer]] — ✅ CHOSEN 80mm thermal printer; ESC/POS over raw TCP 9100 (or local USB, see [[printer-usb-transport]]); driver written, one unit reachable at 10.0.10.6.
- [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network). - [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network).
## Concepts — foundational forces ## Concepts — foundational forces
@@ -65,6 +66,7 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware. - [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
- [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth. - [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth.
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI. - [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
- [[printer-usb-transport]] — ESC/POS drivers drive TCP (9100) OR local USB (/dev/usb/lp0) behind one render layer; USB = usblp char device, reachability-only status; provisioning open (oq#14).
- [[device-status-monitoring]] — unified live status across ALL device categories (healthCheck + printer readStatus) → the booth footer over /api/ws. - [[device-status-monitoring]] — unified live status across ALL device categories (healthCheck + printer readStatus) → the booth footer over /api/ws.
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable. - [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog. - [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
@@ -76,7 +78,8 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
- [[challenge-response-auth]] — asymmetric nonce scheme for the ESP32 (auth + anti-replay). - [[challenge-response-auth]] — asymmetric nonce scheme for the ESP32 (auth + anti-replay).
- [[entry-exit-readers]] — two populations, two integration paths; both can share a relay. - [[entry-exit-readers]] — two populations, two integration paths; both can share a relay.
- [[entry-exit-points]] — pool-of-spaces model (no lane); per-relay direction, reader→relay binding, camera snapshots. - [[entry-exit-points]] — pool-of-spaces model (no lane); per-relay direction, reader→relay binding, camera snapshots.
- [[entry-double-press]] — one car = one ticket: per-relay presence-loop gate (preferred) or cooldown fallback; suppressed press = telemetry. - [[entry-double-press]] — one car = one ticket: per-relay presence gate (loop OR radar) preferred, cooldown fallback; suppressed press = telemetry.
- [[button-light-indicator]] — entry button lamp on a spare relay: radar × camera 3-state (blink/solid/off); aux-output; fails OFF.
- [[uhppote-vs-esp32]] — comparison: detection vs. prevention. - [[uhppote-vs-esp32]] — comparison: detection vs. prevention.
## Concepts — business domain ## Concepts — business domain
@@ -122,4 +125,5 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events). - [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell. - [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell.
- [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot. - [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot.
- [[fleet-deployment-komodo]] — fleet control plane: Komodo Periphery on each booth, driven by Komodo Core over a NetBird mesh, running the same compose files. Deploys manual + pinned to dev-<sha> (no webhook); secrets Komodo-managed per-booth+unique; booth.sh demoted to break-glass. Threat-model caveats: Periphery is a root agent (mesh-bound only), EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius until ATECC608 signs. komodo/ is infra-as-code.
- [[appliance-provisioning]] — booth-PC provisioning runbook (Dell 7070, i5-8500, discrete Nuvoton TPM): BIOS/Secure-Boot → direct-flash Ubuntu 26.04 USB (not Ventoy) → passphrase-LUKS install → manual PCR-7 TPM seal (workaround for the installer's dbt PCR_UNUSABLE error) → Docker. Verified on hardware 2026-06-23; TPM auto-unlock works. - [[appliance-provisioning]] — booth-PC provisioning runbook (Dell 7070, i5-8500, discrete Nuvoton TPM): BIOS/Secure-Boot → direct-flash Ubuntu 26.04 USB (not Ventoy) → passphrase-LUKS install → manual PCR-7 TPM seal (workaround for the installer's dbt PCR_UNUSABLE error) → Docker. Verified on hardware 2026-06-23; TPM auto-unlock works.
+165
View File
@@ -1552,3 +1552,168 @@ username chip links to it), `email` added to the session view + `SessionUser`. 7
builds `.deb` + `.AppImage` on every push to dev/main and uploads them as UNSIGNED workflow artifacts builds `.deb` + `.AppImage` on every push to dev/main and uploads them as UNSIGNED workflow artifacts
(per-commit test build); the signed/versioned release stays on `release.yml` (tag `v*`). See (per-commit test build); the signed/versioned release stays on `release.yml` (tag `v*`). See
[[desktop-shell-tauri]] "Desktop in CI". [[desktop-shell-tauri]] "Desktop in CI".
## [2026-06-24] build | Radar presence input + button-light output on the Dingtian
The first booth wired an **entry button on I1** and a **[[hikvision-radar|Hikvision radar]] on I2**
(closes a dry contact on detection), plus the **button's 12 V lamp on a spare relay**. Modelled as
children of the access controller config — no new device category. (1) The radar reuses the existing
`relays[].presenceInput` one-car-one-ticket gate; added `presenceKind: loop|radar` (label) and
`presenceActiveLow` (a radar may idle opposite the button — the Dingtian has ONE board-wide resting
level, so a per-input override `inputActiveLow` inverts just that terminal; pure helper
`inputActive()`). (2) New device-agnostic **`AuxOutputDevice.setAux(channel,on)`** capability (Dingtian
latch) so business logic drives a NON-barrier lamp through the interface — barriers still only
`pulseOpen` ([[barrier-not-a-door]] preserved). (3) New `ButtonLightController`
(`apps/server/src/button-light.ts`): subscribes to the radar input edge + the camera
[[lpr-camera|lane status]] and drives a **3-state lamp** — radar+car=SOLID, radar-only=BLINK (~1 Hz),
else OFF; **fails OFF**; de-duped. (4) SetupWizard: presence kind + active-low + a button-light relay
picker; i18n parity (sq+en). Tests: `button-light.test.ts` (truth table + blink + fail-OFF + de-dupe),
`access-dingtian.test.ts` (active-level inversion). Workspace build+lint+test green (158 server tests).
A radar detection NEVER opens a barrier on its own — it only gates the button ([[threat-model]]). See
[[hikvision-radar]], [[button-light-indicator]], [[entry-double-press]], [[dingtian-relay]].
## [2026-06-24] fix | Booth bring-up fixes — relay password, form split, lamp concurrency
Three fixes from wiring the radar/lamp on the first booth (committed 420542c, fd15988, 830993b on
top of the 2915d14 feature). (1) **"Offline despite ping"** — the Dingtian's `relay_pw` is in every
binary frame incl. the status read, but had NO form field, so Test connection sent 0 → device
silently drops the packet → "offline" (ping is ICMP, unrelated). Added a **"Relay control password"**
secret field; because the secret is redacted, the test endpoint re-merges it by device id but ONLY
when host/port/driver match the stored row (a redirected probe can't exfiltrate it — `setup-secrets.test.ts`).
(2) **Form split** — the controller editor now has separate **Outputs** (relays + pulse-open + lamp)
and **Inputs** (button + presence/radar terminals, "For relay N") sections; UI-only, storage
unchanged. `pulse open (ms)` clarified as a relay/output setting, not an input. (3) **Lamp stuck
on/off** — the blink fired fire-and-forget `setAux` over UNORDERED UDP; concurrent on/off packets
reordered and the relay latched on the last-processed one. Replaced with a serialized desired-state
worker (one in-flight send/lamp, re-converges to the latest state → final state authoritative). Also
**hot-reload**: the lamp map now reconciles against live config each event, so a button light added
in the UI works without a server restart. Workspace build+lint+test green (163 server tests). See
[[dingtian-relay]] ("offline despite ping" + secret re-merge), [[button-light-indicator]] (serialized
sends + hot-reload).
## [2026-06-24] build | Printer USB transport behind the ESC/POS render layer
The ESC/POS printer drivers were **TCP-only** (every path went through `sendRaw`/`probe` to a raw
socket on port 9100); the original BOM intended one adapter to cover "USB **or** network". Added a
**USB transport** behind the existing render layer without touching a single `render*()` function:
a discriminated `Transport` (`transportFromConfig` → `{kind:"tcp",host,port}` | `{kind:"usb",
devicePath}`) and `sendTo`/`probeTo` dispatchers in `printer-escpos.ts`; USB writes the same ESC/POS
bytes to a kernel **`usblp`** char device (`/dev/usb/lp0`) via a plain `fs` write — **no libusb/CUPS/
native dep** (keeps MIT-only + minimal-deps appliance). `cashino` + `rongta` resolve a Transport once;
both are reachability-only over USB, and the Rongta's HTTP **status page degrades to the open-the-node
probe** over USB (no guessed paper/cover — the standing honesty rule). Non-`usb` configs are unchanged
(host-only = TCP), so no migration. Setup UI gains a **Connection** select + **USB device** field;
host/port made not-required so a USB printer needs neither. Tests: `printer-escpos.test.ts` (USB writes
the exact rendered bytes; probe present/absent; `transportFromConfig` TCP back-compat) +
`printer-cashino.test.ts` (USB-configured driver prints to the node, ready/offline). Devices suite
green (29). **Flagged open-questions #14**: confirm the on-site printer is USB and bake the
**usblp + udev write-access** rule into the appliance image (provisioning, not app code; unverified on
hardware). See [[printer-usb-transport]], [[rongta-printer]].
## [2026-06-24] build | Booth operator wrapper script — scripts/booth.sh
The booth PC (Ubuntu) needs one command instead of the long
`docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env …` line over the
three compose files. Added **`scripts/booth.sh`** (+ root **`.env.example`**): **prod by default**
(`ENV=dev` for the dev override); subcommands `up`/`down`/`restart`/`status`/`logs`/`pull`/`config`/
`exec`, and the requested **`update`** = `compose pull` the moving branch tag → `up -d --remove-orphans`
(recreates only digest-changed services, **named volumes/SQLite ledger preserved**) → `docker image
prune -f`. Prod **refuses to run without `.env`** (no safe `JWT_SECRET` default); dev with no `.env`
injects the documented benign local secret (the base file makes `JWT_SECRET` shell-required via
`${JWT_SECRET:?}`, which the dev override's service-level default alone can't satisfy). `down` never
passes `-v` (would wipe the signed [[append-only-event-chain|ledger]] volume); `help`/unknown-command
short-circuit before any Docker/.env requirement. Verified: prod `config` renders Caddy:80 + internal
server + pinned images + `fast_alpr`; dev `config` renders `:dev` images + `stub` + published ports.
Documented in [[container-deployment]] ("Booth operator wrapper").
## [2026-06-25] fix | Local ANPR silently degraded — `uv run` strips the alpr extra
Diagnosed via the live DB (read-only `VACUUM INTO` copy) why entry `26799912337` recorded a snapshot
but no plate: the dev box's vision service was running **stub**, and earlier real ANPR had stopped.
Root cause (NOT the Docker/compose work, which was an innocent coincidence): the dev machine runs vision
as **bare `uv run uvicorn`** against `apps/vision/.venv`, and a plain `uv run`/`uv sync` re-resolves the
venv to the lockfile **defaults**, **stripping** fast-alpr/onnxruntime — so after any `pnpm dev` the
recognizer vanishes (weights orphaned in `~/.cache`, no module in the venv) and ANPR silently becomes
"snapshot, no plate". Evidence: 28 real reads through 06-22 (yolo-v9 model, ~99% conf), venv frozen lean
since 06-19, no other env with fast_alpr on the box. **The BOOTH was never affected** — it runs the
Docker image, which bakes `uv sync --frozen --extra alpr` at build (immutable, weights pre-warmed); a
booth `ModuleNotFoundError` is a STALE image (fix: `booth.sh update`). **Fix:** vision `package.json`
`dev`/`start`/`recognize` now `uv sync --extra alpr &&` first (self-healing), `.env` set to `fast_alpr`,
+ a `dev:stub` escape hatch. Restored real ANPR locally (`/health` → `fast_alpr` ready, model loaded from
cache, no download). Documented in [[vision-service-packaging]] ("Two runtimes, one fragile").
## [2026-06-26] fix | Hikvision snapshot 503 "Device Busy" — stream selection + retry + Alarm URL helper
Three camera fixes. (1) **503 Device Busy — the REAL fix is stream selection.** First framed as
"transient, just retry" — WRONG for this camera. Hardware probe of **DS-2CD1047G3H-LIU** (10.0.10.13):
`channels/101/picture` (MAIN) → 503 `deviceBusy` on 5 consecutive probes 800ms apart, while
`channels/102/picture` (SUB) → 200 clean JPEG every time. The main encoder is PERSISTENTLY saturated;
a retry loop can't fix it. Added a **`stream` config field** to the Hikvision driver (1=main default
for back-compat, 2=sub; ISAPI id `<channel><stream>`). Verified live: setting the camera to Sub flips
its status degraded→ready (14.7KB JPEG in ~87ms). (2) **Transient retry** (still useful for a genuine
momentary blip + the de-dup case): `HttpCamera.captureSnapshot` retries 503/500 with linear backoff
(250/500/750ms ×4), fails naming it `(device busy)`, does NOT retry 401/404. Plus the already-landed
`captureSnapshotShared` removing concurrent self-collision. `healthCheck` reports a live 503 as
`degraded` (surfaces a saturated main stream rather than hiding it). Covered by `camera.test.ts`
(10 tests: retry + main/sub path). (3) **Alarm Server URL helper:** the camera setup form now generates the camera's Alarm
Settings (Destination IP / URL / Protocol / Port) ready to paste, so the operator never hunts the
deviceId or memorises the endpoint. CRUCIAL: host/port come from the **backend address on the camera's
subnet** (`backendIpForDevice` + server port, the same probe the push-IP picker uses) — NOT
`window.location.origin` (the SPA's dev/proxy origin, which would wrongly say `localhost:5173`).
Verified live: matches the on-camera config field-for-field (10.0.10.203 / …/event / HTTP / 3000).
Shows a "save first" (needs a deviceId) then "test first" (needs the resolved backend IP) hint.
Documented in [[lpr-camera]] ("503 Device Busy"). Devices 6 new tests; server 168 green.
## [2026-06-26] fix | QR reader status was a LIE (hardcoded "ready") → real ICMP liveness
Two genuinely-OFFLINE QR readers showed GREEN in the status bar. Cause: the QR-reader adapter
(`StubReader`) had `healthCheck → { ready, "stub" }` hardcoded — it never probed anything. These are
PUSH devices (scan → GET our backend, resolve by serial) that expose **no TCP port**, so a connect
probe (cameras/printers) has nothing to hit; the stub "solved" that by lying. False-healthy is the
worst failure for a status bar. Fix: an **optional reader IP** (monitor-ONLY — scans still resolve by
serial, operation unchanged) + an **unprivileged ICMP ping** (`drivers/icmp.ts`: shells `/bin/ping`
`-c1`, exit-0 = reply; no native dep, no CAP_NET_RAW). `healthCheck`: IP replies → `ready`, no reply →
`offline`, **no IP → `degraded` ("set IP to monitor")** (never a false green). Booth compose
(`docker-compose.prod.yml`) sets `net.ipv4.ping_group_range=0 2147483647` so `/bin/ping` works
unprivileged for the non-root container user. Verified on hardware: the readers (10.0.10.7/.8) answer
ICMP on the device VLAN (eth1) — distinct MACs — and the UI Test connection shows "● ready — ping
10.0.10.7". (NB: an earlier "offline" reading was a WSL wrong-route artifact, not the readers.) Covered
by `reader.test.ts` (4 tests). Documented in [[device-status-monitoring]]. Devices +4 tests, all green.
## [2026-06-27] fix | booth.sh failed in the flat /opt layout (couldn't find compose files)
The booth deploys the compose files **flat** in `/opt/parking_systems/` with `booth.sh` next to
them, but the script assumed `<repo>/scripts/` and did `cd ..` → `REPO_DIR=/opt` (no compose
files); `usage()` then `sed`-read a now-relative `$0` → "can't read booth.sh". That's why
`sudo ./booth.sh` only printed help and `/bin/bash booth.sh` errored. Fix: **discover** the
compose files (script's own dir → `../` → `$PWD`), `usage()` reads an absolute `$SELF`. Also:
`.env.example` defaulted `TAG=main`, but the registry only has `dev`/`dev-<sha>` (no main build) →
`compose pull` 404s; default to `TAG=dev` + documented the moving-vs-immutable tag scheme.
Reproduced the booth's flat layout in a scratch dir; all forms (`./booth.sh`, `/bin/bash
booth.sh`, `config`, absolute-path) verified. Commit 83298bc.
## [2026-06-27] decision | Fleet deployment → Komodo Periphery over NetBird
booth.sh hit its ceiling: fine for one SSH-able box, but no remote/no-SSH op, no fleet view, no
deploy history, no rollback — and the fleet is **many/growing**. Decision: **Komodo Periphery**
on each appliance, driven by an existing **Komodo Core** over the **NetBird** mesh, running the
**same** compose files ([[container-deployment]] pipeline unchanged); `booth.sh` demoted to
break-glass. Three settled choices: many/growing fleet · deploys **manual + pinned** to a
`dev-<sha>` (no webhook — preserves the determinism we chose by pinning) · secrets
**Komodo-managed, per-booth + unique**. Threat-model caveats recorded: Periphery is a root agent
(bind to NetBird interface only, passkey+TLS, part of the TCB); `EVENT_SIGNING_KEY` in Core is a
fraud-root blast radius → per-booth keys + [[atecc608|ATECC608]] as the real
long-term signer; Core becomes Tier-0. GPL-3.0 OK (external ops tooling, not a shipped dep — same
boundary logic as the AGPL vision exception). New page [[fleet-deployment-komodo]]; infra-as-code
sketch in `komodo/` (`resources.toml` + README + `.env.komodo.example`). Catalogued in `index.md`;
`container-deployment` cross-linked + reframed (booth.sh = fallback).
## [2026-06-27] deploy | First Komodo booth deploy VERIFIED end-to-end (park-buzi)
Took the first booth through the whole Komodo flow on real hardware (Core v2.1.2 → agent reported
v2.2): onboarding key → Periphery installed **user-mode** (runs as `admin`, no root daemon,
**outbound** so the booth opens no inbound port) → server `park-buzi` **OK** in Core → Stack
(repo `mca/parking_solution`@`dev`, base+prod compose, registry account `komodo`, per-booth
`[[…]]` secrets) → all containers green → admin seeded via Komodo's container terminal (no SSH).
Then `komodo/resources.toml` rewritten to mirror the **working** Stack (exported from Core, v2.2
field shape, **Stack-only — no `[[server]]`** since onboarding owns the server), committed + pushed
(`dev` 9918f27); a ResourceSync reads it clean — **empty diff / Execute disabled = already in
sync** (success, not error). `booth.sh` fixed for the flat `/opt` layout earlier (83298bc).
Gotchas that bit us (now in [[appliance-provisioning]] §7 + gotchas 7–11): `core_address` is Core's
**proxy URL** not `:9120` (exposed-not-published → Connection refused); **git-auth ≠ registry-auth**
(blank registry account → `no basic auth credentials`); user-mode + `/etc/komodo` root_directory →
`Permission denied`; config key is **`core_address`** singular. [[appliance-provisioning]] §6 split:
§6 = engine, §7 = Komodo deploy (PRIMARY) with §7c manual `booth.sh` break-glass.