17 Commits

Author SHA1 Message Date
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
44 changed files with 2899 additions and 164 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
+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({});
});
});
+59 -2
View File
@@ -36,6 +36,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 +59,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 +289,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 });
} }
+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)'"
} }
} }
+341 -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,
@@ -13,6 +13,7 @@ import {
type AnprTestResult, type AnprTestResult,
type Assignment, type Assignment,
type BackendIpCandidate, type BackendIpCandidate,
type ButtonLightSpec,
type Catalog, type Catalog,
type CatalogEntry, type CatalogEntry,
type DeviceCategory, type DeviceCategory,
@@ -270,11 +271,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>
); );
} }
@@ -345,6 +359,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,6 +376,7 @@ 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);
@@ -368,22 +388,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 +471,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 +506,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 {
@@ -569,7 +608,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 +681,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 +740,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}>
@@ -760,9 +897,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 +930,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 +970,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 +1105,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 +1113,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 +1150,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>
); );
} }
+20 -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 } : {}) }),
}); });
} }
+41 -3
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,19 @@ 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.",
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…",
+39
View File
@@ -368,6 +368,18 @@ export const sq = {
relaysTitle: "Relet në këtë kontrollues", relaysTitle: "Relet në këtë kontrollues",
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,6 +416,20 @@ 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.",
// 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: "Kontrolluesi",
+7
View File
@@ -36,6 +36,13 @@ services:
# No published port — only the proxy reaches the server, over the private network. # No published port — only the proxy reaches the server, over the private network.
expose: expose:
- "3000" - "3000"
# Let the server ICMP-ping push-only readers (Dingtian/GEE QR) for an honest
# online/offline status WITHOUT CAP_NET_RAW: opening ping_group_range to all gids
# enables `/bin/ping` in unprivileged SOCK_DGRAM mode for the non-root runtime user.
# (The reader exposes no TCP port, so a connect-probe can't work — see reader.ts /
# wiki/entities/dingtian-qr-reader.md.)
sysctls:
- net.ipv4.ping_group_range=0 2147483647
logging: logging:
driver: json-file driver: json-file
options: options:
@@ -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");
});
});
+121 -19
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,23 @@ 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.
create: (c) => // stream 1 → "01" (main), 2 → "02" (sub).
new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`), create: (c) => {
console.log(c);
return new HttpCamera(
"hikvision",
c,
(ch, stream) => `/ISAPI/Streaming/channels/${ch}0${stream}/picture`,
);
},
}; };
export const dahuaDriver: CameraDriver = { export const dahuaDriver: CameraDriver = {
@@ -156,7 +253,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]].
+24 -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
--- ---
@@ -36,6 +36,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,
+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)
+6 -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
+120
View File
@@ -1552,3 +1552,123 @@ 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.