Compare commits
13 Commits
830993bcb8
...
83298bc0c5
| Author | SHA1 | Date | |
|---|---|---|---|
| 83298bc0c5 | |||
| 898cf1953a | |||
| dd0f6e483a | |||
| 40de8a7467 | |||
| f0fd15bb88 | |||
| 40ffa90dac | |||
| b3cb67188e | |||
| b1c4109045 | |||
| 50dd554b43 | |||
| 6d7682ab4a | |||
| 793b8d83ee | |||
| 7366ad19cb | |||
| 5a5fedf4f4 |
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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)'"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)}`,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
Executable
+189
@@ -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
|
||||
@@ -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]].
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]].
|
||||
@@ -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,
|
||||
|
||||
@@ -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]].
|
||||
|
||||
@@ -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]].
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user