Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38481f105f | |||
| 4418594af0 | |||
| 25a72ff20a | |||
| c2a861208f | |||
| a888125eca | |||
| 96fd97efa9 | |||
| 2a13b95da6 | |||
| 513566c89e | |||
| f77ed11782 | |||
| e4a17efd97 | |||
| 6d32e0fc0f | |||
| 3a60367232 | |||
| 045892bc94 | |||
| 916c147b4d | |||
| 1ea1aa4189 | |||
| 9c20faf8de | |||
| a68dc23393 | |||
| c87dcb2253 | |||
| 7eadf71a0b | |||
| 9918f278b2 | |||
| 83298bc0c5 | |||
| 898cf1953a | |||
| dd0f6e483a | |||
| 40de8a7467 | |||
| f0fd15bb88 | |||
| 40ffa90dac | |||
| b3cb67188e | |||
| b1c4109045 | |||
| 50dd554b43 | |||
| 6d7682ab4a | |||
| 793b8d83ee | |||
| 7366ad19cb | |||
| 5a5fedf4f4 | |||
| 830993bcb8 | |||
| fd15988a73 | |||
| 420542ce10 | |||
| 2915d141aa |
@@ -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
|
||||||
@@ -9,5 +9,7 @@
|
|||||||
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
|
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
|
||||||
:80 {
|
:80 {
|
||||||
encode gzip
|
encode gzip
|
||||||
reverse_proxy server:3000
|
# Host network (prod): the server runs on the host's net namespace (to reach the booth LAN /
|
||||||
|
# device VLAN), so reach it over loopback, not the compose service name `server`.
|
||||||
|
reverse_proxy 127.0.0.1:3000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ import type { SubscriptionFlow, SubscriptionMatch } from "./subscription-flow.js
|
|||||||
// Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub
|
// Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub
|
||||||
// (no registry, no network). The factory returns a fresh shot each call.
|
// (no registry, no network). The factory returns a fresh shot each call.
|
||||||
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
|
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
|
||||||
|
// The bridge now goes through captureSnapshotShared (the dedup wrapper, exercised in
|
||||||
|
// snapshot.test.ts); here it just delegates to the fake camera's captureSnapshot so this
|
||||||
|
// suite stays focused on the bridge's own match/debounce/emit logic.
|
||||||
vi.mock("./snapshot.js", () => ({
|
vi.mock("./snapshot.js", () => ({
|
||||||
buildCamera: () => ({ captureSnapshot }),
|
buildCamera: () => ({ captureSnapshot }),
|
||||||
|
captureSnapshotShared: (_id: string, camera: { captureSnapshot: typeof captureSnapshot }, ctx: unknown) =>
|
||||||
|
camera.captureSnapshot(ctx as never),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Import AFTER the mock is registered.
|
// Import AFTER the mock is registered.
|
||||||
@@ -28,13 +33,22 @@ beforeEach(() => {
|
|||||||
captureSnapshot.mockClear();
|
captureSnapshot.mockClear();
|
||||||
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
|
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
|
||||||
delete process.env.ANPR_DEBOUNCE_MS;
|
delete process.env.ANPR_DEBOUNCE_MS;
|
||||||
|
// Poll-until-confident loop: keep the window + interval tiny so a below-floor / no-plate
|
||||||
|
// case gives up in ~one tick instead of the 8s production window (tests stay fast). Each
|
||||||
|
// bridge reads these in its constructor, so set them before `new AnprBridge`.
|
||||||
|
process.env.ANPR_POLL_MS = "1";
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "5";
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
delete process.env.ANPR_POLL_MS;
|
||||||
|
delete process.env.ANPR_POLL_WINDOW_MS;
|
||||||
|
delete process.env.ANPR_POLL_MAX_MS;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */
|
/** A camera bound to an entry relay; `anpr` toggles recognition, `anprAutoTrigger` the
|
||||||
function seedCamera(opts: { anpr?: boolean } = {}): string {
|
* per-camera auto-open gate (absent ⇒ defaults on). */
|
||||||
|
function seedCamera(opts: { anpr?: boolean; anprAutoTrigger?: boolean } = {}): string {
|
||||||
const controllerId = randomUUID();
|
const controllerId = randomUUID();
|
||||||
db.insert(devices).values({
|
db.insert(devices).values({
|
||||||
id: controllerId,
|
id: controllerId,
|
||||||
@@ -48,7 +62,13 @@ function seedCamera(opts: { anpr?: boolean } = {}): string {
|
|||||||
id: camId,
|
id: camId,
|
||||||
category: "camera",
|
category: "camera",
|
||||||
driverId: "hikvision",
|
driverId: "hikvision",
|
||||||
config: { host: "10.0.0.9", controllerId, relay: 1, ...(opts.anpr ? { anpr: true } : {}) },
|
config: {
|
||||||
|
host: "10.0.0.9",
|
||||||
|
controllerId,
|
||||||
|
relay: 1,
|
||||||
|
...(opts.anpr ? { anpr: true } : {}),
|
||||||
|
...(opts.anprAutoTrigger === false ? { anprAutoTrigger: false } : {}),
|
||||||
|
},
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}).run();
|
}).run();
|
||||||
return camId;
|
return camId;
|
||||||
@@ -74,8 +94,17 @@ function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: numb
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
|
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
|
||||||
function fakeSubFlow(match: SubscriptionMatch | null): SubscriptionFlow {
|
function fakeSubFlow(
|
||||||
return { match: vi.fn(() => match) } as unknown as SubscriptionFlow;
|
match: SubscriptionMatch | null,
|
||||||
|
// openOccurrenceCount: a constant, or a sequence consumed per call (to simulate a
|
||||||
|
// credential closing an occurrence mid-poll → count changes).
|
||||||
|
openCounts: number | number[] = 1,
|
||||||
|
): SubscriptionFlow {
|
||||||
|
const seq = Array.isArray(openCounts) ? [...openCounts] : null;
|
||||||
|
return {
|
||||||
|
match: vi.fn(() => match),
|
||||||
|
openOccurrenceCount: vi.fn(() => (seq ? (seq.length > 1 ? seq.shift()! : seq[0]) : (openCounts as number))),
|
||||||
|
} as unknown as SubscriptionFlow;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
|
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
|
||||||
@@ -104,6 +133,18 @@ describe("AnprBridge", () => {
|
|||||||
expect(captureSnapshot).not.toHaveBeenCalled();
|
expect(captureSnapshot).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does NOT auto-trigger when anprAutoTrigger=false (recognition on, auto-open off)", async () => {
|
||||||
|
// Shared entry/exit lane: the exit cam keeps anpr (recognition) but auto-trigger off, so a
|
||||||
|
// car driving IN isn't phantom-EXITed by its back plate. The bridge bails before snapshot.
|
||||||
|
const cam = seedCamera({ anpr: true, anprAutoTrigger: false });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.99 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
expect(captureSnapshot).not.toHaveBeenCalled(); // gated before the poll loop
|
||||||
|
});
|
||||||
|
|
||||||
it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
|
it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
|
||||||
const cam = seedCamera({ anpr: true });
|
const cam = seedCamera({ anpr: true });
|
||||||
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
|
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
|
||||||
@@ -123,6 +164,85 @@ describe("AnprBridge", () => {
|
|||||||
expect(reads).toEqual([]);
|
expect(reads).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("POLLS until confident: low-confidence approach frames, then a clean stop-at-barrier frame", async () => {
|
||||||
|
// The car APPROACHES (garbage reads) then STOPS at the barrier (clean read) — the bridge
|
||||||
|
// must re-pull until one frame clears the floor, not give up on the first bad frame.
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
// analyze escalates: 0.20, 0.20, then 0.97 on the 3rd pull → that one emits.
|
||||||
|
const confs = [0.2, 0.2, 0.97];
|
||||||
|
let i = 0;
|
||||||
|
const vision = {
|
||||||
|
enabled: true,
|
||||||
|
analyze: vi.fn(async () => ({
|
||||||
|
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||||
|
plates: [],
|
||||||
|
lowConfidence: false,
|
||||||
|
modelVersion: "test",
|
||||||
|
tookMs: 1,
|
||||||
|
})),
|
||||||
|
} as unknown as VisionClient;
|
||||||
|
// Generous window so all 3 escalation attempts run deterministically under suite load
|
||||||
|
// (the global beforeEach sets a tiny 5ms window for the give-up cases).
|
||||||
|
process.env.ANPR_POLL_MS = "1";
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "2000";
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
expect(reads[0]).toMatchObject({ value: "AA111BB", kind: "plate" });
|
||||||
|
expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SLIDES the window: a push mid-poll keeps the loop alive past the initial deadline", async () => {
|
||||||
|
// A loop started by an early/far car would expire — but a NEW push (another car arriving)
|
||||||
|
// extends the deadline, so the loop keeps polling and reads the car that settles at the
|
||||||
|
// barrier. Here: a SHORT base window, vision stays low until attempt 5; a second push at
|
||||||
|
// the start bumps the deadline so attempt 5's confident read still lands.
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const confs = [0.2, 0.2, 0.2, 0.2, 0.97];
|
||||||
|
let i = 0;
|
||||||
|
const vision = {
|
||||||
|
enabled: true,
|
||||||
|
analyze: vi.fn(async () => ({
|
||||||
|
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||||
|
plates: [],
|
||||||
|
lowConfidence: false,
|
||||||
|
modelVersion: "test",
|
||||||
|
tookMs: 1,
|
||||||
|
})),
|
||||||
|
} as unknown as VisionClient;
|
||||||
|
process.env.ANPR_POLL_MS = "5";
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "12"; // tiny — would expire ~attempt 2 WITHOUT a slide
|
||||||
|
process.env.ANPR_POLL_MAX_MS = "5000"; // ceiling far above, so the slide is what matters
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(async () => {
|
||||||
|
const loop = bridge.onVehicleDetected(cam); // starts the loop
|
||||||
|
// Joining pushes keep sliding the deadline forward so the slow-to-confident read lands.
|
||||||
|
for (let k = 0; k < 5; k++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
void bridge.onVehicleDetected(cam); // each bumps the deadline (loop already running)
|
||||||
|
}
|
||||||
|
await loop;
|
||||||
|
});
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
expect(reads[0]).toMatchObject({ value: "AA111BB" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ABORTS if the subscriber transacts by another credential mid-poll (no double-act)", async () => {
|
||||||
|
// The car's plate is read (identity known) but stays below the floor; meanwhile the
|
||||||
|
// subscriber scans their card → openOccurrenceCount drops. The bridge must abort and NOT
|
||||||
|
// emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. fleet).
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.5 }); // never clears the floor
|
||||||
|
// openOccurrenceCount: 1 at baseline, then 0 (the card exit closed it) on the next check.
|
||||||
|
const sub = fakeSubFlow(SUB_MATCH, [1, 0]);
|
||||||
|
const bridge = new AnprBridge(db, vision, sub, silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]); // aborted — the credential already handled it
|
||||||
|
});
|
||||||
|
|
||||||
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
|
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
|
||||||
const cam = seedCamera({ anpr: true });
|
const cam = seedCamera({ anpr: true });
|
||||||
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
||||||
|
|||||||
+151
-12
@@ -30,6 +30,10 @@ import type { VisionClient } from "./vision-client.js";
|
|||||||
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
|
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
|
||||||
interface CameraConfig {
|
interface CameraConfig {
|
||||||
readonly anpr?: boolean;
|
readonly anpr?: boolean;
|
||||||
|
/** Whether this camera may AUTO-OPEN the barrier (entry/exit). Absent ⇒ true (when anpr is
|
||||||
|
* on). Set false to keep recognition but suppress auto-trigger — e.g. the exit camera on a
|
||||||
|
* shared entry/exit lane. */
|
||||||
|
readonly anprAutoTrigger?: boolean;
|
||||||
readonly [k: string]: unknown;
|
readonly [k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +53,40 @@ function debounceMs(): number {
|
|||||||
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
|
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A single alarm fires the INSTANT motion starts — the car is still approaching, so the
|
||||||
|
* first frame often has a small/blurry/absent plate (a low-confidence misread). But the car
|
||||||
|
* then STOPS at the barrier (waiting for it to open) — the same stationary, well-framed
|
||||||
|
* moment the manual test reads at ~100%. So instead of one shot, we POLL fresh frames and
|
||||||
|
* re-run ANPR until one clears the confidence floor, or the window elapses. Poll interval: */
|
||||||
|
function pollMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_POLL_MS ?? 1000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long to keep polling AFTER THE LAST vehicle push before giving up. SLIDING: each new
|
||||||
|
* push for the camera extends the deadline by this much from now — so a loop started by a
|
||||||
|
* far/early car keeps pulling fresh frames as the REAL car arrives and settles at the
|
||||||
|
* barrier (the loop tracks "whoever is here now", not the car that started it). */
|
||||||
|
function pollWindowMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_POLL_WINDOW_MS ?? 8000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hard ceiling on a single loop from its START, so a continuously-busy lane (pushes never
|
||||||
|
* stop) can't slide the window forever. The loop ends at min(lastPush + window, start + max). */
|
||||||
|
function pollMaxMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_POLL_MAX_MS ?? 30_000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** A plate DeviceReadEvent skeleton (value filled by the caller) — for matching the
|
||||||
|
* subscriber by plate during the poll loop without re-building the whole event. */
|
||||||
|
function baseRead(row: { driverId: string }, deviceId: string): Omit<DeviceReadEvent, "value"> {
|
||||||
|
return { driverId: row.driverId, deviceId, kind: "plate", at: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
export class AnprBridge {
|
export class AnprBridge {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #vision: VisionClient | null;
|
readonly #vision: VisionClient | null;
|
||||||
@@ -56,9 +94,19 @@ export class AnprBridge {
|
|||||||
readonly #logger: FastifyBaseLogger;
|
readonly #logger: FastifyBaseLogger;
|
||||||
readonly #entryMinConfidence: number;
|
readonly #entryMinConfidence: number;
|
||||||
readonly #debounceMs: number;
|
readonly #debounceMs: number;
|
||||||
|
readonly #pollMs: number;
|
||||||
|
readonly #pollWindowMs: number;
|
||||||
|
readonly #pollMaxMs: number;
|
||||||
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
|
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
|
||||||
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
|
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
|
||||||
readonly #lastFire = new Map<string, number>();
|
readonly #lastFire = new Map<string, number>();
|
||||||
|
/** Cameras with a poll loop already in flight — a re-fired alarm (the camera pushes ~1Hz
|
||||||
|
* while the car sits) must NOT start a second concurrent loop on the same camera. */
|
||||||
|
readonly #polling = new Set<string>();
|
||||||
|
/** Per-camera SLIDING deadline for the running poll loop. A push that joins a running loop
|
||||||
|
* bumps this forward (lastPush + window, capped at start + max), so the loop keeps pulling
|
||||||
|
* fresh frames while cars keep arriving — tracking whoever settles at the barrier. */
|
||||||
|
readonly #pollDeadline = new Map<string, number>();
|
||||||
|
|
||||||
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
@@ -67,6 +115,9 @@ export class AnprBridge {
|
|||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
this.#entryMinConfidence = entryMinConfidence();
|
this.#entryMinConfidence = entryMinConfidence();
|
||||||
this.#debounceMs = debounceMs();
|
this.#debounceMs = debounceMs();
|
||||||
|
this.#pollMs = pollMs();
|
||||||
|
this.#pollWindowMs = pollWindowMs();
|
||||||
|
this.#pollMaxMs = pollMaxMs();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,15 +135,35 @@ export class AnprBridge {
|
|||||||
if (site && site.anprEntryEnabled === false) return;
|
if (site && site.anprEntryEnabled === false) return;
|
||||||
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||||
if (!row || !row.enabled || row.category !== "camera") return;
|
if (!row || !row.enabled || row.category !== "camera") return;
|
||||||
if ((row.config as CameraConfig)?.anpr !== true) return; // opt-in only
|
const cfg = row.config as CameraConfig;
|
||||||
|
if (cfg?.anpr !== true) return; // recognition opt-in (also gates the evidence/advisory path)
|
||||||
|
// Per-camera AUTO-TRIGGER gate. `anpr` keeps recognition (snapshots + plate record) on;
|
||||||
|
// this controls whether THIS camera may auto-open the barrier. A shared entry/exit lane
|
||||||
|
// sets it false on (e.g.) the exit camera so its back-plate read doesn't phantom-exit the
|
||||||
|
// car that just entered. Absent ⇒ true (back-compat: existing anpr cameras still trigger).
|
||||||
|
if (cfg.anprAutoTrigger === false) return;
|
||||||
|
|
||||||
// Camera-level debounce (pre-snapshot): a car re-firing ~1Hz must not pull a
|
// Post-success debounce: once we've emitted a read for this camera, ignore the
|
||||||
// snapshot + analyze every second.
|
// ~1Hz re-fires for #debounceMs (set on success below). A fresh alarm AFTER the
|
||||||
|
// window is a new presentation and may start a new poll loop.
|
||||||
if (this.#debounced(deviceId)) return;
|
if (this.#debounced(deviceId)) return;
|
||||||
this.#stamp(deviceId);
|
// One poll loop per camera. A push that arrives while a loop runs JOINs it — and
|
||||||
|
// SLIDES the deadline forward (a different car arriving mid-loop keeps the loop alive
|
||||||
|
// so it tracks whoever's at the barrier now, instead of giving up on the early car).
|
||||||
|
const now = Date.now();
|
||||||
|
if (this.#polling.has(deviceId)) {
|
||||||
|
const cur = this.#pollDeadline.get(deviceId) ?? now;
|
||||||
|
// Slide to lastPush + window, but never past the per-loop hard ceiling (set at start).
|
||||||
|
this.#pollDeadline.set(deviceId, Math.max(cur, now + this.#pollWindowMs));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#polling.add(deviceId);
|
||||||
|
// Initial deadline; the hard ceiling (start + max) is enforced in the loop below.
|
||||||
|
this.#pollDeadline.set(deviceId, now + this.#pollWindowMs);
|
||||||
|
|
||||||
const camera = buildCamera(row);
|
const camera = buildCamera(row);
|
||||||
if (!camera) {
|
if (!camera) {
|
||||||
|
this.#polling.delete(deviceId);
|
||||||
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
|
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -100,16 +171,74 @@ export class AnprBridge {
|
|||||||
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
|
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
|
||||||
// the gated flow infers the verb from the camera's bound relay direction).
|
// the gated flow infers the verb from the camera's bound relay direction).
|
||||||
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
|
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
|
||||||
const shot = await camera.captureSnapshot({ direction });
|
|
||||||
const result = await this.#vision.analyze(shot.bytes, shot.contentType);
|
|
||||||
if (!result || !result.plate) return; // nothing read
|
|
||||||
|
|
||||||
// Entry floor — stricter than the advisory floor (analyze() still returns the plate
|
// POLL-UNTIL-CONFIDENT. The alarm fires as the car APPROACHES (small/blurry/absent
|
||||||
// object with its confidence even when its own lowConfidence flag is set).
|
// plate → low-confidence misread, e.g. '111'@0.20). But the car then STOPS at the
|
||||||
if (result.plate.confidence < this.#entryMinConfidence) {
|
// barrier — the stationary, well-framed moment the manual test reads at ~100%. So we
|
||||||
|
// pull a FRESH frame every #pollMs and re-run ANPR until one clears the floor, or the
|
||||||
|
// #pollWindowMs window elapses (car drove off / non-subscriber). NB: a fresh pull each
|
||||||
|
// tick — NOT captureSnapshotShared, whose TTL would re-serve the same bad frame.
|
||||||
|
// While polling, watch whether THIS subscriber transacts by another credential
|
||||||
|
// (card/QR at the reader). If their open-occurrence count drops mid-poll, the
|
||||||
|
// subscriber already exited/entered — the bridge must NOT also emit (it would act on
|
||||||
|
// the NEXT open occurrence: a phantom double-exit, worst for a fleet sub). We learn the
|
||||||
|
// subscription as soon as a frame reads the bound plate (identity needs no confidence),
|
||||||
|
// snapshot the count, then keep polling for a CONFIDENT read; abort if the count moved.
|
||||||
|
let result: Awaited<ReturnType<VisionClient["analyze"]>> = null;
|
||||||
|
let watchedSubId: string | null = null;
|
||||||
|
let baselineOpen = 0;
|
||||||
|
// Hard ceiling for THIS loop (start + max); the sliding deadline (bumped by joining
|
||||||
|
// pushes) is read from #pollDeadline each tick but never allowed past this cap.
|
||||||
|
const hardCap = Date.now() + this.#pollMaxMs;
|
||||||
|
let attempts = 0;
|
||||||
|
try {
|
||||||
|
while (Date.now() < Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap)) {
|
||||||
|
attempts++;
|
||||||
|
const shot = await camera.captureSnapshot({ direction });
|
||||||
|
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
|
||||||
|
|
||||||
|
// Identify the subscriber from ANY readable plate (even below the barrier floor),
|
||||||
|
// and baseline their open count once — so we can detect a credential beating us.
|
||||||
|
if (r?.plate?.text) {
|
||||||
|
const m0 = this.#subscription.match({ ...baseRead(row, deviceId), value: r.plate.text.trim().toUpperCase() });
|
||||||
|
if (m0 && watchedSubId == null) {
|
||||||
|
watchedSubId = m0.subscriptionId;
|
||||||
|
baselineOpen = this.#subscription.openOccurrenceCount(watchedSubId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A credential (card/QR) closed/opened an occurrence for this subscriber mid-poll →
|
||||||
|
// they already transacted; stop polling and do NOT emit.
|
||||||
|
if (watchedSubId && this.#subscription.openOccurrenceCount(watchedSubId) !== baselineOpen) {
|
||||||
|
this.#logger.info(
|
||||||
|
`anpr-bridge: subscriber ${watchedSubId} transacted by another credential mid-poll — aborting ANPR`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r?.plate && r.plate.confidence >= this.#entryMinConfidence) {
|
||||||
|
result = r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (r?.plate) {
|
||||||
|
this.#logger.info(
|
||||||
|
`anpr-bridge: '${r.plate.text}' (${r.plate.confidence.toFixed(3)}) below floor ` +
|
||||||
|
`${this.#entryMinConfidence} — re-pulling (attempt ${attempts})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Stop if the next tick would land past the (possibly slid) deadline or the cap.
|
||||||
|
const effDeadline = Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap);
|
||||||
|
if (Date.now() + this.#pollMs >= effDeadline) break;
|
||||||
|
await sleep(this.#pollMs);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.#polling.delete(deviceId);
|
||||||
|
this.#pollDeadline.delete(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result || !result.plate) {
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`anpr-bridge: plate '${result.plate.text}' below entry floor ` +
|
`anpr-bridge: no confident plate from ${deviceId} after ${attempts} attempt(s) ` +
|
||||||
`(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`,
|
`in ${this.#pollWindowMs}ms — gave up`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -133,11 +262,21 @@ export class AnprBridge {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final guard against the credential-mid-poll race: if the subscriber transacted between
|
||||||
|
// our baseline and now (e.g. a card scan in the last tick), don't double-act.
|
||||||
|
if (watchedSubId === match.subscriptionId && this.#subscription.openOccurrenceCount(match.subscriptionId) !== baselineOpen) {
|
||||||
|
this.#logger.info(`anpr-bridge: ${match.subscriptionId} already transacted — skipping ANPR emit`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Plate-level debounce — belt-and-suspenders against a gap that slips the
|
// Plate-level debounce — belt-and-suspenders against a gap that slips the
|
||||||
// camera-level gate re-emitting the SAME plate.
|
// camera-level gate re-emitting the SAME plate.
|
||||||
const plateKey = `${deviceId}:${plate}`;
|
const plateKey = `${deviceId}:${plate}`;
|
||||||
if (this.#debounced(plateKey)) return;
|
if (this.#debounced(plateKey)) return;
|
||||||
this.#stamp(plateKey);
|
this.#stamp(plateKey);
|
||||||
|
// Camera-level debounce stamp — now that we've emitted, suppress the camera's ~1Hz
|
||||||
|
// re-fires (and any new poll loop) for #debounceMs.
|
||||||
|
this.#stamp(deviceId);
|
||||||
|
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
|
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import type { AuxOutputDevice } from "@parking/devices";
|
||||||
|
import { ButtonLightController } from "./button-light.js";
|
||||||
|
import { deviceEvents } from "./device-events.js";
|
||||||
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// ButtonLightController: alert (radarAlert) relays — the entry-button lamp on a spare
|
||||||
|
// relay, driven by the lamp's trigger input vs. the camera lane status. Truth table:
|
||||||
|
// trigger active + lane busy -> SOLID on
|
||||||
|
// trigger active + lane free -> BLINK (~1 Hz)
|
||||||
|
// otherwise -> OFF
|
||||||
|
// Lamp is a non-barrier aux output; fails OFF; de-dupes redundant writes. A controller may
|
||||||
|
// carry several alert relays (each its own row + trigger input), keyed independently.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const CONTROLLER = "ctl-1";
|
||||||
|
const RADAR_INPUT = 2; // I2
|
||||||
|
const LAMP_RELAY = 3; // spare relay R3
|
||||||
|
|
||||||
|
/** A fake aux device recording setAux calls (channel,on). Optionally throws. */
|
||||||
|
function fakeAux(record: Array<{ ch: number; on: boolean }>, throwOnce = { v: false }): AuxOutputDevice {
|
||||||
|
return {
|
||||||
|
async setAux(channel: number, on: boolean): Promise<void> {
|
||||||
|
if (throwOnce.v) {
|
||||||
|
throwOnce.v = false;
|
||||||
|
throw new Error("UDP down");
|
||||||
|
}
|
||||||
|
record.push({ ch: channel, on });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
vi.useFakeTimers();
|
||||||
|
// One controller: entry relay 1 with radar on I2; lamp on spare relay 3.
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CONTROLLER,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Emit a radar (presence input) edge for the controller. */
|
||||||
|
function radar(present: boolean): void {
|
||||||
|
deviceEvents.emitInput({
|
||||||
|
driverId: "dingtian",
|
||||||
|
deviceId: CONTROLLER,
|
||||||
|
input: RADAR_INPUT,
|
||||||
|
edge: present ? "on" : "off",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source: "poll",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit a lane status (entry busy/free). */
|
||||||
|
function lane(entryBusy: boolean): void {
|
||||||
|
deviceEvents.emitLaneStatus({ entry: entryBusy, exit: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flush the microtask queue so serialized setAux promises (and their re-pump on
|
||||||
|
* completion) settle. The lamp worker sends ONE UDP at a time and re-pumps on resolve;
|
||||||
|
* a few turns drain a burst. Needed because sends are now async (was synchronous). */
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
for (let i = 0; i < 6; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ButtonLightController truth table", () => {
|
||||||
|
it("OFF at start (no radar, no car)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||||
|
// confirmedOn starts null; OFF de-dupes (null !== false → one off write), so the
|
||||||
|
// device is confirmed OFF and at most one call was made.
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar present + lane busy -> SOLID on", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // device latched ON
|
||||||
|
// Solid = no blinking: advancing time produces no further sends.
|
||||||
|
const n = calls.length;
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
await flush();
|
||||||
|
expect(calls.length).toBe(n);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar present + lane free -> BLINK (toggles the device over time)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
radar(true); // lane still free
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // on now
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // toggled off
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // toggled on
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blink -> solid when the camera confirms a car (lane busy)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
radar(true); // blink
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
lane(true); // camera confirms
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
// No more toggles (blink torn down) — the device stays ON over time.
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar clears -> OFF", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true); // solid
|
||||||
|
await flush();
|
||||||
|
radar(false); // car gone
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // device latched OFF
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("de-dupes redundant writes (no spam on repeat events)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true); // solid, on
|
||||||
|
await flush();
|
||||||
|
const n = calls.length;
|
||||||
|
radar(true); // same state — no new edge (present unchanged)
|
||||||
|
lane(true); // same lane — no change
|
||||||
|
await flush();
|
||||||
|
expect(calls.length).toBe(n);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails OFF: a setAux error does not throw or escalate", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const throwOnce = { v: true };
|
||||||
|
const aux = fakeAux(calls, throwOnce);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
// First write (initial off) throws — must be swallowed.
|
||||||
|
expect(() => ctl.start()).not.toThrow();
|
||||||
|
await flush();
|
||||||
|
// Subsequent writes work; driving to solid still converges to ON.
|
||||||
|
lane(true);
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores controllers without an alert relay", () => {
|
||||||
|
// A second controller, no alert relay.
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: "ctl-2",
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: { host: "10.0.0.6", relays: [{ relay: 1, direction: "entry", presenceInput: 2 }] },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||||
|
ctl.start();
|
||||||
|
expect(ctl.stateOf("ctl-2")).toBeNull();
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks up an alert relay ADDED after start() (no restart needed)", async () => {
|
||||||
|
// Fresh controller with a radar input but NO alert relay yet.
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
// Replace the seeded controller with one that has the radar but no lamp.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
// No lamp configured → an input does nothing.
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBeNull();
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
radar(false);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Admin saves an alert relay (relay 3, trigger I2) — without restarting the server.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" },
|
||||||
|
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// The very next radar edge reconciles + blinks (lane still free).
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drives two alert relays on one controller independently", async () => {
|
||||||
|
const R3 = 3;
|
||||||
|
const R4 = 4;
|
||||||
|
const I2 = 2;
|
||||||
|
const I3 = 3;
|
||||||
|
// Controller with two alert lamps, each on its own trigger input.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", presenceInput: I2, presenceKind: "radar" },
|
||||||
|
{ relay: R3, direction: "radarAlert", triggerInput: I2, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
{ relay: R4, direction: "radarAlert", triggerInput: I3, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
|
||||||
|
|
||||||
|
// I2 active → only R3 blinks; R4 stays off (different trigger).
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
|
||||||
|
|
||||||
|
// I3 active → R4 blinks too, independently.
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I3, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||||
|
|
||||||
|
// Camera confirms a car → BOTH lock solid (lane-busy is site-wide).
|
||||||
|
lane(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("solid");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||||
|
|
||||||
|
// I2 clears → R3 off, R4 still solid (its trigger still active).
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "off", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an EXIT alert lamp locks on the EXIT camera, not entry", async () => {
|
||||||
|
const R4 = 4;
|
||||||
|
const I5 = 5; // exit radar
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
// Exit alert lamp: triggers on the exit radar, locks on the EXIT camera.
|
||||||
|
{ relay: R4, direction: "radarAlert", triggerInput: I5, lockLane: "exit", blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
const aux = fakeAux([]);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Exit radar active → blink.
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I5, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||||
|
|
||||||
|
// ENTRY camera busy must NOT lock this exit lamp — it still blinks.
|
||||||
|
deviceEvents.emitLaneStatus({ entry: true, exit: false });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||||
|
|
||||||
|
// EXIT camera busy → SOLID.
|
||||||
|
deviceEvents.emitLaneStatus({ entry: true, exit: true });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { eq, devices, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { hasAuxOutput, registry, type AuxOutputDevice } from "@parking/devices";
|
||||||
|
import { deviceEvents, type DeviceInputEvent, type LaneStatusEvent } from "./device-events.js";
|
||||||
|
import { alertRelaysOf, relayForPresence, type RelaySpec } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// Alert (radarAlert) relays — non-barrier indicator lamps, e.g. the entry button's 12 V
|
||||||
|
// light. Each lamp is a `relays[]` row with event `radarAlert`, driven by ITS trigger
|
||||||
|
// input vs. the camera "car in zone" signal (the advisory lane-status). A disagreement
|
||||||
|
// indicator:
|
||||||
|
// trigger active + lane busy (camera confirms a car) → SOLID on
|
||||||
|
// trigger active + lane free (radar sees something, no car) → BLINK (~1 Hz)
|
||||||
|
// otherwise → OFF
|
||||||
|
// The lamp is a NON-barrier aux output (setAux latch), so holding/blinking it is fine
|
||||||
|
// — barrier-not-a-door applies only to barriers, which still only pulseOpen. The lamp
|
||||||
|
// FAILS OFF: any error / shutdown leaves it off, so a dead lamp is "no hint", never a
|
||||||
|
// misleading solid "go". A controller may have several alert relays (each its own row +
|
||||||
|
// trigger input), keyed independently. See wiki/concepts/button-light-indicator.md.
|
||||||
|
|
||||||
|
type LightState = "off" | "solid" | "blink";
|
||||||
|
|
||||||
|
const DEFAULT_BLINK_MS = 500;
|
||||||
|
|
||||||
|
/** Per-lamp live state for the alert rule (one per radarAlert relay). */
|
||||||
|
interface LampState {
|
||||||
|
/** The controller this lamp lives on (its deviceId) — for resolving the aux adapter. */
|
||||||
|
readonly controllerId: string;
|
||||||
|
/** Alert relay row (relay #, triggerInput, blink ms). Mutable: #reconcile updates it in
|
||||||
|
* place when the admin changes the alert config without a restart. */
|
||||||
|
spec: RelaySpec;
|
||||||
|
/** Is the lamp's trigger input (the radar) currently active? */
|
||||||
|
present: boolean;
|
||||||
|
/** The high-level state we're rendering (to avoid restarting a running blink). */
|
||||||
|
rendered: LightState | null;
|
||||||
|
/** Active blink timer, if blinking. */
|
||||||
|
blink: ReturnType<typeof setInterval> | null;
|
||||||
|
/** Blink phase (true = currently on). */
|
||||||
|
blinkOn: boolean;
|
||||||
|
/** The output we WANT the relay to be in. The serialized worker drives the device
|
||||||
|
* toward this. The blink timer only flips this flag — it never sends directly. */
|
||||||
|
desiredOn: boolean;
|
||||||
|
/** The output we last CONFIRMED on the device (after a successful send). null = unknown. */
|
||||||
|
confirmedOn: boolean | null;
|
||||||
|
/** True while a send is in flight for this lamp — serializes UDP so on/off can't
|
||||||
|
* overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */
|
||||||
|
sending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a controller's live aux-output adapter. The default goes through the
|
||||||
|
* driver registry; tests inject a spy. Returns null when the controller has no
|
||||||
|
* aux-output capability (or won't build). */
|
||||||
|
export type AuxResolver = (controllerId: string) => AuxOutputDevice | null;
|
||||||
|
|
||||||
|
export class ButtonLightController {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #resolveAux: AuxResolver;
|
||||||
|
/** Per-lamp state, keyed by `${controllerId}:${relay}` (a controller may have several). */
|
||||||
|
readonly #lamps = new Map<string, LampState>();
|
||||||
|
/** Latest lane status — a camera-confirmed car in the entry / exit zone. A lamp locks
|
||||||
|
* SOLID off its OWN lane's camera (`spec.lockLane`), so an exit radar's lamp tracks the
|
||||||
|
* exit camera, not the entry one. */
|
||||||
|
#entryBusy = false;
|
||||||
|
#exitBusy = false;
|
||||||
|
/** Controllers we've already warned lack the aux-output capability (warn once). */
|
||||||
|
readonly #warned = new Set<string>();
|
||||||
|
#unsubInput: (() => void) | null = null;
|
||||||
|
#unsubLane: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(db: Db, logger: FastifyBaseLogger, resolveAux?: AuxResolver) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
this.#resolveAux = resolveAux ?? ((id) => this.#auxFromRegistry(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to radar input edges + lane status, and initialise every lamp OFF. */
|
||||||
|
start(): void {
|
||||||
|
this.#reconcile();
|
||||||
|
// All lamps start OFF (known-safe baseline) regardless of prior device state.
|
||||||
|
for (const lamp of this.#lamps.values()) this.#apply(lamp);
|
||||||
|
|
||||||
|
this.#unsubInput = deviceEvents.onInput((e) => this.#onInput(e));
|
||||||
|
this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconcile the lamp map with the CURRENT device config (the booth can add/change a
|
||||||
|
* button light without a server restart). Mirrors DeviceMonitor, which re-reads the
|
||||||
|
* device set each tick. Adds lamps for newly-configured controllers, updates the spec
|
||||||
|
* (relay #, blink ms) in place — preserving live `present`/blink state — and drops
|
||||||
|
* lamps whose controller lost its buttonLight or was disabled. Called at start() and
|
||||||
|
* before handling each event, so a just-saved lamp takes effect immediately. */
|
||||||
|
#reconcile(): void {
|
||||||
|
const rows = this.#db.select().from(devices).where(eq(devices.category, "access")).all();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.enabled) continue;
|
||||||
|
for (const spec of alertRelaysOf(row)) {
|
||||||
|
const key = lampKey(row.id, spec.relay);
|
||||||
|
seen.add(key);
|
||||||
|
const existing = this.#lamps.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.spec = spec; // pick up a changed trigger input / blink cadence
|
||||||
|
} else {
|
||||||
|
this.#lamps.set(key, {
|
||||||
|
controllerId: row.id,
|
||||||
|
spec,
|
||||||
|
present: false,
|
||||||
|
rendered: null,
|
||||||
|
blink: null,
|
||||||
|
blinkOn: false,
|
||||||
|
desiredOn: false,
|
||||||
|
confirmedOn: null,
|
||||||
|
sending: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop lamps whose controller no longer declares one (or was disabled/removed).
|
||||||
|
for (const [key, lamp] of this.#lamps) {
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
if (lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = null;
|
||||||
|
}
|
||||||
|
this.#finalOff(lamp); // best-effort fail-OFF before forgetting it
|
||||||
|
this.#lamps.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A radar (presence) edge updates that controller's `present` flag. We resolve the
|
||||||
|
* edge the SAME way the entry flow does (relayForPresence on an entry/both relay),
|
||||||
|
* so the lamp and the one-car-one-ticket gate always agree on "a car is here". */
|
||||||
|
#onInput(e: DeviceInputEvent): void {
|
||||||
|
// Reconcile first so a lamp added/changed since boot (no restart) is picked up.
|
||||||
|
this.#reconcile();
|
||||||
|
const present = e.edge === "on";
|
||||||
|
for (const lamp of this.#lamps.values()) {
|
||||||
|
if (lamp.controllerId !== e.deviceId) continue;
|
||||||
|
// A lamp's trigger is its own `triggerInput`; if unset, fall back to the controller's
|
||||||
|
// entry-relay presence terminal (resolved the SAME way the entry flow does) so the
|
||||||
|
// lamp and the one-car-one-ticket gate always agree on "a car is here".
|
||||||
|
const trigger =
|
||||||
|
lamp.spec.triggerInput ?? relayForPresence(this.#db, e.deviceId, e.input)?.presenceInput;
|
||||||
|
if (trigger !== e.input) continue; // not this lamp's trigger terminal
|
||||||
|
if (present === lamp.present) continue;
|
||||||
|
lamp.present = present;
|
||||||
|
this.#apply(lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lane status changed: a camera-confirmed car in the entry and/or exit zone. */
|
||||||
|
#onLane(s: LaneStatusEvent): void {
|
||||||
|
if (s.entry === this.#entryBusy && s.exit === this.#exitBusy) return;
|
||||||
|
this.#entryBusy = s.entry;
|
||||||
|
this.#exitBusy = s.exit;
|
||||||
|
// Re-render every lamp (each picks its own lane's camera in #apply).
|
||||||
|
for (const lamp of this.#lamps.values()) this.#apply(lamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute + render the target state for one lamp. Drives are fire-and-forget (the
|
||||||
|
* timer/state machine is synchronous; the UDP write resolves on its own). */
|
||||||
|
#apply(lamp: LampState): void {
|
||||||
|
// SOLID only once THIS lamp's lane camera confirms a car (default entry).
|
||||||
|
const laneBusy = lamp.spec.lockLane === "exit" ? this.#exitBusy : this.#entryBusy;
|
||||||
|
const target: LightState = !lamp.present ? "off" : laneBusy ? "solid" : "blink";
|
||||||
|
if (target === lamp.rendered) return; // already rendering this state
|
||||||
|
|
||||||
|
// Tear down any running blink before switching states.
|
||||||
|
if (lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = null;
|
||||||
|
}
|
||||||
|
lamp.rendered = target;
|
||||||
|
|
||||||
|
if (target === "off") {
|
||||||
|
lamp.desiredOn = false;
|
||||||
|
this.#pump(lamp);
|
||||||
|
} else if (target === "solid") {
|
||||||
|
lamp.desiredOn = true;
|
||||||
|
this.#pump(lamp);
|
||||||
|
} else {
|
||||||
|
// BLINK: a wall-clock timer flips ONLY the desired flag; #pump does the actual
|
||||||
|
// (serialized) UDP send. A symmetric cadence uses one interval; an asymmetric one
|
||||||
|
// re-arms each phase with its own duration. Sends never overlap or reorder, so the
|
||||||
|
// relay can't get stuck on a stale packet.
|
||||||
|
const onMs = lamp.spec.blinkOnMs && lamp.spec.blinkOnMs > 0 ? lamp.spec.blinkOnMs : DEFAULT_BLINK_MS;
|
||||||
|
const offMs = lamp.spec.blinkOffMs && lamp.spec.blinkOffMs > 0 ? lamp.spec.blinkOffMs : DEFAULT_BLINK_MS;
|
||||||
|
lamp.blinkOn = true;
|
||||||
|
lamp.desiredOn = true;
|
||||||
|
const tick = () => {
|
||||||
|
lamp.blinkOn = !lamp.blinkOn;
|
||||||
|
lamp.desiredOn = lamp.blinkOn;
|
||||||
|
this.#pump(lamp);
|
||||||
|
if (onMs !== offMs && lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs);
|
||||||
|
lamp.blink.unref?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
lamp.blink = setInterval(tick, onMs);
|
||||||
|
lamp.blink.unref?.();
|
||||||
|
this.#pump(lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialized per-lamp worker: drive the relay toward `desiredOn`, one UDP send at a
|
||||||
|
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
|
||||||
|
* the relay stuck on a stale packet. Here a single in-flight send is guaranteed
|
||||||
|
* (`sending` guard); when it resolves, if the desired state moved on we send again —
|
||||||
|
* so the LAST desired state is always the one finally asserted on the device. */
|
||||||
|
#pump(lamp: LampState): void {
|
||||||
|
if (lamp.sending) return; // a send is already in flight; it'll re-check on completion
|
||||||
|
if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP
|
||||||
|
const aux = this.#resolveAux(lamp.controllerId);
|
||||||
|
if (!aux) return;
|
||||||
|
const target = lamp.desiredOn;
|
||||||
|
lamp.sending = true;
|
||||||
|
void aux
|
||||||
|
.setAux(lamp.spec.relay, target)
|
||||||
|
.then(() => {
|
||||||
|
lamp.confirmedOn = target;
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
// Leave confirmedOn unchanged so the next pump retries this state. Never escalates.
|
||||||
|
this.#logger.error(`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
lamp.sending = false;
|
||||||
|
// Desired state may have changed (or the send failed) while we were busy —
|
||||||
|
// re-pump to converge. This is what makes the final state authoritative.
|
||||||
|
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(lamp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the live aux-output adapter for a controller, or null (logged once). */
|
||||||
|
#auxFromRegistry(controllerId: string): AuxOutputDevice | null {
|
||||||
|
const row = this.#db.select().from(devices).where(eq(devices.id, controllerId)).get();
|
||||||
|
if (!row) return null;
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
let device: unknown;
|
||||||
|
try {
|
||||||
|
device = driver.create(row.config as never);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!hasAuxOutput(device)) {
|
||||||
|
if (!this.#warned.has(controllerId)) {
|
||||||
|
this.#warned.add(controllerId);
|
||||||
|
this.#logger.warn(`button-light: controller ${controllerId} (${row.driverId}) has no aux-output — lamp ignored`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsubscribe, stop all blink timers, and best-effort drive every lamp OFF. */
|
||||||
|
stop(): void {
|
||||||
|
this.#unsubInput?.();
|
||||||
|
this.#unsubLane?.();
|
||||||
|
this.#unsubInput = null;
|
||||||
|
this.#unsubLane = null;
|
||||||
|
for (const lamp of this.#lamps.values()) {
|
||||||
|
if (lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = null;
|
||||||
|
}
|
||||||
|
// Best-effort fail-OFF on shutdown.
|
||||||
|
this.#finalOff(lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired
|
||||||
|
* OFF and pump. The serialized worker still applies, so this can't collide with an
|
||||||
|
* in-flight send — it converges to OFF. */
|
||||||
|
#finalOff(lamp: LampState): void {
|
||||||
|
lamp.desiredOn = false;
|
||||||
|
this.#pump(lamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: current high-level state being rendered for a lamp (controller + relay).
|
||||||
|
* `relay` defaults to the controller's only/first alert relay for single-lamp tests. */
|
||||||
|
stateOf(controllerId: string, relay?: number): LightState | null {
|
||||||
|
return this.#lamp(controllerId, relay)?.rendered ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: the state last CONFIRMED on the device for a lamp (after a successful
|
||||||
|
* send). null = unknown / nothing sent yet. `relay` defaults to the only alert relay. */
|
||||||
|
confirmedOf(controllerId: string, relay?: number): boolean | null {
|
||||||
|
return this.#lamp(controllerId, relay)?.confirmedOn ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a lamp by controller + relay. When `relay` is omitted, returns the
|
||||||
|
* controller's single lamp (the common single-alert case); ambiguous if several. */
|
||||||
|
#lamp(controllerId: string, relay?: number): LampState | undefined {
|
||||||
|
if (relay != null) return this.#lamps.get(lampKey(controllerId, relay));
|
||||||
|
for (const lamp of this.#lamps.values()) if (lamp.controllerId === controllerId) return lamp;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Composite key for the lamp map (a controller may carry several alert relays). */
|
||||||
|
function lampKey(controllerId: string, relay: number): string {
|
||||||
|
return `${controllerId}:${relay}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a controller row's live aux device (exported for reuse/tests). */
|
||||||
|
export function buildAux(db: Db, row: DeviceRow): AuxOutputDevice | null {
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
try {
|
||||||
|
const device = driver.create(row.config as never);
|
||||||
|
return hasAuxOutput(device) ? device : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,16 @@ export interface LaneStatusEvent {
|
|||||||
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
|
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the
|
||||||
|
* entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has
|
||||||
|
* confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink:
|
||||||
|
* radar-present + camera-not-busy. Drives the booth's barrier light blink. Advisory only —
|
||||||
|
* it gates nothing. See wiki/concepts/button-light-indicator.md. */
|
||||||
|
export interface LanePresenceEvent {
|
||||||
|
readonly entry: boolean; // true = a presence input on an entry barrier is active
|
||||||
|
readonly exit: boolean; // true = a presence input on an exit barrier is active
|
||||||
|
}
|
||||||
|
|
||||||
class DeviceEventBus extends EventEmitter {
|
class DeviceEventBus extends EventEmitter {
|
||||||
emitInput(event: DeviceInputEvent): void {
|
emitInput(event: DeviceInputEvent): void {
|
||||||
this.emit("input", event);
|
this.emit("input", event);
|
||||||
@@ -148,6 +158,16 @@ class DeviceEventBus extends EventEmitter {
|
|||||||
this.on("lane-status", cb);
|
this.on("lane-status", cb);
|
||||||
return () => this.off("lane-status", cb);
|
return () => this.off("lane-status", cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Emitted whenever a lane's RADAR presence CHANGES (a presence input shorted/cleared
|
||||||
|
* at an entry/exit barrier). Drives the booth barrier light's blink. Advisory only. */
|
||||||
|
emitLanePresence(event: LanePresenceEvent): void {
|
||||||
|
this.emit("lane-presence", event);
|
||||||
|
}
|
||||||
|
onLanePresence(cb: (event: LanePresenceEvent) => void): () => void {
|
||||||
|
this.on("lane-presence", cb);
|
||||||
|
return () => this.off("lane-presence", cb);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Process-wide device event bus. */
|
/** Process-wide device event bus. */
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
|
|||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
case "access": {
|
case "access": {
|
||||||
const dirs = new Set(relaysOf(row).map((r) => r.direction));
|
// Only barrier relays carry a role direction; alert (radarAlert) relays don't.
|
||||||
|
const dirs = new Set(
|
||||||
|
relaysOf(row)
|
||||||
|
.map((r) => r.direction)
|
||||||
|
.filter((d): d is "entry" | "exit" | "both" => d !== "radarAlert"),
|
||||||
|
);
|
||||||
if (dirs.size === 0) return null;
|
if (dirs.size === 0) return null;
|
||||||
if (dirs.size > 1) return "mixed";
|
if (dirs.size > 1) return "mixed";
|
||||||
const only = [...dirs][0]; // entry | exit | both
|
const only = [...dirs][0]; // entry | exit | both
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { inputsOf, relayForButton, relayForPresence } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// device-resolve: the input resolution layer. Inputs live in config.inputs[] (the first-class
|
||||||
|
// model); a pre-inputs[] controller is back-compat-synthesized from the legacy per-relay
|
||||||
|
// button/presenceInput fields. relayForButton/relayForPresence must resolve IDENTICALLY from
|
||||||
|
// either shape, so an exit radar = just another presence row.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const CTL = "ctl-1";
|
||||||
|
|
||||||
|
function seed(config: Record<string, unknown>): void {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
db.insert(devices).values({ id: CTL, category: "access", driverId: "dingtian", config, enabled: true }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("inputsOf back-compat synth", () => {
|
||||||
|
it("synthesizes inputs[] from legacy relay button/presence fields", () => {
|
||||||
|
seed({
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "radar", presenceActiveLow: true },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const row = db.select().from(devices).get()!;
|
||||||
|
const inputs = inputsOf(row);
|
||||||
|
expect(inputs).toEqual([
|
||||||
|
{ input: 1, role: "button", relay: 1, cooldownSec: undefined },
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar", activeLow: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers an explicit inputs[] over the legacy fields", () => {
|
||||||
|
seed({
|
||||||
|
relays: [{ relay: 1, direction: "entry", button: 9 /* legacy ignored */ }],
|
||||||
|
inputs: [{ input: 1, role: "button", relay: 1 }],
|
||||||
|
});
|
||||||
|
const row = db.select().from(devices).get()!;
|
||||||
|
expect(inputsOf(row)).toEqual([{ input: 1, role: "button", relay: 1 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("relayForButton / relayForPresence", () => {
|
||||||
|
it("resolves a button + presence from inputs[]", () => {
|
||||||
|
seed({
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [
|
||||||
|
{ input: 1, role: "button", relay: 1 },
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const byBtn = relayForButton(db, CTL, 1);
|
||||||
|
expect(byBtn).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2, presenceKind: "radar" });
|
||||||
|
const byPres = relayForPresence(db, CTL, 2);
|
||||||
|
expect(byPres).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves IDENTICALLY from the legacy shape (no inputs[])", () => {
|
||||||
|
seed({ relays: [{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "loop" }] });
|
||||||
|
expect(relayForButton(db, CTL, 1)).toMatchObject({ relay: 1, presenceInput: 2, presenceKind: "loop" });
|
||||||
|
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1, presenceInput: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves an EXIT presence row to the exit relay (the exit radar)", () => {
|
||||||
|
seed({
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
],
|
||||||
|
inputs: [
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar" }, // entry radar
|
||||||
|
{ input: 5, role: "presence", relay: 2, kind: "radar" }, // exit radar
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// NOTE: relayForPresence only gates entry/both relays (transient entry). The exit radar
|
||||||
|
// resolves to null HERE (the exit barrier has no entry gate) — but it's still a valid
|
||||||
|
// inputs[] row the lamp can trigger on. The entry radar resolves to relay 1.
|
||||||
|
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1 });
|
||||||
|
expect(relayForPresence(db, CTL, 5)).toBeNull(); // exit relay isn't a transient-entry gate
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a button on an exit-only relay is not a transient-entry trigger", () => {
|
||||||
|
seed({
|
||||||
|
relays: [{ relay: 2, direction: "exit" }],
|
||||||
|
inputs: [{ input: 1, role: "button", relay: 2 }],
|
||||||
|
});
|
||||||
|
expect(relayForButton(db, CTL, 1)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,35 +10,74 @@ export type Direction = "entry" | "exit" | "both";
|
|||||||
/** A concrete flow a credential/button drives (never "both"). */
|
/** A concrete flow a credential/button drives (never "both"). */
|
||||||
export type FlowDirection = "entry" | "exit";
|
export type FlowDirection = "entry" | "exit";
|
||||||
|
|
||||||
/** One relay on an access controller: which barrier it opens, in which direction,
|
/** The EVENT a relay reacts to. The barrier events (entry/exit/both) `pulseOpen`; the
|
||||||
* and (optionally) the input terminals its entry button + presence loop are wired to. */
|
* `radarAlert` event drives a non-barrier alert lamp (blink while the trigger input is
|
||||||
|
* active, locked SOLID by the camera). A relay is "when EVENT X happens, do its action" —
|
||||||
|
* the action is implied by the event. See wiki/concepts/button-light-indicator.md. */
|
||||||
|
export type RelayEvent = Direction | "radarAlert";
|
||||||
|
|
||||||
|
/** What a controller input terminal MEANS. `button` = a transient-entry button; `presence`
|
||||||
|
* = a one-car-one-ticket sensor (induction loop or radar); `alertTrigger` = the edge that
|
||||||
|
* starts a `radarAlert` lamp blinking. See wiki/concepts/entry-double-press.md. */
|
||||||
|
export type InputRole = "button" | "presence" | "alertTrigger";
|
||||||
|
|
||||||
|
/** One INPUT terminal the host reads, as a first-class citizen (the twin of RelaySpec).
|
||||||
|
* An exit radar is just another `presence` row serving the exit relay. */
|
||||||
|
export interface InputSpec {
|
||||||
|
/** 1-based input terminal the host reads. */
|
||||||
|
readonly input: number;
|
||||||
|
readonly role: InputRole;
|
||||||
|
/** The barrier relay this input serves. Required for `button`/`presence` (the gate is
|
||||||
|
* keyed per relay); optional for `alertTrigger` (a standalone lamp trigger). */
|
||||||
|
readonly relay?: number;
|
||||||
|
/** `presence` only — induction LOOP or RADAR. Label only (gate is identical). Default loop. */
|
||||||
|
readonly kind?: "loop" | "radar";
|
||||||
|
/** This terminal is ACTIVE-LOW (idles HIGH) — e.g. a radar wired opposite the button.
|
||||||
|
* Maps to the driver's per-input `inputActiveLow`. See wiki/entities/hikvision-radar.md. */
|
||||||
|
readonly activeLow?: boolean;
|
||||||
|
/** `button` only — presence-less fallback: suppress repeat presses for N seconds after a
|
||||||
|
* ticket. A timer (mitigation, not a guarantee); used when no `presence` row serves this relay. */
|
||||||
|
readonly cooldownSec?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One relay on an access controller: the event it reacts to. Input wiring (button,
|
||||||
|
* presence) lives in `config.inputs[]`; the LEGACY per-relay fields below are still read
|
||||||
|
* (back-compat) but no longer written by the UI. */
|
||||||
export interface RelaySpec {
|
export interface RelaySpec {
|
||||||
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
|
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
|
||||||
readonly relay: number;
|
readonly relay: number;
|
||||||
readonly direction: Direction;
|
/** The event this relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` →
|
||||||
/** 1-based input terminal of the entry button that fires this relay (transient
|
* drive an alert lamp (blink + camera-lock) via `setAux`, NEVER pulseOpen. */
|
||||||
* entry). Absent = no button at this barrier (subscriber/reader-driven only). */
|
readonly direction: RelayEvent;
|
||||||
|
|
||||||
|
// ── LEGACY input fields (read-only back-compat; superseded by config.inputs[]) ──
|
||||||
|
// Pre-inputs[] configs wired the entry button + presence sensor here. `inputsOf()`
|
||||||
|
// synthesizes InputSpec rows from these when a controller has no `inputs[]` yet.
|
||||||
readonly button?: number;
|
readonly button?: number;
|
||||||
/**
|
|
||||||
* Anti-double-press for the transient entry button (one car must yield ONE ticket).
|
|
||||||
* Two modes, chosen by what barrier feedback exists at this lane:
|
|
||||||
* - PRESENCE (preferred, when a vehicle loop is wired): `presenceInput` = the
|
|
||||||
* 1-based input terminal of an induction loop / barrier presence signal on THIS
|
|
||||||
* controller. A press prints only while a car is present, and no second ticket
|
|
||||||
* issues until the loop CLEARS (car drove in) and a new car re-occupies it. This
|
|
||||||
* makes one-car-one-ticket physical.
|
|
||||||
* - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses
|
|
||||||
* on this relay for N seconds after a ticket prints. A pure timer — mitigation,
|
|
||||||
* not a guarantee. Used when `presenceInput` is unset (or as a secondary guard).
|
|
||||||
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
|
|
||||||
*/
|
|
||||||
readonly presenceInput?: number;
|
readonly presenceInput?: number;
|
||||||
|
readonly presenceKind?: "loop" | "radar";
|
||||||
|
readonly presenceActiveLow?: boolean;
|
||||||
readonly entryCooldownSec?: number;
|
readonly entryCooldownSec?: number;
|
||||||
|
|
||||||
|
// ── radarAlert-only (direction === "radarAlert") ──
|
||||||
|
// A non-barrier indicator lamp wired to this (spare) relay — e.g. the entry button's
|
||||||
|
// 12 V light. Driven by the server ButtonLightController off its trigger input vs. the
|
||||||
|
// camera lane status: blink while the trigger is active + lane free, SOLID once the
|
||||||
|
// camera confirms a car, OFF otherwise. NOT a barrier (uses setAux, never pulseOpen).
|
||||||
|
/** 1-based input terminal whose active edge starts the blink (the radar). */
|
||||||
|
readonly triggerInput?: number;
|
||||||
|
/** Which lane's camera locks this lamp SOLID — the entry or the exit camera. Default
|
||||||
|
* "entry". An exit radar's lamp must lock on the EXIT camera. */
|
||||||
|
readonly lockLane?: FlowDirection;
|
||||||
|
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
|
||||||
|
readonly blinkOnMs?: number;
|
||||||
|
readonly blinkOffMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Access controller config (the `relays[]` map + connection fields). */
|
/** Access controller config (the `relays[]` + `inputs[]` maps + connection fields). */
|
||||||
interface AccessConfig {
|
interface AccessConfig {
|
||||||
readonly relays?: RelaySpec[];
|
readonly relays?: RelaySpec[];
|
||||||
|
readonly inputs?: InputSpec[];
|
||||||
readonly [k: string]: unknown;
|
readonly [k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +99,11 @@ export interface ResolvedRelay {
|
|||||||
readonly controller: DeviceRow;
|
readonly controller: DeviceRow;
|
||||||
readonly relay: number;
|
readonly relay: number;
|
||||||
readonly direction: Direction;
|
readonly direction: Direction;
|
||||||
/** 1-based presence-loop input gating this relay's entry (when wired). */
|
/** 1-based presence input gating this relay's entry (loop or radar, when wired). */
|
||||||
readonly presenceInput?: number;
|
readonly presenceInput?: number;
|
||||||
/** Cooldown seconds suppressing repeat presses (fallback when no presence loop). */
|
/** Sensor kind on the presence input (loop|radar) — telemetry/label only. */
|
||||||
|
readonly presenceKind?: "loop" | "radar";
|
||||||
|
/** Cooldown seconds suppressing repeat presses (fallback when no presence input). */
|
||||||
readonly entryCooldownSec?: number;
|
readonly entryCooldownSec?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +124,49 @@ export function relaysOf(row: DeviceRow): RelaySpec[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a button press to the relay it fires: the access controller with this
|
* The INPUT terminals declared on an access controller — the back-compat keystone. Returns
|
||||||
* deviceId, and the relay whose `button` terminal matches the pressed input. Only
|
* `config.inputs[]` when present; otherwise SYNTHESIZES InputSpec rows from the LEGACY
|
||||||
* an ENTRY (or both) relay is a transient-entry trigger. Returns null otherwise.
|
* per-relay fields (`relays[].button` → a `button` row; `relays[].presenceInput` → a
|
||||||
|
* `presence` row) so a pre-inputs[] controller resolves identically. Everything that reads
|
||||||
|
* inputs goes through here, so the legacy fold lives in exactly one place.
|
||||||
|
*/
|
||||||
|
export function inputsOf(row: DeviceRow): InputSpec[] {
|
||||||
|
const cfg = row.config as AccessConfig;
|
||||||
|
if (Array.isArray(cfg.inputs) && cfg.inputs.length > 0) return cfg.inputs;
|
||||||
|
const synth: InputSpec[] = [];
|
||||||
|
for (const r of relaysOf(row)) {
|
||||||
|
if (typeof r.button === "number") {
|
||||||
|
synth.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec });
|
||||||
|
}
|
||||||
|
if (typeof r.presenceInput === "number") {
|
||||||
|
synth.push({
|
||||||
|
input: r.presenceInput,
|
||||||
|
role: "presence",
|
||||||
|
relay: r.relay,
|
||||||
|
kind: r.presenceKind ?? "loop",
|
||||||
|
activeLow: r.presenceActiveLow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return synth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The barrier RelaySpec a `button`/`presence` input row serves (its `relay`), or null —
|
||||||
|
* only entry/both relays gate transient entry. Narrows `direction` to a barrier Direction. */
|
||||||
|
function barrierForInput(row: DeviceRow, spec: InputSpec): (RelaySpec & { direction: Direction }) | null {
|
||||||
|
if (typeof spec.relay !== "number") return null;
|
||||||
|
const relay = relaysOf(row).find((r) => r.relay === spec.relay);
|
||||||
|
if (!relay) return null;
|
||||||
|
if (relay.direction !== "entry" && relay.direction !== "both") return null;
|
||||||
|
return { ...relay, direction: relay.direction };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a button press to the relay it fires: the access controller with this deviceId,
|
||||||
|
* and the relay served by the `button` input on this terminal (via inputsOf). Only an
|
||||||
|
* ENTRY (or both) relay is a transient-entry trigger. Carries the one-car-one-ticket
|
||||||
|
* config (presence input + cooldown) for that relay so the entry flow can enforce it.
|
||||||
|
* Returns null otherwise.
|
||||||
*/
|
*/
|
||||||
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
||||||
const row = db
|
const row = db
|
||||||
@@ -94,23 +175,28 @@ export function relayForButton(db: Db, controllerId: string, terminal: number):
|
|||||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||||
.get();
|
.get();
|
||||||
if (!row || !row.enabled) return null;
|
if (!row || !row.enabled) return null;
|
||||||
const spec = relaysOf(row).find((r) => r.button === terminal);
|
const inputs = inputsOf(row);
|
||||||
if (!spec) return null;
|
const btn = inputs.find((i) => i.role === "button" && i.input === terminal);
|
||||||
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
if (!btn) return null;
|
||||||
|
const relay = barrierForInput(row, btn);
|
||||||
|
if (!relay) return null;
|
||||||
|
// The presence sensor (if any) serving the SAME relay supplies the gate.
|
||||||
|
const presence = inputs.find((i) => i.role === "presence" && i.relay === relay.relay);
|
||||||
return {
|
return {
|
||||||
controller: row,
|
controller: row,
|
||||||
relay: spec.relay,
|
relay: relay.relay,
|
||||||
direction: spec.direction,
|
direction: relay.direction,
|
||||||
presenceInput: spec.presenceInput,
|
presenceInput: presence?.input,
|
||||||
entryCooldownSec: spec.entryCooldownSec,
|
presenceKind: presence?.kind ?? "loop",
|
||||||
|
entryCooldownSec: btn.cooldownSec,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a PRESENCE-LOOP input edge to the entry relay it gates: the controller with
|
* Resolve a PRESENCE input edge to the entry relay it gates: the controller with this
|
||||||
* this deviceId, and the relay whose `presenceInput` terminal matches the fired input.
|
* deviceId, and the relay served by the `presence` input on this terminal. Lets the entry
|
||||||
* Lets the entry flow track "a car is physically at this entry barrier" so it issues
|
* flow track "a car is physically at this entry barrier" so it issues exactly one ticket
|
||||||
* exactly one ticket per car. Only entry/both relays gate transient entry. Null otherwise.
|
* per car. Only entry/both relays gate transient entry. Null otherwise.
|
||||||
*/
|
*/
|
||||||
export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
||||||
const row = db
|
const row = db
|
||||||
@@ -119,10 +205,43 @@ export function relayForPresence(db: Db, controllerId: string, terminal: number)
|
|||||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||||
.get();
|
.get();
|
||||||
if (!row || !row.enabled) return null;
|
if (!row || !row.enabled) return null;
|
||||||
const spec = relaysOf(row).find((r) => r.presenceInput === terminal);
|
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
|
||||||
if (!spec) return null;
|
if (!presence) return null;
|
||||||
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
const relay = barrierForInput(row, presence);
|
||||||
return { controller: row, relay: spec.relay, direction: spec.direction };
|
if (!relay) return null;
|
||||||
|
return {
|
||||||
|
controller: row,
|
||||||
|
relay: relay.relay,
|
||||||
|
direction: relay.direction,
|
||||||
|
presenceInput: presence.input,
|
||||||
|
presenceKind: presence.kind ?? "loop",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The alert (radarAlert) relay rows declared on an access controller — the lamps the
|
||||||
|
* ButtonLightController drives. Each is a `relays[]` row whose event is `radarAlert`. */
|
||||||
|
export function alertRelaysOf(row: DeviceRow): RelaySpec[] {
|
||||||
|
return relaysOf(row).filter((r) => r.direction === "radarAlert" && typeof r.relay === "number");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which LANE a presence input belongs to — for the booth's barrier-light blink (advisory).
|
||||||
|
* Unlike `relayForPresence` (entry-gated, for the one-car-one-ticket gate), this resolves a
|
||||||
|
* presence input on ANY barrier: entry/both → "entry", exit → "exit". Returns null if the
|
||||||
|
* terminal isn't a presence input on a barrier relay. See lane-presence.ts.
|
||||||
|
*/
|
||||||
|
export function presenceLaneOf(db: Db, controllerId: string, terminal: number): FlowDirection | null {
|
||||||
|
const row = db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||||
|
.get();
|
||||||
|
if (!row || !row.enabled) return null;
|
||||||
|
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
|
||||||
|
if (!presence || typeof presence.relay !== "number") return null;
|
||||||
|
const relay = relaysOf(row).find((r) => r.relay === presence.relay);
|
||||||
|
if (!relay) return null;
|
||||||
|
return relay.direction === "exit" ? "exit" : relay.direction === "radarAlert" ? null : "entry";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,7 +263,10 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
|
|||||||
.get();
|
.get();
|
||||||
if (controller && controller.enabled) {
|
if (controller && controller.enabled) {
|
||||||
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
|
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
|
||||||
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
// Only a barrier relay opens; an alert (radarAlert) relay is never a barrier.
|
||||||
|
if (spec && spec.direction !== "radarAlert") {
|
||||||
|
return { controller, relay: spec.relay, direction: spec.direction };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -164,7 +286,8 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
|
|||||||
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
|
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
|
||||||
for (const controller of accessRows(db)) {
|
for (const controller of accessRows(db)) {
|
||||||
const spec = relaysOf(controller).find(
|
const spec = relaysOf(controller).find(
|
||||||
(r) => r.direction === direction || r.direction === "both",
|
(r): r is RelaySpec & { direction: Direction } =>
|
||||||
|
r.direction === direction || r.direction === "both",
|
||||||
);
|
);
|
||||||
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { LanePresence } from "./lane-presence.js";
|
||||||
|
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
|
||||||
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// LanePresence: a vehicle-presence INPUT edge (loop/radar) on an entry/exit barrier marks
|
||||||
|
// that lane "present" — the same signal that blinks the physical button lamp (relay 3). It
|
||||||
|
// resolves the edge via relayForPresence (the SAME path relay 3 + the entry gate use), and
|
||||||
|
// emits a lane-presence change only when a lane's present/clear state actually flips.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const CTL = "ctl-1";
|
||||||
|
const ENTRY_RADAR = 2;
|
||||||
|
const EXIT_RADAR = 5;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
// Entry relay 1 with a radar on I2; exit relay 2 with a radar on I5.
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CTL,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
],
|
||||||
|
inputs: [
|
||||||
|
{ input: ENTRY_RADAR, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
{ input: EXIT_RADAR, role: "presence", relay: 2, kind: "radar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
function edge(input: number, on: boolean): void {
|
||||||
|
const e: DeviceInputEvent = {
|
||||||
|
driverId: "dingtian",
|
||||||
|
deviceId: CTL,
|
||||||
|
input,
|
||||||
|
edge: on ? "on" : "off",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source: "poll",
|
||||||
|
};
|
||||||
|
deviceEvents.emitInput(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect lane-presence emissions while running `fn`. */
|
||||||
|
function capture(fn: () => void): LanePresenceEvent[] {
|
||||||
|
const seen: LanePresenceEvent[] = [];
|
||||||
|
const off = deviceEvents.onLanePresence((p) => seen.push(p));
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} finally {
|
||||||
|
off();
|
||||||
|
}
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LanePresence", () => {
|
||||||
|
it("starts clear and snapshots clear", () => {
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
expect(lp.snapshot()).toEqual({ entry: false, exit: false });
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an ENTRY radar edge marks the entry lane present, then clears", () => {
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => {
|
||||||
|
edge(ENTRY_RADAR, true);
|
||||||
|
edge(ENTRY_RADAR, false);
|
||||||
|
});
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ entry: true, exit: false },
|
||||||
|
{ entry: false, exit: false },
|
||||||
|
]);
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an EXIT radar edge marks the exit lane independently", () => {
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => {
|
||||||
|
edge(EXIT_RADAR, true);
|
||||||
|
});
|
||||||
|
expect(events).toEqual([{ entry: false, exit: true }]);
|
||||||
|
expect(lp.snapshot()).toEqual({ entry: false, exit: true });
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("de-dupes: a second 'on' from another presence input on the same lane emits once", () => {
|
||||||
|
// Two radars both serving the entry lane.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
{ input: 3, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CTL))
|
||||||
|
.run();
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => {
|
||||||
|
edge(2, true); // entry → present (emit)
|
||||||
|
edge(3, true); // still present (no emit — same lane)
|
||||||
|
edge(2, false); // still present via I3 (no emit)
|
||||||
|
edge(3, false); // now clear (emit)
|
||||||
|
});
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ entry: true, exit: false },
|
||||||
|
{ entry: false, exit: false },
|
||||||
|
]);
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a non-presence input (e.g. a button terminal)", () => {
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [{ input: 1, role: "button", relay: 1 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CTL))
|
||||||
|
.run();
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => edge(1, true));
|
||||||
|
expect(events).toEqual([]);
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
|
||||||
|
import { presenceLaneOf } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// Per-lane RADAR presence for the booth's barrier lights. A vehicle-presence INPUT
|
||||||
|
// (loop/radar) shorted at an entry/exit barrier means "something is in the lane vicinity"
|
||||||
|
// BEFORE the camera confirms a vehicle. This is the SAME signal that makes the physical
|
||||||
|
// button lamp (relay 3) blink — see button-light.ts (#onInput) — so the on-screen light
|
||||||
|
// and the lamp stay in lockstep: both react to a presence edge resolved the SAME way
|
||||||
|
// (relayForPresence, on an entry/both relay). ADVISORY ONLY: it gates nothing.
|
||||||
|
//
|
||||||
|
// A radar serving an entry (or "both") barrier marks the ENTRY lane present; an exit radar
|
||||||
|
// marks EXIT. The lane is resolved via `presenceLaneOf` (direction-agnostic — unlike the
|
||||||
|
// entry-gated `relayForPresence` the one-car-one-ticket gate uses), so both lanes blink.
|
||||||
|
|
||||||
|
export class LanePresence {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
/** Active presence terminals per lane, keyed `${deviceId}:${input}` (several radars may
|
||||||
|
* serve one lane). A lane is "present" while its set is non-empty. */
|
||||||
|
readonly #entry = new Set<string>();
|
||||||
|
readonly #exit = new Set<string>();
|
||||||
|
#unsub: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(db: Db, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to presence input edges. */
|
||||||
|
start(): void {
|
||||||
|
this.#unsub = deviceEvents.onInput((e) => this.#onInput(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current snapshot (for the WS hello). */
|
||||||
|
snapshot(): LanePresenceEvent {
|
||||||
|
return { entry: this.#entry.size > 0, exit: this.#exit.size > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
#onInput(e: DeviceInputEvent): void {
|
||||||
|
const lane = presenceLaneOf(this.#db, e.deviceId, e.input);
|
||||||
|
if (!lane) return; // not a presence terminal on a barrier relay
|
||||||
|
const key = `${e.deviceId}:${e.input}`;
|
||||||
|
const set = lane === "entry" ? this.#entry : this.#exit;
|
||||||
|
const before = set.size > 0;
|
||||||
|
if (e.edge === "on") set.add(key);
|
||||||
|
else set.delete(key);
|
||||||
|
const after = set.size > 0;
|
||||||
|
if (before !== after) {
|
||||||
|
this.#logger.info(`lane-presence: ${lane} -> ${after ? "present" : "clear"}`);
|
||||||
|
deviceEvents.emitLanePresence(this.snapshot());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsubscribe on shutdown. */
|
||||||
|
stop(): void {
|
||||||
|
this.#unsub?.();
|
||||||
|
this.#unsub = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { storedSecrets } from "./setup.js";
|
||||||
|
|
||||||
|
// storedSecrets re-merges a device's machine-only secrets (relayPassword/pushPassword)
|
||||||
|
// into a test/save — but ONLY when the submitted config addresses the SAME device at the
|
||||||
|
// SAME host/port. This guards against a redirected probe exfiltrating the secret to an
|
||||||
|
// attacker host (an admin keeps a real device id but swaps the host). The booth operator
|
||||||
|
// is the threat-model adversary, so an authenticated-admin redirect must NOT leak.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const ID = "ctl-secret";
|
||||||
|
const HOST = "10.0.10.5";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: ID,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: { host: HOST, binaryPort: 60000, relayPassword: 1996, pushPassword: "p-secret" },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storedSecrets identity guard", () => {
|
||||||
|
it("re-merges secrets when host/port/driver match the stored device", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", { host: HOST, binaryPort: 60000 });
|
||||||
|
expect(out.relayPassword).toBe(1996);
|
||||||
|
expect(out.pushPassword).toBe("p-secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-merges when identity fields are OMITTED (fall back to the stored device)", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", {});
|
||||||
|
expect(out.relayPassword).toBe(1996);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REFUSES secrets when the host is redirected (exfiltration attempt)", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", { host: "10.66.66.66", binaryPort: 60000 });
|
||||||
|
expect(out).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REFUSES secrets when a control port is changed", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", { host: HOST, binaryPort: 9999 });
|
||||||
|
expect(out).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REFUSES secrets when the driver doesn't match the stored row", () => {
|
||||||
|
const out = storedSecrets(db, ID, "stub-access", { host: HOST });
|
||||||
|
expect(out).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for an unknown device id", () => {
|
||||||
|
expect(storedSecrets(db, randomUUID(), "dingtian", { host: HOST })).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
isCamera,
|
isCamera,
|
||||||
isDiscoverable,
|
isDiscoverable,
|
||||||
isHardenable,
|
isHardenable,
|
||||||
|
isPrinter,
|
||||||
registerBuiltinDrivers,
|
registerBuiltinDrivers,
|
||||||
registry,
|
registry,
|
||||||
setDeviceLogSink,
|
setDeviceLogSink,
|
||||||
@@ -36,6 +37,11 @@ interface AssignBody {
|
|||||||
interface TestBody {
|
interface TestBody {
|
||||||
driverId: string;
|
driverId: string;
|
||||||
config: Record<string, string | number | boolean>;
|
config: Record<string, string | number | boolean>;
|
||||||
|
/** When editing an EXISTING device, its id — so the test re-merges the stored
|
||||||
|
* machine secrets (relayPassword/pushPassword) the client never received. Without
|
||||||
|
* this, testing an edited device would send no relay password → the device ignores
|
||||||
|
* the probe → a false "offline". Omitted when testing a brand-new device. */
|
||||||
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config keys that hold MACHINE-ONLY secrets — never sent back to the client.
|
// Config keys that hold MACHINE-ONLY secrets — never sent back to the client.
|
||||||
@@ -54,6 +60,41 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connection-identity keys: the fields that decide WHERE a probe is sent. A stored
|
||||||
|
// secret may only be re-merged when these match the stored row — otherwise an admin
|
||||||
|
// could point a test at an attacker host while keeping a real device id and have the
|
||||||
|
// secret sent there (exfiltration). host/port/binaryPort/httpPort cover the Dingtian's
|
||||||
|
// UDP + CGI targets; serial covers serial-bound readers.
|
||||||
|
const IDENTITY_KEYS = ["host", "port", "binaryPort", "httpPort", "serial"] as const;
|
||||||
|
|
||||||
|
/** Stored machine-only secrets (relayPassword/pushPassword) for a device `id`, but ONLY
|
||||||
|
* when the submitted config addresses the SAME device — same driver, and every
|
||||||
|
* connection-identity field (host/port/…) that the submitted config sets equals the
|
||||||
|
* stored value. If the admin redirected the probe (different host/port) or the driver
|
||||||
|
* doesn't match, NO secret is returned: they must re-enter it explicitly. This stops a
|
||||||
|
* redirected test from exfiltrating the secret to an attacker host. */
|
||||||
|
export function storedSecrets(
|
||||||
|
db: Db,
|
||||||
|
id: string,
|
||||||
|
driverId: string,
|
||||||
|
submitted: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const row = db.select().from(devices).where(eq(devices.id, id)).get();
|
||||||
|
if (!row || row.driverId !== driverId) return {};
|
||||||
|
const cfg = row.config as Record<string, unknown>;
|
||||||
|
// Any identity field the client SENT must equal the stored value. (A field the client
|
||||||
|
// omits falls back to the stored device, so it can't be used to redirect.)
|
||||||
|
for (const k of IDENTITY_KEYS) {
|
||||||
|
const sent = submitted[k];
|
||||||
|
if (sent !== undefined && sent !== "" && String(sent) !== String(cfg[k] ?? "")) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const k of SECRET_CONFIG_KEYS) if (cfg[k] !== undefined) out[k] = cfg[k];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Result of the device configure pipeline: a ready-to-persist config, or an
|
/** Result of the device configure pipeline: a ready-to-persist config, or an
|
||||||
* HTTP error to send back. Shared by assign (create) and patch (edit). */
|
* HTTP error to send back. Shared by assign (create) and patch (edit). */
|
||||||
type ConfigureOutcome =
|
type ConfigureOutcome =
|
||||||
@@ -249,13 +290,30 @@ export async function setupRoutes(
|
|||||||
"/api/setup/test",
|
"/api/setup/test",
|
||||||
{ preHandler: adminGuard },
|
{ preHandler: adminGuard },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { driverId, config } = req.body;
|
const { driverId, config, id } = req.body;
|
||||||
const driver = registry.get(driverId);
|
const driver = registry.get(driverId);
|
||||||
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
||||||
|
|
||||||
|
// When editing an existing device, re-merge its stored machine secrets (e.g.
|
||||||
|
// relayPassword) — redacted from the client, so the submitted config omits them.
|
||||||
|
// Submitted values win (an admin can override), but a blank/0 field falls back to
|
||||||
|
// the stored secret so the probe authenticates. Without this, an edited Dingtian
|
||||||
|
// tests with no relay password → false "offline". The submitted-value-wins rule:
|
||||||
|
// only fill a secret from the store when the form didn't send a real one.
|
||||||
|
// Re-merge stored secrets ONLY when this addresses the same device at the same
|
||||||
|
// host/port (storedSecrets enforces identity) — so a redirected probe can't leak
|
||||||
|
// the secret to an attacker host. Submitted values still win.
|
||||||
|
const merged: Record<string, string | number | boolean | undefined> = { ...config };
|
||||||
|
if (id) {
|
||||||
|
for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) {
|
||||||
|
const sent = merged[k];
|
||||||
|
if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let device;
|
let device;
|
||||||
try {
|
try {
|
||||||
device = registry.create(driverId, config);
|
device = registry.create(driverId, merged as Record<string, string | number | boolean>);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reply.code(400).send({ error: (err as Error).message });
|
return reply.code(400).send({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
@@ -334,6 +392,70 @@ export async function setupRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Print a TEST SLIP on a printer config WITHOUT saving. healthCheck only opens the
|
||||||
|
// transport (TCP connect / USB open) — it proves reachability, NOT that paper feeds
|
||||||
|
// and the head fires. This pushes a real short slip through the device-agnostic
|
||||||
|
// printReport(), so the admin can physically confirm the printer is live (the USB
|
||||||
|
// /dev/usb/lpN path or the network printer). Fail-soft like test-anpr: a print error
|
||||||
|
// is reported, never a 500. Mirrors /test's stored-secret re-merge so an edited
|
||||||
|
// network printer still authenticates.
|
||||||
|
app.post<{ Body: TestBody }>(
|
||||||
|
"/api/setup/test-print",
|
||||||
|
{ preHandler: adminGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { driverId, config, id } = req.body;
|
||||||
|
const driver = registry.get(driverId);
|
||||||
|
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
||||||
|
if (driver.category !== "printer") {
|
||||||
|
return reply.code(400).send({ error: `driver ${driverId} is not a printer` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged: Record<string, string | number | boolean | undefined> = { ...config };
|
||||||
|
if (id) {
|
||||||
|
for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) {
|
||||||
|
const sent = merged[k];
|
||||||
|
if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let device;
|
||||||
|
try {
|
||||||
|
device = registry.create(driverId, merged as Record<string, string | number | boolean>);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(400).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
if (!isPrinter(device)) {
|
||||||
|
return reply.code(400).send({ error: `driver ${driverId} cannot print` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
await device.printReport({
|
||||||
|
title: "TEST PRINT",
|
||||||
|
lines: [
|
||||||
|
"Parking System",
|
||||||
|
"Printer test slip",
|
||||||
|
new Date().toLocaleString("sv"), // YYYY-MM-DD HH:MM:SS, locale-stable
|
||||||
|
"",
|
||||||
|
"If you can read this, the",
|
||||||
|
"printer is connected and",
|
||||||
|
"printing correctly.",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// The failure we're testing for (paper out, head fault, transport drop) —
|
||||||
|
// report it, don't 500.
|
||||||
|
return reply.send({
|
||||||
|
ok: false,
|
||||||
|
reason: "print-failed",
|
||||||
|
detail: (err as Error).message,
|
||||||
|
tookMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reply.send({ ok: true, tookMs: Date.now() - startedAt });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Candidate backend IPs the device can push to, for a given device host. The
|
// Candidate backend IPs the device can push to, for a given device host. The
|
||||||
// wizard pre-fills with the on-subnet one and lets the admin override (matters
|
// wizard pre-fills with the on-subnet one and lets the admin override (matters
|
||||||
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import type { Db } from "@parking/db";
|
import type { Db } from "@parking/db";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
import { roleHasPermissions } from "../auth.js";
|
import { roleHasPermissions } from "../auth.js";
|
||||||
import { deviceEvents, type LaneStatusEvent } from "../device-events.js";
|
import { deviceEvents, type LaneStatusEvent, type LanePresenceEvent } from "../device-events.js";
|
||||||
import { enrichEvent } from "../event-enrich.js";
|
import { enrichEvent } from "../event-enrich.js";
|
||||||
import type { DeviceMonitor } from "../device-monitor.js";
|
import type { DeviceMonitor } from "../device-monitor.js";
|
||||||
import type { LaneStatus } from "../lane-status.js";
|
import type { LaneStatus } from "../lane-status.js";
|
||||||
|
import type { LanePresence } from "../lane-presence.js";
|
||||||
import { getOccupancy } from "../occupancy.js";
|
import { getOccupancy } from "../occupancy.js";
|
||||||
|
|
||||||
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
|
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
|
||||||
@@ -53,17 +54,25 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OutMsg =
|
type OutMsg =
|
||||||
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown; lanes: LaneStatusEvent }
|
| {
|
||||||
|
kind: "hello";
|
||||||
|
occupancy: ReturnType<typeof getOccupancy>;
|
||||||
|
devices: unknown;
|
||||||
|
lanes: LaneStatusEvent;
|
||||||
|
radar: LanePresenceEvent;
|
||||||
|
}
|
||||||
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
||||||
| { kind: "printer-status"; event: unknown }
|
| { kind: "printer-status"; event: unknown }
|
||||||
| { kind: "device-status"; event: unknown }
|
| { kind: "device-status"; event: unknown }
|
||||||
| { kind: "lane-status"; lanes: LaneStatusEvent };
|
| { kind: "lane-status"; lanes: LaneStatusEvent }
|
||||||
|
| { kind: "lane-presence"; radar: LanePresenceEvent };
|
||||||
|
|
||||||
export async function wsRoutes(
|
export async function wsRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
deviceMonitor: DeviceMonitor,
|
deviceMonitor: DeviceMonitor,
|
||||||
laneStatus: LaneStatus,
|
laneStatus: LaneStatus,
|
||||||
|
lanePresence: LanePresence,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
app.get(
|
app.get(
|
||||||
"/api/ws",
|
"/api/ws",
|
||||||
@@ -96,7 +105,13 @@ export async function wsRoutes(
|
|||||||
|
|
||||||
// Initial snapshot so the client renders immediately, before any event:
|
// Initial snapshot so the client renders immediately, before any event:
|
||||||
// occupancy AND the current device-status set (for the footer).
|
// occupancy AND the current device-status set (for the footer).
|
||||||
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot(), lanes: laneStatus.snapshot() });
|
send({
|
||||||
|
kind: "hello",
|
||||||
|
occupancy: getOccupancy(db),
|
||||||
|
devices: deviceMonitor.snapshot(),
|
||||||
|
lanes: laneStatus.snapshot(),
|
||||||
|
radar: lanePresence.snapshot(),
|
||||||
|
});
|
||||||
|
|
||||||
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
||||||
// ledger (cheap fold) so the pushed count is always authoritative.
|
// ledger (cheap fold) so the pushed count is always authoritative.
|
||||||
@@ -117,12 +132,17 @@ export async function wsRoutes(
|
|||||||
const offLane = deviceEvents.onLaneStatus((lanes) => {
|
const offLane = deviceEvents.onLaneStatus((lanes) => {
|
||||||
send({ kind: "lane-status", lanes });
|
send({ kind: "lane-status", lanes });
|
||||||
});
|
});
|
||||||
|
// Lane RADAR presence (presence-input edge → barrier-light blink). Advisory.
|
||||||
|
const offPresence = deviceEvents.onLanePresence((radar) => {
|
||||||
|
send({ kind: "lane-presence", radar });
|
||||||
|
});
|
||||||
|
|
||||||
socket.on("close", () => {
|
socket.on("close", () => {
|
||||||
offLedger();
|
offLedger();
|
||||||
offPrinter();
|
offPrinter();
|
||||||
offDevice();
|
offDevice();
|
||||||
offLane();
|
offLane();
|
||||||
|
offPresence();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||||
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
|
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
|
||||||
import { deviceEvents } from "./device-events.js";
|
import { deviceEvents } from "./device-events.js";
|
||||||
|
import { ButtonLightController } from "./button-light.js";
|
||||||
import { EntryFlow } from "./entry-flow.js";
|
import { EntryFlow } from "./entry-flow.js";
|
||||||
import { EventLog } from "./event-log.js";
|
import { EventLog } from "./event-log.js";
|
||||||
import { ExitFlow } from "./exit-flow.js";
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
@@ -27,6 +28,7 @@ import { roleRoutes } from "./routes/roles.js";
|
|||||||
import { deviceRoutes } from "./routes/devices.js";
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
|
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
|
||||||
import { LaneStatus } from "./lane-status.js";
|
import { LaneStatus } from "./lane-status.js";
|
||||||
|
import { LanePresence } from "./lane-presence.js";
|
||||||
import { AnprBridge } from "./anpr-entry.js";
|
import { AnprBridge } from "./anpr-entry.js";
|
||||||
import { eventRoutes } from "./routes/events.js";
|
import { eventRoutes } from "./routes/events.js";
|
||||||
import { reportRoutes } from "./routes/reports.js";
|
import { reportRoutes } from "./routes/reports.js";
|
||||||
@@ -124,6 +126,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
const laneStatus = new LaneStatus(db, app.log);
|
const laneStatus = new LaneStatus(db, app.log);
|
||||||
app.addHook("onClose", async () => laneStatus.stop());
|
app.addHook("onClose", async () => laneStatus.stop());
|
||||||
|
|
||||||
|
// Per-lane RADAR presence (presence-input edges → barrier-light blink). Mirrors the
|
||||||
|
// physical button lamp (relay 3): the SAME presence signal, surfaced to the booth UI.
|
||||||
|
const lanePresence = new LanePresence(db, app.log);
|
||||||
|
lanePresence.start();
|
||||||
|
app.addHook("onClose", async () => lanePresence.stop());
|
||||||
|
|
||||||
// NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read
|
// NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read
|
||||||
// flows are constructed — because the ANPR bridge they carry depends on the
|
// flows are constructed — because the ANPR bridge they carry depends on the
|
||||||
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
|
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
|
||||||
@@ -168,7 +176,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
|
|
||||||
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
||||||
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
||||||
await wsRoutes(app, db, deviceMonitor, laneStatus);
|
await wsRoutes(app, db, deviceMonitor, laneStatus, lanePresence);
|
||||||
|
|
||||||
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
||||||
await snapshotRoutes(app, db);
|
await snapshotRoutes(app, db);
|
||||||
@@ -188,6 +196,13 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeEntry());
|
app.addHook("onClose", async () => unsubscribeEntry());
|
||||||
|
|
||||||
|
// Button-light indicator: drives the entry button's lamp on a spare relay from the
|
||||||
|
// RADAR input vs. the camera lane status (blink = radar-only, solid = radar+camera,
|
||||||
|
// off otherwise). A non-barrier aux output; fails OFF. See button-light.ts.
|
||||||
|
const buttonLight = new ButtonLightController(db, app.log);
|
||||||
|
buttonLight.start();
|
||||||
|
app.addHook("onClose", async () => buttonLight.stop());
|
||||||
|
|
||||||
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
||||||
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
|
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
|
||||||
// transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
|
// transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
|
||||||
|
|||||||
@@ -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 { randomUUID } from "node:crypto";
|
||||||
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
||||||
import { registry, type CameraDevice } from "@parking/devices";
|
import { registry, type CameraDevice, type Snapshot } from "@parking/devices";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
||||||
import type { VisionClient } from "./vision-client.js";
|
import type { VisionClient } from "./vision-client.js";
|
||||||
@@ -62,7 +62,9 @@ export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const shot = await camera.captureSnapshot({ direction });
|
// Shared capture: if the ANPR bridge just pulled this camera's frame for the
|
||||||
|
// same vehicle, reuse it instead of a 2nd concurrent GET (which 503s).
|
||||||
|
const shot = await captureSnapshotShared(row.id, camera, { direction });
|
||||||
const id: string = randomUUID();
|
const id: string = randomUUID();
|
||||||
db.insert(snapshots)
|
db.insert(snapshots)
|
||||||
.values({
|
.values({
|
||||||
@@ -153,6 +155,70 @@ export function buildCamera(row: { driverId: string; config: unknown }): CameraD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- shared snapshot capture (one HTTP pull per camera per vehicle) -----------
|
||||||
|
// A Hikvision camera serves /ISAPI/.../picture SINGLE-THREADED: two concurrent
|
||||||
|
// snapshot GETs to the same unit return HTTP 503 "service busy". On a vehicle entry
|
||||||
|
// TWO paths capture the SAME camera within ~1s — the ANPR bridge (barrier-driving,
|
||||||
|
// anpr-entry.ts) and the advisory snapshotAsync (evidence + telemetry, below). They
|
||||||
|
// each `buildCamera()` a SEPARATE adapter instance, so a per-instance cache can't
|
||||||
|
// dedupe them. This module-level, deviceId-keyed cache does: it coalesces in-flight
|
||||||
|
// captures (the 2nd caller awaits the 1st's pull) AND serves a result captured within
|
||||||
|
// SNAPSHOT_TTL_MS, so the bridge + advisory share ONE frame instead of colliding into
|
||||||
|
// a 503 (which then burned the bridge's 12s debounce → the slow entry observed
|
||||||
|
// 2026-06-25; see wiki/concepts/lane-presence-and-anpr-entry.md).
|
||||||
|
|
||||||
|
/** How long a fresh capture is reused for the same camera. A car is one event for a
|
||||||
|
* couple of seconds; 1.5s comfortably spans the bridge→advisory gap without ever
|
||||||
|
* serving a stale frame for a *different* vehicle (entries are seconds apart). */
|
||||||
|
const SNAPSHOT_TTL_MS = 1500;
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
/** A capture in flight — concurrent callers await this instead of issuing a 2nd GET. */
|
||||||
|
inflight?: Promise<Snapshot>;
|
||||||
|
/** The last SUCCESSFUL capture + when it resolved, for the freshness window. */
|
||||||
|
last?: { shot: Snapshot; at: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshotCache = new Map<string, CacheEntry>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a snapshot for a camera, sharing ONE HTTP pull across concurrent/near-
|
||||||
|
* simultaneous callers (the ANPR bridge and the advisory snapshot). Same contract as
|
||||||
|
* `camera.captureSnapshot` (throws on failure) — a failed pull is NOT cached, so the
|
||||||
|
* next caller retries rather than inheriting the error. Key by the stable `deviceId`.
|
||||||
|
*/
|
||||||
|
export function captureSnapshotShared(
|
||||||
|
deviceId: string,
|
||||||
|
camera: CameraDevice,
|
||||||
|
ctx: { direction: FlowDirection },
|
||||||
|
): Promise<Snapshot> {
|
||||||
|
const now = Date.now();
|
||||||
|
let entry = snapshotCache.get(deviceId);
|
||||||
|
if (!entry) {
|
||||||
|
entry = {};
|
||||||
|
snapshotCache.set(deviceId, entry);
|
||||||
|
}
|
||||||
|
// Fresh enough → reuse the last frame (same vehicle, no second hardware hit).
|
||||||
|
if (entry.last && now - entry.last.at < SNAPSHOT_TTL_MS) {
|
||||||
|
return Promise.resolve(entry.last.shot);
|
||||||
|
}
|
||||||
|
// A capture is already running → join it (this is what prevents the 503 collision).
|
||||||
|
if (entry.inflight) return entry.inflight;
|
||||||
|
// Otherwise issue the single real pull; record it as the in-flight promise.
|
||||||
|
const pull = camera
|
||||||
|
.captureSnapshot(ctx)
|
||||||
|
.then((shot) => {
|
||||||
|
entry.last = { shot, at: Date.now() };
|
||||||
|
return shot;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
// Clear the in-flight slot whether it resolved or threw; a failure is never cached.
|
||||||
|
if (entry.inflight === pull) entry.inflight = undefined;
|
||||||
|
});
|
||||||
|
entry.inflight = pull;
|
||||||
|
return pull;
|
||||||
|
}
|
||||||
|
|
||||||
function recordFailure(
|
function recordFailure(
|
||||||
db: Db,
|
db: Db,
|
||||||
direction: FlowDirection,
|
direction: FlowDirection,
|
||||||
|
|||||||
@@ -310,6 +310,15 @@ export class SubscriptionFlow {
|
|||||||
* subscription, (b) pick which occurrence a read closes, and (c) enforce
|
* subscription, (b) pick which occurrence a read closes, and (c) enforce
|
||||||
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
|
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
|
||||||
*/
|
*/
|
||||||
|
/** How many occurrences this subscription currently has OPEN (entries not yet exited).
|
||||||
|
* Public so the ANPR bridge can detect a credential (card/QR) exit landing mid-poll — if
|
||||||
|
* the count drops while it's polling, the subscriber already transacted and the bridge must
|
||||||
|
* NOT also emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. for
|
||||||
|
* a fleet sub). See anpr-entry.ts. */
|
||||||
|
openOccurrenceCount(subscriptionId: string): number {
|
||||||
|
return this.#openOccurrences(subscriptionId).length;
|
||||||
|
}
|
||||||
|
|
||||||
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
|
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
|
||||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
// Net entries−exits per occurrence identity, keeping the entry order (oldest first).
|
// Net entries−exits per occurrence identity, keeping the entry order (oldest first).
|
||||||
|
|||||||
@@ -3,14 +3,16 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"//": "Thin shim so this Python service is a first-class node in the Turbo task graph (it is NOT a JS package — deps are managed by uv/pyproject.toml). Each script shells to Python tooling. See wiki/decisions/vision-service-packaging.md.",
|
"//": "Thin shim so this Python service is a first-class node in the Turbo task graph (it is NOT a JS package — deps are managed by uv/pyproject.toml). Each script shells to Python tooling. See wiki/decisions/vision-service-packaging.md.",
|
||||||
|
"//alpr": "DEV self-heals real ANPR: `dev`/`start` run `uv sync --extra alpr` FIRST, because a plain `uv run` re-resolves the venv to the lockfile DEFAULTS and STRIPS fast-alpr (the cause of silent 'snapshot but no plate' after a prior pnpm dev). Syncing the extra here guarantees the recognizer survives every run. Use `dev:stub` for a lean, model-free local run. The BOOTH is unaffected — it runs the Docker image, which bakes `--extra alpr` at build (see Dockerfile + docker-compose.prod.yml).",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
|
"dev": "uv sync --extra alpr && uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
|
||||||
"start": "uv run uvicorn vision_service.app:app --host 0.0.0.0 --port 8089",
|
"dev:stub": "uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
|
||||||
|
"start": "uv sync --extra alpr && uv run uvicorn vision_service.app:app --host 0.0.0.0 --port 8089",
|
||||||
"lint": "uv run ruff check .",
|
"lint": "uv run ruff check .",
|
||||||
"format": "uv run ruff format .",
|
"format": "uv run ruff format .",
|
||||||
"typecheck": "uv run mypy vision_service",
|
"typecheck": "uv run mypy vision_service",
|
||||||
"test": "uv run pytest -q",
|
"test": "uv run pytest -q",
|
||||||
"recognize": "uv run python -m vision_service.cli",
|
"recognize": "uv sync --extra alpr && uv run python -m vision_service.cli",
|
||||||
"build": "echo 'no build step (Python service; models fetched at deploy)'"
|
"build": "echo 'no build step (Python service; models fetched at deploy)'"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
# Production build env for the SPA (auto-loaded by `vite build`, which the Tauri
|
# Production build env for the SPA (auto-loaded by `vite build`). NOT loaded by `vite` dev.
|
||||||
# desktop bundle runs via beforeBuildCommand). NOT loaded by `vite` dev.
|
|
||||||
#
|
#
|
||||||
# The desktop shell serves the bundled SPA from tauri://localhost (no proxy, not
|
# RELATIVE /api base (empty value). The booth serves the SPA same-origin (Fastify serves
|
||||||
# same-origin), so the SPA must reach Fastify by absolute origin. This is the
|
# dist/, reached via Caddy on :80), so requests must stay relative — baking an absolute
|
||||||
# appliance's local Fastify address. Not a secret — committed for reproducible
|
# origin here would point the browser at the wrong host. This matches the deploy
|
||||||
# desktop builds. Override per-deployment if Fastify binds elsewhere.
|
# (wiki/decisions/container-deployment.md "Web access"; the 77b2acb fix).
|
||||||
#
|
#
|
||||||
# NOTE: a plain browser prod build (Fastify serving dist/ same-origin) does NOT
|
# DESKTOP (Tauri) NOTE: the desktop shell serves the SPA from tauri://localhost (no proxy,
|
||||||
# want this set. If you build the SPA for that, override VITE_API_BASE="" .
|
# not same-origin) and DOES need an absolute Fastify origin — but the desktop app is a
|
||||||
VITE_API_BASE=http://127.0.0.1:3000
|
# DEFERRED, separate task (it's currently hardcoded to localhost:3000; see apps/desktop +
|
||||||
|
# the desktop-app-hardcoded-localhost note). When that work resumes, set VITE_API_BASE to
|
||||||
|
# the appliance's Fastify origin for the desktop build only (e.g. via apps/desktop or an
|
||||||
|
# exported override), NOT here.
|
||||||
|
VITE_API_BASE=
|
||||||
|
|||||||
@@ -112,40 +112,45 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One barrier light — green = free, red = busy (a vehicle is at the lane vicinity,
|
/** One barrier light — a 3-state indicator mirroring the physical button lamp (relay 3):
|
||||||
* from camera detection). Advisory only; it gates nothing. */
|
* - radar present + camera NOT busy → BLINK green↔red (~1 Hz): "detected, not yet confirmed"
|
||||||
function BarrierLight({ label, busy }: { label: string; busy: boolean }) {
|
* - camera busy → SOLID red: a vehicle is confirmed at the lane vicinity
|
||||||
|
* - otherwise → SOLID green: free
|
||||||
|
* Advisory only; it gates nothing. The blink uses the `.lane-blink` keyframe (index.css),
|
||||||
|
* whose children inherit the alternating colour via `currentColor`. */
|
||||||
|
function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; radar: boolean }) {
|
||||||
|
// Blink only when the radar sees something the camera hasn't confirmed.
|
||||||
|
const blinking = radar && !busy;
|
||||||
|
const solid = busy ? "border-term-red bg-term-red/10 text-term-red" : "border-term-green bg-term-green/10 text-term-green";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${
|
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${blinking ? "lane-blink" : solid}`}
|
||||||
busy ? "border-term-red bg-term-red/10" : "border-term-green bg-term-green/10"
|
|
||||||
}`}
|
|
||||||
title={label}
|
title={label}
|
||||||
>
|
>
|
||||||
{/* Barrier glyph: a post + an arm. Colour carries the state. */}
|
{/* Barrier glyph: a post + an arm. `currentColor` follows the (possibly blinking) state. */}
|
||||||
<svg viewBox="0 0 24 24" className={`h-5 w-5 ${busy ? "text-term-red" : "text-term-green"}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
<line x1="5" y1="21" x2="5" y2="9" />
|
<line x1="5" y1="21" x2="5" y2="9" />
|
||||||
<line x1="5" y1="10" x2="21" y2="6" />
|
<line x1="5" y1="10" x2="21" y2="6" />
|
||||||
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
|
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
|
||||||
</svg>
|
</svg>
|
||||||
<div className="leading-tight">
|
<div className="leading-tight">
|
||||||
<div className="text-[10px] uppercase tracking-wider text-term-muted">{label}</div>
|
<div className="text-[10px] uppercase tracking-wider text-term-muted">{label}</div>
|
||||||
<div className={`text-xs font-bold ${busy ? "text-term-red" : "text-term-green"}`}>
|
<div className="text-xs font-bold">{busy ? "●" : blinking ? "◐" : "○"}</div>
|
||||||
{busy ? "●" : "○"}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The two lane barrier lights (entry / exit) fed by the live lane-status. */
|
/** The two lane barrier lights (entry / exit) fed by the live lane-status (camera busy/free)
|
||||||
|
* and lane-presence (radar). */
|
||||||
function LaneIndicators() {
|
function LaneIndicators() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const lanes = useLiveStore((s) => s.lanes);
|
const lanes = useLiveStore((s) => s.lanes);
|
||||||
|
const radar = useLiveStore((s) => s.radar);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} />
|
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} radar={radar?.entry ?? false} />
|
||||||
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} />
|
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} radar={radar?.exit ?? false} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+589
-82
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, Fragment } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
assignDevice,
|
assignDevice,
|
||||||
@@ -9,8 +9,10 @@ import {
|
|||||||
fetchState,
|
fetchState,
|
||||||
testAnpr,
|
testAnpr,
|
||||||
testDevice,
|
testDevice,
|
||||||
|
testPrint,
|
||||||
unassignDevice,
|
unassignDevice,
|
||||||
type AnprTestResult,
|
type AnprTestResult,
|
||||||
|
type PrintTestResult,
|
||||||
type Assignment,
|
type Assignment,
|
||||||
type BackendIpCandidate,
|
type BackendIpCandidate,
|
||||||
type Catalog,
|
type Catalog,
|
||||||
@@ -19,6 +21,9 @@ import {
|
|||||||
type DeviceConfig,
|
type DeviceConfig,
|
||||||
type Direction,
|
type Direction,
|
||||||
type DiscoveredDevice,
|
type DiscoveredDevice,
|
||||||
|
type InputRole,
|
||||||
|
type InputSpec,
|
||||||
|
type RelayEvent,
|
||||||
type RelaySpec,
|
type RelaySpec,
|
||||||
type TestResult,
|
type TestResult,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
@@ -46,13 +51,63 @@ const BOUND: { key: DeviceCategory; titleKey: string; nounKey: string }[] = [
|
|||||||
{ key: "printer", titleKey: "setup.catPrinters", nounKey: "setup.nounPrinter" },
|
{ key: "printer", titleKey: "setup.catPrinters", nounKey: "setup.nounPrinter" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Translated direction label (relay direction / inherited binding).
|
// Translated relay-event label (barrier direction, inherited binding, or alert).
|
||||||
const DIRECTION_KEYS: Record<Direction, string> = {
|
const DIRECTION_KEYS: Record<RelayEvent, string> = {
|
||||||
entry: "setup.dirEntry",
|
entry: "setup.dirEntry",
|
||||||
exit: "setup.dirExit",
|
exit: "setup.dirExit",
|
||||||
both: "setup.dirBoth",
|
both: "setup.dirBoth",
|
||||||
|
radarAlert: "setup.eventRadarAlert",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The input-role dropdown folds presence `kind` into the choice: one select offers Button,
|
||||||
|
// Presence (loop), Presence (radar), Alert trigger. Each maps to a {role, kind} pair.
|
||||||
|
type InputChoice = "button" | "presenceLoop" | "presenceRadar" | "alertTrigger";
|
||||||
|
const INPUT_CHOICE_KEYS: Record<InputChoice, string> = {
|
||||||
|
button: "setup.roleButton",
|
||||||
|
presenceLoop: "setup.rolePresenceLoop",
|
||||||
|
presenceRadar: "setup.rolePresenceRadar",
|
||||||
|
alertTrigger: "setup.roleAlertTrigger",
|
||||||
|
};
|
||||||
|
function choiceOf(i: InputSpec): InputChoice {
|
||||||
|
if (i.role === "button") return "button";
|
||||||
|
if (i.role === "alertTrigger") return "alertTrigger";
|
||||||
|
return i.kind === "radar" ? "presenceRadar" : "presenceLoop";
|
||||||
|
}
|
||||||
|
function applyChoice(choice: InputChoice): { role: InputRole; kind?: "loop" | "radar" } {
|
||||||
|
switch (choice) {
|
||||||
|
case "button":
|
||||||
|
return { role: "button" };
|
||||||
|
case "alertTrigger":
|
||||||
|
return { role: "alertTrigger" };
|
||||||
|
case "presenceLoop":
|
||||||
|
return { role: "presence", kind: "loop" };
|
||||||
|
case "presenceRadar":
|
||||||
|
return { role: "presence", kind: "radar" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synthesize an inputs[] list from the LEGACY per-relay button/presence fields, so an
|
||||||
|
* existing controller (saved before inputs[]) opens with its inputs populated. Mirrors the
|
||||||
|
* server's `inputsOf()` back-compat fold. */
|
||||||
|
function synthInputsFromRelays(relays: RelaySpec[]): InputSpec[] {
|
||||||
|
const out: InputSpec[] = [];
|
||||||
|
for (const r of relays) {
|
||||||
|
if (typeof r.button === "number") {
|
||||||
|
out.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec });
|
||||||
|
}
|
||||||
|
if (typeof r.presenceInput === "number") {
|
||||||
|
out.push({
|
||||||
|
input: r.presenceInput,
|
||||||
|
role: "presence",
|
||||||
|
relay: r.relay,
|
||||||
|
kind: r.presenceKind ?? "loop",
|
||||||
|
activeLow: r.presenceActiveLow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export function SetupWizard() {
|
export function SetupWizard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||||
@@ -79,7 +134,7 @@ export function SetupWizard() {
|
|||||||
return (
|
return (
|
||||||
<section className="px-4 py-6">
|
<section className="px-4 py-6">
|
||||||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2>
|
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2>
|
||||||
<p className="hint mb-4 max-w-prose">{t("setup.intro")}</p>
|
<p className="hint mb-4 max-w">{t("setup.intro")}</p>
|
||||||
|
|
||||||
<CategorySection
|
<CategorySection
|
||||||
category={CONTROLLER.key}
|
category={CONTROLLER.key}
|
||||||
@@ -270,11 +325,25 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
|
|||||||
if (assignment.category === "access") {
|
if (assignment.category === "access") {
|
||||||
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
|
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
|
||||||
if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
|
if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
|
||||||
|
// Effective inputs: config.inputs[] if present, else synthesized from legacy relay fields.
|
||||||
|
const inputs = Array.isArray(cfg.inputs) ? (cfg.inputs as InputSpec[]) : synthInputsFromRelays(relays);
|
||||||
return (
|
return (
|
||||||
<span className="flex gap-1.5">
|
<span className="flex flex-wrap gap-1.5">
|
||||||
{relays.map((r) => (
|
{relays.map((r) => {
|
||||||
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} />
|
// Alert relay: trigger input + lock lane. Barrier: its button + presence inputs.
|
||||||
))}
|
let wiring = "";
|
||||||
|
if (r.direction === "radarAlert") {
|
||||||
|
if (r.triggerInput) wiring += `·trig${r.triggerInput}`;
|
||||||
|
if (r.lockLane === "exit") wiring += "·lockExit";
|
||||||
|
} else {
|
||||||
|
const served = inputs.filter((x) => x.relay === r.relay);
|
||||||
|
const btn = served.find((x) => x.role === "button");
|
||||||
|
const pres = served.find((x) => x.role === "presence");
|
||||||
|
if (btn) wiring += `·btn${btn.input}`;
|
||||||
|
if (pres) wiring += `·${pres.kind === "radar" ? "radar" : "loop"}${pres.input}`;
|
||||||
|
}
|
||||||
|
return <DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${wiring}`} />;
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -325,9 +394,17 @@ function DeviceForm({
|
|||||||
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
|
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
|
||||||
const isController = category === "access";
|
const isController = category === "access";
|
||||||
const isCamera = category === "camera";
|
const isCamera = category === "camera";
|
||||||
// ANPR opt-in for a camera: when true, the VisionReader polls this camera for plates
|
const isPrinter = category === "printer";
|
||||||
// (config.anpr). Off by default. See wiki/entities/opencv-anpr-service.md.
|
// ANPR opt-in for a camera: when true, this camera's snapshots are run through the
|
||||||
|
// recognizer (plate recorded as evidence, both directions). (config.anpr). Off by default.
|
||||||
|
// See wiki/entities/opencv-anpr-service.md.
|
||||||
const [anpr, setAnpr] = useState<boolean>(editCfg?.anpr === true);
|
const [anpr, setAnpr] = useState<boolean>(editCfg?.anpr === true);
|
||||||
|
// Auto-trigger: when true, THIS camera's vehicle detection may auto-open the barrier
|
||||||
|
// (subscriber entry/exit). Separate from `anpr` so a shared entry/exit lane can keep
|
||||||
|
// RECOGNITION on both cameras but disable auto-open on, e.g., the exit camera (whose
|
||||||
|
// back-plate read would otherwise phantom-exit the car that just entered). Defaults ON
|
||||||
|
// when anpr is on (back-compat). (config.anprAutoTrigger).
|
||||||
|
const [anprAuto, setAnprAuto] = useState<boolean>(editCfg?.anprAutoTrigger !== false);
|
||||||
|
|
||||||
// Pre-fill scalar config fields from the existing assignment when editing.
|
// Pre-fill scalar config fields from the existing assignment when editing.
|
||||||
// (relays/controllerId/relay are model fields handled by their own state below.)
|
// (relays/controllerId/relay are model fields handled by their own state below.)
|
||||||
@@ -341,10 +418,20 @@ function DeviceForm({
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
|
// Controllers: the unified relay map. Each relay reacts to an EVENT — entry/exit/both
|
||||||
|
// (pulse a barrier) or radarAlert (drive an alert lamp). Alert relays carry a trigger
|
||||||
|
// input + blink cadence; barriers carry no input wiring (that lives in `inputs` below).
|
||||||
const [relays, setRelays] = useState<RelaySpec[]>(() =>
|
const [relays, setRelays] = useState<RelaySpec[]>(() =>
|
||||||
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
|
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
|
||||||
);
|
);
|
||||||
|
// Controller INPUTS — a first-class list (button / presence / alertTrigger), each naming
|
||||||
|
// the relay it serves. Seed from config.inputs[] if present, else SYNTHESIZE from the
|
||||||
|
// legacy per-relay button/presence fields so an existing controller opens populated.
|
||||||
|
const [inputs, setInputs] = useState<InputSpec[]>(() => {
|
||||||
|
const stored = editCfg?.inputs;
|
||||||
|
if (Array.isArray(stored) && stored.length > 0) return stored as InputSpec[];
|
||||||
|
return synthInputsFromRelays(Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : []);
|
||||||
|
});
|
||||||
// Bound devices: which controller + relay this device sits at.
|
// Bound devices: which controller + relay this device sits at.
|
||||||
const [controllerId, setControllerId] = useState<string>(
|
const [controllerId, setControllerId] = useState<string>(
|
||||||
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
|
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
|
||||||
@@ -357,9 +444,21 @@ function DeviceForm({
|
|||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testError, setTestError] = useState<string | null>(null);
|
const [testError, setTestError] = useState<string | null>(null);
|
||||||
// ANPR probe (camera + anpr on): snapshot → vision analyze, reported below.
|
// ANPR probe (camera + anpr on): snapshot → vision analyze, reported below.
|
||||||
|
const [alarmUrlCopied, setAlarmUrlCopied] = useState(false);
|
||||||
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
|
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
|
||||||
const [anprTesting, setAnprTesting] = useState(false);
|
const [anprTesting, setAnprTesting] = useState(false);
|
||||||
const [anprError, setAnprError] = useState<string | null>(null);
|
const [anprError, setAnprError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [printResult, setPrintResult] = useState<PrintTestResult | null>(null);
|
||||||
|
const [printTesting, setPrintTesting] = useState(false);
|
||||||
|
const [printError, setPrintError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Which `secret` fields are currently unmasked. The device web password is an
|
||||||
|
// operational credential the admin legitimately needs (to reach the device's web
|
||||||
|
// UI) — it's stored + sent to this admin-only view; a per-field reveal toggle just
|
||||||
|
// makes the already-present value readable. (Machine secrets — relay/push pw — are
|
||||||
|
// redacted server-side and never reach here, so there's nothing to reveal.)
|
||||||
|
const [revealed, setRevealed] = useState<Record<string, boolean>>({});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
|
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
|
||||||
@@ -368,22 +467,31 @@ function DeviceForm({
|
|||||||
|
|
||||||
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
|
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
|
||||||
const [backendIp, setBackendIp] = useState<string>("");
|
const [backendIp, setBackendIp] = useState<string>("");
|
||||||
|
// The server's listen port (e.g. 3000) the device must POST to — NOT the page's
|
||||||
|
// port (the SPA may be served by Vite on :5173 in dev, or behind a proxy on :80).
|
||||||
|
// Comes from the same /api/setup/backend-ips probe as the IPs.
|
||||||
|
const [backendPort, setBackendPort] = useState<number | null>(null);
|
||||||
|
|
||||||
const testedHost = tested ? String(mergedScalarConfig().host ?? "") : "";
|
const testedHost = tested ? String(mergedScalarConfig().host ?? "") : "";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!testedHost || !pushesToBackend) {
|
if (!testedHost || !pushesToBackend) {
|
||||||
setBackendIps(null);
|
setBackendIps(null);
|
||||||
|
setBackendPort(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let live = true;
|
let live = true;
|
||||||
fetchBackendIps(testedHost)
|
fetchBackendIps(testedHost)
|
||||||
.then(({ candidates }) => {
|
.then(({ candidates, port }) => {
|
||||||
if (!live) return;
|
if (!live) return;
|
||||||
setBackendIps(candidates);
|
setBackendIps(candidates);
|
||||||
|
setBackendPort(port);
|
||||||
setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || "");
|
setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || "");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (live) setBackendIps(null);
|
if (live) {
|
||||||
|
setBackendIps(null);
|
||||||
|
setBackendPort(null);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
@@ -437,19 +545,41 @@ function DeviceForm({
|
|||||||
function mergedConfig(): DeviceConfig {
|
function mergedConfig(): DeviceConfig {
|
||||||
const out: DeviceConfig = { ...mergedScalarConfig() };
|
const out: DeviceConfig = { ...mergedScalarConfig() };
|
||||||
if (isController) {
|
if (isController) {
|
||||||
out.relays = relays.map((r) => ({
|
// Relays carry ONLY the event (+ alert fields). Input wiring lives in out.inputs.
|
||||||
relay: r.relay,
|
out.relays = relays.map((r) =>
|
||||||
direction: r.direction,
|
r.direction === "radarAlert"
|
||||||
...(r.button ? { button: r.button } : {}),
|
? {
|
||||||
...(r.presenceInput ? { presenceInput: r.presenceInput } : {}),
|
// Alert lamp: trigger input + lock lane + blink cadence.
|
||||||
...(r.entryCooldownSec ? { entryCooldownSec: r.entryCooldownSec } : {}),
|
relay: r.relay,
|
||||||
}));
|
direction: r.direction,
|
||||||
|
...(r.triggerInput ? { triggerInput: r.triggerInput } : {}),
|
||||||
|
...(r.lockLane && r.lockLane !== "entry" ? { lockLane: r.lockLane } : {}),
|
||||||
|
...(r.blinkOnMs ? { blinkOnMs: r.blinkOnMs } : {}),
|
||||||
|
...(r.blinkOffMs ? { blinkOffMs: r.blinkOffMs } : {}),
|
||||||
|
}
|
||||||
|
: { relay: r.relay, direction: r.direction },
|
||||||
|
);
|
||||||
|
// Inputs: a button/presence row needs its relay; alertTrigger may be standalone.
|
||||||
|
out.inputs = inputs
|
||||||
|
.filter((i) => typeof i.input === "number" && i.input > 0)
|
||||||
|
.map((i) => ({
|
||||||
|
input: i.input,
|
||||||
|
role: i.role,
|
||||||
|
...(typeof i.relay === "number" ? { relay: i.relay } : {}),
|
||||||
|
...(i.role === "presence" && i.kind ? { kind: i.kind } : {}),
|
||||||
|
...(i.role === "presence" && i.activeLow ? { activeLow: true } : {}),
|
||||||
|
...(i.role === "button" && i.cooldownSec ? { cooldownSec: i.cooldownSec } : {}),
|
||||||
|
}));
|
||||||
} else if (controllerId && boundRelay !== "") {
|
} else if (controllerId && boundRelay !== "") {
|
||||||
out.controllerId = controllerId;
|
out.controllerId = controllerId;
|
||||||
out.relay = boundRelay;
|
out.relay = boundRelay;
|
||||||
}
|
}
|
||||||
// Camera ANPR opt-in (only persisted when on, to keep configs minimal).
|
// Camera ANPR opt-in (only persisted when on, to keep configs minimal).
|
||||||
if (isCamera && anpr) out.anpr = true;
|
if (isCamera && anpr) out.anpr = true;
|
||||||
|
// Auto-trigger flag — only meaningful when anpr is on. Persist it (true OR false) so a
|
||||||
|
// park can explicitly DISABLE auto-open on a camera (e.g. the exit cam of a shared lane)
|
||||||
|
// while keeping recognition. Absent ⇒ defaults ON (back-compat for existing cameras).
|
||||||
|
if (isCamera && anpr) out.anprAutoTrigger = anprAuto;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +597,7 @@ function DeviceForm({
|
|||||||
setTestError(null);
|
setTestError(null);
|
||||||
setTested(null);
|
setTested(null);
|
||||||
try {
|
try {
|
||||||
setTested(await testDevice(selected.id, mergedScalarConfig()));
|
setTested(await testDevice(selected.id, mergedScalarConfig(), editing?.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setTestError((e as Error).message);
|
setTestError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -492,6 +622,23 @@ function DeviceForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push a real test slip to the printer — proves it physically prints (healthCheck
|
||||||
|
// only opens the transport). Passes editing?.id so an edited network printer's
|
||||||
|
// stored secrets re-merge. Never blocks save.
|
||||||
|
async function testPrintNow() {
|
||||||
|
if (!selected) return;
|
||||||
|
setPrintTesting(true);
|
||||||
|
setPrintError(null);
|
||||||
|
setPrintResult(null);
|
||||||
|
try {
|
||||||
|
setPrintResult(await testPrint(selected.id, mergedScalarConfig(), editing?.id));
|
||||||
|
} catch (e) {
|
||||||
|
setPrintError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setPrintTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
// Bound devices must point at a controller relay (binding is optional in the
|
// Bound devices must point at a controller relay (binding is optional in the
|
||||||
@@ -569,7 +716,21 @@ function DeviceForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selected.configFields.map((f) =>
|
{selected.configFields
|
||||||
|
// pulseMs + inputRestingHigh are surfaced in the Outputs / Inputs model
|
||||||
|
// sections below (a relay setting and an input setting, respectively), so
|
||||||
|
// skip them here to avoid rendering them twice. See OutputEditor/InputEditor.
|
||||||
|
.filter((f) => !(isController && (f.key === "pulseMs" || f.key === "inputRestingHigh")))
|
||||||
|
// Printer transport is exclusive: when Connection = USB the network fields
|
||||||
|
// (host/port/status-page) don't apply, and vice-versa the USB device path
|
||||||
|
// doesn't. Hide the irrelevant side so the form can't mislead (e.g. a USB
|
||||||
|
// path lingering under a Network printer). Driven by config.transport.
|
||||||
|
.filter((f) => {
|
||||||
|
const transport = String(config.transport ?? "tcp-ip");
|
||||||
|
if (transport === "usb") return !["host", "port", "httpPort"].includes(f.key);
|
||||||
|
return f.key !== "devicePath";
|
||||||
|
})
|
||||||
|
.map((f) =>
|
||||||
f.type === "boolean" ? (
|
f.type === "boolean" ? (
|
||||||
// Boolean config field → a real checkbox (stores a true/false boolean, not
|
// Boolean config field → a real checkbox (stores a true/false boolean, not
|
||||||
// the string "true"). The label sits beside the box, with the help below.
|
// the string "true"). The label sits beside the box, with the help below.
|
||||||
@@ -611,10 +772,36 @@ function DeviceForm({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
) : f.type === "secret" ? (
|
||||||
|
// Secret field with a reveal toggle: the device web password is shown
|
||||||
|
// here (admin-only view) so an admin can read/copy it to reach the
|
||||||
|
// device's own web UI. Masked by default; click the eye to reveal.
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
type={revealed[f.key] ? "text" : "password"}
|
||||||
|
value={(config[f.key] ?? (f.default as string | number | undefined) ?? "") as string | number}
|
||||||
|
placeholder={f.help}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setConfig((c) => ({ ...c, [f.key]: v }));
|
||||||
|
resetStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
aria-label={revealed[f.key] ? t("setup.hideSecret") : t("setup.revealSecret")}
|
||||||
|
title={revealed[f.key] ? t("setup.hideSecret") : t("setup.revealSecret")}
|
||||||
|
onClick={() => setRevealed((r) => ({ ...r, [f.key]: !r[f.key] }))}
|
||||||
|
>
|
||||||
|
{revealed[f.key] ? "🙈" : "👁"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
type={f.type === "number" || f.type === "port" ? "number" : "text"}
|
||||||
value={(config[f.key] ?? (f.default as string | number | undefined) ?? "") as string | number}
|
value={(config[f.key] ?? (f.default as string | number | undefined) ?? "") as string | number}
|
||||||
placeholder={f.help}
|
placeholder={f.help}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -628,8 +815,33 @@ function DeviceForm({
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
|
{/* CONTROLLER — OUTPUTS: the unified relays (barriers pulse, alert relays blink). */}
|
||||||
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
|
{isController && (
|
||||||
|
<OutputEditor
|
||||||
|
relays={relays}
|
||||||
|
onChange={setRelays}
|
||||||
|
pulseMs={config.pulseMs as number | undefined}
|
||||||
|
onPulseMsChange={(v) => {
|
||||||
|
setConfig((c) => ({ ...c, pulseMs: v }));
|
||||||
|
resetStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CONTROLLER — INPUTS: a generic terminal list (button / presence / alert trigger),
|
||||||
|
each naming the relay it serves. Separated from the outputs above. */}
|
||||||
|
{isController && (
|
||||||
|
<InputEditor
|
||||||
|
inputs={inputs}
|
||||||
|
onChange={setInputs}
|
||||||
|
relays={relays}
|
||||||
|
inputsIdleHigh={config.inputRestingHigh as boolean | undefined}
|
||||||
|
onInputsIdleHighChange={(v) => {
|
||||||
|
setConfig((c) => ({ ...c, inputRestingHigh: v }));
|
||||||
|
resetStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* BOUND device: which controller + relay it sits at. */}
|
{/* BOUND device: which controller + relay it sits at. */}
|
||||||
{!isController && (
|
{!isController && (
|
||||||
@@ -661,6 +873,82 @@ function DeviceForm({
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Auto-trigger is only meaningful with ANPR on. Off = this camera RECOGNISES plates
|
||||||
|
(evidence) but does NOT auto-open the barrier — for a shared entry/exit lane where
|
||||||
|
the exit cam's back-plate read would phantom-exit a car that just entered. */}
|
||||||
|
{isCamera && anpr && (
|
||||||
|
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={anprAuto}
|
||||||
|
onChange={(e) => setAnprAuto(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold text-term-text">{t("setup.anprAuto")}</span>
|
||||||
|
<span className="hint mt-0.5 block">{t("setup.anprAutoHint")}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CAMERA + Alarm Server push ON: show the camera's Alarm Server settings,
|
||||||
|
ready to copy, so the operator never has to find the deviceId or memorise the
|
||||||
|
endpoint. The CAMERA reaches us over the device VLAN, NOT via the browser's
|
||||||
|
origin — so host/port are the BACKEND address (backendIp on the camera's
|
||||||
|
subnet + the server's listen port), resolved by the same probe the push-IP
|
||||||
|
picker uses, NOT window.location (which is the SPA's dev/proxy origin). The
|
||||||
|
URL embeds the deviceId, so it needs a SAVED camera; and the backend IP needs
|
||||||
|
a Test connection first. We surface each field separately, matching the
|
||||||
|
camera's Alarm Settings form (Destination IP / URL / Protocol / Port). */}
|
||||||
|
{isCamera && Boolean(config.alarmPushEnabled) && (
|
||||||
|
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
|
||||||
|
<div className="font-semibold text-term-text">{t("setup.alarmUrlTitle")}</div>
|
||||||
|
{!editing?.id ? (
|
||||||
|
<p className="hint mt-1">{t("setup.alarmUrlSaveFirst")}</p>
|
||||||
|
) : !backendIp || backendPort == null ? (
|
||||||
|
<p className="hint mt-1">{t("setup.alarmUrlTestFirst")}</p>
|
||||||
|
) : (
|
||||||
|
(() => {
|
||||||
|
const path = `/api/devices/hikvision/${editing.id}/event`;
|
||||||
|
// What the operator pastes into the camera's Alarm Settings form.
|
||||||
|
const fields: [string, string][] = [
|
||||||
|
[t("setup.alarmFieldHost"), backendIp],
|
||||||
|
[t("setup.alarmFieldUrl"), path],
|
||||||
|
[t("setup.alarmFieldProtocol"), "HTTP"],
|
||||||
|
[t("setup.alarmFieldPort"), String(backendPort)],
|
||||||
|
];
|
||||||
|
const copyText = fields.map(([k, v]) => `${k}: ${v}`).join("\n");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||||
|
{fields.map(([k, v]) => (
|
||||||
|
<Fragment key={k}>
|
||||||
|
<span className="text-term-muted">{k}</span>
|
||||||
|
<code className="break-all rounded bg-term-panel px-2 py-0.5 text-term-green">{v}</code>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard?.writeText(copyText);
|
||||||
|
setAlarmUrlCopied(true);
|
||||||
|
setTimeout(() => setAlarmUrlCopied(false), 2000);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{alarmUrlCopied ? t("setup.alarmUrlCopied") : t("setup.alarmUrlCopy")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="hint mt-1">{t("setup.alarmUrlHint")}</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Test (no save/no device change) then Save (configures + persists). */}
|
{/* Test (no save/no device change) then Save (configures + persists). */}
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
<button type="button" className="btn btn-sm" onClick={test} disabled={testing}>
|
<button type="button" className="btn btn-sm" onClick={test} disabled={testing}>
|
||||||
@@ -730,6 +1018,30 @@ function DeviceForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* PRINTER: push a real test slip so the admin can confirm it physically
|
||||||
|
prints (healthCheck only opens the transport / USB node). */}
|
||||||
|
{isPrinter && (
|
||||||
|
<div className="mt-3 rounded-term border border-term-border bg-term-bg p-2">
|
||||||
|
<button type="button" className="btn btn-sm" onClick={testPrintNow} disabled={printTesting}>
|
||||||
|
{printTesting ? t("setup.printTesting") : t("setup.testPrint")}
|
||||||
|
</button>
|
||||||
|
<p className="hint mt-1">{t("setup.testPrintHint")}</p>
|
||||||
|
|
||||||
|
{printError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: printError })}</p>}
|
||||||
|
{printResult &&
|
||||||
|
(printResult.ok ? (
|
||||||
|
<div className="mt-2 text-[12px] text-term-green">
|
||||||
|
{t("setup.printOk", { ms: printResult.tookMs })}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 text-[12px] text-term-amber">
|
||||||
|
⚠ {t(`setup.printFail.${printResult.reason}`, { defaultValue: printResult.reason })}
|
||||||
|
{printResult.detail && <span className="text-term-muted"> — {printResult.detail}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{backendIps && backendIps.length > 0 && (
|
{backendIps && backendIps.length > 0 && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="field max-w-md">
|
<div className="field max-w-md">
|
||||||
@@ -760,9 +1072,25 @@ function DeviceForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Controller relay map editor: each row = a relay + its direction + (optional)
|
// ── Controller OUTPUTS (relays) ────────────────────────────────────────────
|
||||||
* the input terminal its entry button is wired to. */
|
// A relay is an OUTPUT reacting to an EVENT: entry/exit/both PULSE a barrier; radarAlert
|
||||||
function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) {
|
// BLINKS an indicator lamp (and a camera-confirmed car locks it solid). This section owns
|
||||||
|
// the relay number + event, the pulse-open hold time (barriers), and — for alert relays —
|
||||||
|
// the trigger input + blink cadence. The barrier INPUT terminals (entry button, presence)
|
||||||
|
// live in InputEditor below; the two are deliberately separated.
|
||||||
|
|
||||||
|
/** Relays = the unified event→action outputs + the pulse-open hold time. */
|
||||||
|
function OutputEditor({
|
||||||
|
relays,
|
||||||
|
onChange,
|
||||||
|
pulseMs,
|
||||||
|
onPulseMsChange,
|
||||||
|
}: {
|
||||||
|
relays: RelaySpec[];
|
||||||
|
onChange: (r: RelaySpec[]) => void;
|
||||||
|
pulseMs: number | undefined;
|
||||||
|
onPulseMsChange: (v: number) => void;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
function update(i: number, patch: Partial<RelaySpec>) {
|
function update(i: number, patch: Partial<RelaySpec>) {
|
||||||
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||||
@@ -777,8 +1105,24 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||||
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.relaysTitle")}</strong>
|
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.outputsTitle")}</strong>
|
||||||
<p className="hint mt-0.5 mb-2">{t("setup.relaysHint")}</p>
|
<p className="hint mt-0.5 mb-2">{t("setup.outputsHint")}</p>
|
||||||
|
|
||||||
|
{/* Pulse-open time applies to every barrier relay (how long it's held open). */}
|
||||||
|
<label className="my-1 inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.pulseOpenHint")}>
|
||||||
|
{t("setup.pulseOpenMs")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={100}
|
||||||
|
value={pulseMs ?? ""}
|
||||||
|
placeholder="500"
|
||||||
|
className="input input-sm w-20"
|
||||||
|
onChange={(e) => onPulseMsChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Each relay: number + event. radarAlert reveals its trigger input + blink cadence;
|
||||||
|
barriers pulse (their button/presence terminals are in the Inputs section). */}
|
||||||
{relays.map((r, i) => (
|
{relays.map((r, i) => (
|
||||||
<div key={i} className="my-1 flex flex-wrap items-center gap-2">
|
<div key={i} className="my-1 flex flex-wrap items-center gap-2">
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||||
@@ -791,56 +1135,68 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
|||||||
onChange={(e) => update(i, { relay: Number(e.target.value) })}
|
onChange={(e) => update(i, { relay: Number(e.target.value) })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<select className="select input-sm w-auto" value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}>
|
<select
|
||||||
{(["entry", "exit", "both"] as Direction[]).map((d) => (
|
className="select input-sm w-auto"
|
||||||
|
value={r.direction}
|
||||||
|
onChange={(e) => update(i, { direction: e.target.value as RelayEvent })}
|
||||||
|
>
|
||||||
|
{(["entry", "exit", "both", "radarAlert"] as RelayEvent[]).map((d) => (
|
||||||
<option key={d} value={d}>
|
<option key={d} value={d}>
|
||||||
{t(DIRECTION_KEYS[d])}
|
{t(DIRECTION_KEYS[d])}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{(r.direction === "entry" || r.direction === "both") && (
|
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
{/* Alert relay: which input fires the blink + the blink cadence. */}
|
||||||
{t("setup.entryButtonTerminal")}
|
{r.direction === "radarAlert" && (
|
||||||
<input
|
<>
|
||||||
type="number"
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.triggerInputHint")}>
|
||||||
min={1}
|
{t("setup.triggerInput")}
|
||||||
value={r.button ?? ""}
|
<input
|
||||||
placeholder="—"
|
type="number"
|
||||||
className="input input-sm w-16"
|
min={1}
|
||||||
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
|
value={r.triggerInput ?? ""}
|
||||||
/>
|
placeholder="—"
|
||||||
</label>
|
className="input input-sm w-16"
|
||||||
)}
|
onChange={(e) => update(i, { triggerInput: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||||
{(r.direction === "entry" || r.direction === "both") && (
|
/>
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceInputHint")}>
|
</label>
|
||||||
{t("setup.presenceInput")}
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.lockLaneHint")}>
|
||||||
<input
|
{t("setup.lockLane")}
|
||||||
type="number"
|
<select
|
||||||
min={1}
|
className="select input-sm w-auto"
|
||||||
value={r.presenceInput ?? ""}
|
value={r.lockLane ?? "entry"}
|
||||||
placeholder="—"
|
onChange={(e) => update(i, { lockLane: e.target.value as "entry" | "exit" })}
|
||||||
className="input input-sm w-16"
|
>
|
||||||
onChange={(e) =>
|
<option value="entry">{t("setup.lockLaneEntry")}</option>
|
||||||
update(i, { presenceInput: e.target.value === "" ? undefined : Number(e.target.value) })
|
<option value="exit">{t("setup.lockLaneExit")}</option>
|
||||||
}
|
</select>
|
||||||
/>
|
</label>
|
||||||
</label>
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||||
)}
|
{t("setup.blinkOnMs")}
|
||||||
{(r.direction === "entry" || r.direction === "both") && !r.presenceInput && (
|
<input
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
|
type="number"
|
||||||
{t("setup.entryCooldown")}
|
min={50}
|
||||||
<input
|
value={r.blinkOnMs ?? ""}
|
||||||
type="number"
|
placeholder="500"
|
||||||
min={0}
|
className="input input-sm w-20"
|
||||||
value={r.entryCooldownSec ?? ""}
|
onChange={(e) => update(i, { blinkOnMs: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||||
placeholder="—"
|
/>
|
||||||
className="input input-sm w-16"
|
</label>
|
||||||
onChange={(e) =>
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||||
update(i, { entryCooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })
|
{t("setup.blinkOffMs")}
|
||||||
}
|
<input
|
||||||
/>
|
type="number"
|
||||||
</label>
|
min={50}
|
||||||
|
value={r.blinkOffMs ?? ""}
|
||||||
|
placeholder="500"
|
||||||
|
className="input input-sm w-20"
|
||||||
|
onChange={(e) => update(i, { blinkOffMs: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{relays.length > 1 && (
|
{relays.length > 1 && (
|
||||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
|
||||||
✕
|
✕
|
||||||
@@ -855,6 +1211,152 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Controller INPUTS (terminals) ──────────────────────────────────────────
|
||||||
|
// An input is a TERMINAL the host READS. It's a first-class list (the twin of the relays
|
||||||
|
// list above): each row is a terminal + a ROLE (entry button / presence loop / presence
|
||||||
|
// radar / alert trigger) + the relay it serves. Adding an exit radar = adding a row. The
|
||||||
|
// button never SETS a pulse — its electrical pulse is the device's to report — so no timing
|
||||||
|
// field lives here (pulse-open is an OUTPUT setting, in OutputEditor).
|
||||||
|
|
||||||
|
/** Generic controller-input list: terminal + role + the relay it serves. */
|
||||||
|
function InputEditor({
|
||||||
|
inputs,
|
||||||
|
onChange,
|
||||||
|
relays,
|
||||||
|
inputsIdleHigh,
|
||||||
|
onInputsIdleHighChange,
|
||||||
|
}: {
|
||||||
|
inputs: InputSpec[];
|
||||||
|
onChange: (v: InputSpec[]) => void;
|
||||||
|
relays: RelaySpec[];
|
||||||
|
inputsIdleHigh: boolean | undefined;
|
||||||
|
onInputsIdleHighChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
function update(i: number, patch: Partial<InputSpec>) {
|
||||||
|
onChange(inputs.map((row, idx) => (idx === i ? { ...row, ...patch } : row)));
|
||||||
|
}
|
||||||
|
function add() {
|
||||||
|
const firstEntry = relays.find((r) => r.direction === "entry" || r.direction === "both");
|
||||||
|
onChange([...inputs, { input: 1, role: "button", relay: firstEntry?.relay }]);
|
||||||
|
}
|
||||||
|
function remove(i: number) {
|
||||||
|
onChange(inputs.filter((_, idx) => idx !== i));
|
||||||
|
}
|
||||||
|
// Barrier relays an input can serve (button/presence gate a barrier; alert triggers don't).
|
||||||
|
const barrierRelays = relays.filter((r) => r.direction !== "radarAlert");
|
||||||
|
// A button row shows its cooldown fallback only if no presence row serves the same relay.
|
||||||
|
const hasPresenceFor = (relay?: number) =>
|
||||||
|
relay != null && inputs.some((x) => x.role === "presence" && x.relay === relay);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||||
|
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.inputsTitle")}</strong>
|
||||||
|
<p className="hint mt-0.5 mb-2">{t("setup.inputsHint")}</p>
|
||||||
|
|
||||||
|
{/* Board-wide resting level (idle HIGH vs LOW) — an input property. */}
|
||||||
|
<label className="my-1 inline-flex items-start gap-2 text-[12px] text-term-muted">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={inputsIdleHigh ?? true}
|
||||||
|
onChange={(e) => onInputsIdleHighChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold text-term-text">{t("setup.inputsIdleHigh")}</span>
|
||||||
|
<span className="hint mt-0.5 block">{t("setup.inputsIdleHighHint")}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{inputs.map((row, i) => {
|
||||||
|
const choice = choiceOf(row);
|
||||||
|
const isPresence = row.role === "presence";
|
||||||
|
const isButton = row.role === "button";
|
||||||
|
return (
|
||||||
|
<div key={i} className="my-1 flex flex-wrap items-center gap-2 border-t border-term-border pt-2">
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||||
|
{t("setup.inputTerminal")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={row.input}
|
||||||
|
className="input input-sm w-16"
|
||||||
|
onChange={(e) => update(i, { input: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select input-sm w-auto"
|
||||||
|
value={choice}
|
||||||
|
onChange={(e) => update(i, applyChoice(e.target.value as InputChoice))}
|
||||||
|
>
|
||||||
|
{(["button", "presenceLoop", "presenceRadar", "alertTrigger"] as InputChoice[]).map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{t(INPUT_CHOICE_KEYS[c])}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Which barrier this input serves — button/presence only (alert triggers a lamp). */}
|
||||||
|
{row.role !== "alertTrigger" && (
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||||
|
{t("setup.inputServesRelay")}
|
||||||
|
<select
|
||||||
|
className="select input-sm w-auto"
|
||||||
|
value={row.relay ?? ""}
|
||||||
|
onChange={(e) => update(i, { relay: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
{t("setup.choose")}
|
||||||
|
</option>
|
||||||
|
{barrierRelays.map((r) => (
|
||||||
|
<option key={r.relay} value={r.relay}>
|
||||||
|
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Presence: active-low (a radar wired opposite the button). */}
|
||||||
|
{isPresence && (
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.activeLowHint")}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!row.activeLow}
|
||||||
|
onChange={(e) => update(i, { activeLow: e.target.checked || undefined })}
|
||||||
|
/>
|
||||||
|
{t("setup.activeLow")}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Button cooldown fallback — only when no presence sensor serves this relay. */}
|
||||||
|
{isButton && !hasPresenceFor(row.relay) && (
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
|
||||||
|
{t("setup.entryCooldown")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={row.cooldownSec ?? ""}
|
||||||
|
placeholder="—"
|
||||||
|
className="input input-sm w-16"
|
||||||
|
onChange={(e) => update(i, { cooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button type="button" className="btn btn-sm mt-1" onClick={add}>
|
||||||
|
{t("setup.addInput")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Binding picker for readers/cameras/printers: choose the controller + relay this
|
/** Binding picker for readers/cameras/printers: choose the controller + relay this
|
||||||
* device sits at. Direction is inherited from the chosen relay (shown). */
|
* device sits at. Direction is inherited from the chosen relay (shown). */
|
||||||
function BindingPicker({
|
function BindingPicker({
|
||||||
@@ -909,11 +1411,14 @@ function BindingPicker({
|
|||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
{t("setup.choose")}
|
{t("setup.choose")}
|
||||||
</option>
|
</option>
|
||||||
{relays.map((r) => (
|
{/* Only barrier relays are bindable — an alert lamp opens nothing. */}
|
||||||
<option key={r.relay} value={r.relay}>
|
{relays
|
||||||
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
|
.filter((r) => r.direction !== "radarAlert")
|
||||||
</option>
|
.map((r) => (
|
||||||
))}
|
<option key={r.relay} value={r.relay}>
|
||||||
|
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
|
{chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
|
||||||
@@ -925,14 +1430,16 @@ function BindingPicker({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) {
|
function DirectionBadge({ direction, label }: { direction: RelayEvent; label?: string }) {
|
||||||
// entry=green, exit=amber, both=muted — aligned to the terminal accent palette.
|
// entry=green, exit=amber, radarAlert=red (an alert), both=muted — terminal accents.
|
||||||
const cls =
|
const cls =
|
||||||
direction === "entry"
|
direction === "entry"
|
||||||
? "border-term-green text-term-green"
|
? "border-term-green text-term-green"
|
||||||
: direction === "exit"
|
: direction === "exit"
|
||||||
? "border-term-amber text-term-amber"
|
? "border-term-amber text-term-amber"
|
||||||
: "border-term-muted text-term-muted";
|
: direction === "radarAlert"
|
||||||
|
? "border-term-red text-term-red"
|
||||||
|
: "border-term-muted text-term-muted";
|
||||||
return (
|
return (
|
||||||
<span className={`rounded-term border px-1.5 text-[10px] font-semibold uppercase tracking-wider ${cls}`}>
|
<span className={`rounded-term border px-1.5 text-[10px] font-semibold uppercase tracking-wider ${cls}`}>
|
||||||
{label ?? direction}
|
{label ?? direction}
|
||||||
|
|||||||
+64
-10
@@ -275,18 +275,52 @@ export type DeviceConfig = Record<string, ConfigValue>;
|
|||||||
/** Direction a barrier/relay (or a device bound to it) serves. */
|
/** Direction a barrier/relay (or a device bound to it) serves. */
|
||||||
export type Direction = "entry" | "exit" | "both";
|
export type Direction = "entry" | "exit" | "both";
|
||||||
|
|
||||||
/** One relay on an access controller: which barrier it opens, in which direction,
|
/** The EVENT a relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` → drive a
|
||||||
* and (optionally) the input terminal its entry button is wired to. */
|
* non-barrier alert lamp (blink while its trigger input is active, SOLID once the camera
|
||||||
|
* confirms a car). The action is implied by the event. */
|
||||||
|
export type RelayEvent = Direction | "radarAlert";
|
||||||
|
|
||||||
|
/** What a controller input terminal means: a transient-entry `button`, a one-car-one-ticket
|
||||||
|
* `presence` sensor (loop/radar), or an `alertTrigger` for a radarAlert lamp. */
|
||||||
|
export type InputRole = "button" | "presence" | "alertTrigger";
|
||||||
|
|
||||||
|
/** One INPUT terminal the host reads (the twin of RelaySpec). An exit radar is just another
|
||||||
|
* `presence` row serving the exit relay. */
|
||||||
|
export interface InputSpec {
|
||||||
|
input: number;
|
||||||
|
role: InputRole;
|
||||||
|
/** The barrier relay this input serves (required for button/presence; optional for
|
||||||
|
* alertTrigger). */
|
||||||
|
relay?: number;
|
||||||
|
/** presence only — induction LOOP or RADAR (label only). */
|
||||||
|
kind?: "loop" | "radar";
|
||||||
|
/** This terminal idles HIGH / is active-LOW (e.g. a radar wired opposite the button). */
|
||||||
|
activeLow?: boolean;
|
||||||
|
/** button only — presence-less fallback cooldown (seconds). */
|
||||||
|
cooldownSec?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One relay on an access controller: the event it reacts to. Input wiring lives in
|
||||||
|
* `config.inputs[]`; the legacy per-relay button/presence fields are still read for
|
||||||
|
* back-compat but no longer written. */
|
||||||
export interface RelaySpec {
|
export interface RelaySpec {
|
||||||
relay: number;
|
relay: number;
|
||||||
direction: Direction;
|
/** The event this relay reacts to (UI label: "Event"). */
|
||||||
/** Input terminal of the entry button that fires this relay (transient entry). */
|
direction: RelayEvent;
|
||||||
|
// ── legacy input fields (read-only back-compat; superseded by config.inputs[]) ──
|
||||||
button?: number;
|
button?: number;
|
||||||
/** Anti-double-press (one car = one ticket). PRESENCE: input terminal of a vehicle
|
|
||||||
* loop/barrier-feedback signal; a press prints only with a car present + re-arms when
|
|
||||||
* it clears. COOLDOWN (fallback, no feedback): suppress repeat presses for N seconds. */
|
|
||||||
presenceInput?: number;
|
presenceInput?: number;
|
||||||
|
presenceKind?: "loop" | "radar";
|
||||||
|
presenceActiveLow?: boolean;
|
||||||
entryCooldownSec?: number;
|
entryCooldownSec?: number;
|
||||||
|
// ── radarAlert-only ──
|
||||||
|
/** Input terminal whose active edge starts the blink (the radar). */
|
||||||
|
triggerInput?: number;
|
||||||
|
/** Which lane's camera locks this lamp SOLID (default entry). An exit radar locks on exit. */
|
||||||
|
lockLane?: "entry" | "exit";
|
||||||
|
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
|
||||||
|
blinkOnMs?: number;
|
||||||
|
blinkOffMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TestResult {
|
export interface TestResult {
|
||||||
@@ -297,11 +331,13 @@ export interface TestResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test a device config (reachability + preconditions) without saving. */
|
/** Test a device config (reachability + preconditions) without saving. Pass the
|
||||||
export function testDevice(driverId: string, config: DeviceConfig): Promise<TestResult> {
|
* device `id` when editing an existing one so the server re-merges its stored
|
||||||
|
* machine secrets (e.g. the relay password redacted from the client). */
|
||||||
|
export function testDevice(driverId: string, config: DeviceConfig, id?: string): Promise<TestResult> {
|
||||||
return apiFetch<TestResult>("/api/setup/test", {
|
return apiFetch<TestResult>("/api/setup/test", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ driverId, config }),
|
body: JSON.stringify({ driverId, config, ...(id ? { id } : {}) }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +369,24 @@ export function testAnpr(driverId: string, config: DeviceConfig): Promise<AnprTe
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of a physical print test: a real test slip is pushed to the printer. */
|
||||||
|
export type PrintTestResult =
|
||||||
|
| { ok: true; tookMs: number }
|
||||||
|
| { ok: false; reason: string; detail?: string; tookMs?: number };
|
||||||
|
|
||||||
|
/** Print a real test slip on the printer — without saving. Confirms the printer
|
||||||
|
* actually feeds paper + fires the head (healthCheck only opens the transport). */
|
||||||
|
export function testPrint(
|
||||||
|
driverId: string,
|
||||||
|
config: DeviceConfig,
|
||||||
|
id?: string,
|
||||||
|
): Promise<PrintTestResult> {
|
||||||
|
return apiFetch<PrintTestResult>("/api/setup/test-print", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ driverId, config, id }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Admin reports -------------------------------------------------------
|
// --- Admin reports -------------------------------------------------------
|
||||||
export type ReportBucket = "hour" | "day" | "month";
|
export type ReportBucket = "hour" | "day" | "month";
|
||||||
|
|
||||||
|
|||||||
@@ -428,3 +428,30 @@ html.theme-light .btn:hover:not(:disabled) {
|
|||||||
html.theme-light .btn-primary {
|
html.theme-light .btn-primary {
|
||||||
color: #fafaf7;
|
color: #fafaf7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Barrier-light blink ────────────────────────────────────────────────────
|
||||||
|
The booth Entry/Exit indicator blinks green↔red (~1 Hz) when the radar/presence
|
||||||
|
input is active but the camera hasn't confirmed a vehicle yet — mirroring the
|
||||||
|
physical button lamp (relay 3). Toggles a CSS var the component maps onto its
|
||||||
|
border / tint / glyph, so green and red alternate every 500 ms. */
|
||||||
|
@keyframes lane-blink {
|
||||||
|
0%, 49% { --lane-c: var(--color-term-green); --lane-tint: color-mix(in srgb, var(--color-term-green) 10%, transparent); }
|
||||||
|
50%, 100% { --lane-c: var(--color-term-red); --lane-tint: color-mix(in srgb, var(--color-term-red) 10%, transparent); }
|
||||||
|
}
|
||||||
|
.lane-blink {
|
||||||
|
animation: lane-blink 1s steps(1, end) infinite;
|
||||||
|
border-color: var(--lane-c);
|
||||||
|
background: var(--lane-tint);
|
||||||
|
color: var(--lane-c);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
/* No flashing for motion-sensitive users — hold the "attention" (red) state. */
|
||||||
|
.lane-blink {
|
||||||
|
animation: none;
|
||||||
|
--lane-c: var(--color-term-red);
|
||||||
|
--lane-tint: color-mix(in srgb, var(--color-term-red) 10%, transparent);
|
||||||
|
border-color: var(--lane-c);
|
||||||
|
background: var(--lane-tint);
|
||||||
|
color: var(--lane-c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -359,18 +359,49 @@ export const en: Catalog = {
|
|||||||
relaysTitle: "Relays on this controller",
|
relaysTitle: "Relays on this controller",
|
||||||
relaysHint:
|
relaysHint:
|
||||||
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
|
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
|
||||||
|
outputsTitle: "Outputs — relays (barriers + lamp)",
|
||||||
|
outputsHint:
|
||||||
|
"Relays are OUTPUTS: each opens a barrier (or drives the button lamp). Set the relay number and direction. The input terminals (button, sensor) are in the Inputs section below.",
|
||||||
|
pulseOpenMs: "Pulse open (ms)",
|
||||||
|
pulseOpenHint: "How long a barrier relay is held open (jog). Applies to all barrier relays.",
|
||||||
|
inputsTitle: "Inputs — terminals (button, sensor)",
|
||||||
|
inputsHint:
|
||||||
|
"Inputs are TERMINALS the host READS: the entry button and the presence/radar sensor. Each belongs to an entry barrier — it triggers or gates that relay.",
|
||||||
|
inputsIdleHigh: "Inputs idle HIGH",
|
||||||
|
inputsIdleHighHint: "This board idles inputs HIGH (status 1111); a press pulls LOW.",
|
||||||
relay: "Relay",
|
relay: "Relay",
|
||||||
entryButtonTerminal: "Entry button on terminal",
|
// Generic input rows: terminal + role + the relay it serves.
|
||||||
presenceInput: "Presence loop (terminal)",
|
inputTerminal: "Terminal",
|
||||||
presenceInputHint:
|
inputServesRelay: "Serves relay",
|
||||||
"Input terminal the vehicle-presence loop / barrier feedback is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the loop clears (the car drove in) and a new car re-occupies it. Preferred mode.",
|
roleButton: "Entry button",
|
||||||
|
rolePresenceLoop: "Presence (loop)",
|
||||||
|
rolePresenceRadar: "Presence (radar)",
|
||||||
|
roleAlertTrigger: "Alert trigger",
|
||||||
|
addInput: "+ Add input",
|
||||||
entryCooldown: "Cooldown after ticket (s)",
|
entryCooldown: "Cooldown after ticket (s)",
|
||||||
entryCooldownHint:
|
entryCooldownHint:
|
||||||
"When there's no presence loop: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
|
"When there's no presence sensor: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
|
||||||
|
activeLow: "Active-low",
|
||||||
|
activeLowHint:
|
||||||
|
"Tick if the presence sensor (e.g. a radar) idles HIGH and goes LOW on detection — the opposite of the button. This inverts that terminal's reading so 'present' is read correctly.",
|
||||||
|
eventRadarAlert: "Radar alert (lamp)",
|
||||||
|
triggerInput: "Trigger input",
|
||||||
|
triggerInputHint:
|
||||||
|
"The input terminal (the radar) that starts this relay blinking. Blinks while the trigger is active but the camera doesn't confirm a car; solid on once the camera confirms; off otherwise.",
|
||||||
|
lockLane: "Lock from",
|
||||||
|
lockLaneHint:
|
||||||
|
"Which camera locks the lamp solid: the entry or the exit camera. An exit radar must lock on the EXIT camera.",
|
||||||
|
lockLaneEntry: "Entry camera",
|
||||||
|
lockLaneExit: "Exit camera",
|
||||||
|
blinkOnMs: "Blink on (ms)",
|
||||||
|
blinkOffMs: "Blink off (ms)",
|
||||||
addRelay: "+ Add relay",
|
addRelay: "+ Add relay",
|
||||||
anpr: "Plate recognition (ANPR)",
|
anpr: "Plate recognition (ANPR)",
|
||||||
anprHint:
|
anprHint:
|
||||||
"Enable to scan plates on this camera: the vision service reads the plate from a snapshot and feeds it as a read (advisory only — it never opens a barrier on its own). Requires the vision service running.",
|
"Read plates on this camera: the vision service reads the plate from each snapshot and records it (both entry and exit). Requires the vision service running.",
|
||||||
|
anprAuto: "Auto open/close on subscriber plate",
|
||||||
|
anprAutoHint:
|
||||||
|
"Let THIS camera auto-open the barrier when it recognises a subscriber's plate. Turn OFF on a shared entry/exit lane's exit camera, so a car driving IN isn't auto-EXITed by its back plate (recognition still runs — only the auto-trigger is off).",
|
||||||
testAnpr: "Test ANPR",
|
testAnpr: "Test ANPR",
|
||||||
anprTesting: "Testing ANPR…",
|
anprTesting: "Testing ANPR…",
|
||||||
testAnprHint:
|
testAnprHint:
|
||||||
@@ -381,6 +412,29 @@ export const en: Catalog = {
|
|||||||
"anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.",
|
"anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.",
|
||||||
"anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).",
|
"anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).",
|
||||||
"anprFail.no-plate": "No plate found in the snapshot.",
|
"anprFail.no-plate": "No plate found in the snapshot.",
|
||||||
|
// Printer test slip — pushes a real slip so the admin can confirm it physically prints.
|
||||||
|
testPrint: "Print test slip",
|
||||||
|
printTesting: "Printing…",
|
||||||
|
testPrintHint:
|
||||||
|
"Sends a test slip to the printer now. ‘Connected’ only opens the link — this confirms the printer actually feeds paper.",
|
||||||
|
printOk: "✓ Test slip sent ({{ms}} ms). Check the printer.",
|
||||||
|
"printFail.print-failed": "The printer rejected the job (out of paper, cover open, or the link dropped).",
|
||||||
|
// Reveal/hide toggle for a secret field (e.g. the device web password).
|
||||||
|
revealSecret: "Show password",
|
||||||
|
hideSecret: "Hide password",
|
||||||
|
alarmUrlTitle: "Alarm Server settings (enter these in the camera)",
|
||||||
|
alarmUrlHint:
|
||||||
|
"Enter these in the camera at Configuration → Event → … → Alarm Settings (or Notify Surveillance Center). The camera POSTs every event here — no polling.",
|
||||||
|
alarmUrlCopy: "Copy all",
|
||||||
|
alarmUrlCopied: "Copied ✓",
|
||||||
|
alarmUrlSaveFirst:
|
||||||
|
"Save the camera first — the address is generated once the device has an ID. Re-open it for editing to see it.",
|
||||||
|
alarmUrlTestFirst:
|
||||||
|
"Click “Test connection” first — that resolves this host's IP on the camera's network (so the camera can reach it).",
|
||||||
|
alarmFieldHost: "Destination IP / Host",
|
||||||
|
alarmFieldUrl: "URL",
|
||||||
|
alarmFieldProtocol: "Protocol",
|
||||||
|
alarmFieldPort: "Port",
|
||||||
whichBarrier: "Which barrier does this device serve?",
|
whichBarrier: "Which barrier does this device serve?",
|
||||||
controller: "Controller",
|
controller: "Controller",
|
||||||
choose: "Choose…",
|
choose: "Choose…",
|
||||||
@@ -561,7 +615,7 @@ export const en: Catalog = {
|
|||||||
save: "Save",
|
save: "Save",
|
||||||
saved: "Saved.",
|
saved: "Saved.",
|
||||||
fieldParkName: "Park name",
|
fieldParkName: "Park name",
|
||||||
fieldParkNamePh: "e.g. Acme Parking",
|
fieldParkNamePh: "e.g. Airport Parking",
|
||||||
fieldOperator: "Operator (legal name)",
|
fieldOperator: "Operator (legal name)",
|
||||||
fieldOperatorPh: "operating company",
|
fieldOperatorPh: "operating company",
|
||||||
fieldNius: "NIUS",
|
fieldNius: "NIUS",
|
||||||
|
|||||||
+71
-16
@@ -308,21 +308,21 @@ export const sq = {
|
|||||||
setup: {
|
setup: {
|
||||||
title: "Konfigurimi",
|
title: "Konfigurimi",
|
||||||
intro:
|
intro:
|
||||||
"Shto fillimisht kontrolluesit e barrierave — cakto cili rele është hyrje/dalje dhe në cilin terminal është lidhur butoni i hyrjes. Pastaj shto lexues, kamera dhe printera dhe drejto secilin te barriera që shërben.",
|
"Shto fillimisht kontrollerat e barrierave — cakto cili rele është hyrje/dalje dhe në cilin terminal është lidhur butoni i hyrjes. Pastaj shto lexues, kamera dhe printera dhe drejto secilin te barriera që shërben.",
|
||||||
// Category titles + the singular noun used in buttons/modal titles.
|
// Category titles + the singular noun used in buttons/modal titles.
|
||||||
catControllers: "Kontrolluesit (barrierat + butoni i hyrjes)",
|
catControllers: "Kontrollerat (barrierat + butoni i hyrjes)",
|
||||||
catReaders: "Lexuesit (QR / RFID)",
|
catReaders: "Lexuesit (QR / RFID)",
|
||||||
catCameras: "Kamerat (foto + targë)",
|
catCameras: "Kamerat (foto + targë)",
|
||||||
catPrinters: "Printerat (bileta / vouchera)",
|
catPrinters: "Printerat (bileta / vouchera)",
|
||||||
nounController: "kontrollues",
|
nounController: "kontroller",
|
||||||
nounReader: "lexues",
|
nounReader: "lexues",
|
||||||
nounCamera: "kamerë",
|
nounCamera: "kamerë",
|
||||||
nounPrinter: "printer",
|
nounPrinter: "printer",
|
||||||
add: "+ Shto {{noun}}",
|
add: "+ Shto {{noun}}",
|
||||||
addAnother: "+ Shto edhe një {{noun}}",
|
addAnother: "+ Shto {{noun}}",
|
||||||
addTitle: "Shto {{noun}}",
|
addTitle: "Shto {{noun}}",
|
||||||
editTitle: "Ndrysho {{noun}}",
|
editTitle: "Ndrysho {{noun}}",
|
||||||
needControllerFirst: "Shto fillimisht një kontrollues — {{noun}} drejtohet te një prej releve të tij.",
|
needControllerFirst: "Shto fillimisht një kontroller — {{noun}} drejtohet te një prej releve të tij.",
|
||||||
failedToLoad: "Ngarkimi i konfigurimit dështoi: {{error}}",
|
failedToLoad: "Ngarkimi i konfigurimit dështoi: {{error}}",
|
||||||
loadingCatalog: "Duke ngarkuar katalogun e pajisjeve…",
|
loadingCatalog: "Duke ngarkuar katalogun e pajisjeve…",
|
||||||
// Direction labels (relay direction + inherited binding).
|
// Direction labels (relay direction + inherited binding).
|
||||||
@@ -344,9 +344,9 @@ export const sq = {
|
|||||||
// Device form.
|
// Device form.
|
||||||
noDrivers: "Asnjë drejtues i regjistruar.",
|
noDrivers: "Asnjë drejtues i regjistruar.",
|
||||||
chooseDevice: "Zgjidh një pajisje…",
|
chooseDevice: "Zgjidh një pajisje…",
|
||||||
scan: "Skano për kontrollues",
|
scan: "Skano për kontroller",
|
||||||
scanning: "Duke skanuar…",
|
scanning: "Duke skanuar…",
|
||||||
noControllersFound: "Asnjë kontrollues në LAN.",
|
noControllersFound: "Asnjë kontroller në LAN.",
|
||||||
use: "Përdor",
|
use: "Përdor",
|
||||||
test: "Testo lidhjen",
|
test: "Testo lidhjen",
|
||||||
testing: "Duke testuar…",
|
testing: "Duke testuar…",
|
||||||
@@ -365,22 +365,53 @@ export const sq = {
|
|||||||
noNicOnSubnet: "⚠ asnjë NIC në subnetin e pajisjes — pajisja mund të mos arrijë backend-in",
|
noNicOnSubnet: "⚠ asnjë NIC në subnetin e pajisjes — pajisja mund të mos arrijë backend-in",
|
||||||
backendIpHint: "Adresa te e cila kjo pajisje do të dërgojë eventet e hyrjes.",
|
backendIpHint: "Adresa te e cila kjo pajisje do të dërgojë eventet e hyrjes.",
|
||||||
// Relay editor.
|
// Relay editor.
|
||||||
relaysTitle: "Relet në këtë kontrollues",
|
relaysTitle: "Relet në këtë kontroller",
|
||||||
relaysHint:
|
relaysHint:
|
||||||
"Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.",
|
"Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.",
|
||||||
|
outputsTitle: "Daljet — relet (barrierat + drita)",
|
||||||
|
outputsHint:
|
||||||
|
"Relet janë DALJE: secila hap një barrierë (ose ndez dritën e butonit). Cakto numrin e relesë dhe drejtimin. Terminalet hyrëse (butoni, sensori) janë te seksioni Hyrjet më poshtë.",
|
||||||
|
pulseOpenMs: "Kohëzgjatja e hapjes (ms)",
|
||||||
|
pulseOpenHint: "Sa kohë mbahet rele e barrierës e hapur (jog). Vlen për të gjitha relet e barrierave.",
|
||||||
|
inputsTitle: "Hyrjet — terminalet (buton, sensor)",
|
||||||
|
inputsHint:
|
||||||
|
"Hyrjet janë TERMINALE që hosti i LEXON: butoni i hyrjes dhe sensori i pranisë/radari. Secila i përket një barriere hyrëse — e gateron ose e nis atë rele.",
|
||||||
|
inputsIdleHigh: "Hyrjet në pushim HIGH",
|
||||||
|
inputsIdleHighHint: "Kjo pllakë i mban hyrjet HIGH në pushim (statusi 1111); një shtypje e ul në LOW.",
|
||||||
relay: "Rele",
|
relay: "Rele",
|
||||||
entryButtonTerminal: "Butoni i hyrjes në terminalin",
|
// Generic input rows: terminal + role + the relay it serves.
|
||||||
presenceInput: "Sensori i pranisë (terminali)",
|
inputTerminal: "Terminali",
|
||||||
presenceInputHint:
|
inputServesRelay: "I shërben reles",
|
||||||
"Terminali hyrës ku është lidhur sensori/laku i pranisë së automjetit. Kur vendoset, lëshohet vetëm NJË biletë për automjet: butoni printon vetëm kur ka makinë, dhe nuk lëshon biletë të dytë derisa laku të lirohet (makina hyri) dhe një makinë e re ta zërë. Mënyra e preferuar.",
|
roleButton: "Butoni i hyrjes",
|
||||||
|
rolePresenceLoop: "Prania (lak induktiv)",
|
||||||
|
rolePresenceRadar: "Prania (radar)",
|
||||||
|
roleAlertTrigger: "Trigger alarmi",
|
||||||
|
addInput: "+ Shto hyrje",
|
||||||
entryCooldown: "Pritje pas biletës (sek)",
|
entryCooldown: "Pritje pas biletës (sek)",
|
||||||
entryCooldownHint:
|
entryCooldownHint:
|
||||||
"Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.",
|
"Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.",
|
||||||
|
activeLow: "Aktiv-ulët",
|
||||||
|
activeLowHint:
|
||||||
|
"Shëno nëse sensori i pranisë (p.sh. radari) qëndron HIGH në pushim dhe shkon LOW kur detekton — e kundërta e butonit. Kjo përmbys leximin e atij terminali që 'prania' të lexohet saktë.",
|
||||||
|
eventRadarAlert: "Alarm radar (dritë)",
|
||||||
|
triggerInput: "Trigger input",
|
||||||
|
triggerInputHint:
|
||||||
|
"Terminali i hyrjes (radari) që nis pulsimin e kësaj rele. Pulson kur Trigger input është aktiv por kamera s'konfirmon makinë; ndizet fiks kur kamera konfirmon; përndryshe fiket.",
|
||||||
|
lockLane: "Bllokimi nga",
|
||||||
|
lockLaneHint:
|
||||||
|
"Cila kamerë e ndez dritën fiks: hyrja apo dalja. Një radar i daljes duhet të bllokohet nga kamera e DALJES.",
|
||||||
|
lockLaneEntry: "Kamera e hyrjes",
|
||||||
|
lockLaneExit: "Kamera e daljes",
|
||||||
|
blinkOnMs: "Pulsim ndezur (ms)",
|
||||||
|
blinkOffMs: "Pulsim fikur (ms)",
|
||||||
addRelay: "+ Shto rele",
|
addRelay: "+ Shto rele",
|
||||||
// Camera ANPR opt-in.
|
// Camera ANPR opt-in.
|
||||||
anpr: "Njohja e targave (ANPR)",
|
anpr: "Njohja e targave (ANPR)",
|
||||||
anprHint:
|
anprHint:
|
||||||
"Aktivizo që ky aparat të skanojë targat: shërbimi i vizionit lexon targën nga pamja dhe e dërgon si lexim (vetëm këshillues — nuk hap vetë barrierën). Kërkon shërbimin e vizionit aktiv.",
|
"Lexo targat në këtë aparat: shërbimi i vizionit lexon targën nga çdo pamje dhe e regjistron (hyrje dhe dalje). Kërkon shërbimin e vizionit aktiv.",
|
||||||
|
anprAuto: "Hapje/mbyllje automatike me targën e abonentit",
|
||||||
|
anprAutoHint:
|
||||||
|
"Lejo që KY aparat të hapë vetë barrierën kur njeh targën e një abonenti. ÇAKTIVIZOJE te aparati i daljes në një korsi të përbashkët hyrje/dalje, që një makinë që HYN të mos DALË automatikisht nga targa e pasme (njohja vazhdon — fiket vetëm hapja automatike).",
|
||||||
testAnpr: "Testo ANPR",
|
testAnpr: "Testo ANPR",
|
||||||
anprTesting: "Duke testuar ANPR…",
|
anprTesting: "Duke testuar ANPR…",
|
||||||
testAnprHint:
|
testAnprHint:
|
||||||
@@ -391,12 +422,36 @@ export const sq = {
|
|||||||
"anprFail.vision-disabled": "Shërbimi i vizionit është çaktivizuar — aktivizoje (VISION_ENABLED) për ta testuar ANPR.",
|
"anprFail.vision-disabled": "Shërbimi i vizionit është çaktivizuar — aktivizoje (VISION_ENABLED) për ta testuar ANPR.",
|
||||||
"anprFail.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).",
|
"anprFail.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).",
|
||||||
"anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.",
|
"anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.",
|
||||||
|
// Printer test slip — pushes a real slip so the admin can confirm it physically prints.
|
||||||
|
testPrint: "Printo provë",
|
||||||
|
printTesting: "Duke printuar…",
|
||||||
|
testPrintHint:
|
||||||
|
"Dërgon një fletë prove te printeri tani. ‘I lidhur’ vetëm hap lidhjen — kjo konfirmon se printeri vërtet nxjerr letër.",
|
||||||
|
printOk: "✓ Fleta e provës u dërgua ({{ms}} ms). Kontrollo printerin.",
|
||||||
|
"printFail.print-failed": "Printeri nuk pranoi punën (pa letër, kapaku hapur, ose lidhja ra).",
|
||||||
|
// Reveal/hide toggle for a secret field (e.g. the device web password).
|
||||||
|
revealSecret: "Shfaq fjalëkalimin",
|
||||||
|
hideSecret: "Fshih fjalëkalimin",
|
||||||
|
// Alarm Server push settings — generated for the camera's Event → Alarm Server form.
|
||||||
|
alarmUrlTitle: "Cilësimet e Alarm Server (vendosi te kamera)",
|
||||||
|
alarmUrlHint:
|
||||||
|
"Vendosi këto te kamera: Configuration → Event → … → Alarm Settings (ose Notify Surveillance Center). Kamera do të dërgojë çdo ngjarje këtu — pa polling.",
|
||||||
|
alarmUrlCopy: "Kopjo të gjitha",
|
||||||
|
alarmUrlCopied: "U kopjua ✓",
|
||||||
|
alarmUrlSaveFirst:
|
||||||
|
"Ruaje kamerën më parë — adresa gjenerohet pasi pajisja të marrë një ID. Hape sërish për editim që ta shohësh.",
|
||||||
|
alarmUrlTestFirst:
|
||||||
|
"Kliko “Testo lidhjen” më parë — kështu përcaktohet IP-ja e këtij hosti në rrjetin e kamerës (që kamera ta thërrasë).",
|
||||||
|
alarmFieldHost: "Destination IP / Host",
|
||||||
|
alarmFieldUrl: "URL",
|
||||||
|
alarmFieldProtocol: "Protokolli",
|
||||||
|
alarmFieldPort: "Porta",
|
||||||
// Binding picker.
|
// Binding picker.
|
||||||
whichBarrier: "Cilën barrierë shërben kjo pajisje?",
|
whichBarrier: "Cilën barrierë shërben kjo pajisje?",
|
||||||
controller: "Kontrolluesi",
|
controller: "Kontrolleri",
|
||||||
choose: "Zgjidh…",
|
choose: "Zgjidh…",
|
||||||
relayLabel: "Rele {{relay}} ({{direction}})",
|
relayLabel: "Rele {{relay}} ({{direction}})",
|
||||||
noRelaysConfigured: "Ky kontrollues nuk ka rele të konfiguruar.",
|
noRelaysConfigured: "Ky kontroller nuk ka rele të konfiguruar.",
|
||||||
},
|
},
|
||||||
lab: {
|
lab: {
|
||||||
title: "Lab Tarife",
|
title: "Lab Tarife",
|
||||||
@@ -572,7 +627,7 @@ export const sq = {
|
|||||||
save: "Ruaj",
|
save: "Ruaj",
|
||||||
saved: "U ruajt.",
|
saved: "U ruajt.",
|
||||||
fieldParkName: "Emri i parkimit",
|
fieldParkName: "Emri i parkimit",
|
||||||
fieldParkNamePh: "p.sh. Acme Parking",
|
fieldParkNamePh: "p.sh. Parking Aeroport",
|
||||||
fieldOperator: "Operatori (emri ligjor)",
|
fieldOperator: "Operatori (emri ligjor)",
|
||||||
fieldOperatorPh: "kompania operuese",
|
fieldOperatorPh: "kompania operuese",
|
||||||
fieldNius: "NIUS",
|
fieldNius: "NIUS",
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ export interface LaneStatus {
|
|||||||
exit: boolean; // true = busy
|
exit: boolean; // true = busy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-lane RADAR presence — a presence input (loop/radar) is shorted at the barrier,
|
||||||
|
* i.e. "something is in the lane" BEFORE the camera confirms a vehicle. Drives the
|
||||||
|
* barrier light's BLINK (the same signal as the physical button lamp / relay 3). */
|
||||||
|
export interface LanePresence {
|
||||||
|
entry: boolean; // true = a radar/presence input on an entry barrier is active
|
||||||
|
exit: boolean; // true = … on an exit barrier
|
||||||
|
}
|
||||||
|
|
||||||
/** Cap the in-memory live feed so a long-running booth session can't grow it
|
/** Cap the in-memory live feed so a long-running booth session can't grow it
|
||||||
* unbounded — the full history is always available via the /api/events query. */
|
* unbounded — the full history is always available via the /api/events query. */
|
||||||
const MAX_FEED = 200;
|
const MAX_FEED = 200;
|
||||||
@@ -31,6 +39,8 @@ interface LiveState {
|
|||||||
devices: Record<string, DeviceStatus>;
|
devices: Record<string, DeviceStatus>;
|
||||||
/** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */
|
/** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */
|
||||||
lanes: LaneStatus | null;
|
lanes: LaneStatus | null;
|
||||||
|
/** Per-lane radar presence (advisory blink). Null until the first WS hello. */
|
||||||
|
radar: LanePresence | null;
|
||||||
setStatus: (s: WsStatus) => void;
|
setStatus: (s: WsStatus) => void;
|
||||||
setOccupancy: (o: Occupancy) => void;
|
setOccupancy: (o: Occupancy) => void;
|
||||||
pushEvent: (e: LedgerEvent) => void;
|
pushEvent: (e: LedgerEvent) => void;
|
||||||
@@ -40,6 +50,8 @@ interface LiveState {
|
|||||||
upsertDevice: (d: DeviceStatus) => void;
|
upsertDevice: (d: DeviceStatus) => void;
|
||||||
/** Set lane busy/free (WS hello + each lane-status push). */
|
/** Set lane busy/free (WS hello + each lane-status push). */
|
||||||
setLanes: (l: LaneStatus) => void;
|
setLanes: (l: LaneStatus) => void;
|
||||||
|
/** Set lane radar presence (WS hello + each lane-presence push). */
|
||||||
|
setRadar: (r: LanePresence) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +68,7 @@ export const useLiveStore = create<LiveState>((set) => ({
|
|||||||
feed: [],
|
feed: [],
|
||||||
devices: {},
|
devices: {},
|
||||||
lanes: null,
|
lanes: null,
|
||||||
|
radar: null,
|
||||||
setStatus: (status) => set({ status }),
|
setStatus: (status) => set({ status }),
|
||||||
setOccupancy: (occupancy) => set({ occupancy }),
|
setOccupancy: (occupancy) => set({ occupancy }),
|
||||||
pushEvent: (e) =>
|
pushEvent: (e) =>
|
||||||
@@ -66,5 +79,6 @@ export const useLiveStore = create<LiveState>((set) => ({
|
|||||||
setDevices: (list) => set({ devices: byId(list) }),
|
setDevices: (list) => set({ devices: byId(list) }),
|
||||||
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
|
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
|
||||||
setLanes: (lanes) => set({ lanes }),
|
setLanes: (lanes) => set({ lanes }),
|
||||||
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null }),
|
setRadar: (radar) => set({ radar }),
|
||||||
|
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null, radar: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
||||||
import { qk } from "./query.js";
|
import { qk } from "./query.js";
|
||||||
import { useLiveStore, type LaneStatus } from "./live-store.js";
|
import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js";
|
||||||
import { wsUrl } from "./origin.js";
|
import { wsUrl } from "./origin.js";
|
||||||
|
|
||||||
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
||||||
@@ -14,16 +14,17 @@ import { wsUrl } from "./origin.js";
|
|||||||
|
|
||||||
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
||||||
type WsMessage =
|
type WsMessage =
|
||||||
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus }
|
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus; radar: LanePresence }
|
||||||
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
||||||
| { kind: "printer-status"; event: unknown }
|
| { kind: "printer-status"; event: unknown }
|
||||||
| { kind: "device-status"; event: DeviceStatus }
|
| { kind: "device-status"; event: DeviceStatus }
|
||||||
| { kind: "lane-status"; lanes: LaneStatus };
|
| { kind: "lane-status"; lanes: LaneStatus }
|
||||||
|
| { kind: "lane-presence"; radar: LanePresence };
|
||||||
|
|
||||||
|
|
||||||
export function useLiveFeed(): void {
|
export function useLiveFeed(): void {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes } = useLiveStore();
|
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar } = useLiveStore();
|
||||||
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
||||||
// double-invoke and unmount.
|
// double-invoke and unmount.
|
||||||
const sockRef = useRef<WebSocket | null>(null);
|
const sockRef = useRef<WebSocket | null>(null);
|
||||||
@@ -56,10 +57,13 @@ export function useLiveFeed(): void {
|
|||||||
// Initial device-status snapshot for the footer.
|
// Initial device-status snapshot for the footer.
|
||||||
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
||||||
if (msg.lanes) setLanes(msg.lanes);
|
if (msg.lanes) setLanes(msg.lanes);
|
||||||
|
if (msg.radar) setRadar(msg.radar);
|
||||||
} else if (msg.kind === "device-status") {
|
} else if (msg.kind === "device-status") {
|
||||||
upsertDevice(msg.event);
|
upsertDevice(msg.event);
|
||||||
} else if (msg.kind === "lane-status") {
|
} else if (msg.kind === "lane-status") {
|
||||||
setLanes(msg.lanes);
|
setLanes(msg.lanes);
|
||||||
|
} else if (msg.kind === "lane-presence") {
|
||||||
|
setRadar(msg.radar);
|
||||||
} else if (msg.kind === "ledger") {
|
} else if (msg.kind === "ledger") {
|
||||||
setOccupancy(msg.occupancy);
|
setOccupancy(msg.occupancy);
|
||||||
pushEvent(msg.event);
|
pushEvent(msg.event);
|
||||||
|
|||||||
+54
-13
@@ -7,24 +7,25 @@
|
|||||||
# See wiki/decisions/container-deployment.md.
|
# See wiki/decisions/container-deployment.md.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Reverse proxy: :80 → server:3000 (WebSocket /api/ws upgrades pass through natively).
|
# Reverse proxy: :80 → server (127.0.0.1:3000). On the HOST network (see the server note),
|
||||||
# Caddy is a single static binary with a one-line proxy config; swapping http:// for the
|
# so it reaches the host-net server over loopback and publishes :80 directly on the host.
|
||||||
# site's real hostname later enables automatic HTTPS. The booth is reached at
|
# WebSocket /api/ws upgrades pass through natively. Swapping http:// for the site's real
|
||||||
# http://<name-or-ip>/ (the name set via hosts/DNS on-site — NOT baked into any image).
|
# hostname later enables automatic HTTPS. Reached at http://<name-or-ip>/ (name via hosts/DNS
|
||||||
|
# on-site — NOT baked into any image).
|
||||||
proxy:
|
proxy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
# Host network: Caddy listens on the host's :80 and proxies the host-net server on
|
||||||
- "80:80"
|
# 127.0.0.1:3000. (No `ports:` mapping — host mode publishes directly.)
|
||||||
# - "443:443" # uncomment when moving to TLS (and set a real hostname in Caddyfile)
|
network_mode: host
|
||||||
|
# host mode is mutually exclusive with a named network; the base file doesn't attach proxy,
|
||||||
|
# so nothing to null here (server does — see below).
|
||||||
volumes:
|
volumes:
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
- caddy-data:/data
|
- caddy-data:/data
|
||||||
- caddy-config:/config
|
- caddy-config:/config
|
||||||
depends_on:
|
depends_on:
|
||||||
- server
|
- server
|
||||||
networks:
|
|
||||||
- parking
|
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
@@ -33,9 +34,45 @@ services:
|
|||||||
|
|
||||||
server:
|
server:
|
||||||
restart: always
|
restart: always
|
||||||
# No published port — only the proxy reaches the server, over the private network.
|
# HOST NETWORK — the crux of the appliance. The server is the ONLY container doing device
|
||||||
expose:
|
# I/O (camera ISAPI snapshots, relay control, receiving reader/alarm pushes), all on the
|
||||||
- "3000"
|
# booth's LAN / isolated device VLAN (10.0.10.x). On a bridge network it sees only the Docker
|
||||||
|
# subnet (172.18.0.x) — it can't reach the relay, can't be reached by push devices, and the
|
||||||
|
# backend-IP picker (net.ts networkInterfaces) only sees eth0. Host mode puts it on the real
|
||||||
|
# NICs. Vision stays bridged (it never touches a device — the server hands it JPEG bytes).
|
||||||
|
network_mode: host
|
||||||
|
# host mode is mutually exclusive with a named network — detach the base file's `parking`
|
||||||
|
# attachment (compose errors otherwise: "network_mode and networks cannot both be set").
|
||||||
|
networks: !reset []
|
||||||
|
# Listens on :3000 directly on the host (Caddy proxies it). Loopback to vision:
|
||||||
|
environment:
|
||||||
|
VISION_URL: http://127.0.0.1:8089
|
||||||
|
# NB: NO `sysctls:` here. net.ipv4.ping_group_range is a per-netns sysctl; under host net
|
||||||
|
# there is no separate namespace, and runc REFUSES it ("not allowed in host network
|
||||||
|
# namespace"). Reader liveness ping uses the HOST's setting instead — the booth host must
|
||||||
|
# set net.ipv4.ping_group_range (see appliance-provisioning §7 / disk-os-hardening).
|
||||||
|
#
|
||||||
|
# USB PRINTER PASSTHROUGH. A USB ESC/POS printer (Rongta/Cashino) is the kernel `usblp` char
|
||||||
|
# device /dev/usb/lpN on the HOST — the container has its own /dev and can't see it (probeUsb
|
||||||
|
# open() → ENOENT → printer always "offline"). Two parts, both needed:
|
||||||
|
# - bind-mount /dev/usb so the lpN NODES appear inside the container, and
|
||||||
|
# - a device-cgroup rule permitting the usblp char major (180) so the kernel allows the
|
||||||
|
# open(). `180:*` covers lp0/lp1/lp2… so a USB replug/boot-order renumber still works
|
||||||
|
# (the printer's path can move; set Connection=USB + the matching /dev/usb/lpN in setup).
|
||||||
|
# (Bind-mounting the dir, not a single `devices:` node, is what survives renumbering.)
|
||||||
|
#
|
||||||
|
# ...AND access: the lpN node is `crw-rw---- root:lp` (mode 660). The server runs as the
|
||||||
|
# non-root `app` user, which is NOT in `lp`, so open(O_WRONLY) → EACCES → still "offline".
|
||||||
|
# group_add the HOST's `lp` GID (numeric — `getent group lp`, typically 7 on Debian/Ubuntu)
|
||||||
|
# so the app process gains that supplementary group and can write the 660 node. Least-
|
||||||
|
# privilege (no world-writable device, no root, no rebuild). VERIFY the GID on the booth;
|
||||||
|
# if the host's lp GID differs, change the number here.
|
||||||
|
group_add:
|
||||||
|
- "7"
|
||||||
|
volumes:
|
||||||
|
- /dev/usb:/dev/usb
|
||||||
|
device_cgroup_rules:
|
||||||
|
- "c 180:* rmw"
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
@@ -45,9 +82,13 @@ services:
|
|||||||
vision:
|
vision:
|
||||||
restart: always
|
restart: always
|
||||||
# The real ANPR engine. The image baked the model weights at build (offline-first).
|
# The real ANPR engine. The image baked the model weights at build (offline-first).
|
||||||
|
# Stays on the bridge network (isolated — it makes NO outbound device calls), but PUBLISHES
|
||||||
|
# 8089 on the host LOOPBACK ONLY so the host-net server can reach it. 127.0.0.1 binding keeps
|
||||||
|
# it off the booth LAN — nothing on the network can hit the ANPR service.
|
||||||
environment:
|
environment:
|
||||||
VISION_RECOGNIZER: fast_alpr
|
VISION_RECOGNIZER: fast_alpr
|
||||||
# No published ports — vision is reached only by the server over the private network.
|
ports:
|
||||||
|
- "127.0.0.1:8089:8089"
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
|
|||||||
+5
-2
@@ -13,8 +13,11 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: /data/parking.sqlite
|
DATABASE_URL: /data/parking.sqlite
|
||||||
# Reach the vision service over the private compose network by service name.
|
# Reach the vision service. DEV: the private compose-network service name (`vision`).
|
||||||
VISION_URL: http://vision:8089
|
# PROD: the server runs on the HOST network (to see the booth LAN / device VLAN — it's the
|
||||||
|
# only container doing device I/O), where compose DNS doesn't resolve, so the prod override
|
||||||
|
# sets VISION_URL=http://127.0.0.1:8089 and vision publishes 8089 on the host loopback.
|
||||||
|
VISION_URL: ${VISION_URL:-http://vision:8089}
|
||||||
VISION_ENABLED: ${VISION_ENABLED:-1}
|
VISION_ENABLED: ${VISION_ENABLED:-1}
|
||||||
# JWT signing secret MUST be provided at deploy (no insecure default — see auth.ts).
|
# JWT signing secret MUST be provided at deploy (no insecure default — see auth.ts).
|
||||||
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in the env/.env}
|
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in the env/.env}
|
||||||
|
|||||||
+238
@@ -0,0 +1,238 @@
|
|||||||
|
# i18n long-sentence review — EN vs SQ
|
||||||
|
|
||||||
|
Generated 2026-06-24. Source: `apps/web/src/lib/i18n/en.ts` + `sq.ts`. Threshold: strings ≥ 80 chars in either language (35 keys).
|
||||||
|
|
||||||
|
Review each pair for MEANING (does SQ say the same as EN?). Mark your decision in the **Verdict** line: `OK` / `FIX: <new text>` / `?`.
|
||||||
|
|
||||||
|
Keys with an automated note are tagged **⚑ NOTE**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. `badgeOverstayTitle` ⚑
|
||||||
|
|
||||||
|
- **EN** (en.ts:138, 86 ch): Paid session. The customer failed to exit during the grace period. A new period began.
|
||||||
|
- **SQ** (sq.ts:140, 93 ch): Sesion i paguar. Klienti nuk doli brenda afatit kohor. Ka filluar një periudhë e re tarifimi.
|
||||||
|
- **⚑ NOTE:** Spelling: "brënda" → standard is "brenda" (no ë).
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 2. `activeSince`
|
||||||
|
|
||||||
|
- **EN** (en.ts:254, 133 ch): Active since {{date}} · {{count}} version(s) in history. Publishing creates a new version; past sessions keep their original pricing.
|
||||||
|
- **SQ** (sq.ts:257, 137 ch): Aktive që nga {{date}} · {{count}} version(e) në histori. Publikimi krijon një version të ri; sesionet e kaluara ruajnë çmimin origjinal.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 3. `rateBlocksHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:263, 256 ch): Each band lasts a number of hours and bills at its own price; bands are consumed in order (the first hours, then the next hours). The last band is \"thereafter\" (open-ended) — its price applies once the ladder is exhausted. Price is per billing increment.
|
||||||
|
- **SQ** (sq.ts:266, 251 ch): Çdo brez zgjat një numër orësh dhe faturohet me çmimin e tij; brezat konsumohen me radhë (orët e para, pastaj orët në vijim). Brezi i fundit është \"më pas\" (i hapur) — çmimi i tij zbatohet pas mbarimit të shkallës. Çmimi është për interval faturimi.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 4. `defaultCardHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:275, 98 ch): The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.
|
||||||
|
- **SQ** (sq.ts:278, 116 ch): Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 5. `steppedHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:280, 210 ch): Set the TOTAL price for a stay up to a given time (e.g. up to 3h = 500). The first row whose limit ≥ the duration wins (the limit is inclusive). The last row's total repeats as a per-day price for longer stays.
|
||||||
|
- **SQ** (sq.ts:283, 233 ch): Vendos çmimin TOTAL për një qëndrim deri në një kohë të caktuar (p.sh. deri 3 orë = 500). Fiton rreshti i parë me kufi ≥ kohëzgjatjes (kufiri përfshihet). Totali i rreshtit të fundit përsëritet si çmim ditor për qëndrime më të gjata.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 6. `steppedTiersConflict`
|
||||||
|
|
||||||
|
- **EN** (en.ts:285, 235 ch): ⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price'. Publishing is blocked until this is fixed.
|
||||||
|
- **SQ** (sq.ts:288, 244 ch): ⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks'. Publikimi bllokohet derisa kjo të rregullohet.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 7. `tiersHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:287, 174 ch): Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.
|
||||||
|
- **SQ** (sq.ts:290, 182 ch): Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 8. `intro`
|
||||||
|
|
||||||
|
- **EN** (en.ts:526, 160 ch): Admin-defined plans the operator sells from — the price is looked up, never typed. Editing a plan publishes a new version; past sales keep their recorded price.
|
||||||
|
- **SQ** (sq.ts:537, 193 ch): Planet i përcakton admini; operatori vetëm shet prej tyre — çmimi merret automatikisht, nuk shkruhet. Ndryshimi i një plani publikon një version të ri; shitjet e mëparshme ruajnë çmimin e tyre.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 9. `relaysHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:361, 124 ch): Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.
|
||||||
|
- **SQ** (sq.ts:370, 130 ch): Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 10. `outputsHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:364, 175 ch): Relays are OUTPUTS: each opens a barrier (or drives the button lamp). Set the relay number and direction. The input terminals (button, sensor) are in the Inputs section below.
|
||||||
|
- **SQ** (sq.ts:373, 178 ch): Relet janë DALJE: secila hap një barrierë (ose ndez dritën e butonit). Cakto numrin e relesë dhe drejtimin. Terminalet hyrëse (butoni, sensori) janë te seksioni Hyrjet më poshtë.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 11. `pulseOpenHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:366, 75 ch): How long a barrier relay is held open (jog). Applies to all barrier relays.
|
||||||
|
- **SQ** (sq.ts:375, 85 ch): Sa kohë mbahet rele e barrierës e hapur (jog). Vlen për të gjitha relet e barrierave.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 12. `inputsHint` ⚑
|
||||||
|
|
||||||
|
- **EN** (en.ts:369, 152 ch): Inputs are TERMINALS the host READS: the entry button and the presence/radar sensor. Each belongs to an entry barrier — it triggers or gates that relay.
|
||||||
|
- **SQ** (sq.ts:378, 153 ch): Hyrjet janë TERMINALE që hosti i LEXON: butoni i hyrjes dhe sensori i pranisë/radari. Secila i përket një barriere hyrëse — e gateron ose e nis atë rele.
|
||||||
|
- **⚑ NOTE:** "e gateron" is an anglicism ("gates it"). Native: "e kushtëzon" / "e lejon".
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 13. `inputsIdleHighHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:371, 62 ch): This board idles inputs HIGH (status 1111); a press pulls LOW.
|
||||||
|
- **SQ** (sq.ts:380, 80 ch): Kjo pllakë i mban hyrjet HIGH në pushim (statusi 1111); një shtypje e ul në LOW.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 14. `inputsNoEntryRelay`
|
||||||
|
|
||||||
|
- **EN** (en.ts:373, 87 ch): No entry relay — add an 'Entry' or 'Entry + exit' relay in Outputs to assign terminals.
|
||||||
|
- **SQ** (sq.ts:382, 97 ch): Asnjë rele hyrëse — shto një rele 'Hyrje' ose 'Hyrje + dalje' te Daljet që të caktosh terminalet.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 15. `presenceInputHint` ⚑
|
||||||
|
|
||||||
|
- **EN** (en.ts:378, 290 ch): Input terminal the vehicle-presence sensor (induction loop or radar) is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the sensor clears (the car drove in) and a new car re-occupies it. Preferred mode.
|
||||||
|
- **SQ** (sq.ts:387, 275 ch): Terminali hyrës ku është lidhur sensori/laku i pranisë së automjetit. Kur vendoset, lëshohet vetëm NJË biletë për automjet: butoni printon vetëm kur ka makinë, dhe nuk lëshon biletë të dytë derisa laku të lirohet (makina hyri) dhe një makinë e re ta zërë. Mënyra e preferuar.
|
||||||
|
- **⚑ NOTE:** EN names "induction loop OR radar"; SQ "sensori/laku i pranisë" omits radar explicitly.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 16. `entryCooldownHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:381, 175 ch): When there's no presence sensor: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.
|
||||||
|
- **SQ** (sq.ts:390, 165 ch): Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 17. `presenceActiveLowHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:387, 178 ch): Tick if the presence sensor (e.g. a radar) idles HIGH and goes LOW on detection — the opposite of the button. This inverts that terminal's reading so 'present' is read correctly.
|
||||||
|
- **SQ** (sq.ts:396, 184 ch): Shëno nëse sensori i pranisë (p.sh. radari) qëndron HIGH në pushim dhe shkon LOW kur detekton — e kundërta e butonit. Kjo përmbys leximin e atij terminali që 'prania' të lexohet saktë.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 18. `buttonLightHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:391, 152 ch): The button's 12 V light on a spare relay. Blinks when the radar detects but the camera doesn't confirm a car; solid on when both confirm; off otherwise.
|
||||||
|
- **SQ** (sq.ts:400, 160 ch): Drita 12V e butonit e lidhur në një rele rezervë. Pulson kur radari detekton por kamera s'konfirmon makinë; ndizet fiks kur të dy konfirmojnë; përndryshe fiket.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 19. `anprHint` ⚑
|
||||||
|
|
||||||
|
- **EN** (en.ts:398, 203 ch): Enable to scan plates on this camera: the vision service reads the plate from a snapshot and feeds it as a read (advisory only — it never opens a barrier on its own). Requires the vision service running.
|
||||||
|
- **SQ** (sq.ts:408, 185 ch): Aktivizo që ky aparat të skanojë targat: shërbimi i vizionit lexon targën nga pamja dhe e dërgon si lexim (vetëm këshillues — nuk hap vetë barrierën). Kërkon shërbimin e vizionit aktiv.
|
||||||
|
- **⚑ NOTE:** SQ uses "aparat" for camera; elsewhere camera = "kamerë" (see testAnprHint). Inconsistent.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 20. `testAnprHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:402, 143 ch): Takes a live snapshot from this camera and tries to read a plate, reporting the result and the time it took. Point a plate at the camera first.
|
||||||
|
- **SQ** (sq.ts:412, 169 ch): Merr një pamje të drejtpërdrejtë nga kjo kamerë dhe përpiqet të lexojë një targë, duke raportuar rezultatin dhe kohën e nevojshme. Vendos një targë para kamerës më parë.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 21. `curveHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:443, 88 ch): Fee from entry at several durations — see where the daily cap flattens or windows shift.
|
||||||
|
- **SQ** (sq.ts:454, 96 ch): Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 22. `versionHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:465, 128 ch): Move this subscriber to another version of the same plan. The price stays as billed; only the access hours change going forward.
|
||||||
|
- **SQ** (sq.ts:476, 139 ch): Zhvendos këtë abonent në një version tjetër të të njëjtit plan. Çmimi mbetet siç u faturua; ndryshon vetëm orari i lejuar nga këtu e tutje.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 23. `deleteInUse`
|
||||||
|
|
||||||
|
- **EN** (en.ts:550, 68 ch): Can't delete — subscriptions still use this plan. Retire it instead.
|
||||||
|
- **SQ** (sq.ts:561, 80 ch): S'mund të fshihet — abonime ende e përdorin këtë plan. Tërhiqe në vend të kësaj.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 24. `newVersionHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:553, 84 ch): This publishes a NEW version of the plan — existing sales keep their original price.
|
||||||
|
- **SQ** (sq.ts:564, 86 ch): Kjo publikon një version TË RI të planit — shitjet ekzistuese ruajnë çmimin origjinal.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 25. `timeframesHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:570, 168 ch): A scan outside the allowed window is charged the normal transient tariff for the out-of-window minutes (early entry is deferred to exit; late exit is gated until paid).
|
||||||
|
- **SQ** (sq.ts:581, 184 ch): Një skanim jashtë intervalit të lejuar tarifohet me tarifën normale kalimtare për minutat jashtë intervalit (hyrja e hershme shtyhet në dalje; dalja e vonuar bllokohet derisa paguhet).
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 26. `reserveSubsHint` ⚑
|
||||||
|
|
||||||
|
- **EN** (en.ts:582, 164 ch): Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).
|
||||||
|
- **SQ** (sq.ts:593, 198 ch): Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).
|
||||||
|
- **⚑ NOTE:** EN "handle overflow by valet" → SQ "parkim manual" (manual parking) — meaning shift; valet ≠ manual parking.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 27. `anprEntryHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:584, 195 ch): When on, a subscriber's plate read by a lane camera opens the barrier through the normal subscription gate. Off: subscribers must use their card/QR. Plate snapshots are still recorded either way.
|
||||||
|
- **SQ** (sq.ts:595, 207 ch): Kur është aktiv, targa e një abonenti e lexuar nga kamera e korsisë hap barrierën përmes portës normale të abonimit. Joaktiv: abonentët duhet të përdorin kartën/QR-në. Fotot e targave regjistrohen gjithsesi.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 28. `voucherHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:659, 128 ch): A receipt (Mandat Arkëtimi) adds cash; a disbursement (Mandat Pagese) removes it. The float only moves with an admin's sign-off.
|
||||||
|
- **SQ** (sq.ts:671, 97 ch): Mandat Arkëtimi shton para; Mandat Pagese heq para. Arka lëviz vetëm me autorizimin e një admini.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 29. `xReportHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:667, 83 ch): View only — nothing is recorded. These figures are signed when the shift is closed.
|
||||||
|
- **SQ** (sq.ts:679, 85 ch): Vetëm për shikim — asgjë nuk regjistrohet. Këto shifra nënshkruhen kur mbyllet turni.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 30. `gateBody`
|
||||||
|
|
||||||
|
- **EN** (en.ts:691, 80 ch): No shift is open. Open your shift so payments and exits are recorded against it.
|
||||||
|
- **SQ** (sq.ts:703, 95 ch): Asnjë turn nuk është i hapur. Hap turnin tënd që pagesat dhe daljet të regjistrohen te ky turn.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 31. `gateOtherBody`
|
||||||
|
|
||||||
|
- **EN** (en.ts:694, 120 ch): {{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.
|
||||||
|
- **SQ** (sq.ts:706, 140 ch): {{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 32. `overstayHint` ⚑
|
||||||
|
|
||||||
|
- **EN** (en.ts:813, 155 ch): Earlier session paid. The customer failed to exit during the grace period. Payment for the new period is required. The total below is the new period's fee.
|
||||||
|
- **SQ** (sq.ts:827, 151 ch): Sesion i mëparshëm i paguar. Klienti nuk doli brënda afatit kohor. Kërkohet pagesë për periudhën e re. Totali më poshtë është tarifa e periudhës së re.
|
||||||
|
- **⚑ NOTE:** Spelling: "brënda" → standard is "brenda" (no ë).
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 33. `subAssistHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:837, 101 ch): Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.
|
||||||
|
- **SQ** (sq.ts:851, 108 ch): Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 34. `windowChargeHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:842, 154 ch): This subscriber parked outside their plan's allowed hours. They owe the transient tariff for the out-of-window time — take payment, then open the barrier.
|
||||||
|
- **SQ** (sq.ts:856, 153 ch): Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën, pastaj hap barrierën.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 35. `cancelTicketHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:852, 115 ch): Cancels a wrongly-printed ticket. A signed record is kept (operator + reason); the original entry is never deleted.
|
||||||
|
- **SQ** (sq.ts:866, 129 ch): Anulon një biletë të printuar gabimisht. Ruhet një gjurmë e nënshkruar (operatori + arsyeja); hyrja origjinale nuk fshihet kurrë.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of flagged items
|
||||||
|
|
||||||
|
- `badgeOverstayTitle` — Spelling: "brënda" → standard is "brenda" (no ë).
|
||||||
|
- `inputsHint` — "e gateron" is an anglicism ("gates it"). Native: "e kushtëzon" / "e lejon".
|
||||||
|
- `presenceInputHint` — EN names "induction loop OR radar"; SQ "sensori/laku i pranisë" omits radar explicitly.
|
||||||
|
- `anprHint` — SQ uses "aparat" for camera; elsewhere camera = "kamerë" (see testAnprHint). Inconsistent.
|
||||||
|
- `reserveSubsHint` — EN "handle overflow by valet" → SQ "parkim manual" (manual parking) — meaning shift; valet ≠ manual parking.
|
||||||
|
- `overstayHint` — Spelling: "brënda" → standard is "brenda" (no ë).
|
||||||
|
|
||||||
|
_All other pairs: meaning judged faithful in automated review; confirm during your read._
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Komodo Stack environment — the COMPLETE reference of every env the booth stack reads:
|
||||||
|
# required, image-selection, set-by-compose (don't override), and the optional tunables
|
||||||
|
# with their code defaults. Under Komodo, plain env lives in the Stack definition
|
||||||
|
# (komodo/resources.toml); the two SECRETS come from Core's secret store, PER BOOTH and
|
||||||
|
# UNIQUE. This file is DOCUMENTATION — never fill in real secrets here. See
|
||||||
|
# wiki/decisions/fleet-deployment-komodo.md.
|
||||||
|
#
|
||||||
|
# A minimal working Stack only needs: REGISTRY, TAG, the two secrets, COOKIE_SECURE=0,
|
||||||
|
# VISION_ENABLED=1, WS_ALLOWED_ORIGINS. Everything under "OPTIONAL TUNABLES" has a safe
|
||||||
|
# default in code — set one only to override it.
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# IMAGE SELECTION (picks which container image to pull — not server runtime env)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||||
|
# IMMUTABLE per-commit tag. Manual + pinned. Bump per deploy. Never the moving `dev`
|
||||||
|
# on a PRODUCTION booth (a staging booth may track `dev`).
|
||||||
|
TAG=dev-830993b
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# REQUIRED (no safe default — the server refuses to boot / login breaks without)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Login signing secret (>=32 chars, no change-me/insecure/dev-only). openssl rand -hex 32.
|
||||||
|
JWT_SECRET=[[booth_<name>_jwt_secret]]
|
||||||
|
# Ledger-signing HMAC key — the anti-fraud root. DISTINCT per booth; never reuse. If unset
|
||||||
|
# it falls back to JWT_SECRET (warned). openssl rand -hex 32.
|
||||||
|
EVENT_SIGNING_KEY=[[booth_<name>_event_signing_key]]
|
||||||
|
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT, so
|
||||||
|
# without =0 the auth cookie never sends and operators CANNOT log in. Set 1 only behind TLS.
|
||||||
|
COOKIE_SECURE=0
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# COMMONLY SET (have defaults, but you usually want these explicit on a booth)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Turn the ANPR/vision call on. Default "" (off-ish). Set 1 to enable. (Prod compose also
|
||||||
|
# forces VISION_RECOGNIZER=fast_alpr on the vision container.)
|
||||||
|
VISION_ENABLED=1
|
||||||
|
# Extra origins the booth WebSocket (/api/ws) accepts beyond same-origin. Comma-separated,
|
||||||
|
# e.g. http://parksystems.msai.al. Blank = only the same-origin booth URL. Default "".
|
||||||
|
WS_ALLOWED_ORIGINS=
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# SET BY COMPOSE — do NOT put these in the Stack (the compose files own them)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# VISION_URL=http://127.0.0.1:8089 # prod override (host-net server → loopback vision)
|
||||||
|
# DATABASE_URL=/data/parking.sqlite # the mounted volume (the signed ledger)
|
||||||
|
# VISION_RECOGNIZER=fast_alpr # prod override on the vision container
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# OPTIONAL TUNABLES — all have code defaults; set only to override. (defaults shown)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# --- networking / process ---
|
||||||
|
# PORT=3000 # server listen port
|
||||||
|
# HOST=0.0.0.0 # bind interface (127.0.0.1 = loopback only)
|
||||||
|
# BACKEND_HOST_IP= # override the auto-picked IP devices push back to
|
||||||
|
# # (multi-NIC hosts; usually auto-detected fine)
|
||||||
|
# WEB_DIST_DIR= # where the built SPA lives (the image sets it)
|
||||||
|
# --- logging ---
|
||||||
|
# LOG_LEVEL=info # debug|info|warn|error
|
||||||
|
# LOG_RETENTION_DAYS=30 # app_logs auto-purge age
|
||||||
|
# LOG_RETENTION_MAX_ROWS=50000 # app_logs row cap
|
||||||
|
# RECYCLE_BIN_RETENTION_DAYS=30 # soft-deleted items auto-purge age (0 = keep forever)
|
||||||
|
# --- device monitor / lane ---
|
||||||
|
# DEVICE_POLL_MS=8000 # device health poll interval
|
||||||
|
# PRINTER_POLL_MS=5000 # printer status poll interval
|
||||||
|
# LANE_BUSY_TTL_MS=30000 # how long a lane stays "busy" after a vehicle push
|
||||||
|
# CAPTURE_TTL_MS=30000 # snapshot evidence cache TTL
|
||||||
|
# --- ANPR / vision (server side) ---
|
||||||
|
# VISION_URL is compose-set (above). These are the knobs you may tweak per booth:
|
||||||
|
# VISION_TIMEOUT_MS=1500 # per /analyze call timeout
|
||||||
|
# VISION_MIN_CONFIDENCE=0.5 # advisory floor (telemetry/lane); below = low_confidence
|
||||||
|
# VISION_ENTRY_MIN_CONFIDENCE=0.85 # STRICT barrier-driving floor (auto entry/exit). A
|
||||||
|
# # read below this is ignored (falls back to card/QR).
|
||||||
|
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this = one presentation
|
||||||
|
# ANPR_POLL_MS=1000 # poll-until-confident: re-pull a fresh frame every N ms
|
||||||
|
# ANPR_POLL_WINDOW_MS=8000 # ...for this long AFTER THE LAST vehicle push (sliding:
|
||||||
|
# # a car arriving mid-loop extends it). RAISE if a slow
|
||||||
|
# # barrier means the car waits >8s before reading clean.
|
||||||
|
# ANPR_POLL_MAX_MS=30000 # hard ceiling on one loop from start (so a continuously
|
||||||
|
# # busy lane can't slide the window forever)
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# VISION CONTAINER env (the Python ANPR service — its OWN process, prefix VISION_)
|
||||||
|
# Mostly compose-set; documented here for completeness. (defaults shown)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# VISION_RECOGNIZER=fast_alpr # stub | fast_alpr (prod compose forces fast_alpr)
|
||||||
|
# VISION_HOST=0.0.0.0 # bind (prod publishes 127.0.0.1 only — see compose)
|
||||||
|
# VISION_PORT=8089
|
||||||
|
# VISION_DETECTOR_MODEL=yolo-v9-t-384-license-plate-end2end
|
||||||
|
# VISION_OCR_MODEL=cct-xs-v2-global-model
|
||||||
|
# VISION_MIN_CONFIDENCE=0.5 # the Python service's own floor (keep ~in sync w/ server)
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# SECRETS — referenced by name in resources.toml, stored in Core (never inline here)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# JWT_SECRET -> [[booth_<name>_jwt_secret]] (login)
|
||||||
|
# EVENT_SIGNING_KEY -> [[booth_<name>_event_signing_key]] (ledger signing — fraud root)
|
||||||
|
# periphery passkey -> [[periphery_passkey_booth_<name>]] (agent onboarding)
|
||||||
|
# registry account -> [[gitea_registry_account]] (image pull)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# `komodo/` — fleet deployment as code
|
||||||
|
|
||||||
|
Infra-as-code for the **Komodo Core** control plane that deploys the parking appliance to the
|
||||||
|
booth fleet over the **NetBird** mesh. See `wiki/decisions/fleet-deployment-komodo.md` for the
|
||||||
|
rationale, threat-model analysis, and the three settled choices (many/growing fleet · deploys
|
||||||
|
are **manual + pinned** · secrets are **Komodo-managed, per-booth**).
|
||||||
|
|
||||||
|
This directory does **not** change how images are built or how the app runs — it's only the
|
||||||
|
control plane. The booth still runs the same `docker-compose.yml` + `docker-compose.prod.yml`
|
||||||
|
([[container-deployment]]); Komodo just drives them remotely instead of someone SSH-ing in to
|
||||||
|
run `booth.sh`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- **`resources.toml`** — the Komodo resource definitions (Servers, Stacks, optional Builders/
|
||||||
|
Procedures), synced into Core via a **ResourceSync**. This is the reviewable, version-
|
||||||
|
controlled source of truth for *which booth runs what*.
|
||||||
|
- **`.env.komodo.example`** — the variables a Stack expects, documenting what comes from Core's
|
||||||
|
**secret store** (per-booth `JWT_SECRET` / `EVENT_SIGNING_KEY`) vs. plain Stack env.
|
||||||
|
|
||||||
|
## How Core consumes this (one-time)
|
||||||
|
|
||||||
|
In Komodo Core, create a **ResourceSync** pointing at this repo + path (`komodo/resources.toml`),
|
||||||
|
on the branch you manage from (e.g. `main`). Core reads the file and reconciles Servers/Stacks to
|
||||||
|
match. Thereafter, a PR to this directory + a sync is how you change the fleet — no clicking.
|
||||||
|
|
||||||
|
> Komodo's TOML schema evolves across releases. Treat `resources.toml` as a **starting sketch**:
|
||||||
|
> `resources.toml` mirrors the **working `park-buzi` Stack** (built by hand in the Core UI, then
|
||||||
|
> exported to TOML — so field names match the running Komodo version, v2.2). Import it into the
|
||||||
|
> sync **Unmanaged** first and review the diff; it should be ~empty against the live Stack.
|
||||||
|
|
||||||
|
## How servers get created — NOT here
|
||||||
|
|
||||||
|
There is **no `[[server]]` block** in `resources.toml`. Servers are created by the **Periphery
|
||||||
|
agent onboarding outbound**: in Core, create a one-time **Onboarding Key** (Settings →
|
||||||
|
Onboarding), then install Periphery on the booth passing `--onboarding-key` + `--core-address`
|
||||||
|
(Core's reverse-proxy URL, reached over the NetBird mesh) + `--connect-as=<booth-name>`. The
|
||||||
|
agent self-registers, generates its own auto-rotating key pair (private key never leaves the
|
||||||
|
booth), and connects **outbound** — the booth opens **no inbound port**. The sync owns only the
|
||||||
|
**Stack**, which references the server by the name it onboarded as (`server = "park-buzi"`). See
|
||||||
|
`wiki/decisions/fleet-deployment-komodo.md`.
|
||||||
|
|
||||||
|
## Adding booth N
|
||||||
|
|
||||||
|
Copy the `[[stack]]` block, change `name`, `server` (its onboarded name), and the per-booth
|
||||||
|
secret references (`[[park_<site>_jwt_secret]]`, `[[park_<site>_event_signing_key]]`). Create
|
||||||
|
those secrets in Core's store first.
|
||||||
|
|
||||||
|
## Hard rules encoded here (do not relax without updating the decision page)
|
||||||
|
|
||||||
|
1. **No deploy webhook on a booth Stack.** Deploys are a human action; pin `TAG=dev-<sha>` before
|
||||||
|
a production booth goes live. A moving `:dev` on a production booth is the non-determinism we
|
||||||
|
rejected. (`TAG=dev` here is fine while staging.)
|
||||||
|
2. **Onboarding, outbound, mesh-only.** Servers self-register via an onboarding key; Periphery
|
||||||
|
connects outbound to Core's mesh URL and exposes no inbound port. Never a LAN/WAN address.
|
||||||
|
3. **Secrets are per-booth and unique.** `EVENT_SIGNING_KEY` signs the anti-fraud ledger — one
|
||||||
|
leak must taint one booth, never the fleet. Reference Core secrets by name; never inline a
|
||||||
|
real value in this file (it's in git).
|
||||||
|
4. **Volumes preserved.** The Stack must never run `compose down -v` — that would wipe the
|
||||||
|
`parking-data` volume (the signed ledger). Komodo's "destroy" is gated for the same reason.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Komodo resources — parking appliance fleet (control plane as code)
|
||||||
|
#
|
||||||
|
# Synced into Komodo Core via a ResourceSync pointing at this file. Drives the SAME
|
||||||
|
# compose files the booth runs locally (docker-compose.yml + docker-compose.prod.yml);
|
||||||
|
# Komodo Periphery on each booth executes them. See:
|
||||||
|
# wiki/decisions/fleet-deployment-komodo.md (rationale + threat model)
|
||||||
|
# wiki/decisions/container-deployment.md (image build/tag/registry — unchanged)
|
||||||
|
#
|
||||||
|
# This file mirrors the WORKING park-buzi Stack (built by hand in the Core UI, then
|
||||||
|
# exported to TOML). Field names match the running Komodo version (v2.2).
|
||||||
|
#
|
||||||
|
# NO [[server]] block: servers are created by the AGENT onboarding outbound (a one-time
|
||||||
|
# onboarding key → Periphery self-registers with auto-rotating key pairs). The sync owns
|
||||||
|
# only the Stack; it references the server by the name it onboarded as (`connect_as`).
|
||||||
|
#
|
||||||
|
# Secrets ([[park_buzi_jwt_secret]] etc.) are REFERENCES to Komodo Core's secret store —
|
||||||
|
# per-booth + unique, never inlined here (this file is in git). JWT_SECRET gates login;
|
||||||
|
# EVENT_SIGNING_KEY signs the append-only anti-fraud ledger.
|
||||||
|
#
|
||||||
|
# Deploys are MANUAL + PINNED in spirit: bump TAG to an immutable dev-<sha> before a
|
||||||
|
# production booth goes live (TAG=dev here is the moving tag, fine while staging). NO
|
||||||
|
# deploy webhook is attached to a booth Stack.
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
# Stack — the deployable unit for booth "park-buzi". One Stack per booth; add a
|
||||||
|
# new [[stack]] block per site (unique name, its own per-booth secret refs).
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
[[stack]]
|
||||||
|
name = "park-buzi"
|
||||||
|
[stack.config]
|
||||||
|
server = "park-buzi"
|
||||||
|
git_provider = "git.infra.msai.al"
|
||||||
|
git_account = "komodo"
|
||||||
|
repo = "mca/parking_solution"
|
||||||
|
branch = "dev"
|
||||||
|
file_paths = [
|
||||||
|
"docker-compose.yml",
|
||||||
|
"docker-compose.prod.yml"
|
||||||
|
]
|
||||||
|
registry_provider = "git.infra.msai.al"
|
||||||
|
registry_account = "komodo"
|
||||||
|
environment = """
|
||||||
|
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||||
|
TAG=dev
|
||||||
|
COOKIE_SECURE=0
|
||||||
|
VISION_ENABLED=1
|
||||||
|
WS_ALLOWED_ORIGINS=
|
||||||
|
JWT_SECRET=[[park_buzi_jwt_secret]]
|
||||||
|
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
||||||
|
"""
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { activeLowFrom, inputActive } from "./access-dingtian.js";
|
||||||
|
|
||||||
|
// Per-input active-level normalisation. The board has ONE resting level, but a radar
|
||||||
|
// can idle opposite the button — listing its terminal in `activeLow` inverts just that
|
||||||
|
// input so "present" reads correctly. See wiki/entities/hikvision-radar.md.
|
||||||
|
|
||||||
|
describe("inputActive (per-input active-level)", () => {
|
||||||
|
const none = new Set<number>();
|
||||||
|
const radarOnI2 = new Set<number>([2]);
|
||||||
|
|
||||||
|
it("default board (resting HIGH): a pull LOW is active, HIGH is rest", () => {
|
||||||
|
// Button on I1, board idles HIGH → active when LOW.
|
||||||
|
expect(inputActive(false, 1, true, none)).toBe(true); // LOW = pressed
|
||||||
|
expect(inputActive(true, 1, true, none)).toBe(false); // HIGH = rest
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resting LOW board: a pull HIGH is active", () => {
|
||||||
|
expect(inputActive(true, 1, false, none)).toBe(true);
|
||||||
|
expect(inputActive(false, 1, false, none)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("active-low override inverts ONLY the listed input", () => {
|
||||||
|
// Board idles HIGH (button on I1), radar on I2 idles HIGH and goes LOW on detect →
|
||||||
|
// mark I2 active-low so detection (LOW) reads active.
|
||||||
|
// I1 (button) keeps the board default:
|
||||||
|
expect(inputActive(false, 1, true, radarOnI2)).toBe(true); // button LOW = active
|
||||||
|
expect(inputActive(true, 1, true, radarOnI2)).toBe(false);
|
||||||
|
// I2 (radar) overridden to active-low: active when LOW.
|
||||||
|
expect(inputActive(false, 2, true, radarOnI2)).toBe(true); // radar LOW = detecting
|
||||||
|
expect(inputActive(true, 2, true, radarOnI2)).toBe(false); // radar HIGH = clear
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("activeLowFrom (config → active-low terminal set)", () => {
|
||||||
|
it("reads a config.inputs[] presence row with activeLow", () => {
|
||||||
|
const set = activeLowFrom({
|
||||||
|
inputs: [
|
||||||
|
{ input: 1, role: "button", relay: 1 },
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar", activeLow: true },
|
||||||
|
{ input: 5, role: "presence", relay: 2, kind: "radar", activeLow: true }, // exit radar
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect([...set].sort()).toEqual([2, 5]); // both radars inverted; the button is not
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still reads the LEGACY relays[].presenceActiveLow (back-compat)", () => {
|
||||||
|
const set = activeLowFrom({
|
||||||
|
relays: [{ relay: 1, direction: "entry", presenceInput: 2, presenceActiveLow: true }],
|
||||||
|
});
|
||||||
|
expect([...set]).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours an explicit top-level inputActiveLow escape hatch + merges all sources", () => {
|
||||||
|
const set = activeLowFrom({
|
||||||
|
inputActiveLow: [3],
|
||||||
|
inputs: [{ input: 2, role: "presence", activeLow: true }],
|
||||||
|
relays: [{ relay: 1, direction: "entry", presenceInput: 4, presenceActiveLow: true }],
|
||||||
|
});
|
||||||
|
expect([...set].sort()).toEqual([2, 3, 4]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { createSocket } from "node:dgram";
|
|||||||
import { request as httpRequest } from "node:http";
|
import { request as httpRequest } from "node:http";
|
||||||
import type {
|
import type {
|
||||||
AccessControlDevice,
|
AccessControlDevice,
|
||||||
|
AuxOutputDevice,
|
||||||
DeviceHealth,
|
DeviceHealth,
|
||||||
HardenableDevice,
|
HardenableDevice,
|
||||||
HardenResult,
|
HardenResult,
|
||||||
@@ -166,6 +167,49 @@ interface DingtianStatus {
|
|||||||
channels: number;
|
channels: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise one input line to "active". `high` = the line is currently HIGH. An input
|
||||||
|
* whose 1-based channel is in `activeLow` is active when LOW (idles HIGH), overriding
|
||||||
|
* the board-wide `restingHigh`; otherwise active = differs from the resting level. This
|
||||||
|
* is the seam that lets a radar (wired opposite the button) read correctly. Exported for
|
||||||
|
* unit testing the bit logic without a UDP socket. See wiki/entities/hikvision-radar.md.
|
||||||
|
*/
|
||||||
|
export function inputActive(
|
||||||
|
high: boolean,
|
||||||
|
channel1Based: number,
|
||||||
|
restingHigh: boolean,
|
||||||
|
activeLow: ReadonlySet<number>,
|
||||||
|
): boolean {
|
||||||
|
return activeLow.has(channel1Based) ? !high : high !== restingHigh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the set of 1-based ACTIVE-LOW input terminals from a controller config. Three
|
||||||
|
* sources, all merged: (a) `config.inputs[]` presence rows with `activeLow:true` (the
|
||||||
|
* first-class model); (b) LEGACY per-relay `presenceActiveLow` (pre-inputs[] configs);
|
||||||
|
* (c) an explicit top-level `inputActiveLow` array (escape hatch). A radar terminal wired
|
||||||
|
* opposite the button idles HIGH, so it must be read inverted. */
|
||||||
|
export function activeLowFrom(config: Record<string, unknown>): Set<number> {
|
||||||
|
const set = new Set<number>();
|
||||||
|
const add = (n: unknown) => {
|
||||||
|
const v = Number(n);
|
||||||
|
if (Number.isInteger(v) && v > 0) set.add(v);
|
||||||
|
};
|
||||||
|
if (Array.isArray(config.inputActiveLow)) {
|
||||||
|
for (const n of config.inputActiveLow as unknown[]) add(n);
|
||||||
|
}
|
||||||
|
if (Array.isArray(config.inputs)) {
|
||||||
|
for (const i of config.inputs as Array<Record<string, unknown>>) {
|
||||||
|
if (i?.role === "presence" && i?.activeLow === true) add(i.input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(config.relays)) {
|
||||||
|
for (const r of config.relays as Array<Record<string, unknown>>) {
|
||||||
|
if (r?.presenceActiveLow === true) add(r.presenceInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
const INPUT_LINK_ISSUE = {
|
const INPUT_LINK_ISSUE = {
|
||||||
key: "input_link_relay",
|
key: "input_link_relay",
|
||||||
message:
|
message:
|
||||||
@@ -223,6 +267,7 @@ function configApi(
|
|||||||
class DingtianController
|
class DingtianController
|
||||||
implements
|
implements
|
||||||
AccessControlDevice,
|
AccessControlDevice,
|
||||||
|
AuxOutputDevice,
|
||||||
InputDevice,
|
InputDevice,
|
||||||
PreconditionDevice,
|
PreconditionDevice,
|
||||||
PushConfigurableDevice,
|
PushConfigurableDevice,
|
||||||
@@ -242,6 +287,13 @@ class DingtianController
|
|||||||
readonly #channels: number;
|
readonly #channels: number;
|
||||||
/** Input level at rest; an input is "active" when it differs from this. */
|
/** Input level at rest; an input is "active" when it differs from this. */
|
||||||
readonly #restingHigh: boolean;
|
readonly #restingHigh: boolean;
|
||||||
|
/** 1-based input terminals whose ACTIVE level is LOW, overriding the board-wide
|
||||||
|
* #restingHigh for just those inputs. A button and a radar can idle oppositely:
|
||||||
|
* the button (NO-to-GND) pulls LOW on press while the board idles HIGH, but a
|
||||||
|
* radar's dry contact may idle LOW and go HIGH on detection. Listing the radar's
|
||||||
|
* terminal here flips its edge so "active" still means "detecting". See
|
||||||
|
* wiki/entities/hikvision-radar.md. */
|
||||||
|
readonly #inputActiveLow: Set<number>;
|
||||||
readonly #pulseMs: number;
|
readonly #pulseMs: number;
|
||||||
/** Device web-UI login user (gates the browser UI only, not the CGI API). */
|
/** Device web-UI login user (gates the browser UI only, not the CGI API). */
|
||||||
readonly #webUser: string;
|
readonly #webUser: string;
|
||||||
@@ -269,6 +321,7 @@ class DingtianController
|
|||||||
this.#channels = config.channels ? Number(config.channels) : 4;
|
this.#channels = config.channels ? Number(config.channels) : 4;
|
||||||
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
|
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
|
||||||
this.#restingHigh = config.inputRestingHigh !== false;
|
this.#restingHigh = config.inputRestingHigh !== false;
|
||||||
|
this.#inputActiveLow = activeLowFrom(config);
|
||||||
this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
|
this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
|
||||||
this.#webUser = config.webUser ? String(config.webUser) : "admin";
|
this.#webUser = config.webUser ? String(config.webUser) : "admin";
|
||||||
// webPassword = the DESIRED login (admin's choice; blank → harden generates).
|
// webPassword = the DESIRED login (admin's choice; blank → harden generates).
|
||||||
@@ -319,6 +372,14 @@ class DingtianController
|
|||||||
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
|
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** AuxOutputDevice: latch a NON-barrier output (e.g. a button lamp) on a spare
|
||||||
|
* relay. Same wire op as setRelay — separated so business logic drives indicators
|
||||||
|
* through the aux capability, never the barrier relay methods. Holding/blinking an
|
||||||
|
* aux output is allowed (it is not a barrier). See button-light-indicator.md. */
|
||||||
|
async setAux(channel: number, on: boolean): Promise<void> {
|
||||||
|
await this.setRelay(channel, on);
|
||||||
|
}
|
||||||
|
|
||||||
async getDoorStatus(doorId: number): Promise<"open" | "closed"> {
|
async getDoorStatus(doorId: number): Promise<"open" | "closed"> {
|
||||||
this.#assertChannel(doorId);
|
this.#assertChannel(doorId);
|
||||||
const { relays } = await this.#status();
|
const { relays } = await this.#status();
|
||||||
@@ -672,8 +733,10 @@ class DingtianController
|
|||||||
for (let i = 0; i < this.#channels; i++) {
|
for (let i = 0; i < this.#channels; i++) {
|
||||||
const high = (inputVal & (1 << i)) !== 0;
|
const high = (inputVal & (1 << i)) !== 0;
|
||||||
relays.push((relayVal & (1 << i)) !== 0);
|
relays.push((relayVal & (1 << i)) !== 0);
|
||||||
// active = differs from the resting level (a press pulls the line).
|
// active = differs from the resting level (a press pulls the line); a terminal in
|
||||||
inputs.push(high !== this.#restingHigh);
|
// inputActiveLow is read inverted (active when LOW) — so a radar wired opposite the
|
||||||
|
// button reads right. See inputActive().
|
||||||
|
inputs.push(inputActive(high, i + 1, this.#restingHigh, this.#inputActiveLow));
|
||||||
}
|
}
|
||||||
return { relays, inputs, channels: this.#channels };
|
return { relays, inputs, channels: this.#channels };
|
||||||
}
|
}
|
||||||
@@ -728,6 +791,19 @@ export const dingtianDriver: AccessDriver = {
|
|||||||
{ key: "binaryPort", label: "Binary protocol port", type: "port", required: false, default: 60000, help: "Dingtian binary protocol UDP port — authenticated relay control (default 60000)." },
|
{ key: "binaryPort", label: "Binary protocol port", type: "port", required: false, default: 60000, help: "Dingtian binary protocol UDP port — authenticated relay control (default 60000)." },
|
||||||
{ key: "httpPort", label: "HTTP config port", type: "port", required: false, default: 80, help: "Device web/config-API port (default 80)." },
|
{ key: "httpPort", label: "HTTP config port", type: "port", required: false, default: 80, help: "Device web/config-API port (default 80)." },
|
||||||
{ key: "channels", label: "Channels (relays/inputs)", type: "number", required: true, default: 4 },
|
{ key: "channels", label: "Channels (relays/inputs)", type: "number", required: true, default: 4 },
|
||||||
|
{
|
||||||
|
// relay_pw — the BINARY-protocol control/status password (NOT the web-UI login
|
||||||
|
// below). Every relay command + the status read embeds it; with the wrong/no
|
||||||
|
// value the device silently ignores the packet → healthCheck times out → the
|
||||||
|
// controller shows "offline" even though it pings. Redacted from the client
|
||||||
|
// (SECRET_CONFIG_KEYS), so it renders as a secret: blank KEEPS the stored value
|
||||||
|
// (the server re-merges it on test/save); type a value to set/change it.
|
||||||
|
key: "relayPassword",
|
||||||
|
label: "Relay control password",
|
||||||
|
type: "secret",
|
||||||
|
required: false,
|
||||||
|
help: "Binary-protocol relay password (relay_pw). Leave blank to keep the current one; a wrong/missing value makes the device ignore commands (Test connection times out).",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "pulseMs",
|
key: "pulseMs",
|
||||||
label: "Pulse open (ms)",
|
label: "Pulse open (ms)",
|
||||||
|
|||||||
@@ -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 type { CameraDriver, ConfigField, DeviceConfig } from "../registry.js";
|
||||||
import { hostField, passwordField, portField, usernameField, stubLog } from "./common.js";
|
import {
|
||||||
|
hostField,
|
||||||
|
passwordField,
|
||||||
|
portField,
|
||||||
|
usernameField,
|
||||||
|
stubLog,
|
||||||
|
} from "./common.js";
|
||||||
import { digestGet } from "./http-digest.js";
|
import { digestGet } from "./http-digest.js";
|
||||||
|
|
||||||
// Camera drivers — entry/exit snapshot-on-event. The host pulls a still over
|
// Camera drivers — entry/exit snapshot-on-event. The host pulls a still over
|
||||||
@@ -15,12 +26,32 @@ import { digestGet } from "./http-digest.js";
|
|||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 8000;
|
const DEFAULT_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
// Hikvision returns HTTP 503 (statusCode 2 / "Device Busy" / subStatus deviceBusy) —
|
||||||
|
// and occasionally 500 — when its snapshot encoder is momentarily occupied (another
|
||||||
|
// snapshot in flight, a stream starting, on-camera VCA). It is TRANSIENT: a retry a
|
||||||
|
// few hundred ms later succeeds. The newer G3H sensors (e.g. DS-2CD1047G3H) hit it
|
||||||
|
// more readily. So a standalone capture retries a few times before giving up; we do
|
||||||
|
// NOT retry config errors (401 auth, 404 path/channel) — those won't self-heal.
|
||||||
|
// (Concurrent same-camera hits are separately de-duped by captureSnapshotShared in
|
||||||
|
// the server.) See wiki/entities/lpr-camera.md ("503 Device Busy").
|
||||||
|
const SNAPSHOT_RETRY_STATUSES = new Set([500, 503]);
|
||||||
|
const SNAPSHOT_MAX_ATTEMPTS = 4;
|
||||||
|
const SNAPSHOT_RETRY_BASE_MS = 250;
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
class HttpCamera implements CameraDevice {
|
class HttpCamera implements CameraDevice {
|
||||||
readonly #host: string;
|
readonly #host: string;
|
||||||
readonly #port: number;
|
readonly #port: number;
|
||||||
readonly #user: string;
|
readonly #user: string;
|
||||||
readonly #password: string;
|
readonly #password: string;
|
||||||
readonly #channel: number;
|
readonly #channel: number;
|
||||||
|
/** Hikvision stream within the channel: 1 = main (high-res), 2 = sub (lighter).
|
||||||
|
* Some models (e.g. the G3H) keep the MAIN encoder saturated and return a
|
||||||
|
* persistent 503 deviceBusy on the main-stream snapshot, while the sub-stream
|
||||||
|
* serves fine — so this is selectable. Ignored by drivers (Dahua) that don't
|
||||||
|
* encode a stream in the path. See wiki/entities/lpr-camera.md ("503 Device Busy"). */
|
||||||
|
readonly #stream: number;
|
||||||
readonly #timeout: number;
|
readonly #timeout: number;
|
||||||
// Source outbound from the device-facing NIC on a multi-homed host (the
|
// Source outbound from the device-facing NIC on a multi-homed host (the
|
||||||
// multi-subnet source-address trap — see wiki/concepts/wsl-dev-networking.md).
|
// multi-subnet source-address trap — see wiki/concepts/wsl-dev-networking.md).
|
||||||
@@ -29,16 +60,20 @@ class HttpCamera implements CameraDevice {
|
|||||||
constructor(
|
constructor(
|
||||||
readonly driverId: string,
|
readonly driverId: string,
|
||||||
config: DeviceConfig,
|
config: DeviceConfig,
|
||||||
/** Builds the snapshot path from the configured channel. */
|
/** Builds the snapshot path from the configured channel + stream (1=main, 2=sub). */
|
||||||
private readonly snapshotPath: (channel: number) => string,
|
private readonly snapshotPath: (channel: number, stream: number) => string,
|
||||||
) {
|
) {
|
||||||
this.#host = String(config.host);
|
this.#host = String(config.host);
|
||||||
this.#port = Number(config.port ?? 80);
|
this.#port = Number(config.port ?? 80);
|
||||||
this.#user = String(config.username ?? "");
|
this.#user = String(config.username ?? "");
|
||||||
this.#password = String(config.password ?? "");
|
this.#password = String(config.password ?? "");
|
||||||
this.#channel = Number(config.channel ?? 1);
|
this.#channel = Number(config.channel ?? 1);
|
||||||
|
// 1 = main, 2 = sub. Clamp to those two; default main for back-compat.
|
||||||
|
this.#stream = Number(config.stream) === 2 ? 2 : 1;
|
||||||
this.#timeout = Number(config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
this.#timeout = Number(config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||||
this.#localAddress = config.localAddress ? String(config.localAddress) : undefined;
|
this.#localAddress = config.localAddress
|
||||||
|
? String(config.localAddress)
|
||||||
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(): Promise<void> {}
|
async connect(): Promise<void> {}
|
||||||
@@ -49,8 +84,13 @@ class HttpCamera implements CameraDevice {
|
|||||||
// frame: it exercises reachability + auth + the path/channel in one shot.
|
// frame: it exercises reachability + auth + the path/channel in one shot.
|
||||||
try {
|
try {
|
||||||
const res = await this.#get();
|
const res = await this.#get();
|
||||||
if (res.status === 200) return { status: "ready", detail: `${res.body.length} bytes` };
|
if (res.status === 200)
|
||||||
if (res.status === 401) return { status: "degraded", detail: "auth rejected (check username/password)" };
|
return { status: "ready", detail: `${res.body.length} bytes` };
|
||||||
|
if (res.status === 401)
|
||||||
|
return {
|
||||||
|
status: "degraded",
|
||||||
|
detail: "auth rejected (check username/password)",
|
||||||
|
};
|
||||||
return { status: "degraded", detail: `HTTP ${res.status}` };
|
return { status: "degraded", detail: `HTTP ${res.status}` };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { status: "offline", detail: (err as Error).message };
|
return { status: "offline", detail: (err as Error).message };
|
||||||
@@ -58,13 +98,37 @@ class HttpCamera implements CameraDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async captureSnapshot(ctx: SnapshotContext): Promise<Snapshot> {
|
async captureSnapshot(ctx: SnapshotContext): Promise<Snapshot> {
|
||||||
const res = await this.#get();
|
// Retry transient "Device Busy" (503/500); a config error (401/404) fails fast.
|
||||||
|
let res = await this.#get();
|
||||||
|
for (
|
||||||
|
let attempt = 1;
|
||||||
|
res.status !== 200 &&
|
||||||
|
SNAPSHOT_RETRY_STATUSES.has(res.status) &&
|
||||||
|
attempt < SNAPSHOT_MAX_ATTEMPTS;
|
||||||
|
attempt++
|
||||||
|
) {
|
||||||
|
// Linear backoff (250/500/750ms) — the encoder frees within a frame or two.
|
||||||
|
await sleep(SNAPSHOT_RETRY_BASE_MS * attempt);
|
||||||
|
stubLog(
|
||||||
|
this.driverId,
|
||||||
|
`captureSnapshot ${ctx.direction} retry ${attempt} (was HTTP ${res.status})`,
|
||||||
|
);
|
||||||
|
res = await this.#get();
|
||||||
|
}
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
|
// Name the busy case so the operator/telemetry can tell "camera busy" from a
|
||||||
|
// real fault (offline / auth / wrong path).
|
||||||
|
const busy = SNAPSHOT_RETRY_STATUSES.has(res.status)
|
||||||
|
? " (device busy)"
|
||||||
|
: "";
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${this.driverId} snapshot failed (${ctx.direction}): HTTP ${res.status}`,
|
`${this.driverId} snapshot failed (${ctx.direction}): HTTP ${res.status}${busy}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
stubLog(this.driverId, `captureSnapshot ${ctx.direction} (${res.body.length} bytes)`);
|
stubLog(
|
||||||
|
this.driverId,
|
||||||
|
`captureSnapshot ${ctx.direction} (${res.body.length} bytes)`,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
bytes: res.body,
|
bytes: res.body,
|
||||||
contentType: res.contentType || "image/jpeg",
|
contentType: res.contentType || "image/jpeg",
|
||||||
@@ -76,7 +140,7 @@ class HttpCamera implements CameraDevice {
|
|||||||
return digestGet({
|
return digestGet({
|
||||||
host: this.#host,
|
host: this.#host,
|
||||||
port: this.#port,
|
port: this.#port,
|
||||||
path: this.snapshotPath(this.#channel),
|
path: this.snapshotPath(this.#channel, this.#stream),
|
||||||
user: this.#user,
|
user: this.#user,
|
||||||
password: this.#password,
|
password: this.#password,
|
||||||
timeoutMs: this.#timeout,
|
timeoutMs: this.#timeout,
|
||||||
@@ -93,7 +157,32 @@ const channelField: ConfigField = {
|
|||||||
default: 1,
|
default: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, channelField];
|
// Hikvision stream-within-channel for the snapshot: main (01) is full-res; sub (02)
|
||||||
|
// is lighter. Default MAIN (back-compat). Switch to SUB when the main encoder is
|
||||||
|
// saturated and returns a persistent 503 deviceBusy (seen on DS-2CD1047G3H-LIU) —
|
||||||
|
// the sub-stream is also the better fit for snapshot/ANPR (smaller, faster, doesn't
|
||||||
|
// contend with live-view/recording). See wiki/entities/lpr-camera.md.
|
||||||
|
const streamField: ConfigField = {
|
||||||
|
key: "stream",
|
||||||
|
label: "Snapshot stream",
|
||||||
|
type: "select",
|
||||||
|
required: false,
|
||||||
|
default: "1",
|
||||||
|
options: [
|
||||||
|
{ value: "1", label: "Main (01)" },
|
||||||
|
{ value: "2", label: "Sub (02)" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dahua has no stream selector (its CGI snapshot isn't stream-encoded in the path).
|
||||||
|
const cameraConfigFields = [
|
||||||
|
hostField,
|
||||||
|
portField(80),
|
||||||
|
usernameField,
|
||||||
|
passwordField,
|
||||||
|
channelField,
|
||||||
|
];
|
||||||
|
const hikvisionConfigFields = [...cameraConfigFields, streamField];
|
||||||
|
|
||||||
// Hikvision "Alarm Server" PUSH config. The newer firmware (Event → Smart/VCA →
|
// Hikvision "Alarm Server" PUSH config. The newer firmware (Event → Smart/VCA →
|
||||||
// "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm Settings →
|
// "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm Settings →
|
||||||
@@ -138,15 +227,21 @@ export const hikvisionDriver: CameraDriver = {
|
|||||||
id: "hikvision",
|
id: "hikvision",
|
||||||
category: "camera",
|
category: "camera",
|
||||||
label: "Hikvision camera",
|
label: "Hikvision camera",
|
||||||
description: "Hikvision snapshot via ISAPI (HTTP Digest) + optional Alarm Server event push.",
|
description:
|
||||||
|
"Hikvision snapshot via ISAPI (HTTP Digest) + optional Alarm Server event push.",
|
||||||
transports: ["tcp-ip"],
|
transports: ["tcp-ip"],
|
||||||
// The camera PULLS snapshots, but with Alarm Server on it ALSO pushes events to us —
|
// The camera PULLS snapshots, but with Alarm Server on it ALSO pushes events to us —
|
||||||
// so it may need the backend push IP at assign time (like the Dingtian).
|
// so it may need the backend push IP at assign time (like the Dingtian).
|
||||||
pushesToBackend: true,
|
pushesToBackend: true,
|
||||||
configFields: [...cameraConfigFields, ...alarmPushFields],
|
configFields: [...hikvisionConfigFields, ...alarmPushFields],
|
||||||
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch2 main = 201.
|
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch1 sub = 102, ch2 main = 201.
|
||||||
|
// stream 1 → "01" (main), 2 → "02" (sub).
|
||||||
create: (c) =>
|
create: (c) =>
|
||||||
new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`),
|
new HttpCamera(
|
||||||
|
"hikvision",
|
||||||
|
c,
|
||||||
|
(ch, stream) => `/ISAPI/Streaming/channels/${ch}0${stream}/picture`,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dahuaDriver: CameraDriver = {
|
export const dahuaDriver: CameraDriver = {
|
||||||
@@ -156,7 +251,12 @@ export const dahuaDriver: CameraDriver = {
|
|||||||
description: "Dahua snapshot via CGI (HTTP Digest).",
|
description: "Dahua snapshot via CGI (HTTP Digest).",
|
||||||
transports: ["tcp-ip"],
|
transports: ["tcp-ip"],
|
||||||
configFields: cameraConfigFields,
|
configFields: cameraConfigFields,
|
||||||
// Dahua channels are 0-based on the CGI; the admin enters 1-based.
|
// Dahua channels are 0-based on the CGI; the admin enters 1-based. No stream in the
|
||||||
|
// path (the second arg is ignored — Dahua has no main/sub snapshot distinction here).
|
||||||
create: (c) =>
|
create: (c) =>
|
||||||
new HttpCamera("dahua", c, (ch) => `/cgi-bin/snapshot.cgi?channel=${Math.max(0, ch - 1)}`),
|
new HttpCamera(
|
||||||
|
"dahua",
|
||||||
|
c,
|
||||||
|
(ch) => `/cgi-bin/snapshot.cgi?channel=${Math.max(0, ch - 1)}`,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
||||||
import { hostField, portField, stubLog } from "./common.js";
|
import { hostField, portField, stubLog } from "./common.js";
|
||||||
import {
|
import {
|
||||||
probe,
|
devicePathField,
|
||||||
|
probeTo,
|
||||||
renderReceipt,
|
renderReceipt,
|
||||||
renderReport,
|
renderReport,
|
||||||
renderSubscriptionCard,
|
renderSubscriptionCard,
|
||||||
renderTicket,
|
renderTicket,
|
||||||
renderWindowChargeNotice,
|
renderWindowChargeNotice,
|
||||||
sendRaw,
|
sendTo,
|
||||||
|
transportField,
|
||||||
|
transportFromConfig,
|
||||||
|
type Transport,
|
||||||
} from "./printer-escpos.js";
|
} from "./printer-escpos.js";
|
||||||
|
|
||||||
// Cashino 80mm network thermal printer driver. The Cashino is an ESC/POS clone:
|
// Cashino 80mm thermal printer driver (network OR USB). The Cashino is an ESC/POS
|
||||||
// it PRINTS identically to the Rongta (same byte stream — see ./printer-escpos.ts),
|
// clone: it PRINTS identically to the Rongta (same byte stream — see
|
||||||
// so tickets, reports and subscription cards render the same. What it does NOT
|
// ./printer-escpos.ts), so tickets, reports and subscription cards render the same,
|
||||||
// have is the Rongta board's decoded status web page (/prn_stat.htm). It cannot
|
// over either transport. What it does NOT have is the Rongta board's decoded status
|
||||||
// report paper-out / cover-open / cutter faults in a form we trust.
|
// web page (/prn_stat.htm). It cannot report paper-out / cover-open / cutter faults
|
||||||
|
// in a form we trust.
|
||||||
|
//
|
||||||
|
// TRANSPORT: a single `config.transport` ("tcp-ip" | "usb") picks the wire; the
|
||||||
|
// driver resolves it ONCE into a Transport and every print/probe stays transport-
|
||||||
|
// blind (see transportFromConfig/sendTo/probeTo). USB writes the same bytes to a
|
||||||
|
// local usblp char device (/dev/usb/lp0); TCP writes to the raw print socket. This
|
||||||
|
// clone is the natural USB candidate — reachability-only, no status page to lose.
|
||||||
//
|
//
|
||||||
// Therefore this driver deliberately does NOT implement MonitorableDevice
|
// Therefore this driver deliberately does NOT implement MonitorableDevice
|
||||||
// (no readStatus). The device monitor then falls back to the generic
|
// (no readStatus). The device monitor then falls back to the generic
|
||||||
@@ -40,13 +51,11 @@ import {
|
|||||||
|
|
||||||
class CashinoPrinter implements PrinterDevice {
|
class CashinoPrinter implements PrinterDevice {
|
||||||
readonly driverId = "cashino";
|
readonly driverId = "cashino";
|
||||||
readonly #host: string;
|
readonly #transport: Transport;
|
||||||
readonly #port: number;
|
|
||||||
readonly #timeout: number;
|
readonly #timeout: number;
|
||||||
|
|
||||||
constructor(config: DeviceConfig) {
|
constructor(config: DeviceConfig) {
|
||||||
this.#host = String(config.host);
|
this.#transport = transportFromConfig(config);
|
||||||
this.#port = config.port ? Number(config.port) : 9100;
|
|
||||||
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
|
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,15 +68,15 @@ class CashinoPrinter implements PrinterDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reachability only — a TCP connect probe of the raw print socket. The Cashino
|
* Reachability only — a connect probe (TCP) or char-device open probe (USB) of
|
||||||
* has no trustworthy status protocol, so this is the floor and the ceiling of
|
* the print path. The Cashino has no trustworthy status protocol, so this is the
|
||||||
* what we report: reachable → ready, unreachable → offline. Deliberately NO
|
* floor and the ceiling of what we report: reachable → ready, unreachable →
|
||||||
* readStatus(): the monitor uses this for the traffic-light, never a guessed
|
* offline. Deliberately NO readStatus(): the monitor uses this for the
|
||||||
* paper/cover state.
|
* traffic-light, never a guessed paper/cover state.
|
||||||
*/
|
*/
|
||||||
async healthCheck(): Promise<DeviceHealth> {
|
async healthCheck(): Promise<DeviceHealth> {
|
||||||
try {
|
try {
|
||||||
await probe(this.#host, this.#port, this.#timeout);
|
await probeTo(this.#transport, this.#timeout);
|
||||||
return { status: "ready" };
|
return { status: "ready" };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { status: "offline", detail: (err as Error).message };
|
return { status: "offline", detail: (err as Error).message };
|
||||||
@@ -75,12 +84,12 @@ class CashinoPrinter implements PrinterDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async printTicket(data: TicketData): Promise<void> {
|
async printTicket(data: TicketData): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
|
await sendTo(this.#transport, renderTicket(data), this.#timeout);
|
||||||
stubLog(this.driverId, `printed ticket ${data.ticketId}`);
|
stubLog(this.driverId, `printed ticket ${data.ticketId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async printReport(report: PrintReport): Promise<void> {
|
async printReport(report: PrintReport): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout);
|
await sendTo(this.#transport, renderReport(report), this.#timeout);
|
||||||
stubLog(
|
stubLog(
|
||||||
this.driverId,
|
this.driverId,
|
||||||
`printed report "${report.title}" (${report.lines.length} lines)`,
|
`printed report "${report.title}" (${report.lines.length} lines)`,
|
||||||
@@ -88,17 +97,12 @@ class CashinoPrinter implements PrinterDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
|
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
|
||||||
await sendRaw(
|
await sendTo(this.#transport, renderSubscriptionCard(data), this.#timeout);
|
||||||
this.#host,
|
|
||||||
this.#port,
|
|
||||||
renderSubscriptionCard(data),
|
|
||||||
this.#timeout,
|
|
||||||
);
|
|
||||||
stubLog(this.driverId, `printed subscription card ${data.code}`);
|
stubLog(this.driverId, `printed subscription card ${data.code}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async printReceipt(data: ReceiptData): Promise<void> {
|
async printReceipt(data: ReceiptData): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout);
|
await sendTo(this.#transport, renderReceipt(data), this.#timeout);
|
||||||
stubLog(
|
stubLog(
|
||||||
this.driverId,
|
this.driverId,
|
||||||
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
|
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
|
||||||
@@ -106,7 +110,7 @@ class CashinoPrinter implements PrinterDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
|
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderWindowChargeNotice(data), this.#timeout);
|
await sendTo(this.#transport, renderWindowChargeNotice(data), this.#timeout);
|
||||||
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
|
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,14 +145,17 @@ export const cashinoDriver: PrinterDriver = {
|
|||||||
category: "printer",
|
category: "printer",
|
||||||
label: "Cashino 80mm thermal printer",
|
label: "Cashino 80mm thermal printer",
|
||||||
description:
|
description:
|
||||||
"Cashino 80mm thermal printer (ESC/POS over raw TCP, port 9100). Prints like the Rongta but has no status page — monitored by reachability ping only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
|
"Cashino 80mm thermal printer (ESC/POS over raw TCP port 9100, OR local USB /dev/usb/lp0). Prints like the Rongta but has no status page — monitored by reachability only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
|
||||||
transports: ["tcp-ip"],
|
transports: ["tcp-ip", "usb"],
|
||||||
configFields: [
|
configFields: [
|
||||||
hostField,
|
transportField,
|
||||||
|
devicePathField,
|
||||||
|
// host/port are TCP-only; not required because a USB printer needs neither.
|
||||||
|
{ ...hostField, required: false, help: `${hostField.help} Leave blank for a USB printer.` },
|
||||||
{
|
{
|
||||||
...portField(9100),
|
...portField(9100),
|
||||||
required: false,
|
required: false,
|
||||||
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).",
|
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100). TCP only.",
|
||||||
},
|
},
|
||||||
roleField,
|
roleField,
|
||||||
rankField,
|
rankField,
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
import {
|
import {
|
||||||
renderTicket,
|
renderTicket,
|
||||||
renderReceipt,
|
renderReceipt,
|
||||||
renderWindowChargeNotice,
|
renderWindowChargeNotice,
|
||||||
renderSubscriptionCard,
|
renderSubscriptionCard,
|
||||||
|
probeUsb,
|
||||||
|
sendRawUsb,
|
||||||
|
transportFromConfig,
|
||||||
stamp,
|
stamp,
|
||||||
} from "./printer-escpos.js";
|
} from "./printer-escpos.js";
|
||||||
|
|
||||||
@@ -103,6 +109,69 @@ describe("CP852 character mapping (the misprint fixes)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("USB transport (sendRawUsb / probeUsb / transportFromConfig)", () => {
|
||||||
|
// A regular file stands in for the usblp character device: open(O_WRONLY) + write
|
||||||
|
// is the same syscall path. This proves the transport is byte-blind — the EXACT
|
||||||
|
// ESC/POS stream renderTicket produces lands at the device path, with no transport
|
||||||
|
// touching a rendered byte (the whole point of the seam).
|
||||||
|
let dir: string;
|
||||||
|
let devicePath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), "escpos-usb-"));
|
||||||
|
devicePath = join(dir, "lp0");
|
||||||
|
// A real usblp node already EXISTS (created by the kernel on enumeration); we open
|
||||||
|
// it O_WRONLY without O_CREAT, never create it. Pre-create the stand-in file so the
|
||||||
|
// test mirrors that — opening an ABSENT path means "printer not present" (offline).
|
||||||
|
writeFileSync(devicePath, "");
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the exact rendered ESC/POS bytes to the device path", async () => {
|
||||||
|
const payload = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" });
|
||||||
|
await sendRawUsb(devicePath, payload, 1000);
|
||||||
|
const written = readFileSync(devicePath);
|
||||||
|
expect(written.equals(payload)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the device path can't be opened (printer not present)", async () => {
|
||||||
|
await expect(
|
||||||
|
sendRawUsb(join(dir, "absent-lp0"), Buffer.from([0x1b, 0x40]), 1000),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("probeUsb resolves for an existing node, rejects for a missing one", async () => {
|
||||||
|
await expect(probeUsb(devicePath, 1000)).resolves.toBeUndefined();
|
||||||
|
await expect(probeUsb(join(dir, "nope"), 1000)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transportFromConfig: transport=usb selects the char device (default /dev/usb/lp0)", () => {
|
||||||
|
expect(transportFromConfig({ transport: "usb", devicePath: "/dev/usb/lp1" })).toEqual({
|
||||||
|
kind: "usb",
|
||||||
|
devicePath: "/dev/usb/lp1",
|
||||||
|
});
|
||||||
|
expect(transportFromConfig({ transport: "usb" })).toEqual({
|
||||||
|
kind: "usb",
|
||||||
|
devicePath: "/dev/usb/lp0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transportFromConfig: anything else is TCP (back-compat with host-only configs)", () => {
|
||||||
|
expect(transportFromConfig({ host: "10.0.0.9" })).toEqual({
|
||||||
|
kind: "tcp",
|
||||||
|
host: "10.0.0.9",
|
||||||
|
port: 9100,
|
||||||
|
});
|
||||||
|
expect(transportFromConfig({ host: "10.0.0.9", port: 9101 })).toEqual({
|
||||||
|
kind: "tcp",
|
||||||
|
host: "10.0.0.9",
|
||||||
|
port: 9101,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("stamp (Albanian date format)", () => {
|
describe("stamp (Albanian date format)", () => {
|
||||||
it("formats an ISO time as '<day> <Month> <year> HH:MM:SS'", () => {
|
it("formats an ISO time as '<day> <Month> <year> HH:MM:SS'", () => {
|
||||||
// Local-time dependent, so assert the structure + the Albanian month name.
|
// Local-time dependent, so assert the structure + the Albanian month name.
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Socket } from "node:net";
|
import { Socket } from "node:net";
|
||||||
|
import { open } from "node:fs/promises";
|
||||||
|
import { constants as FS } from "node:fs";
|
||||||
import type {
|
import type {
|
||||||
PrintReport,
|
PrintReport,
|
||||||
ReceiptData,
|
ReceiptData,
|
||||||
@@ -567,7 +569,150 @@ export function probe(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- USB transport (kernel usblp character device) ----------------------------
|
||||||
|
// An ESC/POS USB printer plugged into the appliance enumerates as a character
|
||||||
|
// device (e.g. /dev/usb/lp0) via the in-box `usblp` kernel driver. We deliver the
|
||||||
|
// SAME ESC/POS byte stream there as over TCP — only the transport differs, not a
|
||||||
|
// single rendered byte. No libusb / CUPS / native addon: a plain file write keeps
|
||||||
|
// the MIT-only + offline-first, minimal-deps appliance constraints, and the path is
|
||||||
|
// a LOCAL char device the booth operator (the threat model's adversary) can't reach
|
||||||
|
// over the network. Paper/cover is NOT sensed here — same honesty floor as the
|
||||||
|
// Cashino TCP probe. usblp + a udev rule granting the server write access to the
|
||||||
|
// node are a provisioning dependency. See wiki/concepts/printer-usb-transport.md.
|
||||||
|
|
||||||
|
/** Bound a promise with a timeout — a wedged USB printer can block a write (or even
|
||||||
|
* the open) indefinitely, and a stuck print must surface as a failure rather than
|
||||||
|
* hang the entry flow. The underlying handle leaks on timeout, but the process is
|
||||||
|
* the appliance server; a failed print is logged and retried/failed-over upstream. */
|
||||||
|
function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const t = setTimeout(() => reject(new Error(msg)), ms);
|
||||||
|
p.then(
|
||||||
|
(v) => {
|
||||||
|
clearTimeout(t);
|
||||||
|
resolve(v);
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
clearTimeout(t);
|
||||||
|
reject(e as Error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
|
||||||
|
* is a RAW character device: a single open + write delivers the job — there is no
|
||||||
|
* FIN/half-close dance (that was a TCP concern, where an early destroy() could
|
||||||
|
* truncate the stream). We always close the handle (even on a failed write). */
|
||||||
|
export async function sendRawUsb(
|
||||||
|
devicePath: string,
|
||||||
|
payload: Buffer,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const handle = await withTimeout(
|
||||||
|
open(devicePath, FS.O_WRONLY | FS.O_NONBLOCK),
|
||||||
|
timeoutMs,
|
||||||
|
"usb open timeout",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await withTimeout(handle.write(payload), timeoutMs, "usb write timeout");
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reachability for a USB printer: the floor is "does the char device exist and
|
||||||
|
* open writable". A present, openable /dev/usb/lp0 means usblp bound a powered,
|
||||||
|
* enumerated printer — the USB analogue of the TCP connect probe. (Like the Cashino
|
||||||
|
* TCP probe, this reports reachability only, never a guessed paper/cover state.) */
|
||||||
|
export async function probeUsb(devicePath: string, timeoutMs: number): Promise<void> {
|
||||||
|
const handle = await withTimeout(
|
||||||
|
open(devicePath, FS.O_WRONLY | FS.O_NONBLOCK),
|
||||||
|
timeoutMs,
|
||||||
|
"usb open timeout",
|
||||||
|
);
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- transport dispatch -------------------------------------------------------
|
||||||
|
// A discriminated transport so each driver resolves the wire ONCE (from config) and
|
||||||
|
// every print/probe call site stays transport-blind. Adding a transport = one more
|
||||||
|
// arm here + the render layer is untouched.
|
||||||
|
|
||||||
|
/** Where a printer's bytes go: a TCP raw-print socket, or a local USB char device. */
|
||||||
|
export type Transport =
|
||||||
|
| { kind: "tcp"; host: string; port: number }
|
||||||
|
| { kind: "usb"; devicePath: string };
|
||||||
|
|
||||||
|
/** Build a Transport from a driver's flat config. `transport: "usb"` selects the
|
||||||
|
* USB char device (`devicePath`, default /dev/usb/lp0); anything else is TCP
|
||||||
|
* (host + port, default 9100) — so existing network configs with no `transport`
|
||||||
|
* key keep working unchanged. */
|
||||||
|
export function transportFromConfig(config: {
|
||||||
|
transport?: unknown;
|
||||||
|
host?: unknown;
|
||||||
|
port?: unknown;
|
||||||
|
devicePath?: unknown;
|
||||||
|
}): Transport {
|
||||||
|
if (config.transport === "usb") {
|
||||||
|
return { kind: "usb", devicePath: String(config.devicePath ?? "/dev/usb/lp0") };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "tcp",
|
||||||
|
host: String(config.host),
|
||||||
|
port: config.port ? Number(config.port) : 9100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send an ESC/POS payload over whichever transport the printer is configured for. */
|
||||||
|
export function sendTo(t: Transport, payload: Buffer, timeoutMs: number): Promise<void> {
|
||||||
|
return t.kind === "usb"
|
||||||
|
? sendRawUsb(t.devicePath, payload, timeoutMs)
|
||||||
|
: sendRaw(t.host, t.port, payload, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reachability probe over whichever transport the printer is configured for. */
|
||||||
|
export function probeTo(t: Transport, timeoutMs: number): Promise<void> {
|
||||||
|
return t.kind === "usb"
|
||||||
|
? probeUsb(t.devicePath, timeoutMs)
|
||||||
|
: probe(t.host, t.port, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human label for a transport, for status detail / logs. */
|
||||||
|
export function transportLabel(t: Transport): string {
|
||||||
|
return t.kind === "usb" ? t.devicePath : `${t.host}:${t.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
// --- shared driver config fields ----------------------------------------------
|
// --- shared driver config fields ----------------------------------------------
|
||||||
// Role + failover are identical across ESC/POS printers; defined here so each
|
// Role + failover are identical across ESC/POS printers; defined here so each
|
||||||
// driver shares them. See wiki/concepts/printer-roles-failover.md.
|
// driver shares them. See wiki/concepts/printer-roles-failover.md.
|
||||||
export type PrinterRole = "entry-dispenser" | "booth-receipt";
|
export type PrinterRole = "entry-dispenser" | "booth-receipt";
|
||||||
|
|
||||||
|
// --- shared printer config fields (transport) ---------------------------------
|
||||||
|
// TCP-or-USB is offered identically across the ESC/POS drivers; defined here so each
|
||||||
|
// shares the exact field set. The setup wizard renders these generically.
|
||||||
|
import type { ConfigField } from "../registry.js";
|
||||||
|
|
||||||
|
/** Connection-transport select: network (raw TCP 9100) or local USB char device. */
|
||||||
|
export const transportField: ConfigField = {
|
||||||
|
key: "transport",
|
||||||
|
label: "Connection",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
default: "tcp-ip",
|
||||||
|
options: [
|
||||||
|
{ value: "tcp-ip", label: "Network (raw TCP, port 9100)" },
|
||||||
|
{ value: "usb", label: "USB (local /dev/usb/lp0)" },
|
||||||
|
],
|
||||||
|
help: "USB drives a printer plugged into the appliance (usblp); Network drives one on the isolated device VLAN.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** USB character-device path; used only when transport=usb (ignored for TCP). */
|
||||||
|
export const devicePathField: ConfigField = {
|
||||||
|
key: "devicePath",
|
||||||
|
label: "USB device",
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
default: "/dev/usb/lp0",
|
||||||
|
help: "Character device for a USB printer (usblp), e.g. /dev/usb/lp0. Only used when Connection is USB.",
|
||||||
|
};
|
||||||
|
|||||||
@@ -13,22 +13,28 @@ import type {
|
|||||||
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
||||||
import { hostField, portField, stubLog } from "./common.js";
|
import { hostField, portField, stubLog } from "./common.js";
|
||||||
import {
|
import {
|
||||||
probe,
|
devicePathField,
|
||||||
|
probeTo,
|
||||||
renderReceipt,
|
renderReceipt,
|
||||||
renderReport,
|
renderReport,
|
||||||
renderSubscriptionCard,
|
renderSubscriptionCard,
|
||||||
renderTicket,
|
renderTicket,
|
||||||
renderWindowChargeNotice,
|
renderWindowChargeNotice,
|
||||||
sendRaw,
|
sendTo,
|
||||||
|
transportField,
|
||||||
|
transportFromConfig,
|
||||||
|
type Transport,
|
||||||
} from "./printer-escpos.js";
|
} from "./printer-escpos.js";
|
||||||
|
|
||||||
// Rongta 80mm network thermal printer driver. Rongta RP-series printers (and the
|
// Rongta 80mm thermal printer driver (network OR USB). Rongta RP-series printers
|
||||||
// many OEM clones that share their firmware) speak ESC/POS over a raw TCP socket
|
// (and the many OEM clones that share their firmware) speak ESC/POS over a raw TCP
|
||||||
// on port 9100 — the JetDirect/RAW convention. The ESC/POS rendering + transport
|
// socket on port 9100 — the JetDirect/RAW convention — or over a local USB usblp
|
||||||
// are shared with the other ESC/POS clones in ./printer-escpos.ts; what is unique
|
// char device. The ESC/POS rendering + transport are shared with the other ESC/POS
|
||||||
// to Rongta — and lives here — is LIVE STATUS via the board's own status web page.
|
// clones in ./printer-escpos.ts (config.transport picks the wire); what is unique to
|
||||||
// There is no auth on the print socket; like the other field devices it lives on
|
// Rongta — and lives here — is LIVE STATUS via the board's own status web page. That
|
||||||
// the isolated device VLAN.
|
// page is a NETWORK feature: a USB Rongta degrades to reachability-only monitoring
|
||||||
|
// (see readStatus). There is no auth on the print socket; like the other field
|
||||||
|
// devices a networked unit lives on the isolated device VLAN.
|
||||||
// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md.
|
// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md.
|
||||||
//
|
//
|
||||||
// ROLES + FAILOVER: a lane has more than one printer. Each instance declares a
|
// ROLES + FAILOVER: a lane has more than one printer. Each instance declares a
|
||||||
@@ -128,14 +134,15 @@ function parseStatusPage(html: string): StatusFlags {
|
|||||||
|
|
||||||
class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||||
readonly driverId = "rongta";
|
readonly driverId = "rongta";
|
||||||
|
readonly #transport: Transport;
|
||||||
readonly #host: string;
|
readonly #host: string;
|
||||||
readonly #port: number;
|
|
||||||
readonly #httpPort: number;
|
readonly #httpPort: number;
|
||||||
readonly #timeout: number;
|
readonly #timeout: number;
|
||||||
|
|
||||||
constructor(config: DeviceConfig) {
|
constructor(config: DeviceConfig) {
|
||||||
this.#host = String(config.host);
|
this.#transport = transportFromConfig(config);
|
||||||
this.#port = config.port ? Number(config.port) : 9100;
|
// Kept for the HTTP status page (TCP only); empty on a USB printer.
|
||||||
|
this.#host = config.host ? String(config.host) : "";
|
||||||
this.#httpPort = config.httpPort ? Number(config.httpPort) : 80;
|
this.#httpPort = config.httpPort ? Number(config.httpPort) : 80;
|
||||||
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
|
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
|
||||||
}
|
}
|
||||||
@@ -150,7 +157,7 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
|||||||
|
|
||||||
async healthCheck(): Promise<DeviceHealth> {
|
async healthCheck(): Promise<DeviceHealth> {
|
||||||
try {
|
try {
|
||||||
await probe(this.#host, this.#port, this.#timeout);
|
await probeTo(this.#transport, this.#timeout);
|
||||||
return { status: "ready" };
|
return { status: "ready" };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { status: "offline", detail: (err as Error).message };
|
return { status: "offline", detail: (err as Error).message };
|
||||||
@@ -158,12 +165,12 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async printTicket(data: TicketData): Promise<void> {
|
async printTicket(data: TicketData): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
|
await sendTo(this.#transport, renderTicket(data), this.#timeout);
|
||||||
stubLog(this.driverId, `printed ticket ${data.ticketId}`);
|
stubLog(this.driverId, `printed ticket ${data.ticketId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async printReport(report: PrintReport): Promise<void> {
|
async printReport(report: PrintReport): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout);
|
await sendTo(this.#transport, renderReport(report), this.#timeout);
|
||||||
stubLog(
|
stubLog(
|
||||||
this.driverId,
|
this.driverId,
|
||||||
`printed report "${report.title}" (${report.lines.length} lines)`,
|
`printed report "${report.title}" (${report.lines.length} lines)`,
|
||||||
@@ -171,17 +178,12 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
|
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
|
||||||
await sendRaw(
|
await sendTo(this.#transport, renderSubscriptionCard(data), this.#timeout);
|
||||||
this.#host,
|
|
||||||
this.#port,
|
|
||||||
renderSubscriptionCard(data),
|
|
||||||
this.#timeout,
|
|
||||||
);
|
|
||||||
stubLog(this.driverId, `printed subscription card ${data.code}`);
|
stubLog(this.driverId, `printed subscription card ${data.code}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async printReceipt(data: import("../interfaces.js").ReceiptData): Promise<void> {
|
async printReceipt(data: import("../interfaces.js").ReceiptData): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout);
|
await sendTo(this.#transport, renderReceipt(data), this.#timeout);
|
||||||
stubLog(
|
stubLog(
|
||||||
this.driverId,
|
this.driverId,
|
||||||
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
|
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
|
||||||
@@ -189,7 +191,7 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
|
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderWindowChargeNotice(data), this.#timeout);
|
await sendTo(this.#transport, renderWindowChargeNotice(data), this.#timeout);
|
||||||
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
|
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +208,18 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
|||||||
*/
|
*/
|
||||||
async readStatus(): Promise<PrinterStatus> {
|
async readStatus(): Promise<PrinterStatus> {
|
||||||
const checkedAt = new Date().toISOString();
|
const checkedAt = new Date().toISOString();
|
||||||
|
// The status page is an HTTP feature of the network board; a USB printer has no
|
||||||
|
// such page. Degrade to the reachability floor (open the char device) and report
|
||||||
|
// ready/offline only — never a guessed paper/cover state, same honesty rule as
|
||||||
|
// the Cashino. (A USB Rongta is effectively a Cashino for monitoring purposes.)
|
||||||
|
if (this.#transport.kind === "usb") {
|
||||||
|
try {
|
||||||
|
await probeTo(this.#transport, this.#timeout);
|
||||||
|
return { status: "ready", checkedAt };
|
||||||
|
} catch (err) {
|
||||||
|
return { status: "offline", detail: (err as Error).message, checkedAt };
|
||||||
|
}
|
||||||
|
}
|
||||||
let html: string;
|
let html: string;
|
||||||
try {
|
try {
|
||||||
html = await fetchStatusPage(this.#host, this.#httpPort, this.#timeout);
|
html = await fetchStatusPage(this.#host, this.#httpPort, this.#timeout);
|
||||||
@@ -281,14 +295,17 @@ export const rongtaDriver: PrinterDriver = {
|
|||||||
category: "printer",
|
category: "printer",
|
||||||
label: "Rongta 80mm thermal printer",
|
label: "Rongta 80mm thermal printer",
|
||||||
description:
|
description:
|
||||||
"Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones that serve the /prn_stat.htm status page) over raw TCP (port 9100). No auth on the print socket — isolate the VLAN.",
|
"Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones that serve the /prn_stat.htm status page) over raw TCP (port 9100), OR local USB /dev/usb/lp0. The decoded status page is a network feature — a USB Rongta is monitored by reachability only. No auth on the print socket — isolate the VLAN.",
|
||||||
transports: ["tcp-ip"],
|
transports: ["tcp-ip", "usb"],
|
||||||
configFields: [
|
configFields: [
|
||||||
hostField,
|
transportField,
|
||||||
|
devicePathField,
|
||||||
|
// host/port/status-page are TCP-only; not required for a USB printer.
|
||||||
|
{ ...hostField, required: false, help: `${hostField.help} Leave blank for a USB printer.` },
|
||||||
{
|
{
|
||||||
...portField(9100),
|
...portField(9100),
|
||||||
required: false,
|
required: false,
|
||||||
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).",
|
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100). TCP only.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "httpPort",
|
key: "httpPort",
|
||||||
@@ -296,7 +313,7 @@ export const rongtaDriver: PrinterDriver = {
|
|||||||
type: "port",
|
type: "port",
|
||||||
required: false,
|
required: false,
|
||||||
default: 80,
|
default: 80,
|
||||||
help: "Device status page (/prn_stat.htm) port for live monitoring (default 80).",
|
help: "Device status page (/prn_stat.htm) port for live monitoring (default 80). TCP only.",
|
||||||
},
|
},
|
||||||
roleField,
|
roleField,
|
||||||
rankField,
|
rankField,
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Reader health: push-only QR readers expose no TCP port, so liveness is an ICMP
|
||||||
|
// ping of the (optional) configured IP. With no IP we must NOT claim "ready" (the old
|
||||||
|
// stub did, hiding offline readers behind a green dot) — we report degraded instead.
|
||||||
|
// icmpPing is mocked so the test is deterministic + offline.
|
||||||
|
|
||||||
|
const icmpPing = vi.fn<(host: string, timeoutMs?: number) => Promise<boolean>>();
|
||||||
|
vi.mock("./icmp.js", () => ({ icmpPing: (...a: [string, number?]) => icmpPing(...a) }));
|
||||||
|
|
||||||
|
const { geeQrReaderDriver } = await import("./reader.js");
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
icmpPing.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("QR reader healthCheck (ICMP liveness)", () => {
|
||||||
|
it("with an IP that replies → ready", async () => {
|
||||||
|
icmpPing.mockResolvedValue(true);
|
||||||
|
const r = geeQrReaderDriver.create({ serial: "H05M2AFA", host: "10.0.10.7" });
|
||||||
|
expect(await r.healthCheck()).toEqual({ status: "ready", detail: "ping 10.0.10.7" });
|
||||||
|
expect(icmpPing).toHaveBeenCalledWith("10.0.10.7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("with an IP that does NOT reply → offline (this is the bug fix)", async () => {
|
||||||
|
icmpPing.mockResolvedValue(false);
|
||||||
|
const r = geeQrReaderDriver.create({ serial: "H05M2AFA", host: "10.0.10.7" });
|
||||||
|
expect(await r.healthCheck()).toEqual({ status: "offline", detail: "no ping reply from 10.0.10.7" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("with NO IP → degraded (never a false 'ready')", async () => {
|
||||||
|
const r = geeQrReaderDriver.create({ serial: "H05M2AFA" });
|
||||||
|
const h = await r.healthCheck();
|
||||||
|
expect(h.status).toBe("degraded");
|
||||||
|
expect(icmpPing).not.toHaveBeenCalled(); // nothing to ping
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes an optional host field for monitoring", () => {
|
||||||
|
const hostField = geeQrReaderDriver.configFields.find((f) => f.key === "host");
|
||||||
|
expect(hostField).toBeDefined();
|
||||||
|
expect(hostField!.required).toBe(false); // operation is push-by-serial; IP is monitor-only
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { DeviceHealth, ReaderDevice, ReaderEvent } from "../interfaces.js";
|
import type { DeviceHealth, ReaderDevice, ReaderEvent } from "../interfaces.js";
|
||||||
import type { DeviceConfig, ReaderDriver } from "../registry.js";
|
import type { DeviceConfig, ReaderDriver } from "../registry.js";
|
||||||
import { hostField, portField, stubLog } from "./common.js";
|
import { hostField, portField, stubLog } from "./common.js";
|
||||||
|
import { icmpPing } from "./icmp.js";
|
||||||
|
|
||||||
// Reader drivers (RF / optical). Two integration paths: Wiegand reads reach the
|
// Reader drivers (RF / optical). Two integration paths: Wiegand reads reach the
|
||||||
// access controller directly (autonomous); TCP-IP readers are seen host-side.
|
// access controller directly (autonomous); TCP-IP readers are seen host-side.
|
||||||
@@ -18,8 +19,23 @@ class StubReader implements ReaderDevice {
|
|||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
stubLog(this.driverId, "disconnect");
|
stubLog(this.driverId, "disconnect");
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Liveness. These readers PUSH (scan → GET our backend) and expose no TCP port, so
|
||||||
|
* there's nothing to connect-probe. If the admin gave the reader's IP we ICMP-ping
|
||||||
|
* it (powered + on-network); a reply → ready, no reply → offline. With NO IP we
|
||||||
|
* report `degraded` ("set IP to monitor") rather than a false `ready` — a push
|
||||||
|
* device that's silent is indistinguishable from a dead one, so claiming `ready`
|
||||||
|
* unconditionally (the old behaviour) hid offline readers behind a green dot.
|
||||||
|
*/
|
||||||
async healthCheck(): Promise<DeviceHealth> {
|
async healthCheck(): Promise<DeviceHealth> {
|
||||||
return { status: "ready", detail: "stub" };
|
const host = this.config.host ? String(this.config.host) : "";
|
||||||
|
if (!host) {
|
||||||
|
return { status: "degraded", detail: "push device — set IP to monitor" };
|
||||||
|
}
|
||||||
|
const alive = await icmpPing(host);
|
||||||
|
return alive
|
||||||
|
? { status: "ready", detail: `ping ${host}` }
|
||||||
|
: { status: "offline", detail: `no ping reply from ${host}` };
|
||||||
}
|
}
|
||||||
onRead(cb: (r: ReaderEvent) => void): void {
|
onRead(cb: (r: ReaderEvent) => void): void {
|
||||||
this.#cb = cb;
|
this.#cb = cb;
|
||||||
@@ -80,6 +96,14 @@ export const geeQrReaderDriver: ReaderDriver = {
|
|||||||
required: true,
|
required: true,
|
||||||
help: "The reader's serial as it reports in each scan (the `cjihao` field). Used to map scans to this lane.",
|
help: "The reader's serial as it reports in each scan (the `cjihao` field). Used to map scans to this lane.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// OPTIONAL: the reader pushes by serial (operation needs no IP), but giving its
|
||||||
|
// IP lets the status monitor ICMP-ping it for a real online/offline dot instead
|
||||||
|
// of an always-green stub. Leave blank to skip monitoring (shows "set IP").
|
||||||
|
...hostField,
|
||||||
|
required: false,
|
||||||
|
help: "Optional: the reader's IP, used ONLY to monitor it (ping). Scans still resolve by serial. Leave blank to skip liveness monitoring.",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
create: (c) => new StubReader("gee-qr-reader", c),
|
create: (c) => new StubReader("gee-qr-reader", c),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,6 +35,23 @@ export interface AccessControlDevice extends Device {
|
|||||||
getDoorStatus(doorId: number): Promise<"open" | "closed">;
|
getDoorStatus(doorId: number): Promise<"open" | "closed">;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Auxiliary outputs (non-barrier latched signals) ---------------------
|
||||||
|
// Optional capability for controllers with SPARE relays wired to something that
|
||||||
|
// is NOT a barrier — a button lamp, a "wait"/"go" sign. setAux LATCHES the output
|
||||||
|
// on or off and holds it (unlike pulseOpen, which is momentary). The
|
||||||
|
// barrier-not-a-door rule does NOT apply here: this output never gates a vehicle,
|
||||||
|
// so holding/blinking it is fine. Business logic drives indicators through THIS,
|
||||||
|
// never the driver's own relay methods. See wiki/concepts/button-light-indicator.md.
|
||||||
|
export interface AuxOutputDevice {
|
||||||
|
/** Latch an auxiliary output on/off. 1-based channel (a spare relay). */
|
||||||
|
setAux(channel: number, on: boolean): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feature-detect the aux-output capability on a built device adapter. */
|
||||||
|
export function hasAuxOutput(d: unknown): d is AuxOutputDevice {
|
||||||
|
return typeof (d as Partial<AuxOutputDevice>)?.setAux === "function";
|
||||||
|
}
|
||||||
|
|
||||||
// --- Inputs (buttons / dry contacts) -------------------------------------
|
// --- Inputs (buttons / dry contacts) -------------------------------------
|
||||||
// Optional capability for controllers that expose host-readable inputs SEPARATE
|
// Optional capability for controllers that expose host-readable inputs SEPARATE
|
||||||
// from their relays — e.g. the Dingtian board. This is what enables host-in-the-
|
// from their relays — e.g. the Dingtian board. This is what enables host-in-the-
|
||||||
|
|||||||
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
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8099
|
||||||
|
|
||||||
|
|
||||||
|
class Sink(BaseHTTPRequestHandler):
|
||||||
|
def _log(self, method: str) -> None:
|
||||||
|
ts = datetime.now().strftime("%H:%M:%S")
|
||||||
|
src = self.client_address[0]
|
||||||
|
clen = int(self.headers.get("Content-Length", 0) or 0)
|
||||||
|
body = self.rfile.read(clen) if clen else b""
|
||||||
|
ctype = self.headers.get("Content-Type", "-")
|
||||||
|
print(f"\n=== {ts} {method} {self.path} from {src} ===", flush=True)
|
||||||
|
print(f" Content-Type: {ctype} ({clen} bytes)", flush=True)
|
||||||
|
text = body.decode("utf-8", "replace")
|
||||||
|
cut = text.find('Content-Type: image/jpeg')
|
||||||
|
if cut != -1:
|
||||||
|
text = text[:cut] + "\n [...JPEG image part omitted...]"
|
||||||
|
print(" body:\n" + text, flush=True)
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Length", "2")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"OK")
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
self._log("POST")
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
self._log("GET")
|
||||||
|
|
||||||
|
def do_PUT(self):
|
||||||
|
self._log("PUT")
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(f"camera-event post test listening on 0.0.0.0:{PORT}", flush=True)
|
||||||
|
print("Ctrl-C to stop.\n", flush=True)
|
||||||
|
ThreadingHTTPServer(("0.0.0.0", PORT), Sink).serve_forever()
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, device, indicator, radar, camera, aux-output, barrier-not-a-door, event-relay]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-28
|
||||||
|
status: settled
|
||||||
|
---
|
||||||
|
|
||||||
|
# Alert relays (radar × camera disagreement lamp)
|
||||||
|
|
||||||
|
A relay on the [[dingtian-relay|Dingtian]] controller is uniformly **"when EVENT X happens, do
|
||||||
|
action Y"** — see [[entry-exit-points|relays carry an event]]. The barrier events (`entry`/`exit`/
|
||||||
|
`both`) **pulse** a barrier; the **`radarAlert`** event drives a non-barrier **indicator lamp**
|
||||||
|
(blink + camera-lock) on a spare relay. The entry button's **12 V light** is the canonical alert
|
||||||
|
relay, a 3-state indicator that combines a **[[hikvision-radar|radar]]** trigger input with the
|
||||||
|
**camera "car in zone"** signal:
|
||||||
|
|
||||||
|
| Trigger input (radar) | Camera (lane entry busy) | Alert lamp |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| active | **free** — no car confirmed | **BLINK** (~1 Hz) |
|
||||||
|
| active | **busy** — camera confirms a car | **SOLID on** |
|
||||||
|
| inactive | — | **OFF** |
|
||||||
|
|
||||||
|
It is a **disagreement indicator**: the radar sees *something* but the camera hasn't confirmed a
|
||||||
|
real vehicle → blink (attention / "pull forward"); both agree → solid; nothing there → off.
|
||||||
|
|
||||||
|
Because it's just another relay row, a controller can carry **several** alert relays (e.g. R3 and a
|
||||||
|
future R4), each with its own trigger input — no new config shape, no code change.
|
||||||
|
|
||||||
|
## Signals
|
||||||
|
|
||||||
|
- **Trigger** = the alert relay's own `triggerInput` edge (the [[hikvision-radar|radar]]). When
|
||||||
|
unset, it falls back to the controller's entry-relay `presenceInput` — the same edge the
|
||||||
|
[[entry-double-press|one-car-one-ticket]] gate observes, so the lamp and the gate agree on "a car
|
||||||
|
is here".
|
||||||
|
- **Lock (camera "car in zone")** = the existing **[[lpr-camera|lane status]]** (`LaneStatusEvent`,
|
||||||
|
from camera vehicle detection). Already advisory; already drives the booth's barrier lights. Each
|
||||||
|
lamp picks **which lane's camera** locks it via `relays[].lockLane: "entry"|"exit"` (default
|
||||||
|
entry) — so an **exit radar's lamp locks on the EXIT camera**, not the entry one. (Lane-busy is the
|
||||||
|
only lock *kind* wired today; the model leaves room for others later.)
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
An alert lamp is a `config.relays[]` row with `direction: "radarAlert"`, carrying
|
||||||
|
`{ relay, triggerInput?, blinkOnMs?, blinkOffMs? }`. No separate `buttonLight` block (that was the
|
||||||
|
pre-2026-06-28 shape — barriers and the lamp were two different configs; now they're one list).
|
||||||
|
Blink defaults to 500 ms / 500 ms. The operator picks a **spare** relay (an alert relay never opens
|
||||||
|
a barrier; every barrier resolver skips `radarAlert` rows).
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
`apps/server/src/button-light.ts` — `ButtonLightController` reads the `radarAlert` rows
|
||||||
|
(`alertRelaysOf()` in `device-resolve.ts`), subscribes to `deviceEvents.onInput` (radar) +
|
||||||
|
`onLaneStatus` (camera), computes the target state **per lamp** (keyed `controllerId:relay`, so
|
||||||
|
several alert relays on one controller are independent), and drives each lamp via a **device-agnostic
|
||||||
|
aux-output** capability.
|
||||||
|
|
||||||
|
- **Aux-output capability.** `AuxOutputDevice { setAux(channel, on) }` on the device interface (the
|
||||||
|
Dingtian driver implements it as a latch). Business logic drives the lamp through this — **never**
|
||||||
|
the driver's barrier methods.
|
||||||
|
- **Barrier-not-a-door is preserved.** The lamp is **not a barrier**, so holding / blinking it on a
|
||||||
|
timer is fine — the [[barrier-not-a-door]] rule forbids timing a *barrier* closed, and barriers
|
||||||
|
still only ever `pulseOpen`. The lamp uses the separate `setAux` latch.
|
||||||
|
- **Fails OFF.** On host loss, shutdown, or a `setAux` error the lamp defaults OFF — a dead lamp is
|
||||||
|
"no hint", never a misleading solid "go". SOLID is only ever held while busy + present is actively
|
||||||
|
true (never latched on through a crash path).
|
||||||
|
- **Serialized sends (must — UDP is unordered).** The first cut fired fire-and-forget `setAux` every
|
||||||
|
500 ms; over **unordered UDP** the on/off packets reordered/overlapped and the relay **latched on
|
||||||
|
whichever packet the device processed last** — the lamp got stuck on/off at random (observed on
|
||||||
|
hardware). Fix: a **desired-state + serialized worker** (`#pump`). The blink timer only flips a
|
||||||
|
`desiredOn` flag; the worker guarantees **one in-flight send per lamp** and, on completion,
|
||||||
|
re-converges to the latest desired state. So the **final state is always authoritative** and a
|
||||||
|
lost/stale packet self-corrects. This also de-dupes (it skips a send when `confirmedOn === desiredOn`),
|
||||||
|
so the input stream never spams the controller.
|
||||||
|
- **Hot-reloads the config (no restart).** The lamp map is reconciled against the live device config
|
||||||
|
at start AND before each event (mirroring [[device-status-monitoring|DeviceMonitor]], which re-reads
|
||||||
|
the device set each tick) — adding/updating/dropping lamps. So a button light added or re-pointed in
|
||||||
|
the setup UI takes effect on the **next radar edge**, not after a server restart. (The first cut
|
||||||
|
loaded the map once at boot, so a just-saved lamp silently did nothing until restart.)
|
||||||
|
|
||||||
|
## On-screen twin — the booth barrier lights
|
||||||
|
|
||||||
|
The booth's **Hyrje / Dalje (Entry / Exit) indicators** mirror the physical lamp with the SAME
|
||||||
|
3-state rule, per lane: radar-present + camera-free → **blink green↔red** (~1 Hz); camera-busy →
|
||||||
|
**solid red**; else **solid green**. So the operator sees the same "detected, not yet confirmed →
|
||||||
|
confirmed → clear" story on screen as the lamp shows on the post.
|
||||||
|
|
||||||
|
The radar half is sourced by a small server tracker, **`LanePresence` (`lane-presence.ts`)**, that
|
||||||
|
subscribes to `deviceEvents.onInput` and resolves each presence edge to its lane via
|
||||||
|
**`presenceLaneOf`** (`device-resolve.ts`) — direction-agnostic (entry **and** exit), unlike the
|
||||||
|
entry-gated `relayForPresence` the one-car-one-ticket gate uses. It emits a `lane-presence`
|
||||||
|
`{entry,exit}` bus event on change; the WS forwards it (hello snapshot + push) into the booth's
|
||||||
|
`live-store.radar`, and `BarrierLight` (`BoothScreen.tsx`) blinks via the `.lane-blink` keyframe
|
||||||
|
(`index.css`, holds solid-red under `prefers-reduced-motion`). The camera half is the existing
|
||||||
|
[[lpr-camera|lane status]]. Same inputs, same rule as the lamp, so screen and post never disagree.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Built 2026-06-24 for the first booth (button I1, radar I2, lamp on a spare relay); the serialized-send
|
||||||
|
+ hot-reload fixes landed the same day after the lamp stuck on/off on hardware. **Reframed
|
||||||
|
2026-06-28**: the dedicated `config.buttonLight` block was folded into the unified `relays[]` list as
|
||||||
|
a `radarAlert` event-relay (carrying its own `triggerInput`), so the operator can add arbitrary
|
||||||
|
event-driven blinkers (e.g. R4) without code changes; the 3-state machine itself is unchanged.
|
||||||
|
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, lamp-added-after-start reconcile, and two independent
|
||||||
|
alert relays on one controller).
|
||||||
|
Related: [[hikvision-radar]], [[entry-double-press]], [[lpr-camera]], [[dingtian-relay]],
|
||||||
|
[[entry-exit-points]], [[barrier-not-a-door]].
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, device, monitoring, reliability, ui]
|
tags: [parking, device, monitoring, reliability, ui]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-18
|
updated: 2026-06-26
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,6 +25,15 @@ talks only to the adapter interfaces ([[device-adapter-pattern]]), never a drive
|
|||||||
- **Relays / readers / cameras** → the generic `Device.healthCheck()` **reachability** probe every
|
- **Relays / readers / cameras** → the generic `Device.healthCheck()` **reachability** probe every
|
||||||
adapter implements (`ready | degraded | offline`). This is presence/up-ness, not a deep fault
|
adapter implements (`ready | degraded | offline`). This is presence/up-ness, not a deep fault
|
||||||
model — a relay either answers or it doesn't.
|
model — a relay either answers or it doesn't.
|
||||||
|
> **Reader health was a LIE until 2026-06-26.** The QR-reader adapter (a PUSH device: it GETs our
|
||||||
|
> backend on each scan and exposes **no TCP port**) had a hardcoded `healthCheck → { ready, "stub" }`,
|
||||||
|
> so two genuinely-offline readers still showed **green**. A push device that's silent is
|
||||||
|
> indistinguishable from a dead one — so claiming `ready` unconditionally is the worst failure
|
||||||
|
> (false-healthy). Fix: an **optional reader IP** (monitor-only; scans still resolve by serial) +
|
||||||
|
> an **unprivileged ICMP ping** (`drivers/icmp.ts`, shells `/bin/ping` in SOCK_DGRAM mode — no
|
||||||
|
> CAP_NET_RAW, no native dep; the booth compose sets `net.ipv4.ping_group_range`). Reply → `ready`,
|
||||||
|
> no reply → `offline`; **no IP set → `degraded` ("set IP to monitor")**, never a false green.
|
||||||
|
> Verified on hardware: pinged the real readers on the device VLAN. See [[gee-qr-er80]].
|
||||||
|
|
||||||
Both collapse to one **traffic-light**: `ready | degraded | offline`, plus a `detail` string. Fail
|
Both collapse to one **traffic-light**: `ready | degraded | offline`, plus a `detail` string. Fail
|
||||||
**toward "there's a problem"**, never false-healthy: a probe that throws or times out reads
|
**toward "there's a problem"**, never false-healthy: a probe that throws or times out reads
|
||||||
|
|||||||
@@ -26,10 +26,17 @@ press → print → press again issued a second ticket immediately. That is not
|
|||||||
The guard lives on the entry relay's spec (`config.relays[]` — see [[entry-exit-points]]), because
|
The guard lives on the entry relay's spec (`config.relays[]` — see [[entry-exit-points]]), because
|
||||||
whether real one-car-one-ticket is *possible* depends on the hardware at that lane. Two modes:
|
whether real one-car-one-ticket is *possible* depends on the hardware at that lane. Two modes:
|
||||||
|
|
||||||
### PRESENCE mode (preferred — when a vehicle loop is wired)
|
### PRESENCE mode (preferred — when a vehicle-presence sensor is wired)
|
||||||
`relays[].presenceInput` = the 1-based input terminal of an **induction loop / barrier presence
|
Inputs are a first-class `config.inputs[]` list (the twin of `relays[]`): each row is a terminal +
|
||||||
signal** on the same [[dingtian-relay|controller]] (the Dingtian's inputs are decoupled from its
|
a **role** (`button` / `presence` / `alertTrigger`) + the `relay` it serves. A **presence** row =
|
||||||
relays, and loops are already in the [[bom]]). The rule makes one-car-one-ticket **physical**:
|
the 1-based input terminal of a **vehicle-presence sensor** serving an entry/both relay on the same
|
||||||
|
[[dingtian-relay|controller]] (the Dingtian's inputs are decoupled from its relays). The sensor may
|
||||||
|
be an **induction loop** OR a **[[hikvision-radar|radar]]** (`inputs[].kind: "loop"|"radar"` — a
|
||||||
|
label; the gate behaviour is identical). A radar wired to idle opposite the button needs
|
||||||
|
`inputs[].activeLow: true` so its edge reads correctly. **Multiple radars (entry + exit) are just
|
||||||
|
multiple presence rows** — adding an exit radar is adding a row. (Pre-2026-06-28 configs wired this
|
||||||
|
on `relays[].presenceInput/presenceKind/presenceActiveLow`; the resolvers still read those as
|
||||||
|
back-compat, synthesizing inputs[] from them.) The rule makes one-car-one-ticket **physical**:
|
||||||
|
|
||||||
- A press prints **only while a car is present** on the loop.
|
- A press prints **only while a car is present** on the loop.
|
||||||
- After a ticket prints, the relay is **disarmed** — no second ticket — **until the loop CLEARS**
|
- After a ticket prints, the relay is **disarmed** — no second ticket — **until the loop CLEARS**
|
||||||
@@ -66,11 +73,14 @@ in telemetry if ever needed.
|
|||||||
host (single-writer); it is derived from live input edges, never the source of truth. A restart
|
host (single-writer); it is derived from live input edges, never the source of truth. A restart
|
||||||
starts armed (the first press after a restart works), which is the safe default.
|
starts armed (the first press after a restart works), which is the safe default.
|
||||||
|
|
||||||
## As-built (2026-06-19)
|
## As-built (2026-06-19; inputs[] 2026-06-28)
|
||||||
|
|
||||||
- `RelaySpec` gains `presenceInput?` + `entryCooldownSec?` (`device-resolve.ts`); `relayForButton`
|
- Inputs live in `config.inputs[] = [{ input, role, relay?, kind?, activeLow?, cooldownSec? }]`
|
||||||
carries them onto the `ResolvedRelay`, and a new `relayForPresence()` resolves a loop-input edge to
|
(`device-resolve.ts`). `inputsOf(row)` returns them, **or synthesizes** the list from the legacy
|
||||||
the entry relay it gates.
|
`relays[].button/presenceInput/...` fields when a controller predates inputs[] (one back-compat
|
||||||
|
shim; the UI no longer writes the legacy fields). `relayForButton`/`relayForPresence` resolve
|
||||||
|
through `inputsOf`, carry `presenceInput`/`entryCooldownSec` onto the `ResolvedRelay`, and only ever
|
||||||
|
gate entry/both relays. An exit radar = a `presence` row on the exit relay.
|
||||||
- `EntryFlow` (`entry-flow.ts`) keeps a `#guard` map keyed `controllerId:relay`: `#onPresenceEdge`
|
- `EntryFlow` (`entry-flow.ts`) keeps a `#guard` map keyed `controllerId:relay`: `#onPresenceEdge`
|
||||||
tracks the loop, `#suppressReason` decides presence/cooldown, `#recordSuppressedPress` writes the
|
tracks the loop, `#suppressReason` decides presence/cooldown, `#recordSuppressedPress` writes the
|
||||||
telemetry. The guard disarms + stamps the cooldown on **print success** (not on open).
|
telemetry. The guard disarms + stamps the cooldown on **print success** (not on open).
|
||||||
|
|||||||
@@ -62,7 +62,49 @@ then does the gated entry/exit + barrier open. Constructed in `server.ts` (the f
|
|||||||
above the hik-alarm registration so the bridge can take `subscriptionFlow`). Fail-soft throughout —
|
above the hik-alarm registration so the bridge can take `subscriptionFlow`). Fail-soft throughout —
|
||||||
any snapshot/vision error degrades to the subscriber's card/QR, never throws into the push handler.
|
any snapshot/vision error degrades to the subscriber's card/QR, never throws into the push handler.
|
||||||
Two new env knobs: `VISION_ENTRY_MIN_CONFIDENCE` (0.85), `ANPR_DEBOUNCE_MS` (12_000). Covered by
|
Two new env knobs: `VISION_ENTRY_MIN_CONFIDENCE` (0.85), `ANPR_DEBOUNCE_MS` (12_000). Covered by
|
||||||
`anpr-entry.test.ts` (7) + `hikvision-alarm.test.ts` wiring (3).
|
`anpr-entry.test.ts` + `hikvision-alarm.test.ts` wiring.
|
||||||
|
|
||||||
|
**Toggles — two levels (the auto-open is optional).** ANPR auto entry/exit can be turned off without
|
||||||
|
losing plate recognition:
|
||||||
|
- **Site-wide:** `site_config.anprEntryEnabled` (a Site Settings switch) gates the WHOLE bridge
|
||||||
|
(both directions); off ⇒ no camera auto-opens, recognition/lane-status unaffected.
|
||||||
|
- **Per-camera** (2026-06-27): `config.anprAutoTrigger` (absent ⇒ on when `anpr` is on). This
|
||||||
|
separates **recognition** (`config.anpr` — snapshots run through the recognizer, plate recorded,
|
||||||
|
BOTH directions) from **auto-open** (`anprAutoTrigger` — may this camera fire the barrier). The
|
||||||
|
case it solves: a **shared entry/exit lane** where ONE physical lane has both an entry and an exit
|
||||||
|
camera. A subscriber driving IN is admitted by the entry cam — but the exit cam sees the SAME car
|
||||||
|
leaving its frame and would **phantom-exit** the occurrence just opened (its back plate). Set the
|
||||||
|
exit cam's `anprAutoTrigger = false`: it still recognises plates for the record, but never
|
||||||
|
auto-opens. (The Setup checkbox "Auto open/close on subscriber plate" appears under ANPR.)
|
||||||
|
|
||||||
|
> **POLL-until-confident (2026-06-27).** The single-shot capture above was upgraded to a **poll
|
||||||
|
> loop**. The camera fires its vehicle alarm the INSTANT motion starts — the car is still
|
||||||
|
> APPROACHING, so the first frame's plate is small/blurry/half-in-frame and ANPR returns a
|
||||||
|
> low-confidence misread (observed live on the exit lane: `'111'`@0.20, `'AE18671'`@0.19 …, while
|
||||||
|
> the manual `test-anpr` on the SAME stopped car read `AA890XX`@1.00). The car then STOPS at the
|
||||||
|
> barrier waiting for it to open — the stationary, well-framed moment the test reads at ~100%. So
|
||||||
|
> the bridge now **pulls a FRESH frame every `ANPR_POLL_MS` (1000) and re-runs ANPR until one clears
|
||||||
|
> the floor, or `ANPR_POLL_WINDOW_MS` (8000) elapses** (car drove off / non-subscriber → give up
|
||||||
|
> cleanly). One loop per camera (`#polling` set) so the ~1Hz alarm re-fires JOIN it, not spawn N;
|
||||||
|
> each tick is a fresh `camera.captureSnapshot` (NOT `captureSnapshotShared`, whose TTL would
|
||||||
|
> re-serve the same bad frame). VERIFIED on hardware: 7 garbage approach frames → `AA890XX`@0.999
|
||||||
|
> at the barrier → signed `vehicle_exit`. This is what made subscriber **auto-exit** actually work
|
||||||
|
> on the DS-2CD1047G3H-LIU (whose alarm fires on approach, not at the readable moment — see
|
||||||
|
> [[lpr-camera]] "auto-enter but don't auto-exit"). Still advisory + fail-soft; a barrier never
|
||||||
|
> opens on a low-confidence read.
|
||||||
|
>
|
||||||
|
> **Two concurrency guards on the loop** (the edges a single push-triggered loop creates):
|
||||||
|
> 1. **Credential-mid-poll abort.** If the subscriber scans their card/QR at the reader DURING the
|
||||||
|
> loop, they've already transacted — the bridge watches their `openOccurrenceCount` (baselined
|
||||||
|
> once a frame reads the bound plate) and **aborts without emitting** if it moves, so it never
|
||||||
|
> double-acts (which would exit the NEXT open occurrence — bad for a fleet sub).
|
||||||
|
> 2. **SLIDING window for a different car arriving mid-poll.** A loop started by a far/early car
|
||||||
|
> must not (a) give up before the REAL car settles, nor (b) swallow the real car's pushes. So a
|
||||||
|
> push that joins a running loop **extends the deadline** (`lastPush + ANPR_POLL_WINDOW_MS`),
|
||||||
|
> capped at `start + ANPR_POLL_MAX_MS` (30s) so a continuously-busy lane can't slide forever.
|
||||||
|
> Because each tick pulls a FRESH frame, the loop naturally tracks whoever is at the barrier
|
||||||
|
> *now*, not the car that started it. (Knobs: `ANPR_POLL_MS`, `ANPR_POLL_WINDOW_MS`,
|
||||||
|
> `ANPR_POLL_MAX_MS`.)
|
||||||
|
|
||||||
The goal (narrowed deliberately — see Rejected below): **a subscriber's plate, read by the lane
|
The goal (narrowed deliberately — see Rejected below): **a subscriber's plate, read by the lane
|
||||||
camera, admits them through the same gated flow a QR/card scan uses.** Scope was cut to subscribers
|
camera, admits them through the same gated flow a QR/card scan uses.** Scope was cut to subscribers
|
||||||
|
|||||||
@@ -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,17 +2,18 @@
|
|||||||
type: reference
|
type: reference
|
||||||
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
|
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-23
|
updated: 2026-06-27
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
# Appliance provisioning runbook (booth PC)
|
# Appliance provisioning runbook (booth PC)
|
||||||
|
|
||||||
Step-by-step to take a booth PC from factory Windows to a hardened, encrypted, container-running
|
Step-by-step to take a booth PC from factory Windows to a hardened, encrypted, container-running
|
||||||
parking appliance. Written from the **first real provisioning, 2026-06-23**, on the actual hardware
|
parking appliance. Written from the **first real provisioning, 2026-06-23** (hardening) +
|
||||||
below — every command here was run and verified on that machine, including the firmware-specific
|
**first Komodo deploy, 2026-06-27** (the runtime) — every command here was run and verified on the
|
||||||
workaround. Companion to [[disk-os-hardening]] (the *why*), [[tpm]] (TPM analysis), and
|
actual hardware, including the firmware-specific workaround. Companion to [[disk-os-hardening]] (the
|
||||||
[[container-deployment]] (the images this runs).
|
*why*), [[tpm]] (TPM analysis), [[container-deployment]] (the images), and
|
||||||
|
[[fleet-deployment-komodo]] (the deploy control plane this runbook's §7 uses).
|
||||||
|
|
||||||
> ⚠ This box is the [[threat-model|outsider-with-the-box]] defence. The load-bearing anti-fraud
|
> ⚠ This box is the [[threat-model|outsider-with-the-box]] defence. The load-bearing anti-fraud
|
||||||
> control is still [[reconciliation]] over the [[append-only-event-chain|signed chain]] — disk
|
> control is still [[reconciliation]] over the [[append-only-event-chain|signed chain]] — disk
|
||||||
@@ -171,50 +172,140 @@ default) — `admin`+sudo IS the root path; enabling root adds risk, no gain.
|
|||||||
|
|
||||||
## 5b. Further hardening (TODO — not yet done)
|
## 5b. Further hardening (TODO — not yet done)
|
||||||
|
|
||||||
- **Key-based SSH only** (disable password auth) if SSH is enabled at all.
|
- **Key-based SSH only** (disable password auth) if SSH is enabled at all. Routine ops no longer
|
||||||
|
need SSH — Komodo Periphery (§7) drives deploys + gives a container terminal over the mesh — so
|
||||||
|
SSH can be locked down hard or disabled, leaving the mesh + Komodo as the management path.
|
||||||
- **No/locked-down desktop + kiosk autostart** — single-purpose; the operator never reaches a shell
|
- **No/locked-down desktop + kiosk autostart** — single-purpose; the operator never reaches a shell
|
||||||
([[desktop-shell-tauri]]).
|
([[desktop-shell-tauri]]).
|
||||||
- Consider moving the host **event-signing key into the TPM** (non-extractable) — [[tpm]], [[open-questions]] #12.
|
- Consider moving the host **event-signing key into the TPM** (non-extractable) — [[tpm]], [[open-questions]] #12.
|
||||||
- `sudo apt autoremove` the leftover old kernel once the new one is proven.
|
- `sudo apt autoremove` the leftover old kernel once the new one is proven.
|
||||||
|
|
||||||
## 6. Runtime — Docker stack (VERIFIED 2026-06-23)
|
## 6. Runtime — Docker engine (VERIFIED 2026-06-23)
|
||||||
|
|
||||||
Install Docker Engine + compose (as `admin`). NB Ubuntu 26.04 codename is **`resolute`**, which
|
Install Docker Engine + compose (as `admin`). NB Ubuntu 26.04 codename is **`resolute`**, which
|
||||||
download.docker.com may not yet publish — pin the repo line to `noble`, OR use Ubuntu's `docker.io`.
|
download.docker.com may not yet publish — pin the repo line to `noble`, OR use Ubuntu's `docker.io`.
|
||||||
Add only `admin` to the `docker` group (root-equivalent — NEVER the operator).
|
Add only `admin` to the `docker` group (root-equivalent — NEVER the operator).
|
||||||
|
|
||||||
Deploy from a standalone dir (hand-copied; no repo on the appliance), e.g. `/opt/parking_solution`:
|
This gives the appliance the engine. **How the stack gets ONTO it is step 7** — and as of
|
||||||
`docker-compose.yml` + `docker-compose.prod.yml` (the Caddy/prod override) + `Caddyfile` + a `.env`
|
2026-06-27 the primary path is **Komodo (remote, no-SSH)**, not a hand-copied dir. The manual
|
||||||
(chmod 600). The `.env` (driven into the containers by the base compose):
|
`docker compose` flow survives as a **break-glass fallback** (§7c).
|
||||||
|
|
||||||
```
|
## 7. Deploy the stack — Komodo Periphery (PRIMARY, 2026-06-27)
|
||||||
JWT_SECRET=<openssl rand -hex 32> # server REFUSES to boot without (>=32, no insecure default)
|
|
||||||
EVENT_SIGNING_KEY=<a DIFFERENT openssl rand -hex 32>
|
The booth is driven by a central **Komodo Core** over the **NetBird** mesh. The appliance runs a
|
||||||
COOKIE_SECURE=0 # CRITICAL on plain-http or the auth cookie never sends → no login
|
small **Periphery** agent that *dials out* to Core; Core then deploys the same compose files. No
|
||||||
WS_ALLOWED_ORIGINS=http://<name-or-ip> # any REMOTE origin admins use (same-origin always passes)
|
inbound port on the booth, no SSH for routine ops. Full rationale + threat model:
|
||||||
VISION_ENABLED=1
|
[[fleet-deployment-komodo]]. Verified end-to-end on the first booth (`park-buzi`) 2026-06-27.
|
||||||
# REGISTRY/TAG default to git.infra.msai.al/mca/parking_solution + dev; set TAG=main to pin.
|
|
||||||
```
|
### 7a. Install Periphery (on the booth, as `admin`)
|
||||||
|
|
||||||
|
Prereq: the booth is on the **NetBird** mesh and can reach Core's reverse-proxy URL
|
||||||
|
(`https://komodo.infra.msai.al`).
|
||||||
|
|
||||||
|
1. In Core: **Settings → Onboarding → + New Onboarding Key** (Name = the booth, e.g. `park-buzi`;
|
||||||
|
Expiry ~1 day; Pre-Existing Key empty). Copy the one-time `O-…` key. **Single-use** — delete it
|
||||||
|
after the agent connects.
|
||||||
|
2. On the booth, install Periphery in **user mode** (runs as `admin`, who is in `docker`; NO root
|
||||||
|
daemon; **outbound** → opens no inbound port):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker login git.infra.msai.al # a read-only package token, not the account password
|
curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/setup-periphery.py | python3 - --user \
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml config # dry-run: verify the merged env
|
--core-address="https://komodo.infra.msai.al" \
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
|
--connect-as="park-buzi" \
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
--onboarding-key="O-…"
|
||||||
# Seed the FIRST admin (DB starts empty → nobody can log in until this runs; idempotent):
|
sudo loginctl enable-linger admin # so the user service starts at boot without a login
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec \
|
|
||||||
-e ADMIN_USER=admin -e ADMIN_PASS='<strong-pw>' server node scripts/seed-admin.mjs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Healthy startup logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked
|
- `--connect-as` is the **Server name in Core** — unique, stable, site-meaningful (the fleet's
|
||||||
weights), server `[migrate] done` → `SPA static serving enabled` → `Server listening`. The transient
|
primary key). Booth #2 = a different name (e.g. `park-durres`); never reuse one.
|
||||||
|
- `--core-address` is Core's **reverse-proxy URL** (the URL you load the Core UI at over the mesh),
|
||||||
|
NOT `:9120` — Core's container port `9120` is exposed-not-published; the agent reaches it through
|
||||||
|
the proxy. (Gotcha #7 below.)
|
||||||
|
- Config lands at `~/.config/komodo/periphery.config.toml`. The key field is **`core_address`**
|
||||||
|
(singular); `root_directory` must be a path `admin` can write (user-mode default is fine — a
|
||||||
|
`/etc/komodo` default from a system install would `Permission denied` for the user service).
|
||||||
|
|
||||||
|
Verify: `systemctl --user status periphery` → active; the server **`park-buzi`** appears and goes
|
||||||
|
**OK/green** in Core → Servers. Then **delete the onboarding key**.
|
||||||
|
|
||||||
|
### 7b. Deploy the Stack (in Core — by hand once, then code)
|
||||||
|
|
||||||
|
Add **Registry Account** + **Git Account** for `git.infra.msai.al` (user `komodo`, tokens) in Core
|
||||||
|
so Periphery can clone the repo AND pull the private images. Two distinct credential types — the
|
||||||
|
git clone working does NOT imply the image pull is authed (gotcha #8). Per-booth secrets
|
||||||
|
(`park_<booth>_jwt_secret`, `park_<booth>_event_signing_key` — distinct values, `openssl rand -hex
|
||||||
|
32`) live in Core's **Variables/Secrets** store, referenced from the Stack as `[[…]]`.
|
||||||
|
|
||||||
|
Create a **Stack** (UI → Stacks → New), name = the booth (`park-buzi`):
|
||||||
|
|
||||||
|
- **Server:** `park-buzi` · **Source:** repo `mca/parking_solution`, branch `dev`, files
|
||||||
|
`docker-compose.yml` + `docker-compose.prod.yml` · **Registry account:** `komodo` (else the pull
|
||||||
|
is anonymous → `no basic auth credentials`).
|
||||||
|
- **Environment** (Komodo writes this to a `.env` on the booth at deploy, substituting `[[…]]`):
|
||||||
|
|
||||||
|
```
|
||||||
|
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||||
|
TAG=dev # moving tag (staging). PIN to dev-<sha> for a live booth.
|
||||||
|
COOKIE_SECURE=0 # CRITICAL on plain-http or the auth cookie never sends → no login
|
||||||
|
VISION_ENABLED=1
|
||||||
|
WS_ALLOWED_ORIGINS= # browser at the booth URL is same-origin; leave empty (the
|
||||||
|
# Tauri desktop app needs its origin here — separate task)
|
||||||
|
JWT_SECRET=[[park_buzi_jwt_secret]]
|
||||||
|
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy → Periphery pulls + `compose up`s. All containers (`proxy`/Caddy, `server`, `vision`) green.
|
||||||
|
Seed the FIRST admin (DB starts empty → nobody can log in until this runs; idempotent) **via
|
||||||
|
Komodo's terminal on the `server` container** (no SSH):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it -e ADMIN_USER=admin -e ADMIN_PASS='<strong-pw>' \
|
||||||
|
park-buzi-server-1 node scripts/seed-admin.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Secrets-on-disk note.** The generated `.env` lands on the booth with **cleartext** secrets
|
||||||
|
> (compose needs real values). That's why the disk is LUKS-encrypted (§3–4) and keys are per-booth
|
||||||
|
> — the encryption is the control, and a single-booth compromise leaks only that booth's key. See
|
||||||
|
> [[fleet-deployment-komodo]] (the `EVENT_SIGNING_KEY`-in-Core blast-radius caveat; ATECC608 is the
|
||||||
|
> intended long-term signer).
|
||||||
|
|
||||||
|
### 7b-bis. Fleet-as-code (`resources.toml`) — optional but recommended
|
||||||
|
|
||||||
|
The repo's `komodo/resources.toml` mirrors the working Stack. Pointing a Core **ResourceSync** at
|
||||||
|
it makes the fleet **git-managed**: booth #N is a copy-pasted `[[stack]]` block; an image bump is a
|
||||||
|
one-line `TAG=` edit + push + Execute; every change is an auditable commit; a rebuilt Core
|
||||||
|
re-creates everything from the file. Keep the sync **Unmanaged** + **Delete-Unmatched OFF** until
|
||||||
|
trusted. An **empty diff / disabled Execute = the file already matches the live Stack** (success,
|
||||||
|
not an error). See `komodo/README.md` and [[fleet-deployment-komodo]].
|
||||||
|
|
||||||
|
### 7c. Break-glass — manual compose (mesh/Core down)
|
||||||
|
|
||||||
|
When the mesh or Core is unreachable, the same compose files run locally via `scripts/booth.sh`
|
||||||
|
(or raw `docker compose`). Needs a local `.env` and a `docker login git.infra.msai.al` (a
|
||||||
|
read-only package token). This is the FALLBACK, not the routine path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login git.infra.msai.al
|
||||||
|
ENV=prod ./booth.sh config # dry-run the merged env
|
||||||
|
ENV=prod ./booth.sh up
|
||||||
|
```
|
||||||
|
|
||||||
|
`booth.sh` runs from wherever it sits next to the compose files (the booth deploys them flat, e.g.
|
||||||
|
`/opt/parking_systems/`). See [[container-deployment]].
|
||||||
|
|
||||||
|
### Healthy startup + web-access
|
||||||
|
|
||||||
|
Healthy logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked weights),
|
||||||
|
server `[migrate] done` → `SPA static serving enabled` → `Server listening`. The transient
|
||||||
`vision-service -> offline` at boot then `-> ready (fast_alpr)` ~8s later is normal (monitor polls
|
`vision-service -> offline` at boot then `-> ready (fast_alpr)` ~8s later is normal (monitor polls
|
||||||
before vision finishes loading). Reach the UI at **`http://<name-or-ip>/`** (Caddy on :80).
|
before vision finishes loading). Reach the UI at **`http://<name-or-ip>/`** (Caddy on :80).
|
||||||
|
|
||||||
**Web-access gotchas (all fixed in the images/compose — see [[container-deployment]] "Web access"):**
|
**Web-access gotchas (all fixed in the images/compose — see [[container-deployment]] "Web access"):**
|
||||||
the SPA uses a RELATIVE `/api` base (works from any host; do NOT bake a domain) + a Caddy proxy gives
|
the SPA uses a RELATIVE `/api` base (works from any host; do NOT bake a domain) + a Caddy proxy gives
|
||||||
the clean port-80 URL; the domain (`parksystems.msai.al`) is pointed at the booth's LAN IP via
|
the clean port-80 URL; the domain (`parksystems.msai.al`) is pointed at the booth's LAN IP via
|
||||||
`hosts`/DNS ON-SITE, never an image rebuild.
|
`hosts`/DNS ON-SITE, never an image rebuild. The **Tauri desktop app** is hardcoded to
|
||||||
|
`localhost:3000` (CSP + endpoints) and can't reach a remote booth without code changes — a browser
|
||||||
|
works; the desktop app is a separate workstream.
|
||||||
|
|
||||||
## Quick-reference: the gotchas, in order they bit us
|
## Quick-reference: the gotchas, in order they bit us
|
||||||
|
|
||||||
@@ -225,3 +316,19 @@ the clean port-80 URL; the domain (`parksystems.msai.al`) is pointed at the boot
|
|||||||
5. Always keep the **password slot** + an off-machine copy of the passphrase (TPM is never the only key).
|
5. Always keep the **password slot** + an off-machine copy of the passphrase (TPM is never the only key).
|
||||||
6. GRUB password MUST be **edit-only** (`--unrestricted` on entries) or it prompts on EVERY boot →
|
6. GRUB password MUST be **edit-only** (`--unrestricted` on entries) or it prompts on EVERY boot →
|
||||||
breaks unattended reboot. Verify `grep -c unrestricted /boot/grub/grub.cfg` ≥1 before rebooting.
|
breaks unattended reboot. Verify `grep -c unrestricted /boot/grub/grub.cfg` ≥1 before rebooting.
|
||||||
|
|
||||||
|
### Komodo deploy gotchas (2026-06-27)
|
||||||
|
|
||||||
|
7. Periphery `core_address` is **Core's reverse-proxy URL** (`https://komodo.infra.msai.al`), NOT
|
||||||
|
`100.x:9120`. Core's `9120` is exposed-not-published (`docker ps` shows `9120/tcp` with no `->`)
|
||||||
|
→ a direct dial gets `Connection refused`. Ping/SSH working over the mesh does NOT mean `:9120`
|
||||||
|
is reachable.
|
||||||
|
8. **Git auth ≠ registry auth.** The repo cloning fine does not mean image pull is authed — they're
|
||||||
|
separate Komodo credentials. A blank registry account on the Stack → anonymous pull →
|
||||||
|
`no basic auth credentials`. Set the Stack's **Registry Account** (`komodo`).
|
||||||
|
9. **User-mode Periphery + `/etc/komodo` `root_directory` = `Permission denied`** writing the agent
|
||||||
|
key. User-mode (runs as `admin`, no root daemon) must keep `root_directory` under `$HOME`.
|
||||||
|
10. The config key is **`core_address`** (singular). And `--core-address` derives `wss://` from
|
||||||
|
`https://` — if Core were plain-HTTP you'd need `http://` (→ `ws://`).
|
||||||
|
11. ResourceSync **Execute disabled + file shown clean in Info = empty diff = already in sync**
|
||||||
|
(success). Execute only enables when the file and Core diverge (e.g. you edit `TAG`).
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: decision
|
type: decision
|
||||||
tags: [parking, deployment, docker, ci, offline-first]
|
tags: [parking, deployment, docker, ci, offline-first]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-22
|
updated: 2026-06-24
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -12,6 +12,12 @@ How the parking system's runtime apps are packaged as containers, tagged, and pu
|
|||||||
Settled 2026-06-22. Companion to [[vision-service-packaging]] (which scopes the vision service
|
Settled 2026-06-22. Companion to [[vision-service-packaging]] (which scopes the vision service
|
||||||
into the monorepo) and the desktop [[desktop-shell-tauri]] (a separate, tag-only bundle).
|
into the monorepo) and the desktop [[desktop-shell-tauri]] (a separate, tag-only bundle).
|
||||||
|
|
||||||
|
> **The build/tag/registry pipeline below is current.** What changed (2026-06-27): the
|
||||||
|
> *deploy mechanism* is no longer "SSH in and run `booth.sh`". At fleet scale that's superseded
|
||||||
|
> by [[fleet-deployment-komodo]] (Komodo Periphery over a NetBird mesh, driving these same
|
||||||
|
> compose files). `scripts/booth.sh` is now a **break-glass local fallback**, not the primary
|
||||||
|
> deploy path.
|
||||||
|
|
||||||
## Two images (the desktop app is NOT containerized)
|
## Two images (the desktop app is NOT containerized)
|
||||||
|
|
||||||
- **`parking-server`** — the Fastify API **plus the built React SPA**. One container serves both:
|
- **`parking-server`** — the Fastify API **plus the built React SPA**. One container serves both:
|
||||||
@@ -36,6 +42,29 @@ The **desktop** app stays on its own tag-only `release.yml` (Tauri installers),
|
|||||||
`restart: always`, `fast_alpr`, vision kept internal). `REGISTRY`/`TAG` come from env, so a deploy
|
`restart: always`, `fast_alpr`, vision kept internal). `REGISTRY`/`TAG` come from env, so a deploy
|
||||||
on a branch pulls that branch's image — the branch→environment mapping IS the override file.
|
on a branch pulls that branch's image — the branch→environment mapping IS the override file.
|
||||||
|
|
||||||
|
## Booth operator wrapper — `scripts/booth.sh`
|
||||||
|
|
||||||
|
So the on-site operator runs one command instead of the long `docker compose -f … -f … --env-file …`
|
||||||
|
line, **`scripts/booth.sh`** wraps the base + override + env-file. **Prod by default** (the booth is
|
||||||
|
prod); `ENV=dev` switches to the dev override.
|
||||||
|
|
||||||
|
- `./scripts/booth.sh up` — start (detached). `down` / `restart` / `status` / `logs [service]` /
|
||||||
|
`pull` / `config` / `exec <svc> …` as expected.
|
||||||
|
- **`./scripts/booth.sh update`** — the "**I know there are new images**" path: `compose pull` the
|
||||||
|
moving branch tag, then `up -d --remove-orphans` (recreates only services whose image digest moved;
|
||||||
|
**named volumes — the SQLite ledger — are preserved**), then `docker image prune -f` to reclaim the
|
||||||
|
old layers. This is the routine update after a `dev`/`main` push republishes the branch tag.
|
||||||
|
- **Env handling.** Reads **`.env`** (copy from `.env.example`: `REGISTRY`, `TAG`, `JWT_SECRET`,
|
||||||
|
`EVENT_SIGNING_KEY`, `COOKIE_SECURE=0`, `WS_ALLOWED_ORIGINS`). Prod **refuses to run without
|
||||||
|
`.env`** (no safe `JWT_SECRET` default — `auth.ts` rejects weak ones). Dev with no `.env` injects
|
||||||
|
the documented benign local secret so `up` works out of the box. The base file makes `JWT_SECRET`
|
||||||
|
shell-required (`${JWT_SECRET:?}`), so the env-file is mandatory for both — the script surfaces that
|
||||||
|
early with a clear message rather than a raw compose interpolation error.
|
||||||
|
- **Safety:** `down` never passes `-v` (deleting `parking-data` would wipe the signed
|
||||||
|
[[append-only-event-chain|ledger]]); `help`/unknown-command short-circuit before any Docker/.env
|
||||||
|
requirement. The operator never types `JWT_SECRET` on the CLI — it lives in `.env` (the user
|
||||||
|
generates it with `openssl rand -hex 32`).
|
||||||
|
|
||||||
## Registry + CI
|
## Registry + CI
|
||||||
|
|
||||||
- Published to the house **Gitea registry** `git.infra.msai.al/mca/parking_solution/{parking-server,
|
- Published to the house **Gitea registry** `git.infra.msai.al/mca/parking_solution/{parking-server,
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
---
|
||||||
|
type: decision
|
||||||
|
tags: [parking, deployment, fleet, komodo, netbird, offline-first, threat-model]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-27
|
||||||
|
status: settled
|
||||||
|
---
|
||||||
|
|
||||||
|
# Fleet deployment — Komodo Periphery over a NetBird mesh
|
||||||
|
|
||||||
|
How the parking appliance is deployed and managed **at fleet scale**, superseding the
|
||||||
|
single-box, SSH-and-`booth.sh` model. The image build/tag/registry pipeline
|
||||||
|
([[container-deployment]]) is unchanged — this decides only the *control plane* that drives
|
||||||
|
those same compose files onto many booths. Settled 2026-06-27.
|
||||||
|
|
||||||
|
## The problem `booth.sh` couldn't solve
|
||||||
|
|
||||||
|
[[container-deployment|`scripts/booth.sh`]] is a thin wrapper over `docker compose -f base -f
|
||||||
|
prod --env-file .env`. It works for **one** appliance you can get a shell on, but as the fleet
|
||||||
|
grows (the stated direction is **many/growing** sites) it gives us none of:
|
||||||
|
|
||||||
|
- **Remote, no-SSH operation** — an update means someone gets a root shell on the booth.
|
||||||
|
- **A fleet view** — which booth runs which `dev-<sha>`, which is healthy/offline.
|
||||||
|
- **A deploy audit trail** — who deployed what, when.
|
||||||
|
- **One-click rollback** to a previous immutable `dev-<sha>`.
|
||||||
|
|
||||||
|
These are exactly the gaps a deployment controller fills. We already run every prerequisite
|
||||||
|
(a **Komodo Core**, a **NetBird** zero-trust mesh, the **Gitea registry**), so the marginal
|
||||||
|
cost is low.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Adopt **Komodo Periphery** on each appliance, driven by the existing **Komodo Core** over the
|
||||||
|
**NetBird** mesh. Keep the compose files and the [[container-deployment|image pipeline]]
|
||||||
|
verbatim — Komodo consumes them as a *Stack*; it does not replace them. `booth.sh` is demoted
|
||||||
|
to a **break-glass local fallback** for when the mesh/Core is unreachable.
|
||||||
|
|
||||||
|
```
|
||||||
|
Gitea push ─▶ build-images.yml ─▶ registry (parking-server:dev-<sha>, parking-vision:dev-<sha>)
|
||||||
|
│
|
||||||
|
Komodo Core (off-site) ──── NetBird mesh ─┼─▶ Periphery @ booth-A ─▶ docker compose up (pinned sha)
|
||||||
|
• fleet table / history / rollback ├─▶ Periphery @ booth-B
|
||||||
|
• per-booth secret injection └─▶ Periphery @ booth-C …
|
||||||
|
• NO deploy webhook (manual + pinned)
|
||||||
|
```
|
||||||
|
|
||||||
|
### The three load-bearing choices (settled with the user 2026-06-27)
|
||||||
|
|
||||||
|
1. **Fleet size: many/growing.** Komodo is treated as load-bearing infrastructure, not a
|
||||||
|
convenience. This is what tips the decision away from "SSH-over-NetBird + a playbook".
|
||||||
|
2. **Deploy trigger: always manual + pinned.** **No deploy webhook on a booth Stack.** A human
|
||||||
|
deploys a specific immutable `TAG=dev-<sha>` from Core. This preserves the determinism we
|
||||||
|
chose when pinning the booth tag (a moving `:dev` auto-redeploying a production booth is the
|
||||||
|
surprise we explicitly rejected). A *staging* booth MAY track `:dev`; a production booth
|
||||||
|
never does.
|
||||||
|
3. **Secrets: Komodo-managed (per-booth, unique).** Core's secret store injects `JWT_SECRET`
|
||||||
|
and `EVENT_SIGNING_KEY` into the Stack at deploy. This scales (no SSH-to-N-booths to rotate
|
||||||
|
a key) — but see the threat-model tension below; the keys MUST be **distinct per booth**.
|
||||||
|
|
||||||
|
## Why this is safe (against the project's two forces)
|
||||||
|
|
||||||
|
### Offline-first ([[offline-first]]) — Core is orchestration, never a runtime dependency
|
||||||
|
|
||||||
|
The booth must run **fully when the mesh is down**. Komodo's agent model satisfies this:
|
||||||
|
Periphery + the local containers keep operating if Core is unreachable; we lose *remote
|
||||||
|
management* until the mesh returns, **not operation**. There must be **no runtime path** from
|
||||||
|
booth operation to Core — Core only deploys. (Periphery's own liveness is irrelevant to entry/
|
||||||
|
exit; the Fastify server and SQLite ledger run independently of it.)
|
||||||
|
|
||||||
|
### Threat model — the adversary is the booth operator ([[threat-model]])
|
||||||
|
|
||||||
|
This is the sharp edge, and the reason this page is explicit rather than a footnote.
|
||||||
|
|
||||||
|
- **Periphery is a root-capable remote-exec agent on the appliance.** If the operator
|
||||||
|
compromises the box, the agent is a lever. Mitigations: bind Periphery **only to the NetBird
|
||||||
|
interface** (never `0.0.0.0`), enforce its **passkey + TLS**, and fold the agent into the
|
||||||
|
[[disk-os-hardening]] surface. It is part of the trusted computing base now.
|
||||||
|
- **`EVENT_SIGNING_KEY` is the anti-fraud root.** It signs the [[append-only-event-chain|
|
||||||
|
append-only ledger]] — the control between us and a booth operator forging entry/exit events.
|
||||||
|
Holding it in Core means **a Core compromise can forge any booth's ledger that shares a key**.
|
||||||
|
Two mitigations make central management acceptable:
|
||||||
|
- **Per-booth, unique keys.** Never reuse a signing key across sites, so a single leak taints
|
||||||
|
one booth, not the fleet.
|
||||||
|
- **The [[atecc608|ATECC608]] is the real long-term signer.** The
|
||||||
|
`EVENT_SIGNING_KEY` HMAC is the *interim* mechanism; once the secure element signs the
|
||||||
|
chain, the key in Core stops being the fraud root. Tracked in [[open-questions]].
|
||||||
|
- **Core becomes a Tier-0 asset.** It now holds login + ledger keys for the whole fleet, so it
|
||||||
|
must be hardened to the booths' bar: Komodo API bound to the NetBird mesh only, never a public
|
||||||
|
interface; access-controlled; backed up.
|
||||||
|
|
||||||
|
### Licensing — Komodo is GPL-3.0, and that's fine here
|
||||||
|
|
||||||
|
The hard MIT/Apache/BSD constraint ([[technology-stack]]) is about **shipped app dependencies**
|
||||||
|
(code we distribute/link). Komodo is **external ops tooling we self-host and don't distribute**,
|
||||||
|
so its GPL-3.0 does not taint the product — exactly like the [[vision-service|AGPL ANPR
|
||||||
|
exception]] reasoning (a separate process / external boundary, not a linked dependency). Noted
|
||||||
|
here so it isn't re-litigated.
|
||||||
|
|
||||||
|
## What lives where
|
||||||
|
|
||||||
|
| Concern | Where | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Image build + tags | Gitea CI ([[container-deployment]]) | unchanged: `:dev` moving + `:dev-<sha>` immutable |
|
||||||
|
| Compose files | the repo + on the booth | unchanged base + `docker-compose.prod.yml` |
|
||||||
|
| Stack / deploy definition | **Komodo Core** | git-synced from `komodo/` (infra-as-code) |
|
||||||
|
| Which sha is deployed | **Komodo Core**, manual | `TAG=dev-<sha>`, pinned, no webhook |
|
||||||
|
| `JWT_SECRET`, `EVENT_SIGNING_KEY` | **Komodo Core** secret store | **per-booth, unique** |
|
||||||
|
| `COOKIE_SECURE=0`, `TAG`, `REGISTRY` | Komodo Stack env | per-environment |
|
||||||
|
| Registry pull creds | **Komodo Core** | so Periphery can pull from Gitea |
|
||||||
|
| Local break-glass | `booth.sh` + a local `.env` | mesh-down fallback only |
|
||||||
|
|
||||||
|
## Setup outline
|
||||||
|
|
||||||
|
**On each appliance** (after [[appliance-provisioning]]):
|
||||||
|
1. Install **Komodo Periphery** (binary or container), bound **only** to the NetBird interface;
|
||||||
|
set its passkey/TLS.
|
||||||
|
2. Point its compose/stack dir at `/opt/parking_systems/` (the existing files).
|
||||||
|
3. Keep `booth.sh` + a minimal local `.env` (no real secrets) as break-glass.
|
||||||
|
|
||||||
|
**In Komodo Core:**
|
||||||
|
1. Add the booth as a **Server**, address = its **NetBird IP** (mesh, not LAN/WAN).
|
||||||
|
2. Define the **Stack** = base + `docker-compose.prod.yml`, env from Core's secret store, secrets
|
||||||
|
**per booth**.
|
||||||
|
3. **No deploy webhook** on the booth Stack — deploys are manual; set `TAG=dev-<sha>` explicitly.
|
||||||
|
4. Add Gitea registry creds so Periphery can pull.
|
||||||
|
5. Sync the Stack/Server definitions from the repo's `komodo/` directory (infra-as-code:
|
||||||
|
`komodo/resources.toml` + README) so the control plane is itself reviewable +
|
||||||
|
version-controlled.
|
||||||
|
|
||||||
|
## Open / not yet done
|
||||||
|
|
||||||
|
- **Per-booth secret generation + rotation flow** — how a new site's unique `EVENT_SIGNING_KEY`
|
||||||
|
is generated and registered in Core (vs. on-site `openssl rand`). Tie-in: [[open-questions]]
|
||||||
|
JWT-key item.
|
||||||
|
- **ATECC608 as the signer** supersedes `EVENT_SIGNING_KEY`-in-Core as the fraud root — until
|
||||||
|
then central secrets carry the blast-radius noted above.
|
||||||
|
- **Periphery hardening checklist** folded into [[disk-os-hardening]] (interface binding, passkey,
|
||||||
|
TLS, agent as TCB).
|
||||||
|
- **Staging vs production booth split** (a staging booth on `:dev` with a webhook; production
|
||||||
|
manual+pinned) — not yet modelled in `komodo/`.
|
||||||
|
- **Core backup / DR** — Core is now Tier-0; its loss = no fleet management (operation
|
||||||
|
unaffected, per offline-first). Backup story TBD.
|
||||||
|
|
||||||
|
## Supersedes / relates
|
||||||
|
|
||||||
|
- **Supersedes** the "SSH + `booth.sh` is the deploy mechanism" assumption in
|
||||||
|
[[container-deployment]] (that page's *build/tag/registry* content stands; its `booth.sh`-as-
|
||||||
|
primary-deploy framing is now the fallback). Cross-linked there.
|
||||||
|
- Companion: the `komodo/` infra-as-code sketch (in the repo, not the wiki),
|
||||||
|
[[appliance-provisioning]] (what runs *before* Periphery), [[disk-os-hardening]] (the
|
||||||
|
appliance's hardening surface).
|
||||||
@@ -95,3 +95,15 @@ procurement. (See [[parking-system-architecture]] §10.)
|
|||||||
vs. serve-degraded — lean **serve-degraded + loud alarm** (fail-open on exit still governs;
|
vs. serve-degraded — lean **serve-degraded + loud alarm** (fail-open on exit still governs;
|
||||||
refusing to boot could strand a lane). Software-only, independent of the TPM/[[atecc608]] hardware.
|
refusing to boot could strand a lane). Software-only, independent of the TPM/[[atecc608]] hardware.
|
||||||
See [[append-only-event-chain]].
|
See [[append-only-event-chain]].
|
||||||
|
14. **Printer USB transport — confirm the on-site printer + bake the provisioning.** _(Recorded
|
||||||
|
2026-06-24; the transport code is built — see [[printer-usb-transport]].)_ The ESC/POS drivers
|
||||||
|
now drive **TCP (port 9100) OR local USB (`/dev/usb/lp0`)** behind one render layer, selectable
|
||||||
|
per device. **Open:** is the actual booth printer USB or network? (The site's verified units are
|
||||||
|
*networked* — Cashino `10.0.10.9`, Rongta `10.0.10.10` — so USB may be unused here; the original
|
||||||
|
BOM listed "Epson TM / Citizen (USB **or** network)", so a future site may need it.) If USB is
|
||||||
|
used, the **appliance image** must (a) load/keep the **`usblp`** kernel module bound to the
|
||||||
|
printer (CUPS can claim the interface first), and (b) ship a **udev rule** giving the non-root
|
||||||
|
server process write access to `/dev/usb/lp*`. Both are [[appliance-provisioning]] steps, **not
|
||||||
|
app code**, and are **unverified on hardware**. Close this once the printer transport per site is
|
||||||
|
fixed and (if USB) the udev/usblp rule is in the image and a real USB print is verified. Relates
|
||||||
|
to #1 (lane topology / image standardization). See [[printer-usb-transport]], [[rongta-printer]].
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: decision
|
type: decision
|
||||||
tags: [parking, decisions, vision, anpr, monorepo, packaging]
|
tags: [parking, decisions, vision, anpr, monorepo, packaging]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-19
|
updated: 2026-06-25
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -114,3 +114,29 @@ The skeleton is **built and wired** (no recognizer models yet):
|
|||||||
> **Resolved 2026-06-22 → [[container-deployment]]:** the vision service now ships as the
|
> **Resolved 2026-06-22 → [[container-deployment]]:** the vision service now ships as the
|
||||||
> `parking-vision` Docker image (uv base, `--extra alpr`), model weights **pre-warmed into the image
|
> `parking-vision` Docker image (uv base, `--extra alpr`), model weights **pre-warmed into the image
|
||||||
> layer** at build (offline-first), and runs under **docker-compose** (base + per-env override).
|
> layer** at build (offline-first), and runs under **docker-compose** (base + per-env override).
|
||||||
|
|
||||||
|
## Two runtimes, one fragile (the `uv run` strips-the-extra trap) — 2026-06-25
|
||||||
|
|
||||||
|
Real ANPR runs **completely differently on the two machines**, and only the dev path was fragile:
|
||||||
|
|
||||||
|
- **Booth (deployment) = the Docker image.** The `Dockerfile` runs `uv sync --frozen --extra alpr`
|
||||||
|
at build, so fast-alpr/onnxruntime are **baked into an immutable image layer** and the weights are
|
||||||
|
pre-warmed in. `docker-compose.prod.yml` forces `VISION_RECOGNIZER=fast_alpr`. Nothing at runtime
|
||||||
|
re-resolves the venv → **the booth's real ANPR cannot silently degrade.** (A booth
|
||||||
|
`ModuleNotFoundError: fast_alpr` is a STALE image, not this bug — fix with `booth.sh update` to pull
|
||||||
|
the current image.)
|
||||||
|
- **Dev machine = bare `uv run uvicorn …`** against `apps/vision/.venv`. **This is the trap:** a plain
|
||||||
|
`uv run` (or `uv sync` with no `--extra alpr`) re-resolves the venv to the lockfile **defaults** and
|
||||||
|
**REMOVES** the alpr stack — leaving the model weights orphaned in `~/.cache/open-image-models` but
|
||||||
|
no recognizer in the venv. So a dev box that ran real ANPR (weights downloaded, plate reads
|
||||||
|
recorded) silently degrades to "**snapshot captured but no plate**" after the next `pnpm dev`. This
|
||||||
|
exactly explains a gap observed 2026-06-25: real reads on 06-22, then nothing — the venv (frozen
|
||||||
|
since 06-19, lean) had been stripped, while the Docker/compose work (06-23) was an innocent
|
||||||
|
coincidence, not the cause.
|
||||||
|
|
||||||
|
**Fix (2026-06-25):** the vision `package.json` `dev`/`start`/`recognize` scripts now run
|
||||||
|
`uv sync --extra alpr &&` FIRST, so `pnpm dev` is **self-healing** — the recognizer survives every
|
||||||
|
run. A `dev:stub` script is the lean, model-free escape hatch. The booth (Docker) is untouched.
|
||||||
|
**Implication:** local real-ANPR and booth real-ANPR are now both reliable; CI/light contributors who
|
||||||
|
don't want the heavy stack use `dev:stub` or run the suite (tests are stub-mode, offline). See
|
||||||
|
[[opencv-anpr-service]].
|
||||||
|
|||||||
@@ -46,11 +46,41 @@ see [[dingtian-vs-mqtt]].
|
|||||||
|
|
||||||
## Driver & config API
|
## Driver & config API
|
||||||
|
|
||||||
The `dingtian` driver ([[device-registry]]) implements three capabilities:
|
The `dingtian` driver ([[device-registry]]) implements:
|
||||||
`AccessControlDevice` (relay pulse/latch over UDP), `InputDevice` (read inputs + poll-based
|
`AccessControlDevice` (relay pulse/latch over UDP), `AuxOutputDevice` (latch a NON-barrier output —
|
||||||
press/release events ~50 ms), and `PreconditionDevice` (below). Config fields include a separate
|
see below), `InputDevice` (read inputs + poll-based press/release events ~50 ms), and
|
||||||
**`httpPort`** — the device's web/config API is on a configurable HTTP port (default **80**),
|
`PreconditionDevice` (below). Config fields include a separate **`httpPort`** — the device's
|
||||||
distinct from the UDP control port 60001.
|
web/config API is on a configurable HTTP port (default **80**), distinct from the UDP control port
|
||||||
|
60001.
|
||||||
|
|
||||||
|
### Spare relays + aux outputs (`setAux`)
|
||||||
|
|
||||||
|
A 4-input board typically has spare relays once the entry/exit barriers are wired. These drive
|
||||||
|
**non-barrier indicators** — e.g. the entry button's 12 V lamp. Every relay is a `config.relays[]`
|
||||||
|
row carrying the **event** it reacts to (`direction`): the barrier events (`entry`/`exit`/`both`)
|
||||||
|
pulse, while a **`radarAlert`** row is an [[button-light-indicator|alert relay]] (blink + camera-lock).
|
||||||
|
Business logic drives alert relays through the device-agnostic `AuxOutputDevice.setAux(channel, on)`
|
||||||
|
(a latch), **never** the barrier `pulseOpen`; every barrier resolver skips `radarAlert` rows. The
|
||||||
|
[[barrier-not-a-door]] rule doesn't apply to an aux output (it never gates a vehicle), so
|
||||||
|
holding/blinking it is fine.
|
||||||
|
|
||||||
|
### Inputs are a first-class list (`config.inputs[]`)
|
||||||
|
|
||||||
|
Input wiring lives in `config.inputs[] = [{ input, role, relay?, kind?, activeLow?, cooldownSec? }]`
|
||||||
|
— the twin of `relays[]`. `role` ∈ `button` | `presence` | `alertTrigger`; a button/presence row
|
||||||
|
names the `relay` it serves; presence rows carry `kind` (loop/radar) + `activeLow`. Adding an exit
|
||||||
|
radar is adding a `presence` row. (Pre-2026-06-28 configs wired this on the relay itself —
|
||||||
|
`relays[].button/presenceInput/...`; `inputsOf()` synthesizes inputs[] from those for back-compat,
|
||||||
|
so old configs keep working until re-saved.)
|
||||||
|
|
||||||
|
### Per-input active level (`inputs[].activeLow` / `inputActiveLow`)
|
||||||
|
|
||||||
|
Inputs are normalised against ONE board-wide resting level (`inputRestingHigh`). When a sensor (e.g.
|
||||||
|
a [[hikvision-radar|radar]]) idles **opposite** the button, mark its terminal active-LOW — sourced
|
||||||
|
from `inputs[].activeLow` (and the legacy `relays[].presenceActiveLow`, plus an explicit top-level
|
||||||
|
`inputActiveLow[]` escape hatch), all merged by `activeLowFrom()` into the driver's `inputActiveLow`
|
||||||
|
set — so that one input is read inverted while the button keeps the board default. (`inputActive()`
|
||||||
|
is the pure helper; push-mode uses the device's own `ilu.active_level` instead.)
|
||||||
|
|
||||||
### Precondition: input_link_relay must be OFF
|
### Precondition: input_link_relay must be OFF
|
||||||
|
|
||||||
@@ -140,6 +170,28 @@ On assign the driver runs `harden()` (the [[device-registry|HardenableDevice]] c
|
|||||||
> drop connections (ECONNRESET), locking out the API the driver depends on — recoverable only by
|
> drop connections (ECONNRESET), locking out the API the driver depends on — recoverable only by
|
||||||
> factory reset. `harden()` deliberately never touches it.
|
> factory reset. `harden()` deliberately never touches it.
|
||||||
|
|
||||||
|
## `relayPassword` field + the "offline despite ping" gotcha (2026-06-24)
|
||||||
|
|
||||||
|
`relay_pw` is in **every** binary frame — control AND the status read `healthCheck()` uses. With a
|
||||||
|
wrong/missing value the device **silently drops the packet** (no NAK), so the probe **times out →
|
||||||
|
the controller shows "offline" even though it pings** (ping is ICMP and never touches the binary
|
||||||
|
protocol). This bit a real bring-up: the driver read `config.relayPassword` but there was **no form
|
||||||
|
field** for it, so Test connection sent `0` → timeout → "offline", while `relay_pw` was actually a
|
||||||
|
non-zero value the harden flow had set. Diagnostic: a raw UDP status frame
|
||||||
|
(`FF AA <s> 00 <pwLo> <pwHi>`) replies *only* with the right password — `pw=N` → `ffaa…`, `pw=0` →
|
||||||
|
timeout — and binding the WSL socket to the device-facing NIC (`localAddress`) also broke the reply
|
||||||
|
(leave it unbound on WSL). Fix: a **"Relay control password"** config field (a **secret**; blank =
|
||||||
|
keep the stored value).
|
||||||
|
|
||||||
|
> 🔒 **Secret re-merge is identity-gated (don't let a redirected probe exfiltrate it).** Because
|
||||||
|
> `relayPassword`/`pushPassword` are redacted from the client ([[first-run-setup]]), the edit form
|
||||||
|
> can't resend them, so `/api/setup/test` re-merges the stored secret by device **id** — but ONLY
|
||||||
|
> when the submitted config addresses the **same device**: matching `driverId` and every
|
||||||
|
> connection-identity field it sets (`host`/`port`/`binaryPort`/`httpPort`/`serial`). A redirected
|
||||||
|
> host/port or mismatched driver returns NO secret, so an authenticated admin can't point a test at
|
||||||
|
> an attacker host and have the password sent there (the booth operator is the [[threat-model]]
|
||||||
|
> adversary). Save already merged from the stored row; this closes the same gap on test.
|
||||||
|
|
||||||
## Status — VERIFIED on hardware (DT-R004, sw V3.1.5461A, 10.0.10.172)
|
## Status — VERIFIED on hardware (DT-R004, sw V3.1.5461A, 10.0.10.172)
|
||||||
|
|
||||||
- ✅ status read (`0000:1111:4`), relay pulse, input press/release events (active-LOW, idle HIGH).
|
- ✅ status read (`0000:1111:4`), relay pulse, input press/release events (active-LOW, idle HIGH).
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
type: entity
|
||||||
|
tags: [parking, device, sensor, radar, entry, presence]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-24
|
||||||
|
status: settled
|
||||||
|
---
|
||||||
|
|
||||||
|
# Hikvision Radar (vehicle-presence sensor)
|
||||||
|
|
||||||
|
A radar mounted at an entry barrier that **closes a dry-contact relay when it detects something in
|
||||||
|
its vicinity** (a vehicle approaching the barrier). Wired to a **[[dingtian-relay|Dingtian]] input
|
||||||
|
terminal**, it acts as the vehicle-**presence** signal for the entry flow — functionally the same
|
||||||
|
role as an induction loop, just a different sensor.
|
||||||
|
|
||||||
|
## Where it sits in the model
|
||||||
|
|
||||||
|
The radar is a **child of the access controller config**, not a standalone device. On the entry
|
||||||
|
relay's spec (`config.relays[]`):
|
||||||
|
- `presenceInput` = the 1-based input terminal the radar's contact is wired to (e.g. **I2**).
|
||||||
|
- `presenceKind: "radar"` = a label (vs. `"loop"`) for the UI + telemetry; the **gate behaviour is
|
||||||
|
identical** either way.
|
||||||
|
- `presenceActiveLow` = set when the radar idles HIGH and pulls LOW on detection (see below).
|
||||||
|
|
||||||
|
The booth's wiring (first install): **button on I1, radar on I2**, both on the same 4-input Dingtian.
|
||||||
|
|
||||||
|
## Its job: the one-car-one-ticket gate (advisory, never opens a barrier)
|
||||||
|
|
||||||
|
The radar feeds the **[[entry-double-press|one car = one ticket]]** gate exactly as a loop does: the
|
||||||
|
entry button prints a ticket **only while the radar shows a vehicle present**, and **no second
|
||||||
|
ticket** issues until the radar **clears** (the car drove in) and a new car re-occupies the zone.
|
||||||
|
|
||||||
|
> The radar is **advisory**. A detection NEVER opens a barrier on its own — it only *gates* the
|
||||||
|
> button press. Entry still requires the physical press (and the capacity gate). This is the
|
||||||
|
> [[threat-model]] rule: a sensor reading is never the sole reason a barrier opens. (Distinct from
|
||||||
|
> the [[lane-presence-and-anpr-entry|ANPR bridge]], which admits *subscribers* through the gated
|
||||||
|
> subscription flow — also never a transient open.)
|
||||||
|
|
||||||
|
## The active-level gotcha (why `presenceActiveLow` exists)
|
||||||
|
|
||||||
|
The Dingtian normalises **all** inputs against one board-wide resting level (`inputRestingHigh`).
|
||||||
|
The booth's **button** (NO contact to GND) idles HIGH and pulls LOW on press. A **radar's dry
|
||||||
|
contact may idle the opposite way** — and if it does, the controller would read "vehicle present"
|
||||||
|
exactly when the zone is *clear*, inverting the gate (and the [[button-light-indicator|button
|
||||||
|
lamp]]).
|
||||||
|
|
||||||
|
Fix: mark the radar's terminal **active-LOW** (`presenceActiveLow: true` on the relay spec). The
|
||||||
|
driver then reads just that input inverted (active when LOW), leaving the button on the board
|
||||||
|
default. Implemented as a per-input override in `access-dingtian.ts` (`inputActive()` +
|
||||||
|
`inputActiveLow` set, derived from each relay's `presenceActiveLow`). Push-mode (the
|
||||||
|
`/input/:n/:edge` HTTP path) relies instead on the device's own `ilu.active_level`; the override is
|
||||||
|
the **poll-mode** equivalent.
|
||||||
|
|
||||||
|
## Also drives the button light
|
||||||
|
|
||||||
|
The same radar present/clear signal, combined with the camera's lane status, drives the entry
|
||||||
|
button's 12 V lamp on a spare relay — see [[button-light-indicator]].
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Modelled 2026-06-24 (button I1 + radar I2 on the first booth's Dingtian). Gate behaviour reuses the
|
||||||
|
existing presence path; only the label + active-level override were added. Related:
|
||||||
|
[[dingtian-relay]], [[entry-double-press]], [[lpr-camera]], [[entry-exit-points]].
|
||||||
+104
-1
@@ -2,7 +2,7 @@
|
|||||||
type: entity
|
type: entity
|
||||||
tags: [parking, hardware, readers, offline-first]
|
tags: [parking, hardware, readers, offline-first]
|
||||||
sources: [parking-system-architecture]
|
sources: [parking-system-architecture]
|
||||||
updated: 2026-06-15
|
updated: 2026-06-27
|
||||||
---
|
---
|
||||||
|
|
||||||
# LPR Camera
|
# LPR Camera
|
||||||
@@ -59,6 +59,57 @@ A **Hikvision** unit ("Camera 20", MAC `94:e1:ac:…`, Hikvision OUI) at `10.0.1
|
|||||||
- Reaching it from the WSL dev box required forcing the source address (`config.localAddress`,
|
- Reaching it from the WSL dev box required forcing the source address (`config.localAddress`,
|
||||||
threaded into the driver) — see [[wsl-dev-networking]] (multi-subnet source-selection trap).
|
threaded into the driver) — see [[wsl-dev-networking]] (multi-subnet source-selection trap).
|
||||||
|
|
||||||
|
### HTTP 503 "Device Busy" — can be PERSISTENT; the real fix is stream selection (2026-06-26)
|
||||||
|
|
||||||
|
The snapshot endpoint returns **HTTP 503** with the ISAPI body `statusCode 2` / `"Device Busy"` /
|
||||||
|
`subStatusCode deviceBusy` (occasionally **500**). It comes in two flavours, and they need different
|
||||||
|
fixes — **don't assume it's a momentary blip**:
|
||||||
|
|
||||||
|
- **Transient** — the encoder is briefly occupied (another snapshot in flight, a stream starting).
|
||||||
|
Clears on retry within a frame or two.
|
||||||
|
- **Persistent** — the **MAIN-stream encoder is saturated** and 503s on EVERY main-stream snapshot.
|
||||||
|
Confirmed on hardware (**DS-2CD1047G3H-LIU**, 2026-06-26): `channels/101/picture` → 503 on five
|
||||||
|
consecutive probes 800 ms apart, while **`channels/102/picture` (the SUB stream) → 200 every time**,
|
||||||
|
a clean ~15 KB JPEG. So the path/API was correct (the camera answered with a structured Hikvision
|
||||||
|
status); the main encoder was simply never free. A retry loop **cannot** fix this — it just delays
|
||||||
|
the failure.
|
||||||
|
|
||||||
|
> **Sharper finding (2026-06-27, same DS-2CD1047G3H-LIU).** Re-probed `10.0.10.13` directly after a
|
||||||
|
> camera reboot with every web/live-view connection closed. **Sub (102): 10/10 rapid back-to-back
|
||||||
|
> pulls → 200** (~50 ms, ~14.7 KB) — flawless even with NO delay, harder than the live ANPR cadence.
|
||||||
|
> **Main (101): 3/3 → 503 in ~20 ms** — an *instant* reject, not a timeout. So main isn't merely
|
||||||
|
> "saturated/busy" on this model — its snapshot endpoint is **structurally unavailable**; **sub (102)
|
||||||
|
> is mandatory**, not just preferable. SEPARATELY, the camera 503s on **any** stream when its
|
||||||
|
> **connection slots are exhausted** — a human holding the web UI / live-view, or parallel
|
||||||
|
> main-stream experiments, consume slots; a **reboot clears stuck slots**. This was the actual cause
|
||||||
|
> of the **2026-06-27 "subscribers auto-enter but don't auto-exit"** scare: manual main-stream
|
||||||
|
> testing held the exit camera's slots → the system's sub-stream snapshot pulls got "Device Busy" →
|
||||||
|
> the ANPR exit read never got a frame → no auto-exit. **NOT a code/flow bug** — the subscription
|
||||||
|
> exit logic, camera→relay binding, and `stream: "2"` config were all correct (auto-exit/entry pairs
|
||||||
|
> were clean before the test storm and after the reboot). (Also recorded as the LLM memory
|
||||||
|
> `g3h-main-stream-snapshot-503`.)
|
||||||
|
|
||||||
|
**The fix that actually works: snapshot from the SUB stream.** The Hikvision ISAPI channel id is
|
||||||
|
`<channel><stream>` (e.g. ch1 main = `101`, ch1 **sub = `102`**). The driver now has a **`stream`
|
||||||
|
config field** (`1` = main, default for back-compat; `2` = sub). Set the G3H camera to **Sub (02)** in
|
||||||
|
the setup form → its status flips `degraded → ready` (verified live: pulled a 14.7 KB JPEG in ~87 ms).
|
||||||
|
The sub-stream is also the better fit for snapshot/ANPR anyway (smaller/faster; doesn't contend with
|
||||||
|
live-view/recording for the main encoder).
|
||||||
|
|
||||||
|
Two more complementary mitigations (both BUILT, for the *transient* case):
|
||||||
|
1. **Don't cause concurrent busy.** On a vehicle entry two server paths used to snapshot the same
|
||||||
|
camera at once (the ANPR bridge + the advisory `snapshotAsync`); the 2nd concurrent GET drew a 503.
|
||||||
|
They now share ONE pull via `captureSnapshotShared` (deviceId-keyed, `apps/server/src/snapshot.ts`)
|
||||||
|
— the main cause of the slow 2026-06-25 subscriber entry. See [[lane-presence-and-anpr-entry]].
|
||||||
|
2. **Retry a transient one.** `HttpCamera.captureSnapshot` retries 503/500 with a short linear backoff
|
||||||
|
(250/500/750 ms, ≤4 attempts), then fails naming it `(device busy)`; it does NOT retry 401/404
|
||||||
|
(config errors won't self-heal). This recovers a momentary blip but, by design, still fails a
|
||||||
|
PERSISTENTLY-busy main stream — the cue to switch that camera to the sub-stream.
|
||||||
|
|
||||||
|
Covered by `packages/devices/src/drivers/camera.test.ts` (retry behaviour + the main/sub path
|
||||||
|
selection). `healthCheck()` deliberately reports a live 503 as `degraded` (it surfaces a genuinely
|
||||||
|
saturated main stream rather than hiding it behind a retry).
|
||||||
|
|
||||||
## Camera PUSH — "Alarm Server" event notifications (2026-06-22)
|
## Camera PUSH — "Alarm Server" event notifications (2026-06-22)
|
||||||
|
|
||||||
Separate from the **pull** snapshot path above: newer Hikvision firmware can **push** an event to
|
Separate from the **pull** snapshot path above: newer Hikvision firmware can **push** an event to
|
||||||
@@ -90,6 +141,58 @@ Center**, then **Alarm Settings → Alarm Server**, makes the camera **HTTP-POST
|
|||||||
use it directly; this `DS-2CD1043G2`
|
use it directly; this `DS-2CD1043G2`
|
||||||
does not, so the server pulls the frame and hands it to the [[opencv-anpr-service|vision service]].
|
does not, so the server pulls the frame and hands it to the [[opencv-anpr-service|vision service]].
|
||||||
|
|
||||||
|
### "Subscribers auto-enter but don't auto-exit" — a 4-layer CAMERA fault, NOT our code (2026-06-27)
|
||||||
|
|
||||||
|
A long debugging session on the **DS-2CD1047G3H-LIU** exit camera (`10.0.10.13`, exit-lane). The
|
||||||
|
symptom: subscribers (e.g. Caca) auto-entered via ANPR fine but **never auto-exited**. **Every
|
||||||
|
assumption about *our* code was wrong; all four real causes were camera-side.** Method that finally
|
||||||
|
cracked it: a **dumb HTTP sink** (`scratch-camera-sink.py`) the camera's Alarm Server was pointed
|
||||||
|
at, to see — verbatim — what the camera actually sends, independent of our app's parsing/acceptance.
|
||||||
|
|
||||||
|
The wrong turns, and what was actually true:
|
||||||
|
|
||||||
|
1. **Wrong assumption: "the exit camera 503s, so harden the snapshot retry / reduce load."** The 503
|
||||||
|
storm in the data was mostly **manual main-stream testing**: on this G3H, `channels/101/picture`
|
||||||
|
(MAIN) **503s instantly every time** — structurally unavailable, not "busy" — while `102` (SUB)
|
||||||
|
serves 10/10 rapid pulls cleanly. AND the camera **503s on *any* stream when its connection slots
|
||||||
|
are exhausted** (a held web UI / live-view, parallel experiments); a **reboot clears stuck slots**.
|
||||||
|
So the snapshot retry/flow code was fine. See [[#HTTP 503 "Device Busy"]] + the
|
||||||
|
`g3h-main-stream-snapshot-503` memory. (The exit/subscription FLOW logic, camera→relay binding,
|
||||||
|
and `stream:"2"` config were all correct the whole time — verified: clean entry/exit pairs before
|
||||||
|
the test storm, and after the reboot.)
|
||||||
|
|
||||||
|
2. **The actual blocker #1 — the exit camera never POSTed at all.** `alarmPushEnabled=true` in our
|
||||||
|
config, but `10.0.10.13` had sent **ZERO** alarms ever (entry cam `.12`: 1126). The sink received
|
||||||
|
nothing from `.13`; our `/event` endpoint logged no rejections either → the camera wasn't sending.
|
||||||
|
Cause found in the camera's own **Diagnose Information** dump: **`Main Db is broken` /
|
||||||
|
`db_restore failed` / `Going to reset cfg`** — the camera's internal config DB (`ipc_db`) was
|
||||||
|
**CORRUPT**, plus repeated reboots. A broken config DB means the event→linkage→push pipeline can't
|
||||||
|
reliably read its own config, so it silently never POSTs. **Fix: factory-reset the camera** (rebuilds
|
||||||
|
`ipc_db`), then reconfigure. (If corruption returns after a clean reset → failing flash → RMA.)
|
||||||
|
|
||||||
|
3. **Wrong assumption: "missing gateway/DNS blocks the push."** A documented Hikvision note says a
|
||||||
|
gateway is needed even same-subnet — but here it was **inverted**: the WORKING cam `.12` has NO
|
||||||
|
gateway/DNS; the broken `.13` HAD both. Red herring. Gateway/DNS was not the cause.
|
||||||
|
|
||||||
|
4. **The actual blocker #2 — after reset, the camera pushed PLAIN MOTION, not vehicle.** Post-reset
|
||||||
|
`.13` POSTed `<eventType>VMD</eventType>` with **no target tag**. The backend gates on
|
||||||
|
`target == "vehicle"` (`hikvision-alarm.ts` `isVehicleActive`, matches `<targetType>` /
|
||||||
|
`<detectionTarget>` / `<objectType>`), so a plain-motion push is **ignored** → bridge never fires.
|
||||||
|
The working `.12` sends `eventType=VMD` **with `target=vehicle`**. The difference is the AcuSense
|
||||||
|
**Detection-Target = Vehicle** filter ON the Motion event — defaulted OFF after factory reset.
|
||||||
|
**Fix: enable Vehicle target classification** on `.13`'s Motion Detection. Confirmed live: a real
|
||||||
|
drive-through then POSTed `<eventType>VMD</eventType> … <targetType>vehicle</targetType>` — exactly
|
||||||
|
what the backend needs. (So "VMD/Motion" IS the right event for this camera class; it's the *target
|
||||||
|
filter* that matters, not switching to a different event type.)
|
||||||
|
|
||||||
|
**Takeaways:** (a) a camera that's silently not-pushing looks identical to "fine" in our logs — the
|
||||||
|
sink-to-prove-it-sends method is the fastest disambiguator; flagged as an observability gap (a
|
||||||
|
`alarmPushEnabled=true` camera with 0 pushes ever should be a surfaced condition, like the
|
||||||
|
[[device-status-monitoring|reader-liveness]] fix). (b) For ANPR the camera must send a **vehicle
|
||||||
|
`targetType`** — verify the push body, not just the UI toggles. (c) Hikvision config-DB corruption
|
||||||
|
is real; factory reset is the cure. None of this was a code bug. See
|
||||||
|
[[lane-presence-and-anpr-entry]] for the push→bridge→exit path.
|
||||||
|
|
||||||
### Gotchas learned the hard way (2026-06-22 field session)
|
### Gotchas learned the hard way (2026-06-22 field session)
|
||||||
|
|
||||||
Several traps surfaced trying to get a real camera to push. In order of how long each cost:
|
Several traps surfaced trying to get a real camera to push. In order of how long each cost:
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ many ESC/POS-compatible OEM clones that share its firmware). Driver `rongta` in
|
|||||||
We scrape that rather than hand-decode `DLE EOT` — this clone's DLE EOT reply bytes do **not**
|
We scrape that rather than hand-decode `DLE EOT` — this clone's DLE EOT reply bytes do **not**
|
||||||
match the canonical ESC/POS bit layout (verified on hardware), so trusting the device's own
|
match the canonical ESC/POS bit layout (verified on hardware), so trusting the device's own
|
||||||
decode avoids a false-healthy. Implemented as `readStatus()`; see [[printer-status-monitoring]].
|
decode avoids a false-healthy. Implemented as `readStatus()`; see [[printer-status-monitoring]].
|
||||||
|
- **USB transport (added 2026-06-24).** The same driver can instead drive a printer over a local
|
||||||
|
USB `usblp` char device (`/dev/usb/lp0`) — `config.transport` (`tcp-ip` | `usb`) picks the wire
|
||||||
|
behind one render layer (the ESC/POS bytes are identical). The status web page is a **network**
|
||||||
|
feature, so a **USB Rongta degrades to reachability-only** monitoring (open-the-node probe, no
|
||||||
|
paper/cover verdict — the same honesty floor as the Cashino). Driving USB depends on the appliance
|
||||||
|
image (`usblp` bound + a udev write-access rule) — a provisioning step, open-questions #14. Full
|
||||||
|
rationale in [[printer-usb-transport]].
|
||||||
|
|
||||||
## Deployment (this site)
|
## Deployment (this site)
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -42,8 +42,9 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
|
|||||||
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
|
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
|
||||||
- [[gee-qr-er80]] — QR access reader on hand; host-side serial → `read` bus (the QR-ticket scanner).
|
- [[gee-qr-er80]] — QR access reader on hand; host-side serial → `read` bus (the QR-ticket scanner).
|
||||||
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
|
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
|
||||||
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware).
|
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware); spare relays drive aux outputs (`setAux`).
|
||||||
- [[rongta-printer]] — ✅ CHOSEN 80mm thermal printer; ESC/POS over raw TCP 9100; driver written, one unit reachable at 10.0.10.6.
|
- [[hikvision-radar]] — vehicle-presence radar on a Dingtian input; the entry presence gate (per-input active-level caveat).
|
||||||
|
- [[rongta-printer]] — ✅ CHOSEN 80mm thermal printer; ESC/POS over raw TCP 9100 (or local USB, see [[printer-usb-transport]]); driver written, one unit reachable at 10.0.10.6.
|
||||||
- [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network).
|
- [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network).
|
||||||
|
|
||||||
## Concepts — foundational forces
|
## Concepts — foundational forces
|
||||||
@@ -65,6 +66,7 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
|
|||||||
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
|
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
|
||||||
- [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth.
|
- [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth.
|
||||||
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
|
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
|
||||||
|
- [[printer-usb-transport]] — ESC/POS drivers drive TCP (9100) OR local USB (/dev/usb/lp0) behind one render layer; USB = usblp char device, reachability-only status; provisioning open (oq#14).
|
||||||
- [[device-status-monitoring]] — unified live status across ALL device categories (healthCheck + printer readStatus) → the booth footer over /api/ws.
|
- [[device-status-monitoring]] — unified live status across ALL device categories (healthCheck + printer readStatus) → the booth footer over /api/ws.
|
||||||
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
||||||
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
||||||
@@ -76,7 +78,8 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
|
|||||||
- [[challenge-response-auth]] — asymmetric nonce scheme for the ESP32 (auth + anti-replay).
|
- [[challenge-response-auth]] — asymmetric nonce scheme for the ESP32 (auth + anti-replay).
|
||||||
- [[entry-exit-readers]] — two populations, two integration paths; both can share a relay.
|
- [[entry-exit-readers]] — two populations, two integration paths; both can share a relay.
|
||||||
- [[entry-exit-points]] — pool-of-spaces model (no lane); per-relay direction, reader→relay binding, camera snapshots.
|
- [[entry-exit-points]] — pool-of-spaces model (no lane); per-relay direction, reader→relay binding, camera snapshots.
|
||||||
- [[entry-double-press]] — one car = one ticket: per-relay presence-loop gate (preferred) or cooldown fallback; suppressed press = telemetry.
|
- [[entry-double-press]] — one car = one ticket: per-relay presence gate (loop OR radar) preferred, cooldown fallback; suppressed press = telemetry.
|
||||||
|
- [[button-light-indicator]] — entry button lamp on a spare relay: radar × camera 3-state (blink/solid/off); aux-output; fails OFF.
|
||||||
- [[uhppote-vs-esp32]] — comparison: detection vs. prevention.
|
- [[uhppote-vs-esp32]] — comparison: detection vs. prevention.
|
||||||
|
|
||||||
## Concepts — business domain
|
## Concepts — business domain
|
||||||
@@ -122,4 +125,5 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
|
|||||||
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
|
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
|
||||||
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell.
|
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell.
|
||||||
- [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot.
|
- [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot.
|
||||||
|
- [[fleet-deployment-komodo]] — fleet control plane: Komodo Periphery on each booth, driven by Komodo Core over a NetBird mesh, running the same compose files. Deploys manual + pinned to dev-<sha> (no webhook); secrets Komodo-managed per-booth+unique; booth.sh demoted to break-glass. Threat-model caveats: Periphery is a root agent (mesh-bound only), EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius until ATECC608 signs. komodo/ is infra-as-code.
|
||||||
- [[appliance-provisioning]] — booth-PC provisioning runbook (Dell 7070, i5-8500, discrete Nuvoton TPM): BIOS/Secure-Boot → direct-flash Ubuntu 26.04 USB (not Ventoy) → passphrase-LUKS install → manual PCR-7 TPM seal (workaround for the installer's dbt PCR_UNUSABLE error) → Docker. Verified on hardware 2026-06-23; TPM auto-unlock works.
|
- [[appliance-provisioning]] — booth-PC provisioning runbook (Dell 7070, i5-8500, discrete Nuvoton TPM): BIOS/Secure-Boot → direct-flash Ubuntu 26.04 USB (not Ventoy) → passphrase-LUKS install → manual PCR-7 TPM seal (workaround for the installer's dbt PCR_UNUSABLE error) → Docker. Verified on hardware 2026-06-23; TPM auto-unlock works.
|
||||||
|
|||||||
+258
@@ -1552,3 +1552,261 @@ username chip links to it), `email` added to the session view + `SessionUser`. 7
|
|||||||
builds `.deb` + `.AppImage` on every push to dev/main and uploads them as UNSIGNED workflow artifacts
|
builds `.deb` + `.AppImage` on every push to dev/main and uploads them as UNSIGNED workflow artifacts
|
||||||
(per-commit test build); the signed/versioned release stays on `release.yml` (tag `v*`). See
|
(per-commit test build); the signed/versioned release stays on `release.yml` (tag `v*`). See
|
||||||
[[desktop-shell-tauri]] "Desktop in CI".
|
[[desktop-shell-tauri]] "Desktop in CI".
|
||||||
|
|
||||||
|
## [2026-06-24] build | Radar presence input + button-light output on the Dingtian
|
||||||
|
The first booth wired an **entry button on I1** and a **[[hikvision-radar|Hikvision radar]] on I2**
|
||||||
|
(closes a dry contact on detection), plus the **button's 12 V lamp on a spare relay**. Modelled as
|
||||||
|
children of the access controller config — no new device category. (1) The radar reuses the existing
|
||||||
|
`relays[].presenceInput` one-car-one-ticket gate; added `presenceKind: loop|radar` (label) and
|
||||||
|
`presenceActiveLow` (a radar may idle opposite the button — the Dingtian has ONE board-wide resting
|
||||||
|
level, so a per-input override `inputActiveLow` inverts just that terminal; pure helper
|
||||||
|
`inputActive()`). (2) New device-agnostic **`AuxOutputDevice.setAux(channel,on)`** capability (Dingtian
|
||||||
|
latch) so business logic drives a NON-barrier lamp through the interface — barriers still only
|
||||||
|
`pulseOpen` ([[barrier-not-a-door]] preserved). (3) New `ButtonLightController`
|
||||||
|
(`apps/server/src/button-light.ts`): subscribes to the radar input edge + the camera
|
||||||
|
[[lpr-camera|lane status]] and drives a **3-state lamp** — radar+car=SOLID, radar-only=BLINK (~1 Hz),
|
||||||
|
else OFF; **fails OFF**; de-duped. (4) SetupWizard: presence kind + active-low + a button-light relay
|
||||||
|
picker; i18n parity (sq+en). Tests: `button-light.test.ts` (truth table + blink + fail-OFF + de-dupe),
|
||||||
|
`access-dingtian.test.ts` (active-level inversion). Workspace build+lint+test green (158 server tests).
|
||||||
|
A radar detection NEVER opens a barrier on its own — it only gates the button ([[threat-model]]). See
|
||||||
|
[[hikvision-radar]], [[button-light-indicator]], [[entry-double-press]], [[dingtian-relay]].
|
||||||
|
|
||||||
|
## [2026-06-24] fix | Booth bring-up fixes — relay password, form split, lamp concurrency
|
||||||
|
Three fixes from wiring the radar/lamp on the first booth (committed 420542c, fd15988, 830993b on
|
||||||
|
top of the 2915d14 feature). (1) **"Offline despite ping"** — the Dingtian's `relay_pw` is in every
|
||||||
|
binary frame incl. the status read, but had NO form field, so Test connection sent 0 → device
|
||||||
|
silently drops the packet → "offline" (ping is ICMP, unrelated). Added a **"Relay control password"**
|
||||||
|
secret field; because the secret is redacted, the test endpoint re-merges it by device id but ONLY
|
||||||
|
when host/port/driver match the stored row (a redirected probe can't exfiltrate it — `setup-secrets.test.ts`).
|
||||||
|
(2) **Form split** — the controller editor now has separate **Outputs** (relays + pulse-open + lamp)
|
||||||
|
and **Inputs** (button + presence/radar terminals, "For relay N") sections; UI-only, storage
|
||||||
|
unchanged. `pulse open (ms)` clarified as a relay/output setting, not an input. (3) **Lamp stuck
|
||||||
|
on/off** — the blink fired fire-and-forget `setAux` over UNORDERED UDP; concurrent on/off packets
|
||||||
|
reordered and the relay latched on the last-processed one. Replaced with a serialized desired-state
|
||||||
|
worker (one in-flight send/lamp, re-converges to the latest state → final state authoritative). Also
|
||||||
|
**hot-reload**: the lamp map now reconciles against live config each event, so a button light added
|
||||||
|
in the UI works without a server restart. Workspace build+lint+test green (163 server tests). See
|
||||||
|
[[dingtian-relay]] ("offline despite ping" + secret re-merge), [[button-light-indicator]] (serialized
|
||||||
|
sends + hot-reload).
|
||||||
|
|
||||||
|
## [2026-06-24] build | Printer USB transport behind the ESC/POS render layer
|
||||||
|
The ESC/POS printer drivers were **TCP-only** (every path went through `sendRaw`/`probe` to a raw
|
||||||
|
socket on port 9100); the original BOM intended one adapter to cover "USB **or** network". Added a
|
||||||
|
**USB transport** behind the existing render layer without touching a single `render*()` function:
|
||||||
|
a discriminated `Transport` (`transportFromConfig` → `{kind:"tcp",host,port}` | `{kind:"usb",
|
||||||
|
devicePath}`) and `sendTo`/`probeTo` dispatchers in `printer-escpos.ts`; USB writes the same ESC/POS
|
||||||
|
bytes to a kernel **`usblp`** char device (`/dev/usb/lp0`) via a plain `fs` write — **no libusb/CUPS/
|
||||||
|
native dep** (keeps MIT-only + minimal-deps appliance). `cashino` + `rongta` resolve a Transport once;
|
||||||
|
both are reachability-only over USB, and the Rongta's HTTP **status page degrades to the open-the-node
|
||||||
|
probe** over USB (no guessed paper/cover — the standing honesty rule). Non-`usb` configs are unchanged
|
||||||
|
(host-only = TCP), so no migration. Setup UI gains a **Connection** select + **USB device** field;
|
||||||
|
host/port made not-required so a USB printer needs neither. Tests: `printer-escpos.test.ts` (USB writes
|
||||||
|
the exact rendered bytes; probe present/absent; `transportFromConfig` TCP back-compat) +
|
||||||
|
`printer-cashino.test.ts` (USB-configured driver prints to the node, ready/offline). Devices suite
|
||||||
|
green (29). **Flagged open-questions #14**: confirm the on-site printer is USB and bake the
|
||||||
|
**usblp + udev write-access** rule into the appliance image (provisioning, not app code; unverified on
|
||||||
|
hardware). See [[printer-usb-transport]], [[rongta-printer]].
|
||||||
|
|
||||||
|
## [2026-06-24] build | Booth operator wrapper script — scripts/booth.sh
|
||||||
|
The booth PC (Ubuntu) needs one command instead of the long
|
||||||
|
`docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env …` line over the
|
||||||
|
three compose files. Added **`scripts/booth.sh`** (+ root **`.env.example`**): **prod by default**
|
||||||
|
(`ENV=dev` for the dev override); subcommands `up`/`down`/`restart`/`status`/`logs`/`pull`/`config`/
|
||||||
|
`exec`, and the requested **`update`** = `compose pull` the moving branch tag → `up -d --remove-orphans`
|
||||||
|
(recreates only digest-changed services, **named volumes/SQLite ledger preserved**) → `docker image
|
||||||
|
prune -f`. Prod **refuses to run without `.env`** (no safe `JWT_SECRET` default); dev with no `.env`
|
||||||
|
injects the documented benign local secret (the base file makes `JWT_SECRET` shell-required via
|
||||||
|
`${JWT_SECRET:?}`, which the dev override's service-level default alone can't satisfy). `down` never
|
||||||
|
passes `-v` (would wipe the signed [[append-only-event-chain|ledger]] volume); `help`/unknown-command
|
||||||
|
short-circuit before any Docker/.env requirement. Verified: prod `config` renders Caddy:80 + internal
|
||||||
|
server + pinned images + `fast_alpr`; dev `config` renders `:dev` images + `stub` + published ports.
|
||||||
|
Documented in [[container-deployment]] ("Booth operator wrapper").
|
||||||
|
|
||||||
|
## [2026-06-25] fix | Local ANPR silently degraded — `uv run` strips the alpr extra
|
||||||
|
Diagnosed via the live DB (read-only `VACUUM INTO` copy) why entry `26799912337` recorded a snapshot
|
||||||
|
but no plate: the dev box's vision service was running **stub**, and earlier real ANPR had stopped.
|
||||||
|
Root cause (NOT the Docker/compose work, which was an innocent coincidence): the dev machine runs vision
|
||||||
|
as **bare `uv run uvicorn`** against `apps/vision/.venv`, and a plain `uv run`/`uv sync` re-resolves the
|
||||||
|
venv to the lockfile **defaults**, **stripping** fast-alpr/onnxruntime — so after any `pnpm dev` the
|
||||||
|
recognizer vanishes (weights orphaned in `~/.cache`, no module in the venv) and ANPR silently becomes
|
||||||
|
"snapshot, no plate". Evidence: 28 real reads through 06-22 (yolo-v9 model, ~99% conf), venv frozen lean
|
||||||
|
since 06-19, no other env with fast_alpr on the box. **The BOOTH was never affected** — it runs the
|
||||||
|
Docker image, which bakes `uv sync --frozen --extra alpr` at build (immutable, weights pre-warmed); a
|
||||||
|
booth `ModuleNotFoundError` is a STALE image (fix: `booth.sh update`). **Fix:** vision `package.json`
|
||||||
|
`dev`/`start`/`recognize` now `uv sync --extra alpr &&` first (self-healing), `.env` set to `fast_alpr`,
|
||||||
|
+ a `dev:stub` escape hatch. Restored real ANPR locally (`/health` → `fast_alpr` ready, model loaded from
|
||||||
|
cache, no download). Documented in [[vision-service-packaging]] ("Two runtimes, one fragile").
|
||||||
|
|
||||||
|
## [2026-06-26] fix | Hikvision snapshot 503 "Device Busy" — stream selection + retry + Alarm URL helper
|
||||||
|
Three camera fixes. (1) **503 Device Busy — the REAL fix is stream selection.** First framed as
|
||||||
|
"transient, just retry" — WRONG for this camera. Hardware probe of **DS-2CD1047G3H-LIU** (10.0.10.13):
|
||||||
|
`channels/101/picture` (MAIN) → 503 `deviceBusy` on 5 consecutive probes 800ms apart, while
|
||||||
|
`channels/102/picture` (SUB) → 200 clean JPEG every time. The main encoder is PERSISTENTLY saturated;
|
||||||
|
a retry loop can't fix it. Added a **`stream` config field** to the Hikvision driver (1=main default
|
||||||
|
for back-compat, 2=sub; ISAPI id `<channel><stream>`). Verified live: setting the camera to Sub flips
|
||||||
|
its status degraded→ready (14.7KB JPEG in ~87ms). (2) **Transient retry** (still useful for a genuine
|
||||||
|
momentary blip + the de-dup case): `HttpCamera.captureSnapshot` retries 503/500 with linear backoff
|
||||||
|
(250/500/750ms ×4), fails naming it `(device busy)`, does NOT retry 401/404. Plus the already-landed
|
||||||
|
`captureSnapshotShared` removing concurrent self-collision. `healthCheck` reports a live 503 as
|
||||||
|
`degraded` (surfaces a saturated main stream rather than hiding it). Covered by `camera.test.ts`
|
||||||
|
(10 tests: retry + main/sub path). (3) **Alarm Server URL helper:** the camera setup form now generates the camera's Alarm
|
||||||
|
Settings (Destination IP / URL / Protocol / Port) ready to paste, so the operator never hunts the
|
||||||
|
deviceId or memorises the endpoint. CRUCIAL: host/port come from the **backend address on the camera's
|
||||||
|
subnet** (`backendIpForDevice` + server port, the same probe the push-IP picker uses) — NOT
|
||||||
|
`window.location.origin` (the SPA's dev/proxy origin, which would wrongly say `localhost:5173`).
|
||||||
|
Verified live: matches the on-camera config field-for-field (10.0.10.203 / …/event / HTTP / 3000).
|
||||||
|
Shows a "save first" (needs a deviceId) then "test first" (needs the resolved backend IP) hint.
|
||||||
|
Documented in [[lpr-camera]] ("503 Device Busy"). Devices 6 new tests; server 168 green.
|
||||||
|
|
||||||
|
## [2026-06-26] fix | QR reader status was a LIE (hardcoded "ready") → real ICMP liveness
|
||||||
|
Two genuinely-OFFLINE QR readers showed GREEN in the status bar. Cause: the QR-reader adapter
|
||||||
|
(`StubReader`) had `healthCheck → { ready, "stub" }` hardcoded — it never probed anything. These are
|
||||||
|
PUSH devices (scan → GET our backend, resolve by serial) that expose **no TCP port**, so a connect
|
||||||
|
probe (cameras/printers) has nothing to hit; the stub "solved" that by lying. False-healthy is the
|
||||||
|
worst failure for a status bar. Fix: an **optional reader IP** (monitor-ONLY — scans still resolve by
|
||||||
|
serial, operation unchanged) + an **unprivileged ICMP ping** (`drivers/icmp.ts`: shells `/bin/ping`
|
||||||
|
`-c1`, exit-0 = reply; no native dep, no CAP_NET_RAW). `healthCheck`: IP replies → `ready`, no reply →
|
||||||
|
`offline`, **no IP → `degraded` ("set IP to monitor")** (never a false green). Booth compose
|
||||||
|
(`docker-compose.prod.yml`) sets `net.ipv4.ping_group_range=0 2147483647` so `/bin/ping` works
|
||||||
|
unprivileged for the non-root container user. Verified on hardware: the readers (10.0.10.7/.8) answer
|
||||||
|
ICMP on the device VLAN (eth1) — distinct MACs — and the UI Test connection shows "● ready — ping
|
||||||
|
10.0.10.7". (NB: an earlier "offline" reading was a WSL wrong-route artifact, not the readers.) Covered
|
||||||
|
by `reader.test.ts` (4 tests). Documented in [[device-status-monitoring]]. Devices +4 tests, all green.
|
||||||
|
|
||||||
|
## [2026-06-27] fix | booth.sh failed in the flat /opt layout (couldn't find compose files)
|
||||||
|
|
||||||
|
The booth deploys the compose files **flat** in `/opt/parking_systems/` with `booth.sh` next to
|
||||||
|
them, but the script assumed `<repo>/scripts/` and did `cd ..` → `REPO_DIR=/opt` (no compose
|
||||||
|
files); `usage()` then `sed`-read a now-relative `$0` → "can't read booth.sh". That's why
|
||||||
|
`sudo ./booth.sh` only printed help and `/bin/bash booth.sh` errored. Fix: **discover** the
|
||||||
|
compose files (script's own dir → `../` → `$PWD`), `usage()` reads an absolute `$SELF`. Also:
|
||||||
|
`.env.example` defaulted `TAG=main`, but the registry only has `dev`/`dev-<sha>` (no main build) →
|
||||||
|
`compose pull` 404s; default to `TAG=dev` + documented the moving-vs-immutable tag scheme.
|
||||||
|
Reproduced the booth's flat layout in a scratch dir; all forms (`./booth.sh`, `/bin/bash
|
||||||
|
booth.sh`, `config`, absolute-path) verified. Commit 83298bc.
|
||||||
|
|
||||||
|
## [2026-06-27] decision | Fleet deployment → Komodo Periphery over NetBird
|
||||||
|
|
||||||
|
booth.sh hit its ceiling: fine for one SSH-able box, but no remote/no-SSH op, no fleet view, no
|
||||||
|
deploy history, no rollback — and the fleet is **many/growing**. Decision: **Komodo Periphery**
|
||||||
|
on each appliance, driven by an existing **Komodo Core** over the **NetBird** mesh, running the
|
||||||
|
**same** compose files ([[container-deployment]] pipeline unchanged); `booth.sh` demoted to
|
||||||
|
break-glass. Three settled choices: many/growing fleet · deploys **manual + pinned** to a
|
||||||
|
`dev-<sha>` (no webhook — preserves the determinism we chose by pinning) · secrets
|
||||||
|
**Komodo-managed, per-booth + unique**. Threat-model caveats recorded: Periphery is a root agent
|
||||||
|
(bind to NetBird interface only, passkey+TLS, part of the TCB); `EVENT_SIGNING_KEY` in Core is a
|
||||||
|
fraud-root blast radius → per-booth keys + [[atecc608|ATECC608]] as the real
|
||||||
|
long-term signer; Core becomes Tier-0. GPL-3.0 OK (external ops tooling, not a shipped dep — same
|
||||||
|
boundary logic as the AGPL vision exception). New page [[fleet-deployment-komodo]]; infra-as-code
|
||||||
|
sketch in `komodo/` (`resources.toml` + README + `.env.komodo.example`). Catalogued in `index.md`;
|
||||||
|
`container-deployment` cross-linked + reframed (booth.sh = fallback).
|
||||||
|
|
||||||
|
## [2026-06-27] deploy | First Komodo booth deploy VERIFIED end-to-end (park-buzi)
|
||||||
|
|
||||||
|
Took the first booth through the whole Komodo flow on real hardware (Core v2.1.2 → agent reported
|
||||||
|
v2.2): onboarding key → Periphery installed **user-mode** (runs as `admin`, no root daemon,
|
||||||
|
**outbound** so the booth opens no inbound port) → server `park-buzi` **OK** in Core → Stack
|
||||||
|
(repo `mca/parking_solution`@`dev`, base+prod compose, registry account `komodo`, per-booth
|
||||||
|
`[[…]]` secrets) → all containers green → admin seeded via Komodo's container terminal (no SSH).
|
||||||
|
Then `komodo/resources.toml` rewritten to mirror the **working** Stack (exported from Core, v2.2
|
||||||
|
field shape, **Stack-only — no `[[server]]`** since onboarding owns the server), committed + pushed
|
||||||
|
(`dev` 9918f27); a ResourceSync reads it clean — **empty diff / Execute disabled = already in
|
||||||
|
sync** (success, not error). `booth.sh` fixed for the flat `/opt` layout earlier (83298bc).
|
||||||
|
Gotchas that bit us (now in [[appliance-provisioning]] §7 + gotchas 7–11): `core_address` is Core's
|
||||||
|
**proxy URL** not `:9120` (exposed-not-published → Connection refused); **git-auth ≠ registry-auth**
|
||||||
|
(blank registry account → `no basic auth credentials`); user-mode + `/etc/komodo` root_directory →
|
||||||
|
`Permission denied`; config key is **`core_address`** singular. [[appliance-provisioning]] §6 split:
|
||||||
|
§6 = engine, §7 = Komodo deploy (PRIMARY) with §7c manual `booth.sh` break-glass.
|
||||||
|
|
||||||
|
## [2026-06-27] query | "Subscribers auto-enter but don't auto-exit" → NOT a bug; G3H main-stream snapshot is structurally dead
|
||||||
|
|
||||||
|
Investigated via a read-only VACUUM copy of the dev DB. Caca subscriber's ledger: clean
|
||||||
|
entry/exit pairs until ~16:38, then 6 entries + 0 exits. Traced to the **exit camera 10.0.10.13
|
||||||
|
(DS-2CD1047G3H-LIU)** producing only 2 reads ever (vs 60 on the entry cam) — every exit-direction
|
||||||
|
snapshot after 16:38 was **HTTP 503 "device busy"**, so the ANPR exit read never got a frame →
|
||||||
|
no `emitRead` → no auto-exit. The subscription-flow exit logic, camera→relay binding (relay 2 =
|
||||||
|
exit, anpr on), and `stream: "2"` config were all **correct**. Direct hardware re-probe after a
|
||||||
|
camera reboot + closing web connections: **sub (102) 10/10 rapid → 200 (~50 ms)**, **main (101)
|
||||||
|
3/3 → 503 in ~20 ms (instant reject)**. So main-stream snapshots are **structurally unavailable**
|
||||||
|
on this model (sub mandatory), and the 503 storm was **connection-slot exhaustion from manual
|
||||||
|
main-stream testing** holding the camera's slots (reboot clears). Recorded in the
|
||||||
|
the `g3h-main-stream-snapshot-503` LLM memory + a 2026-06-27 sharper-finding note in [[lpr-camera]]
|
||||||
|
("503 Device Busy"). No code changed — diagnosis only.
|
||||||
|
|
||||||
|
## [2026-06-27] query | "Auto-exit" RESOLVED — a 4-layer CAMERA fault on the G3H, never our code
|
||||||
|
|
||||||
|
Continuation of the above. Drove the full diagnosis to ground using a **dumb HTTP sink**
|
||||||
|
(`scratch-camera-sink.py`) the exit camera's Alarm Server was pointed at, to capture the verbatim
|
||||||
|
push. Every assumption about *our* code was wrong; all real causes were camera-side on the
|
||||||
|
**DS-2CD1047G3H-LIU** (`10.0.10.13`):
|
||||||
|
(1) 503s were mostly **manual main-stream testing** (main 101 = instant 503 structurally; sub 102 =
|
||||||
|
perfect) + connection-slot exhaustion (reboot clears) — NOT a retry/flow bug.
|
||||||
|
(2) **Blocker #1:** the camera never POSTed at all (0 alarms ever vs 1126 on the entry cam); its
|
||||||
|
**Diagnose dump showed a CORRUPT config DB** (`Main Db is broken`/`db_restore failed`/`reset cfg`) —
|
||||||
|
factory-reset fixed it.
|
||||||
|
(3) Gateway/DNS was a **red herring** (working cam had none; broken cam had both).
|
||||||
|
(4) **Blocker #2:** post-reset it pushed **plain `VMD` with no target** → backend gates on
|
||||||
|
`target=="vehicle"` (`hikvision-alarm.ts` reads `<targetType>`/`<detectionTarget>`/`<objectType>`)
|
||||||
|
→ ignored. Enabling the AcuSense **Vehicle target filter** made the push carry
|
||||||
|
`<targetType>vehicle</targetType>` (confirmed live on a drive-through). "VMD/Motion" is the right
|
||||||
|
event for this class — it's the *target filter* that matters.
|
||||||
|
Flagged an **observability gap**: a camera with `alarmPushEnabled=true` and 0 pushes ever should be
|
||||||
|
a surfaced status (cf. the reader-liveness fix). Recorded in the `g3h-anpr-push-gotchas` memory + a
|
||||||
|
new troubleshooting section in [[lpr-camera]]. No code changed — diagnosis + camera reconfig only.
|
||||||
|
|
||||||
|
## [2026-06-28] refactor | Unified controller relays into one event→action list (drop config.buttonLight)
|
||||||
|
Reframed the controller "Outputs — relays" model with the user: **Entry / Exit / Both are EVENTS**,
|
||||||
|
not a "direction" — a relay is uniformly *"when EVENT X happens, do action Y"*. Barrier events
|
||||||
|
(`entry`/`exit`/`both`) `pulseOpen`; a new **`radarAlert`** event drives a non-barrier alert lamp
|
||||||
|
(blink while its trigger input is active, SOLID once the camera confirms a car). **Dropped the
|
||||||
|
separate `config.buttonLight` block** — the lamp is now just another `config.relays[]` row
|
||||||
|
(`direction:"radarAlert"`, carrying `triggerInput` + blink cadence). One list, one editor, one shape;
|
||||||
|
a future "R4 alert" is just another row with its own trigger input — no new config, no code change.
|
||||||
|
The proven `ButtonLightController` 3-state machine (serialized UDP, fail-OFF, hot-reload) is kept
|
||||||
|
verbatim — only its source changed from `buttonLightOf()` to `alertRelaysOf()`, keyed per
|
||||||
|
`controllerId:relay` so several alert relays on one controller run independently. Every barrier
|
||||||
|
resolver skips `radarAlert` rows (no auto-open; barrier-not-a-door intact). Touched
|
||||||
|
`device-resolve.ts`, `button-light.ts`, `device-monitor.ts`, web `api.ts` + `SetupWizard.tsx` (the
|
||||||
|
dropdown gained a "Radar alert" option that reveals trigger/blink inputs), i18n sq+en. Tests:
|
||||||
|
rewrote `button-light.test.ts` to the `radarAlert` row + added a two-independent-alert-relays case;
|
||||||
|
full workspace `build lint test` green (173 server tests). Updated [[button-light-indicator]],
|
||||||
|
[[dingtian-relay]], memory `access-direction-is-per-relay`.
|
||||||
|
|
||||||
|
## [2026-06-28] refactor | Generic controller inputs (config.inputs[]) — the twin of unified relays[]
|
||||||
|
After unifying OUTPUTS into one event→action `relays[]`, did the same for INPUTS — the user hit the
|
||||||
|
wall that **there was no way to add a free-standing input** (e.g. an EXIT radar): inputs were fields
|
||||||
|
bolted onto an entry barrier relay (`relays[].button/presenceInput/...`) and the UI only rendered a
|
||||||
|
button+presence block per entry/both relay. Now a first-class **`config.inputs[]`** list — each row
|
||||||
|
`{ input, role: "button"|"presence"|"alertTrigger", relay?, kind?, activeLow?, cooldownSec? }` — with
|
||||||
|
a "+ Add input" button. An exit radar = just another `presence` row serving the exit relay. **Keystone:
|
||||||
|
`inputsOf(row)`** returns `config.inputs[]` or SYNTHESIZES it from the legacy per-relay fields, so
|
||||||
|
`relayForButton`/`relayForPresence` resolve identically from either shape — **zero-downtime, no DB
|
||||||
|
migration** (old configs keep working until re-saved; the UI seeds its editor from the synth).
|
||||||
|
`entry-flow.ts` is unchanged (resolves through the same functions). Also fixed a latent bug this
|
||||||
|
exposed: the alert lamp's camera **lock** was hardcoded to the ENTRY camera — added
|
||||||
|
`relays[].lockLane: "entry"|"exit"` (button-light tracks both `#entryBusy`/`#exitBusy`; a lamp goes
|
||||||
|
SOLID off its own lane's camera), so an exit radar's lamp locks on the EXIT camera. Driver: extracted
|
||||||
|
`activeLowFrom(config)` (merges inputs[] `activeLow` + legacy `presenceActiveLow` + the `inputActiveLow`
|
||||||
|
escape hatch). Touched `device-resolve.ts`, `button-light.ts`, `access-dingtian.ts`, web `api.ts` +
|
||||||
|
`SetupWizard.tsx` (InputEditor rewritten to a generic list; role select folds loop/radar; OutputEditor
|
||||||
|
radarAlert row gained a lock-lane select), i18n sq+en. Tests: new `device-resolve.test.ts` (inputs[]
|
||||||
|
resolution + legacy-fallback identical + exit-radar resolves to the exit relay), exit-lamp lockLane
|
||||||
|
case in `button-light.test.ts`, `activeLowFrom` cases in the dingtian suite. Full workspace
|
||||||
|
`build lint test` green. Updated [[entry-double-press]], [[button-light-indicator]], [[dingtian-relay]],
|
||||||
|
memory `access-direction-is-per-relay`.
|
||||||
|
|
||||||
|
## [2026-06-28] feat | Booth Entry/Exit lights blink on radar presence (mirror relay 3)
|
||||||
|
The on-screen Hyrje/Dalje barrier lights were 2-state (green=free / red=busy) off the camera
|
||||||
|
lane-status only — they couldn't show the radar-only "detected, not yet confirmed" state that makes
|
||||||
|
the physical button lamp (relay 3) blink. Added that signal end-to-end: a small server tracker
|
||||||
|
**`LanePresence`** (lane-presence.ts) subscribes to `deviceEvents.onInput`, resolves each presence
|
||||||
|
edge to its lane via a new **`presenceLaneOf`** (device-resolve.ts) — direction-agnostic (entry AND
|
||||||
|
exit), unlike the entry-gated `relayForPresence` — and emits a `lane-presence {entry,exit}` bus
|
||||||
|
event on change. The WS forwards it (hello snapshot + push) into `live-store.radar`; `BarrierLight`
|
||||||
|
(BoothScreen.tsx) became **3-state**, mirroring relay 3 exactly: radar+camera-free → BLINK green↔red
|
||||||
|
~1 Hz (`.lane-blink` keyframe in index.css, holds solid-red under prefers-reduced-motion);
|
||||||
|
camera-busy → SOLID red; else SOLID green. Same input + same rule as the lamp, so screen and post
|
||||||
|
never disagree. A test (lane-presence.test.ts) caught a real bug: the first cut reused
|
||||||
|
`relayForPresence`, so the EXIT lane never resolved (entry-gated) and never blinked —
|
||||||
|
`presenceLaneOf` fixes it. Full workspace build/lint/test green (185 server tests). Updated
|
||||||
|
[[button-light-indicator]] (new "On-screen twin" section).
|
||||||
|
|||||||
Reference in New Issue
Block a user