13 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
32 changed files with 1554 additions and 99 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
// (no registry, no network). The factory returns a fresh shot each call.
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", () => ({
buildCamera: () => ({ captureSnapshot }),
captureSnapshotShared: (_id: string, camera: { captureSnapshot: typeof captureSnapshot }, ctx: unknown) =>
camera.captureSnapshot(ctx as never),
}));
// 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 { deviceEvents, type DeviceReadEvent } from "./device-events.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 { 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 —
// the gated flow infers the verb from the camera's bound relay direction).
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);
if (!result || !result.plate) return; // nothing read
+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 { 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 { devicesByDirection, type FlowDirection } from "./device-resolve.js";
import type { VisionClient } from "./vision-client.js";
@@ -62,7 +62,9 @@ export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
return null;
}
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();
db.insert(snapshots)
.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(
db: Db,
direction: FlowDirection,
+5 -3
View File
@@ -3,14 +3,16 @@
"version": "0.0.0",
"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.",
"//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": {
"dev": "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": "uv sync --extra alpr && uv run uvicorn vision_service.app:app --reload --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 .",
"format": "uv run ruff format .",
"typecheck": "uv run mypy vision_service",
"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)'"
}
}
+80 -3
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 {
assignDevice,
@@ -376,6 +376,7 @@ function DeviceForm({
const [testing, setTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null);
// ANPR probe (camera + anpr on): snapshot → vision analyze, reported below.
const [alarmUrlCopied, setAlarmUrlCopied] = useState(false);
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
const [anprTesting, setAnprTesting] = useState(false);
const [anprError, setAnprError] = useState<string | null>(null);
@@ -387,22 +388,31 @@ function DeviceForm({
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
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 ?? "") : "";
useEffect(() => {
if (!testedHost || !pushesToBackend) {
setBackendIps(null);
setBackendPort(null);
return;
}
let live = true;
fetchBackendIps(testedHost)
.then(({ candidates }) => {
.then(({ candidates, port }) => {
if (!live) return;
setBackendIps(candidates);
setBackendPort(port);
setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || "");
})
.catch(() => {
if (live) setBackendIps(null);
if (live) {
setBackendIps(null);
setBackendPort(null);
}
});
return () => {
live = false;
@@ -603,6 +613,15 @@ function DeviceForm({
// 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" ? (
// Boolean config field → a real checkbox (stores a true/false boolean, not
@@ -721,6 +740,64 @@ function DeviceForm({
</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). */}
<div className="mt-3 flex items-center gap-2">
<button type="button" className="btn btn-sm" onClick={test} disabled={testing}>
+13
View File
@@ -406,6 +406,19 @@ export const en: Catalog = {
"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.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?",
controller: "Controller",
choose: "Choose…",
+14
View File
@@ -416,6 +416,20 @@ export const sq = {
"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.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.
whichBarrier: "Cilën barrierë shërben kjo pajisje?",
controller: "Kontrolluesi",
+7
View File
@@ -36,6 +36,13 @@ services:
# No published port — only the proxy reaches the server, over the private network.
expose:
- "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:
driver: json-file
options:
+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 { hostField, passwordField, portField, usernameField, stubLog } from "./common.js";
import {
hostField,
passwordField,
portField,
usernameField,
stubLog,
} from "./common.js";
import { digestGet } from "./http-digest.js";
// 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;
// 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 {
readonly #host: string;
readonly #port: number;
readonly #user: string;
readonly #password: string;
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;
// 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).
@@ -29,16 +60,20 @@ class HttpCamera implements CameraDevice {
constructor(
readonly driverId: string,
config: DeviceConfig,
/** Builds the snapshot path from the configured channel. */
private readonly snapshotPath: (channel: number) => string,
/** Builds the snapshot path from the configured channel + stream (1=main, 2=sub). */
private readonly snapshotPath: (channel: number, stream: number) => string,
) {
this.#host = String(config.host);
this.#port = Number(config.port ?? 80);
this.#user = String(config.username ?? "");
this.#password = String(config.password ?? "");
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.#localAddress = config.localAddress ? String(config.localAddress) : undefined;
this.#localAddress = config.localAddress
? String(config.localAddress)
: undefined;
}
async connect(): Promise<void> {}
@@ -49,8 +84,13 @@ class HttpCamera implements CameraDevice {
// frame: it exercises reachability + auth + the path/channel in one shot.
try {
const res = await this.#get();
if (res.status === 200) return { status: "ready", detail: `${res.body.length} bytes` };
if (res.status === 401) return { status: "degraded", detail: "auth rejected (check username/password)" };
if (res.status === 200)
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}` };
} catch (err) {
return { status: "offline", detail: (err as Error).message };
@@ -58,13 +98,37 @@ class HttpCamera implements CameraDevice {
}
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) {
// 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(
`${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 {
bytes: res.body,
contentType: res.contentType || "image/jpeg",
@@ -76,7 +140,7 @@ class HttpCamera implements CameraDevice {
return digestGet({
host: this.#host,
port: this.#port,
path: this.snapshotPath(this.#channel),
path: this.snapshotPath(this.#channel, this.#stream),
user: this.#user,
password: this.#password,
timeoutMs: this.#timeout,
@@ -93,7 +157,32 @@ const channelField: ConfigField = {
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 →
// "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm Settings →
@@ -138,15 +227,23 @@ export const hikvisionDriver: CameraDriver = {
id: "hikvision",
category: "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"],
// 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).
pushesToBackend: true,
configFields: [...cameraConfigFields, ...alarmPushFields],
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch2 main = 201.
create: (c) =>
new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`),
configFields: [...hikvisionConfigFields, ...alarmPushFields],
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch1 sub = 102, ch2 main = 201.
// stream 1 → "01" (main), 2 → "02" (sub).
create: (c) => {
console.log(c);
return new HttpCamera(
"hikvision",
c,
(ch, stream) => `/ISAPI/Streaming/channels/${ch}0${stream}/picture`,
);
},
};
export const dahuaDriver: CameraDriver = {
@@ -156,7 +253,12 @@ export const dahuaDriver: CameraDriver = {
description: "Dahua snapshot via CGI (HTTP Digest).",
transports: ["tcp-ip"],
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) =>
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 { hostField, portField, stubLog } from "./common.js";
import {
probe,
devicePathField,
probeTo,
renderReceipt,
renderReport,
renderSubscriptionCard,
renderTicket,
renderWindowChargeNotice,
sendRaw,
sendTo,
transportField,
transportFromConfig,
type Transport,
} from "./printer-escpos.js";
// Cashino 80mm network thermal printer driver. The Cashino is an ESC/POS clone:
// it PRINTS identically to the Rongta (same byte stream — see ./printer-escpos.ts),
// so tickets, reports and subscription cards render the same. What it does NOT
// have is the Rongta board's decoded status web page (/prn_stat.htm). It cannot
// report paper-out / cover-open / cutter faults in a form we trust.
// Cashino 80mm thermal printer driver (network OR USB). The Cashino is an ESC/POS
// clone: it PRINTS identically to the Rongta (same byte stream — see
// ./printer-escpos.ts), so tickets, reports and subscription cards render the same,
// over either transport. What it does NOT have is the Rongta board's decoded status
// 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
// (no readStatus). The device monitor then falls back to the generic
@@ -40,13 +51,11 @@ import {
class CashinoPrinter implements PrinterDevice {
readonly driverId = "cashino";
readonly #host: string;
readonly #port: number;
readonly #transport: Transport;
readonly #timeout: number;
constructor(config: DeviceConfig) {
this.#host = String(config.host);
this.#port = config.port ? Number(config.port) : 9100;
this.#transport = transportFromConfig(config);
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
* has no trustworthy status protocol, so this is the floor and the ceiling of
* what we report: reachable → ready, unreachable → offline. Deliberately NO
* readStatus(): the monitor uses this for the traffic-light, never a guessed
* paper/cover state.
* Reachability only — a connect probe (TCP) or char-device open probe (USB) of
* the print path. The Cashino has no trustworthy status protocol, so this is the
* floor and the ceiling of what we report: reachable → ready, unreachable →
* offline. Deliberately NO readStatus(): the monitor uses this for the
* traffic-light, never a guessed paper/cover state.
*/
async healthCheck(): Promise<DeviceHealth> {
try {
await probe(this.#host, this.#port, this.#timeout);
await probeTo(this.#transport, this.#timeout);
return { status: "ready" };
} catch (err) {
return { status: "offline", detail: (err as Error).message };
@@ -75,12 +84,12 @@ class CashinoPrinter implements PrinterDevice {
}
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}`);
}
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(
this.driverId,
`printed report "${report.title}" (${report.lines.length} lines)`,
@@ -88,17 +97,12 @@ class CashinoPrinter implements PrinterDevice {
}
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
await sendRaw(
this.#host,
this.#port,
renderSubscriptionCard(data),
this.#timeout,
);
await sendTo(this.#transport, renderSubscriptionCard(data), this.#timeout);
stubLog(this.driverId, `printed subscription card ${data.code}`);
}
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(
this.driverId,
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
@@ -106,7 +110,7 @@ class CashinoPrinter implements PrinterDevice {
}
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}`);
}
}
@@ -141,14 +145,17 @@ export const cashinoDriver: PrinterDriver = {
category: "printer",
label: "Cashino 80mm thermal printer",
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.",
transports: ["tcp-ip"],
"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", "usb"],
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),
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,
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 {
renderTicket,
renderReceipt,
renderWindowChargeNotice,
renderSubscriptionCard,
probeUsb,
sendRawUsb,
transportFromConfig,
stamp,
} 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)", () => {
it("formats an ISO time as '<day> <Month> <year> HH:MM:SS'", () => {
// Local-time dependent, so assert the structure + the Albanian month name.
@@ -1,4 +1,6 @@
import { Socket } from "node:net";
import { open } from "node:fs/promises";
import { constants as FS } from "node:fs";
import type {
PrintReport,
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 ----------------------------------------------
// Role + failover are identical across ESC/POS printers; defined here so each
// driver shares them. See wiki/concepts/printer-roles-failover.md.
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 { hostField, portField, stubLog } from "./common.js";
import {
probe,
devicePathField,
probeTo,
renderReceipt,
renderReport,
renderSubscriptionCard,
renderTicket,
renderWindowChargeNotice,
sendRaw,
sendTo,
transportField,
transportFromConfig,
type Transport,
} from "./printer-escpos.js";
// Rongta 80mm network thermal printer driver. Rongta RP-series printers (and the
// many OEM clones that share their firmware) speak ESC/POS over a raw TCP socket
// on port 9100 — the JetDirect/RAW convention. The ESC/POS rendering + transport
// are shared with the other ESC/POS clones in ./printer-escpos.ts; what is unique
// to Rongta — and lives here — is LIVE STATUS via the board's own status web page.
// There is no auth on the print socket; like the other field devices it lives on
// the isolated device VLAN.
// Rongta 80mm thermal printer driver (network OR USB). Rongta RP-series printers
// (and the many OEM clones that share their firmware) speak ESC/POS over a raw TCP
// socket on port 9100 — the JetDirect/RAW convention — or over a local USB usblp
// char device. The ESC/POS rendering + transport are shared with the other ESC/POS
// clones in ./printer-escpos.ts (config.transport picks the wire); what is unique to
// Rongta — and lives here — is LIVE STATUS via the board's own status web page. That
// 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.
//
// 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 {
readonly driverId = "rongta";
readonly #transport: Transport;
readonly #host: string;
readonly #port: number;
readonly #httpPort: number;
readonly #timeout: number;
constructor(config: DeviceConfig) {
this.#host = String(config.host);
this.#port = config.port ? Number(config.port) : 9100;
this.#transport = transportFromConfig(config);
// 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.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
}
@@ -150,7 +157,7 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
async healthCheck(): Promise<DeviceHealth> {
try {
await probe(this.#host, this.#port, this.#timeout);
await probeTo(this.#transport, this.#timeout);
return { status: "ready" };
} catch (err) {
return { status: "offline", detail: (err as Error).message };
@@ -158,12 +165,12 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
}
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}`);
}
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(
this.driverId,
`printed report "${report.title}" (${report.lines.length} lines)`,
@@ -171,17 +178,12 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
}
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
await sendRaw(
this.#host,
this.#port,
renderSubscriptionCard(data),
this.#timeout,
);
await sendTo(this.#transport, renderSubscriptionCard(data), this.#timeout);
stubLog(this.driverId, `printed subscription card ${data.code}`);
}
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(
this.driverId,
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
@@ -189,7 +191,7 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
}
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}`);
}
@@ -206,6 +208,18 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
*/
async readStatus(): Promise<PrinterStatus> {
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;
try {
html = await fetchStatusPage(this.#host, this.#httpPort, this.#timeout);
@@ -281,14 +295,17 @@ export const rongtaDriver: PrinterDriver = {
category: "printer",
label: "Rongta 80mm thermal printer",
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.",
transports: ["tcp-ip"],
"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", "usb"],
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),
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",
@@ -296,7 +313,7 @@ export const rongtaDriver: PrinterDriver = {
type: "port",
required: false,
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,
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 { DeviceConfig, ReaderDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js";
import { icmpPing } from "./icmp.js";
// Reader drivers (RF / optical). Two integration paths: Wiegand reads reach the
// access controller directly (autonomous); TCP-IP readers are seen host-side.
@@ -18,8 +19,23 @@ class StubReader implements ReaderDevice {
async disconnect(): Promise<void> {
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> {
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 {
this.#cb = cb;
@@ -80,6 +96,14 @@ export const geeQrReaderDriver: ReaderDriver = {
required: true,
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),
};
+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
+17 -4
View File
@@ -51,12 +51,25 @@ a **device-agnostic aux-output** capability.
- **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).
- **De-duped.** Only writes when the effective output changes, so the 50 ms input poll doesn't spam
the controller over UDP.
- **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). Covered by
`apps/server/src/button-light.test.ts` (the truth table + blink toggling + fail-OFF + de-dupe).
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
tags: [parking, device, monitoring, reliability, ui]
sources: []
updated: 2026-06-18
updated: 2026-06-26
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
adapter implements (`ready | degraded | offline`). This is presence/up-ness, not a deep fault
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
**toward "there's a problem"**, never false-healthy: a probe that throws or times out reads
+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
tags: [parking, deployment, docker, ci, offline-first]
sources: []
updated: 2026-06-22
updated: 2026-06-24
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
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
- 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;
refusing to boot could strand a lane). Software-only, independent of the TPM/[[atecc608]] hardware.
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
tags: [parking, decisions, vision, anpr, monorepo, packaging]
sources: []
updated: 2026-06-19
updated: 2026-06-25
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
> `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).
## 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]].
+22
View File
@@ -157,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
> 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 read (`0000:1111:4`), relay pulse, input press/release events (active-LOW, idle HIGH).
+37 -1
View File
@@ -2,7 +2,7 @@
type: entity
tags: [parking, hardware, readers, offline-first]
sources: [parking-system-architecture]
updated: 2026-06-15
updated: 2026-06-26
---
# 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`,
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)
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**
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]].
- **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)
+2 -1
View File
@@ -44,7 +44,7 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
- [[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); spare relays drive aux outputs (`setAux`).
- [[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; driver written, one unit reachable at 10.0.10.6.
- [[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).
## Concepts — foundational forces
@@ -66,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.
- [[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-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.
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
+102
View File
@@ -1570,3 +1570,105 @@ picker; i18n parity (sq+en). Tests: `button-light.test.ts` (truth table + blink
`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.