Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db, ty
|
|||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
|
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
|
||||||
import { directionOf, type FlowDirection } from "./device-resolve.js";
|
import { directionOf, type FlowDirection } from "./device-resolve.js";
|
||||||
import { buildCamera } from "./snapshot.js";
|
import { buildCamera, captureSnapshotShared } from "./snapshot.js";
|
||||||
import type { SubscriptionFlow } from "./subscription-flow.js";
|
import type { SubscriptionFlow } from "./subscription-flow.js";
|
||||||
import type { VisionClient } from "./vision-client.js";
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
|
||||||
@@ -100,7 +100,9 @@ export class AnprBridge {
|
|||||||
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
|
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
|
||||||
// the gated flow infers the verb from the camera's bound relay direction).
|
// the gated flow infers the verb from the camera's bound relay direction).
|
||||||
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
|
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
|
||||||
const shot = await camera.captureSnapshot({ direction });
|
// Shared capture (deviceId-keyed): coalesces with the advisory snapshotAsync for
|
||||||
|
// the SAME vehicle so the single-threaded camera isn't hit twice (→ HTTP 503).
|
||||||
|
const shot = await captureSnapshotShared(deviceId, camera, { direction });
|
||||||
const result = await this.#vision.analyze(shot.bytes, shot.contentType);
|
const result = await this.#vision.analyze(shot.bytes, shot.contentType);
|
||||||
if (!result || !result.plate) return; // nothing read
|
if (!result || !result.plate) return; // nothing read
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import type { AuxOutputDevice } from "@parking/devices";
|
||||||
|
import { ButtonLightController } from "./button-light.js";
|
||||||
|
import { deviceEvents } from "./device-events.js";
|
||||||
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// ButtonLightController: the entry-button lamp on a spare relay, driven by the RADAR
|
||||||
|
// input vs. the camera lane status. Truth table:
|
||||||
|
// radar present + lane busy -> SOLID on
|
||||||
|
// radar present + lane free -> BLINK (~1 Hz)
|
||||||
|
// otherwise -> OFF
|
||||||
|
// Lamp is a non-barrier aux output; fails OFF; de-dupes redundant writes.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const CONTROLLER = "ctl-1";
|
||||||
|
const RADAR_INPUT = 2; // I2
|
||||||
|
const LAMP_RELAY = 3; // spare relay R3
|
||||||
|
|
||||||
|
/** A fake aux device recording setAux calls (channel,on). Optionally throws. */
|
||||||
|
function fakeAux(record: Array<{ ch: number; on: boolean }>, throwOnce = { v: false }): AuxOutputDevice {
|
||||||
|
return {
|
||||||
|
async setAux(channel: number, on: boolean): Promise<void> {
|
||||||
|
if (throwOnce.v) {
|
||||||
|
throwOnce.v = false;
|
||||||
|
throw new Error("UDP down");
|
||||||
|
}
|
||||||
|
record.push({ ch: channel, on });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
vi.useFakeTimers();
|
||||||
|
// One controller: entry relay 1 with radar on I2; lamp on spare relay 3.
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CONTROLLER,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
],
|
||||||
|
buttonLight: { relay: LAMP_RELAY, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Emit a radar (presence input) edge for the controller. */
|
||||||
|
function radar(present: boolean): void {
|
||||||
|
deviceEvents.emitInput({
|
||||||
|
driverId: "dingtian",
|
||||||
|
deviceId: CONTROLLER,
|
||||||
|
input: RADAR_INPUT,
|
||||||
|
edge: present ? "on" : "off",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source: "poll",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit a lane status (entry busy/free). */
|
||||||
|
function lane(entryBusy: boolean): void {
|
||||||
|
deviceEvents.emitLaneStatus({ entry: entryBusy, exit: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flush the microtask queue so serialized setAux promises (and their re-pump on
|
||||||
|
* completion) settle. The lamp worker sends ONE UDP at a time and re-pumps on resolve;
|
||||||
|
* a few turns drain a burst. Needed because sends are now async (was synchronous). */
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
for (let i = 0; i < 6; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ButtonLightController truth table", () => {
|
||||||
|
it("OFF at start (no radar, no car)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||||
|
// confirmedOn starts null; OFF de-dupes (null !== false → one off write), so the
|
||||||
|
// device is confirmed OFF and at most one call was made.
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar present + lane busy -> SOLID on", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // device latched ON
|
||||||
|
// Solid = no blinking: advancing time produces no further sends.
|
||||||
|
const n = calls.length;
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
await flush();
|
||||||
|
expect(calls.length).toBe(n);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar present + lane free -> BLINK (toggles the device over time)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
radar(true); // lane still free
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // on now
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // toggled off
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // toggled on
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blink -> solid when the camera confirms a car (lane busy)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
radar(true); // blink
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
lane(true); // camera confirms
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
// No more toggles (blink torn down) — the device stays ON over time.
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar clears -> OFF", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true); // solid
|
||||||
|
await flush();
|
||||||
|
radar(false); // car gone
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // device latched OFF
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("de-dupes redundant writes (no spam on repeat events)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true); // solid, on
|
||||||
|
await flush();
|
||||||
|
const n = calls.length;
|
||||||
|
radar(true); // same state — no new edge (present unchanged)
|
||||||
|
lane(true); // same lane — no change
|
||||||
|
await flush();
|
||||||
|
expect(calls.length).toBe(n);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails OFF: a setAux error does not throw or escalate", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const throwOnce = { v: true };
|
||||||
|
const aux = fakeAux(calls, throwOnce);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
// First write (initial off) throws — must be swallowed.
|
||||||
|
expect(() => ctl.start()).not.toThrow();
|
||||||
|
await flush();
|
||||||
|
// Subsequent writes work; driving to solid still converges to ON.
|
||||||
|
lane(true);
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores controllers without a buttonLight config", () => {
|
||||||
|
// A second controller, no lamp.
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: "ctl-2",
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: { host: "10.0.0.6", relays: [{ relay: 1, direction: "entry", presenceInput: 2 }] },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||||
|
ctl.start();
|
||||||
|
expect(ctl.stateOf("ctl-2")).toBeNull();
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks up a button light ADDED after start() (no restart needed)", async () => {
|
||||||
|
// Fresh controller with a radar input but NO buttonLight yet.
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
// Replace the seeded controller with one that has the radar but no lamp.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
// No lamp configured → an input does nothing.
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBeNull();
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
radar(false);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Admin saves a button light (relay 3) — without restarting the server.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }],
|
||||||
|
buttonLight: { relay: LAMP_RELAY, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// The very next radar edge reconciles + blinks (lane still free).
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { eq, devices, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { hasAuxOutput, registry, type AuxOutputDevice } from "@parking/devices";
|
||||||
|
import { deviceEvents, type DeviceInputEvent, type LaneStatusEvent } from "./device-events.js";
|
||||||
|
import { buttonLightOf, relayForPresence, type ButtonLightSpec } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// The entry button's 12 V light, driven by the RADAR input vs. the camera "car in
|
||||||
|
// zone" signal (the existing advisory lane-status). A disagreement indicator:
|
||||||
|
// radar present + lane busy (camera confirms a car) → SOLID on
|
||||||
|
// radar present + lane free (radar sees something, no car) → BLINK (~1 Hz)
|
||||||
|
// otherwise → OFF
|
||||||
|
// The lamp is a NON-barrier aux output (setAux latch), so holding/blinking it is fine
|
||||||
|
// — barrier-not-a-door applies only to barriers, which still only pulseOpen. The lamp
|
||||||
|
// FAILS OFF: any error / shutdown leaves it off, so a dead lamp is "no hint", never a
|
||||||
|
// misleading solid "go". See wiki/concepts/button-light-indicator.md.
|
||||||
|
|
||||||
|
type LightState = "off" | "solid" | "blink";
|
||||||
|
|
||||||
|
const DEFAULT_BLINK_MS = 500;
|
||||||
|
|
||||||
|
/** Per-controller live state for the lamp rule. */
|
||||||
|
interface LampState {
|
||||||
|
/** Lamp config (relay #, blink ms). Mutable: #reconcile updates it in place when the
|
||||||
|
* admin changes the button-light config without a restart. */
|
||||||
|
spec: ButtonLightSpec;
|
||||||
|
/** Is the radar (presence input on an entry relay) currently active? */
|
||||||
|
present: boolean;
|
||||||
|
/** The high-level state we're rendering (to avoid restarting a running blink). */
|
||||||
|
rendered: LightState | null;
|
||||||
|
/** Active blink timer, if blinking. */
|
||||||
|
blink: ReturnType<typeof setInterval> | null;
|
||||||
|
/** Blink phase (true = currently on). */
|
||||||
|
blinkOn: boolean;
|
||||||
|
/** The output we WANT the relay to be in. The serialized worker drives the device
|
||||||
|
* toward this. The blink timer only flips this flag — it never sends directly. */
|
||||||
|
desiredOn: boolean;
|
||||||
|
/** The output we last CONFIRMED on the device (after a successful send). null = unknown. */
|
||||||
|
confirmedOn: boolean | null;
|
||||||
|
/** True while a send is in flight for this lamp — serializes UDP so on/off can't
|
||||||
|
* overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */
|
||||||
|
sending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a controller's live aux-output adapter. The default goes through the
|
||||||
|
* driver registry; tests inject a spy. Returns null when the controller has no
|
||||||
|
* aux-output capability (or won't build). */
|
||||||
|
export type AuxResolver = (controllerId: string) => AuxOutputDevice | null;
|
||||||
|
|
||||||
|
export class ButtonLightController {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #resolveAux: AuxResolver;
|
||||||
|
/** Per-controller state, keyed by controller deviceId. */
|
||||||
|
readonly #lamps = new Map<string, LampState>();
|
||||||
|
/** Latest lane status (entry busy = a camera-confirmed car in the entry zone). */
|
||||||
|
#entryBusy = false;
|
||||||
|
/** Controllers we've already warned lack the aux-output capability (warn once). */
|
||||||
|
readonly #warned = new Set<string>();
|
||||||
|
#unsubInput: (() => void) | null = null;
|
||||||
|
#unsubLane: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(db: Db, logger: FastifyBaseLogger, resolveAux?: AuxResolver) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
this.#resolveAux = resolveAux ?? ((id) => this.#auxFromRegistry(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to radar input edges + lane status, and initialise every lamp OFF. */
|
||||||
|
start(): void {
|
||||||
|
this.#reconcile();
|
||||||
|
// All lamps start OFF (known-safe baseline) regardless of prior device state.
|
||||||
|
for (const [controllerId, lamp] of this.#lamps) this.#apply(controllerId, lamp);
|
||||||
|
|
||||||
|
this.#unsubInput = deviceEvents.onInput((e) => this.#onInput(e));
|
||||||
|
this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconcile the lamp map with the CURRENT device config (the booth can add/change a
|
||||||
|
* button light without a server restart). Mirrors DeviceMonitor, which re-reads the
|
||||||
|
* device set each tick. Adds lamps for newly-configured controllers, updates the spec
|
||||||
|
* (relay #, blink ms) in place — preserving live `present`/blink state — and drops
|
||||||
|
* lamps whose controller lost its buttonLight or was disabled. Called at start() and
|
||||||
|
* before handling each event, so a just-saved lamp takes effect immediately. */
|
||||||
|
#reconcile(): void {
|
||||||
|
const rows = this.#db.select().from(devices).where(eq(devices.category, "access")).all();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.enabled) continue;
|
||||||
|
const spec = buttonLightOf(row);
|
||||||
|
if (!spec) continue;
|
||||||
|
seen.add(row.id);
|
||||||
|
const existing = this.#lamps.get(row.id);
|
||||||
|
if (existing) {
|
||||||
|
existing.spec = spec; // pick up a changed relay # / blink cadence
|
||||||
|
} else {
|
||||||
|
this.#lamps.set(row.id, {
|
||||||
|
spec,
|
||||||
|
present: false,
|
||||||
|
rendered: null,
|
||||||
|
blink: null,
|
||||||
|
blinkOn: false,
|
||||||
|
desiredOn: false,
|
||||||
|
confirmedOn: null,
|
||||||
|
sending: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop lamps whose controller no longer declares one (or was disabled/removed).
|
||||||
|
for (const [id, lamp] of this.#lamps) {
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
if (lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = null;
|
||||||
|
}
|
||||||
|
this.#finalOff(id, lamp); // best-effort fail-OFF before forgetting it
|
||||||
|
this.#lamps.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A radar (presence) edge updates that controller's `present` flag. We resolve the
|
||||||
|
* edge the SAME way the entry flow does (relayForPresence on an entry/both relay),
|
||||||
|
* so the lamp and the one-car-one-ticket gate always agree on "a car is here". */
|
||||||
|
#onInput(e: DeviceInputEvent): void {
|
||||||
|
// Reconcile first so a lamp added/changed since boot (no restart) is picked up.
|
||||||
|
this.#reconcile();
|
||||||
|
const lamp = this.#lamps.get(e.deviceId);
|
||||||
|
if (!lamp) return; // no lamp on this controller
|
||||||
|
const presence = relayForPresence(this.#db, e.deviceId, e.input);
|
||||||
|
if (!presence) return; // not the presence/radar terminal
|
||||||
|
const present = e.edge === "on";
|
||||||
|
if (present === lamp.present) return;
|
||||||
|
lamp.present = present;
|
||||||
|
this.#apply(e.deviceId, lamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lane status changed: entry busy = a camera-confirmed car in the entry zone. */
|
||||||
|
#onLane(s: LaneStatusEvent): void {
|
||||||
|
if (s.entry === this.#entryBusy) return;
|
||||||
|
this.#entryBusy = s.entry;
|
||||||
|
// Re-render every lamp (the camera signal is site-wide entry status).
|
||||||
|
for (const [controllerId, lamp] of this.#lamps) this.#apply(controllerId, lamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute + render the target state for one lamp. Drives are fire-and-forget (the
|
||||||
|
* timer/state machine is synchronous; the UDP write resolves on its own). */
|
||||||
|
#apply(controllerId: string, lamp: LampState): void {
|
||||||
|
const target: LightState = !lamp.present ? "off" : this.#entryBusy ? "solid" : "blink";
|
||||||
|
if (target === lamp.rendered) return; // already rendering this state
|
||||||
|
|
||||||
|
// Tear down any running blink before switching states.
|
||||||
|
if (lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = null;
|
||||||
|
}
|
||||||
|
lamp.rendered = target;
|
||||||
|
|
||||||
|
if (target === "off") {
|
||||||
|
lamp.desiredOn = false;
|
||||||
|
this.#pump(controllerId, lamp);
|
||||||
|
} else if (target === "solid") {
|
||||||
|
lamp.desiredOn = true;
|
||||||
|
this.#pump(controllerId, lamp);
|
||||||
|
} else {
|
||||||
|
// BLINK: a wall-clock timer flips ONLY the desired flag; #pump does the actual
|
||||||
|
// (serialized) UDP send. A symmetric cadence uses one interval; an asymmetric one
|
||||||
|
// re-arms each phase with its own duration. Sends never overlap or reorder, so the
|
||||||
|
// relay can't get stuck on a stale packet.
|
||||||
|
const onMs = lamp.spec.blinkOnMs && lamp.spec.blinkOnMs > 0 ? lamp.spec.blinkOnMs : DEFAULT_BLINK_MS;
|
||||||
|
const offMs = lamp.spec.blinkOffMs && lamp.spec.blinkOffMs > 0 ? lamp.spec.blinkOffMs : DEFAULT_BLINK_MS;
|
||||||
|
lamp.blinkOn = true;
|
||||||
|
lamp.desiredOn = true;
|
||||||
|
const tick = () => {
|
||||||
|
lamp.blinkOn = !lamp.blinkOn;
|
||||||
|
lamp.desiredOn = lamp.blinkOn;
|
||||||
|
this.#pump(controllerId, lamp);
|
||||||
|
if (onMs !== offMs && lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs);
|
||||||
|
lamp.blink.unref?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
lamp.blink = setInterval(tick, onMs);
|
||||||
|
lamp.blink.unref?.();
|
||||||
|
this.#pump(controllerId, lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialized per-lamp worker: drive the relay toward `desiredOn`, one UDP send at a
|
||||||
|
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
|
||||||
|
* the relay stuck on a stale packet. Here a single in-flight send is guaranteed
|
||||||
|
* (`sending` guard); when it resolves, if the desired state moved on we send again —
|
||||||
|
* so the LAST desired state is always the one finally asserted on the device. */
|
||||||
|
#pump(controllerId: string, lamp: LampState): void {
|
||||||
|
if (lamp.sending) return; // a send is already in flight; it'll re-check on completion
|
||||||
|
if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP
|
||||||
|
const aux = this.#resolveAux(controllerId);
|
||||||
|
if (!aux) return;
|
||||||
|
const target = lamp.desiredOn;
|
||||||
|
lamp.sending = true;
|
||||||
|
void aux
|
||||||
|
.setAux(lamp.spec.relay, target)
|
||||||
|
.then(() => {
|
||||||
|
lamp.confirmedOn = target;
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
// Leave confirmedOn unchanged so the next pump retries this state. Never escalates.
|
||||||
|
this.#logger.error(`button-light setAux failed (${controllerId} R${lamp.spec.relay}): ${(err as Error).message}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
lamp.sending = false;
|
||||||
|
// Desired state may have changed (or the send failed) while we were busy —
|
||||||
|
// re-pump to converge. This is what makes the final state authoritative.
|
||||||
|
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(controllerId, lamp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the live aux-output adapter for a controller, or null (logged once). */
|
||||||
|
#auxFromRegistry(controllerId: string): AuxOutputDevice | null {
|
||||||
|
const row = this.#db.select().from(devices).where(eq(devices.id, controllerId)).get();
|
||||||
|
if (!row) return null;
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
let device: unknown;
|
||||||
|
try {
|
||||||
|
device = driver.create(row.config as never);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!hasAuxOutput(device)) {
|
||||||
|
if (!this.#warned.has(controllerId)) {
|
||||||
|
this.#warned.add(controllerId);
|
||||||
|
this.#logger.warn(`button-light: controller ${controllerId} (${row.driverId}) has no aux-output — lamp ignored`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsubscribe, stop all blink timers, and best-effort drive every lamp OFF. */
|
||||||
|
stop(): void {
|
||||||
|
this.#unsubInput?.();
|
||||||
|
this.#unsubLane?.();
|
||||||
|
this.#unsubInput = null;
|
||||||
|
this.#unsubLane = null;
|
||||||
|
for (const [controllerId, lamp] of this.#lamps) {
|
||||||
|
if (lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = null;
|
||||||
|
}
|
||||||
|
// Best-effort fail-OFF on shutdown.
|
||||||
|
this.#finalOff(controllerId, lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired
|
||||||
|
* OFF and pump. The serialized worker still applies, so this can't collide with an
|
||||||
|
* in-flight send — it converges to OFF. */
|
||||||
|
#finalOff(controllerId: string, lamp: LampState): void {
|
||||||
|
lamp.desiredOn = false;
|
||||||
|
this.#pump(controllerId, lamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: current high-level state being rendered for a controller. */
|
||||||
|
stateOf(controllerId: string): LightState | null {
|
||||||
|
return this.#lamps.get(controllerId)?.rendered ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: the state last CONFIRMED on the device for a controller (after a
|
||||||
|
* successful send). null = unknown / nothing sent yet. */
|
||||||
|
confirmedOf(controllerId: string): boolean | null {
|
||||||
|
return this.#lamps.get(controllerId)?.confirmedOn ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a controller row's live aux device (exported for reuse/tests). */
|
||||||
|
export function buildAux(db: Db, row: DeviceRow): AuxOutputDevice | null {
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
try {
|
||||||
|
const device = driver.create(row.config as never);
|
||||||
|
return hasAuxOutput(device) ? device : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,12 +33,32 @@ export interface RelaySpec {
|
|||||||
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
|
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
|
||||||
*/
|
*/
|
||||||
readonly presenceInput?: number;
|
readonly presenceInput?: number;
|
||||||
|
/** What kind of sensor is on `presenceInput` — an induction LOOP or a RADAR. Label
|
||||||
|
* only (the gate behaviour is identical); drives UI copy + telemetry. Default loop. */
|
||||||
|
readonly presenceKind?: "loop" | "radar";
|
||||||
|
/** The presence terminal's ACTIVE level is LOW (idles HIGH). Maps to the driver's
|
||||||
|
* per-input `inputActiveLow` override so a radar wired opposite the button reads
|
||||||
|
* right. See wiki/entities/hikvision-radar.md. */
|
||||||
|
readonly presenceActiveLow?: boolean;
|
||||||
readonly entryCooldownSec?: number;
|
readonly entryCooldownSec?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A non-barrier indicator lamp wired to a spare relay (e.g. the entry button's
|
||||||
|
* 12 V light). Driven by the server LightController off the radar + lane status —
|
||||||
|
* NOT a barrier. See wiki/concepts/button-light-indicator.md. */
|
||||||
|
export interface ButtonLightSpec {
|
||||||
|
/** 1-based spare relay channel the lamp is wired to. */
|
||||||
|
readonly relay: number;
|
||||||
|
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
|
||||||
|
readonly blinkOnMs?: number;
|
||||||
|
readonly blinkOffMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** Access controller config (the `relays[]` map + connection fields). */
|
/** Access controller config (the `relays[]` map + connection fields). */
|
||||||
interface AccessConfig {
|
interface AccessConfig {
|
||||||
readonly relays?: RelaySpec[];
|
readonly relays?: RelaySpec[];
|
||||||
|
/** Optional button-lamp output on a spare relay. */
|
||||||
|
readonly buttonLight?: ButtonLightSpec;
|
||||||
readonly [k: string]: unknown;
|
readonly [k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +80,11 @@ export interface ResolvedRelay {
|
|||||||
readonly controller: DeviceRow;
|
readonly controller: DeviceRow;
|
||||||
readonly relay: number;
|
readonly relay: number;
|
||||||
readonly direction: Direction;
|
readonly direction: Direction;
|
||||||
/** 1-based presence-loop input gating this relay's entry (when wired). */
|
/** 1-based presence input gating this relay's entry (loop or radar, when wired). */
|
||||||
readonly presenceInput?: number;
|
readonly presenceInput?: number;
|
||||||
/** Cooldown seconds suppressing repeat presses (fallback when no presence loop). */
|
/** Sensor kind on the presence input (loop|radar) — telemetry/label only. */
|
||||||
|
readonly presenceKind?: "loop" | "radar";
|
||||||
|
/** Cooldown seconds suppressing repeat presses (fallback when no presence input). */
|
||||||
readonly entryCooldownSec?: number;
|
readonly entryCooldownSec?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +124,7 @@ export function relayForButton(db: Db, controllerId: string, terminal: number):
|
|||||||
relay: spec.relay,
|
relay: spec.relay,
|
||||||
direction: spec.direction,
|
direction: spec.direction,
|
||||||
presenceInput: spec.presenceInput,
|
presenceInput: spec.presenceInput,
|
||||||
|
presenceKind: spec.presenceKind ?? "loop",
|
||||||
entryCooldownSec: spec.entryCooldownSec,
|
entryCooldownSec: spec.entryCooldownSec,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -122,7 +145,20 @@ export function relayForPresence(db: Db, controllerId: string, terminal: number)
|
|||||||
const spec = relaysOf(row).find((r) => r.presenceInput === terminal);
|
const spec = relaysOf(row).find((r) => r.presenceInput === terminal);
|
||||||
if (!spec) return null;
|
if (!spec) return null;
|
||||||
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
||||||
return { controller: row, relay: spec.relay, direction: spec.direction };
|
return {
|
||||||
|
controller: row,
|
||||||
|
relay: spec.relay,
|
||||||
|
direction: spec.direction,
|
||||||
|
presenceInput: spec.presenceInput,
|
||||||
|
presenceKind: spec.presenceKind ?? "loop",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The button-lamp output declared on an access controller, or null. */
|
||||||
|
export function buttonLightOf(row: DeviceRow): ButtonLightSpec | null {
|
||||||
|
const cfg = row.config as AccessConfig;
|
||||||
|
const bl = cfg.buttonLight;
|
||||||
|
return bl && typeof bl.relay === "number" ? bl : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { storedSecrets } from "./setup.js";
|
||||||
|
|
||||||
|
// storedSecrets re-merges a device's machine-only secrets (relayPassword/pushPassword)
|
||||||
|
// into a test/save — but ONLY when the submitted config addresses the SAME device at the
|
||||||
|
// SAME host/port. This guards against a redirected probe exfiltrating the secret to an
|
||||||
|
// attacker host (an admin keeps a real device id but swaps the host). The booth operator
|
||||||
|
// is the threat-model adversary, so an authenticated-admin redirect must NOT leak.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const ID = "ctl-secret";
|
||||||
|
const HOST = "10.0.10.5";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: ID,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: { host: HOST, binaryPort: 60000, relayPassword: 1996, pushPassword: "p-secret" },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storedSecrets identity guard", () => {
|
||||||
|
it("re-merges secrets when host/port/driver match the stored device", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", { host: HOST, binaryPort: 60000 });
|
||||||
|
expect(out.relayPassword).toBe(1996);
|
||||||
|
expect(out.pushPassword).toBe("p-secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-merges when identity fields are OMITTED (fall back to the stored device)", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", {});
|
||||||
|
expect(out.relayPassword).toBe(1996);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REFUSES secrets when the host is redirected (exfiltration attempt)", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", { host: "10.66.66.66", binaryPort: 60000 });
|
||||||
|
expect(out).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REFUSES secrets when a control port is changed", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", { host: HOST, binaryPort: 9999 });
|
||||||
|
expect(out).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REFUSES secrets when the driver doesn't match the stored row", () => {
|
||||||
|
const out = storedSecrets(db, ID, "stub-access", { host: HOST });
|
||||||
|
expect(out).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for an unknown device id", () => {
|
||||||
|
expect(storedSecrets(db, randomUUID(), "dingtian", { host: HOST })).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,11 @@ interface AssignBody {
|
|||||||
interface TestBody {
|
interface TestBody {
|
||||||
driverId: string;
|
driverId: string;
|
||||||
config: Record<string, string | number | boolean>;
|
config: Record<string, string | number | boolean>;
|
||||||
|
/** When editing an EXISTING device, its id — so the test re-merges the stored
|
||||||
|
* machine secrets (relayPassword/pushPassword) the client never received. Without
|
||||||
|
* this, testing an edited device would send no relay password → the device ignores
|
||||||
|
* the probe → a false "offline". Omitted when testing a brand-new device. */
|
||||||
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config keys that hold MACHINE-ONLY secrets — never sent back to the client.
|
// Config keys that hold MACHINE-ONLY secrets — never sent back to the client.
|
||||||
@@ -54,6 +59,41 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connection-identity keys: the fields that decide WHERE a probe is sent. A stored
|
||||||
|
// secret may only be re-merged when these match the stored row — otherwise an admin
|
||||||
|
// could point a test at an attacker host while keeping a real device id and have the
|
||||||
|
// secret sent there (exfiltration). host/port/binaryPort/httpPort cover the Dingtian's
|
||||||
|
// UDP + CGI targets; serial covers serial-bound readers.
|
||||||
|
const IDENTITY_KEYS = ["host", "port", "binaryPort", "httpPort", "serial"] as const;
|
||||||
|
|
||||||
|
/** Stored machine-only secrets (relayPassword/pushPassword) for a device `id`, but ONLY
|
||||||
|
* when the submitted config addresses the SAME device — same driver, and every
|
||||||
|
* connection-identity field (host/port/…) that the submitted config sets equals the
|
||||||
|
* stored value. If the admin redirected the probe (different host/port) or the driver
|
||||||
|
* doesn't match, NO secret is returned: they must re-enter it explicitly. This stops a
|
||||||
|
* redirected test from exfiltrating the secret to an attacker host. */
|
||||||
|
export function storedSecrets(
|
||||||
|
db: Db,
|
||||||
|
id: string,
|
||||||
|
driverId: string,
|
||||||
|
submitted: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const row = db.select().from(devices).where(eq(devices.id, id)).get();
|
||||||
|
if (!row || row.driverId !== driverId) return {};
|
||||||
|
const cfg = row.config as Record<string, unknown>;
|
||||||
|
// Any identity field the client SENT must equal the stored value. (A field the client
|
||||||
|
// omits falls back to the stored device, so it can't be used to redirect.)
|
||||||
|
for (const k of IDENTITY_KEYS) {
|
||||||
|
const sent = submitted[k];
|
||||||
|
if (sent !== undefined && sent !== "" && String(sent) !== String(cfg[k] ?? "")) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const k of SECRET_CONFIG_KEYS) if (cfg[k] !== undefined) out[k] = cfg[k];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Result of the device configure pipeline: a ready-to-persist config, or an
|
/** Result of the device configure pipeline: a ready-to-persist config, or an
|
||||||
* HTTP error to send back. Shared by assign (create) and patch (edit). */
|
* HTTP error to send back. Shared by assign (create) and patch (edit). */
|
||||||
type ConfigureOutcome =
|
type ConfigureOutcome =
|
||||||
@@ -249,13 +289,30 @@ export async function setupRoutes(
|
|||||||
"/api/setup/test",
|
"/api/setup/test",
|
||||||
{ preHandler: adminGuard },
|
{ preHandler: adminGuard },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { driverId, config } = req.body;
|
const { driverId, config, id } = req.body;
|
||||||
const driver = registry.get(driverId);
|
const driver = registry.get(driverId);
|
||||||
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
||||||
|
|
||||||
|
// When editing an existing device, re-merge its stored machine secrets (e.g.
|
||||||
|
// relayPassword) — redacted from the client, so the submitted config omits them.
|
||||||
|
// Submitted values win (an admin can override), but a blank/0 field falls back to
|
||||||
|
// the stored secret so the probe authenticates. Without this, an edited Dingtian
|
||||||
|
// tests with no relay password → false "offline". The submitted-value-wins rule:
|
||||||
|
// only fill a secret from the store when the form didn't send a real one.
|
||||||
|
// Re-merge stored secrets ONLY when this addresses the same device at the same
|
||||||
|
// host/port (storedSecrets enforces identity) — so a redirected probe can't leak
|
||||||
|
// the secret to an attacker host. Submitted values still win.
|
||||||
|
const merged: Record<string, string | number | boolean | undefined> = { ...config };
|
||||||
|
if (id) {
|
||||||
|
for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) {
|
||||||
|
const sent = merged[k];
|
||||||
|
if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let device;
|
let device;
|
||||||
try {
|
try {
|
||||||
device = registry.create(driverId, config);
|
device = registry.create(driverId, merged as Record<string, string | number | boolean>);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reply.code(400).send({ error: (err as Error).message });
|
return reply.code(400).send({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||||
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
|
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
|
||||||
import { deviceEvents } from "./device-events.js";
|
import { deviceEvents } from "./device-events.js";
|
||||||
|
import { ButtonLightController } from "./button-light.js";
|
||||||
import { EntryFlow } from "./entry-flow.js";
|
import { EntryFlow } from "./entry-flow.js";
|
||||||
import { EventLog } from "./event-log.js";
|
import { EventLog } from "./event-log.js";
|
||||||
import { ExitFlow } from "./exit-flow.js";
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
@@ -188,6 +189,13 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeEntry());
|
app.addHook("onClose", async () => unsubscribeEntry());
|
||||||
|
|
||||||
|
// Button-light indicator: drives the entry button's lamp on a spare relay from the
|
||||||
|
// RADAR input vs. the camera lane status (blink = radar-only, solid = radar+camera,
|
||||||
|
// off otherwise). A non-barrier aux output; fails OFF. See button-light.ts.
|
||||||
|
const buttonLight = new ButtonLightController(db, app.log);
|
||||||
|
buttonLight.start();
|
||||||
|
app.addHook("onClose", async () => buttonLight.stop());
|
||||||
|
|
||||||
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
||||||
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
|
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
|
||||||
// transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
|
// transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)'"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+354
-48
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, Fragment } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
assignDevice,
|
assignDevice,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type AnprTestResult,
|
type AnprTestResult,
|
||||||
type Assignment,
|
type Assignment,
|
||||||
type BackendIpCandidate,
|
type BackendIpCandidate,
|
||||||
|
type ButtonLightSpec,
|
||||||
type Catalog,
|
type Catalog,
|
||||||
type CatalogEntry,
|
type CatalogEntry,
|
||||||
type DeviceCategory,
|
type DeviceCategory,
|
||||||
@@ -270,11 +271,24 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
|
|||||||
if (assignment.category === "access") {
|
if (assignment.category === "access") {
|
||||||
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
|
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
|
||||||
if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
|
if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
|
||||||
|
const bl = cfg.buttonLight as ButtonLightSpec | undefined;
|
||||||
return (
|
return (
|
||||||
<span className="flex gap-1.5">
|
<span className="flex flex-wrap gap-1.5">
|
||||||
{relays.map((r) => (
|
{relays.map((r) => {
|
||||||
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} />
|
const presence = r.presenceInput
|
||||||
))}
|
? `·${r.presenceKind === "radar" ? "radar" : "loop"}${r.presenceInput}`
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
<DirectionBadge
|
||||||
|
key={r.relay}
|
||||||
|
direction={r.direction}
|
||||||
|
label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}${presence}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{bl?.relay != null && (
|
||||||
|
<DirectionBadge direction="both" label={`lamp·R${bl.relay}`} />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -345,6 +359,11 @@ function DeviceForm({
|
|||||||
const [relays, setRelays] = useState<RelaySpec[]>(() =>
|
const [relays, setRelays] = useState<RelaySpec[]>(() =>
|
||||||
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
|
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
|
||||||
);
|
);
|
||||||
|
// Controller-level button-lamp output (a spare relay), driven by the radar + camera.
|
||||||
|
const [buttonLight, setButtonLight] = useState<ButtonLightSpec | null>(() => {
|
||||||
|
const bl = editCfg?.buttonLight as ButtonLightSpec | undefined;
|
||||||
|
return bl && typeof bl.relay === "number" ? bl : null;
|
||||||
|
});
|
||||||
// Bound devices: which controller + relay this device sits at.
|
// Bound devices: which controller + relay this device sits at.
|
||||||
const [controllerId, setControllerId] = useState<string>(
|
const [controllerId, setControllerId] = useState<string>(
|
||||||
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
|
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
|
||||||
@@ -357,6 +376,7 @@ function DeviceForm({
|
|||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testError, setTestError] = useState<string | null>(null);
|
const [testError, setTestError] = useState<string | null>(null);
|
||||||
// ANPR probe (camera + anpr on): snapshot → vision analyze, reported below.
|
// ANPR probe (camera + anpr on): snapshot → vision analyze, reported below.
|
||||||
|
const [alarmUrlCopied, setAlarmUrlCopied] = useState(false);
|
||||||
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
|
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
|
||||||
const [anprTesting, setAnprTesting] = useState(false);
|
const [anprTesting, setAnprTesting] = useState(false);
|
||||||
const [anprError, setAnprError] = useState<string | null>(null);
|
const [anprError, setAnprError] = useState<string | null>(null);
|
||||||
@@ -368,22 +388,31 @@ function DeviceForm({
|
|||||||
|
|
||||||
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
|
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
|
||||||
const [backendIp, setBackendIp] = useState<string>("");
|
const [backendIp, setBackendIp] = useState<string>("");
|
||||||
|
// The server's listen port (e.g. 3000) the device must POST to — NOT the page's
|
||||||
|
// port (the SPA may be served by Vite on :5173 in dev, or behind a proxy on :80).
|
||||||
|
// Comes from the same /api/setup/backend-ips probe as the IPs.
|
||||||
|
const [backendPort, setBackendPort] = useState<number | null>(null);
|
||||||
|
|
||||||
const testedHost = tested ? String(mergedScalarConfig().host ?? "") : "";
|
const testedHost = tested ? String(mergedScalarConfig().host ?? "") : "";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!testedHost || !pushesToBackend) {
|
if (!testedHost || !pushesToBackend) {
|
||||||
setBackendIps(null);
|
setBackendIps(null);
|
||||||
|
setBackendPort(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let live = true;
|
let live = true;
|
||||||
fetchBackendIps(testedHost)
|
fetchBackendIps(testedHost)
|
||||||
.then(({ candidates }) => {
|
.then(({ candidates, port }) => {
|
||||||
if (!live) return;
|
if (!live) return;
|
||||||
setBackendIps(candidates);
|
setBackendIps(candidates);
|
||||||
|
setBackendPort(port);
|
||||||
setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || "");
|
setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || "");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (live) setBackendIps(null);
|
if (live) {
|
||||||
|
setBackendIps(null);
|
||||||
|
setBackendPort(null);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
@@ -442,8 +471,18 @@ function DeviceForm({
|
|||||||
direction: r.direction,
|
direction: r.direction,
|
||||||
...(r.button ? { button: r.button } : {}),
|
...(r.button ? { button: r.button } : {}),
|
||||||
...(r.presenceInput ? { presenceInput: r.presenceInput } : {}),
|
...(r.presenceInput ? { presenceInput: r.presenceInput } : {}),
|
||||||
|
...(r.presenceInput && r.presenceKind ? { presenceKind: r.presenceKind } : {}),
|
||||||
|
...(r.presenceInput && r.presenceActiveLow ? { presenceActiveLow: true } : {}),
|
||||||
...(r.entryCooldownSec ? { entryCooldownSec: r.entryCooldownSec } : {}),
|
...(r.entryCooldownSec ? { entryCooldownSec: r.entryCooldownSec } : {}),
|
||||||
}));
|
}));
|
||||||
|
// Button-lamp output (a spare relay), persisted only when a relay is chosen.
|
||||||
|
if (buttonLight && buttonLight.relay) {
|
||||||
|
out.buttonLight = {
|
||||||
|
relay: buttonLight.relay,
|
||||||
|
...(buttonLight.blinkOnMs ? { blinkOnMs: buttonLight.blinkOnMs } : {}),
|
||||||
|
...(buttonLight.blinkOffMs ? { blinkOffMs: buttonLight.blinkOffMs } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
} else if (controllerId && boundRelay !== "") {
|
} else if (controllerId && boundRelay !== "") {
|
||||||
out.controllerId = controllerId;
|
out.controllerId = controllerId;
|
||||||
out.relay = boundRelay;
|
out.relay = boundRelay;
|
||||||
@@ -467,7 +506,7 @@ function DeviceForm({
|
|||||||
setTestError(null);
|
setTestError(null);
|
||||||
setTested(null);
|
setTested(null);
|
||||||
try {
|
try {
|
||||||
setTested(await testDevice(selected.id, mergedScalarConfig()));
|
setTested(await testDevice(selected.id, mergedScalarConfig(), editing?.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setTestError((e as Error).message);
|
setTestError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -569,7 +608,21 @@ function DeviceForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selected.configFields.map((f) =>
|
{selected.configFields
|
||||||
|
// pulseMs + inputRestingHigh are surfaced in the Outputs / Inputs model
|
||||||
|
// sections below (a relay setting and an input setting, respectively), so
|
||||||
|
// skip them here to avoid rendering them twice. See OutputEditor/InputEditor.
|
||||||
|
.filter((f) => !(isController && (f.key === "pulseMs" || f.key === "inputRestingHigh")))
|
||||||
|
// Printer transport is exclusive: when Connection = USB the network fields
|
||||||
|
// (host/port/status-page) don't apply, and vice-versa the USB device path
|
||||||
|
// doesn't. Hide the irrelevant side so the form can't mislead (e.g. a USB
|
||||||
|
// path lingering under a Network printer). Driven by config.transport.
|
||||||
|
.filter((f) => {
|
||||||
|
const transport = String(config.transport ?? "tcp-ip");
|
||||||
|
if (transport === "usb") return !["host", "port", "httpPort"].includes(f.key);
|
||||||
|
return f.key !== "devicePath";
|
||||||
|
})
|
||||||
|
.map((f) =>
|
||||||
f.type === "boolean" ? (
|
f.type === "boolean" ? (
|
||||||
// Boolean config field → a real checkbox (stores a true/false boolean, not
|
// Boolean config field → a real checkbox (stores a true/false boolean, not
|
||||||
// the string "true"). The label sits beside the box, with the help below.
|
// the string "true"). The label sits beside the box, with the help below.
|
||||||
@@ -628,8 +681,34 @@ function DeviceForm({
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
|
{/* CONTROLLER — OUTPUTS: the relays (barriers + the button lamp) + pulse time. */}
|
||||||
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
|
{isController && (
|
||||||
|
<OutputEditor
|
||||||
|
relays={relays}
|
||||||
|
onChange={setRelays}
|
||||||
|
buttonLight={buttonLight}
|
||||||
|
onButtonLightChange={setButtonLight}
|
||||||
|
pulseMs={config.pulseMs as number | undefined}
|
||||||
|
onPulseMsChange={(v) => {
|
||||||
|
setConfig((c) => ({ ...c, pulseMs: v }));
|
||||||
|
resetStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CONTROLLER — INPUTS: the terminals (entry button, presence/radar), each bound
|
||||||
|
to the output relay it drives. Separated from the outputs above. */}
|
||||||
|
{isController && (
|
||||||
|
<InputEditor
|
||||||
|
relays={relays}
|
||||||
|
onChange={setRelays}
|
||||||
|
inputsIdleHigh={config.inputRestingHigh as boolean | undefined}
|
||||||
|
onInputsIdleHighChange={(v) => {
|
||||||
|
setConfig((c) => ({ ...c, inputRestingHigh: v }));
|
||||||
|
resetStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* BOUND device: which controller + relay it sits at. */}
|
{/* BOUND device: which controller + relay it sits at. */}
|
||||||
{!isController && (
|
{!isController && (
|
||||||
@@ -661,6 +740,64 @@ function DeviceForm({
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* CAMERA + Alarm Server push ON: show the camera's Alarm Server settings,
|
||||||
|
ready to copy, so the operator never has to find the deviceId or memorise the
|
||||||
|
endpoint. The CAMERA reaches us over the device VLAN, NOT via the browser's
|
||||||
|
origin — so host/port are the BACKEND address (backendIp on the camera's
|
||||||
|
subnet + the server's listen port), resolved by the same probe the push-IP
|
||||||
|
picker uses, NOT window.location (which is the SPA's dev/proxy origin). The
|
||||||
|
URL embeds the deviceId, so it needs a SAVED camera; and the backend IP needs
|
||||||
|
a Test connection first. We surface each field separately, matching the
|
||||||
|
camera's Alarm Settings form (Destination IP / URL / Protocol / Port). */}
|
||||||
|
{isCamera && Boolean(config.alarmPushEnabled) && (
|
||||||
|
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
|
||||||
|
<div className="font-semibold text-term-text">{t("setup.alarmUrlTitle")}</div>
|
||||||
|
{!editing?.id ? (
|
||||||
|
<p className="hint mt-1">{t("setup.alarmUrlSaveFirst")}</p>
|
||||||
|
) : !backendIp || backendPort == null ? (
|
||||||
|
<p className="hint mt-1">{t("setup.alarmUrlTestFirst")}</p>
|
||||||
|
) : (
|
||||||
|
(() => {
|
||||||
|
const path = `/api/devices/hikvision/${editing.id}/event`;
|
||||||
|
// What the operator pastes into the camera's Alarm Settings form.
|
||||||
|
const fields: [string, string][] = [
|
||||||
|
[t("setup.alarmFieldHost"), backendIp],
|
||||||
|
[t("setup.alarmFieldUrl"), path],
|
||||||
|
[t("setup.alarmFieldProtocol"), "HTTP"],
|
||||||
|
[t("setup.alarmFieldPort"), String(backendPort)],
|
||||||
|
];
|
||||||
|
const copyText = fields.map(([k, v]) => `${k}: ${v}`).join("\n");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||||
|
{fields.map(([k, v]) => (
|
||||||
|
<Fragment key={k}>
|
||||||
|
<span className="text-term-muted">{k}</span>
|
||||||
|
<code className="break-all rounded bg-term-panel px-2 py-0.5 text-term-green">{v}</code>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard?.writeText(copyText);
|
||||||
|
setAlarmUrlCopied(true);
|
||||||
|
setTimeout(() => setAlarmUrlCopied(false), 2000);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{alarmUrlCopied ? t("setup.alarmUrlCopied") : t("setup.alarmUrlCopy")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="hint mt-1">{t("setup.alarmUrlHint")}</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Test (no save/no device change) then Save (configures + persists). */}
|
{/* Test (no save/no device change) then Save (configures + persists). */}
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
<button type="button" className="btn btn-sm" onClick={test} disabled={testing}>
|
<button type="button" className="btn btn-sm" onClick={test} disabled={testing}>
|
||||||
@@ -760,9 +897,28 @@ function DeviceForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Controller relay map editor: each row = a relay + its direction + (optional)
|
// ── Controller OUTPUTS (relays) ────────────────────────────────────────────
|
||||||
* the input terminal its entry button is wired to. */
|
// A relay is an OUTPUT: it opens a barrier (or drives the button lamp). This section
|
||||||
function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) {
|
// owns relay number + direction, the pulse-open time (relay hold ms), and the lamp
|
||||||
|
// relay. The INPUT terminals wired to these relays live in InputEditor below — the two
|
||||||
|
// are deliberately separated (a controller's inputs and outputs are distinct things).
|
||||||
|
|
||||||
|
/** Relays = outputs (barriers + lamp) + the pulse-open hold time. */
|
||||||
|
function OutputEditor({
|
||||||
|
relays,
|
||||||
|
onChange,
|
||||||
|
buttonLight,
|
||||||
|
onButtonLightChange,
|
||||||
|
pulseMs,
|
||||||
|
onPulseMsChange,
|
||||||
|
}: {
|
||||||
|
relays: RelaySpec[];
|
||||||
|
onChange: (r: RelaySpec[]) => void;
|
||||||
|
buttonLight: ButtonLightSpec | null;
|
||||||
|
onButtonLightChange: (v: ButtonLightSpec | null) => void;
|
||||||
|
pulseMs: number | undefined;
|
||||||
|
onPulseMsChange: (v: number) => void;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
function update(i: number, patch: Partial<RelaySpec>) {
|
function update(i: number, patch: Partial<RelaySpec>) {
|
||||||
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||||
@@ -774,11 +930,27 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
|||||||
function remove(i: number) {
|
function remove(i: number) {
|
||||||
onChange(relays.filter((_, idx) => idx !== i));
|
onChange(relays.filter((_, idx) => idx !== i));
|
||||||
}
|
}
|
||||||
|
const barrierRelays = new Set(relays.map((r) => r.relay));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||||
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.relaysTitle")}</strong>
|
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.outputsTitle")}</strong>
|
||||||
<p className="hint mt-0.5 mb-2">{t("setup.relaysHint")}</p>
|
<p className="hint mt-0.5 mb-2">{t("setup.outputsHint")}</p>
|
||||||
|
|
||||||
|
{/* Pulse-open time applies to every barrier relay (how long it's held open). */}
|
||||||
|
<label className="my-1 inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.pulseOpenHint")}>
|
||||||
|
{t("setup.pulseOpenMs")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={100}
|
||||||
|
value={pulseMs ?? ""}
|
||||||
|
placeholder="500"
|
||||||
|
className="input input-sm w-20"
|
||||||
|
onChange={(e) => onPulseMsChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Barrier relays: number + direction. (Input terminals are in the Inputs section.) */}
|
||||||
{relays.map((r, i) => (
|
{relays.map((r, i) => (
|
||||||
<div key={i} className="my-1 flex flex-wrap items-center gap-2">
|
<div key={i} className="my-1 flex flex-wrap items-center gap-2">
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||||
@@ -798,7 +970,130 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{(r.direction === "entry" || r.direction === "both") && (
|
{relays.length > 1 && (
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn btn-sm mt-1" onClick={add}>
|
||||||
|
{t("setup.addRelay")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Button-lamp output (a spare relay) — an OUTPUT, so it lives here. Driven by the
|
||||||
|
radar + camera (blink = radar-only, solid = car confirmed, off otherwise). */}
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-3 border-t border-term-border pt-2">
|
||||||
|
<span className="text-[12px] text-term-muted" title={t("setup.buttonLightHint")}>
|
||||||
|
{t("setup.buttonLight")}
|
||||||
|
</span>
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||||
|
{t("setup.buttonLightRelay")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={buttonLight?.relay ?? ""}
|
||||||
|
placeholder="—"
|
||||||
|
className="input input-sm w-16"
|
||||||
|
onChange={(e) =>
|
||||||
|
onButtonLightChange(e.target.value === "" ? null : { ...buttonLight, relay: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{buttonLight?.relay != null && barrierRelays.has(buttonLight.relay) && (
|
||||||
|
<span className="text-[11px] text-term-amber">{t("setup.buttonLightBarrierWarn")}</span>
|
||||||
|
)}
|
||||||
|
{buttonLight?.relay != null && (
|
||||||
|
<>
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||||
|
{t("setup.blinkOnMs")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={50}
|
||||||
|
value={buttonLight.blinkOnMs ?? ""}
|
||||||
|
placeholder="500"
|
||||||
|
className="input input-sm w-20"
|
||||||
|
onChange={(e) =>
|
||||||
|
onButtonLightChange({ ...buttonLight, blinkOnMs: e.target.value === "" ? undefined : Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||||
|
{t("setup.blinkOffMs")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={50}
|
||||||
|
value={buttonLight.blinkOffMs ?? ""}
|
||||||
|
placeholder="500"
|
||||||
|
className="input input-sm w-20"
|
||||||
|
onChange={(e) =>
|
||||||
|
onButtonLightChange({ ...buttonLight, blinkOffMs: e.target.value === "" ? undefined : Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controller INPUTS (terminals) ──────────────────────────────────────────
|
||||||
|
// An input is a TERMINAL the host READS: the entry button, the presence/radar sensor.
|
||||||
|
// Each input belongs to an entry barrier (it triggers/gates that relay's entry), so we
|
||||||
|
// render one block per entry/both relay, labelled with the output relay it drives. The
|
||||||
|
// button never SETS a pulse — its electrical pulse is the device's to report — so no
|
||||||
|
// timing field lives here (pulse-open is an OUTPUT setting, in OutputEditor).
|
||||||
|
|
||||||
|
/** Per-entry-relay input terminals: the entry button + the presence/radar sensor. */
|
||||||
|
function InputEditor({
|
||||||
|
relays,
|
||||||
|
onChange,
|
||||||
|
inputsIdleHigh,
|
||||||
|
onInputsIdleHighChange,
|
||||||
|
}: {
|
||||||
|
relays: RelaySpec[];
|
||||||
|
onChange: (r: RelaySpec[]) => void;
|
||||||
|
inputsIdleHigh: boolean | undefined;
|
||||||
|
onInputsIdleHighChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
function update(i: number, patch: Partial<RelaySpec>) {
|
||||||
|
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||||
|
}
|
||||||
|
// Inputs only matter for entry/both relays (transient entry). Keep each row's real
|
||||||
|
// index so updates target the right relay.
|
||||||
|
const entryRelays = relays
|
||||||
|
.map((r, i) => ({ r, i }))
|
||||||
|
.filter(({ r }) => r.direction === "entry" || r.direction === "both");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||||
|
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.inputsTitle")}</strong>
|
||||||
|
<p className="hint mt-0.5 mb-2">{t("setup.inputsHint")}</p>
|
||||||
|
|
||||||
|
{/* Board-wide resting level (idle HIGH vs LOW) — an input property. */}
|
||||||
|
<label className="my-1 inline-flex items-start gap-2 text-[12px] text-term-muted">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={inputsIdleHigh ?? true}
|
||||||
|
onChange={(e) => onInputsIdleHighChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold text-term-text">{t("setup.inputsIdleHigh")}</span>
|
||||||
|
<span className="hint mt-0.5 block">{t("setup.inputsIdleHighHint")}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{entryRelays.length === 0 ? (
|
||||||
|
<p className="hint">{t("setup.inputsNoEntryRelay")}</p>
|
||||||
|
) : (
|
||||||
|
entryRelays.map(({ r, i }) => (
|
||||||
|
<div key={i} className="my-1 flex flex-wrap items-center gap-2 border-t border-term-border pt-2">
|
||||||
|
<span className="text-[11px] uppercase tracking-wider text-term-amber">
|
||||||
|
{t("setup.inputsForRelay", { relay: r.relay })}
|
||||||
|
</span>
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||||
{t("setup.entryButtonTerminal")}
|
{t("setup.entryButtonTerminal")}
|
||||||
<input
|
<input
|
||||||
@@ -810,8 +1105,6 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
|||||||
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
|
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
)}
|
|
||||||
{(r.direction === "entry" || r.direction === "both") && (
|
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceInputHint")}>
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceInputHint")}>
|
||||||
{t("setup.presenceInput")}
|
{t("setup.presenceInput")}
|
||||||
<input
|
<input
|
||||||
@@ -820,37 +1113,50 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
|||||||
value={r.presenceInput ?? ""}
|
value={r.presenceInput ?? ""}
|
||||||
placeholder="—"
|
placeholder="—"
|
||||||
className="input input-sm w-16"
|
className="input input-sm w-16"
|
||||||
onChange={(e) =>
|
onChange={(e) => update(i, { presenceInput: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||||
update(i, { presenceInput: e.target.value === "" ? undefined : Number(e.target.value) })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
)}
|
{/* Sensor kind + active-level — only once a presence terminal is set. */}
|
||||||
{(r.direction === "entry" || r.direction === "both") && !r.presenceInput && (
|
{!!r.presenceInput && (
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
|
<>
|
||||||
{t("setup.entryCooldown")}
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||||
<input
|
{t("setup.presenceKind")}
|
||||||
type="number"
|
<select
|
||||||
min={0}
|
value={r.presenceKind ?? "loop"}
|
||||||
value={r.entryCooldownSec ?? ""}
|
className="input input-sm w-24"
|
||||||
placeholder="—"
|
onChange={(e) => update(i, { presenceKind: e.target.value as "loop" | "radar" })}
|
||||||
className="input input-sm w-16"
|
>
|
||||||
onChange={(e) =>
|
<option value="loop">{t("setup.presenceKindLoop")}</option>
|
||||||
update(i, { entryCooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })
|
<option value="radar">{t("setup.presenceKindRadar")}</option>
|
||||||
}
|
</select>
|
||||||
/>
|
</label>
|
||||||
</label>
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceActiveLowHint")}>
|
||||||
)}
|
<input
|
||||||
{relays.length > 1 && (
|
type="checkbox"
|
||||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
|
checked={!!r.presenceActiveLow}
|
||||||
✕
|
onChange={(e) => update(i, { presenceActiveLow: e.target.checked || undefined })}
|
||||||
</button>
|
/>
|
||||||
)}
|
{t("setup.presenceActiveLow")}
|
||||||
</div>
|
</label>
|
||||||
))}
|
</>
|
||||||
<button type="button" className="btn btn-sm mt-1" onClick={add}>
|
)}
|
||||||
{t("setup.addRelay")}
|
{/* Cooldown fallback only when no presence sensor is wired. */}
|
||||||
</button>
|
{!r.presenceInput && (
|
||||||
|
<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={r.entryCooldownSec ?? ""}
|
||||||
|
placeholder="—"
|
||||||
|
className="input input-sm w-16"
|
||||||
|
onChange={(e) => update(i, { entryCooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-3
@@ -286,9 +286,24 @@ export interface RelaySpec {
|
|||||||
* loop/barrier-feedback signal; a press prints only with a car present + re-arms when
|
* loop/barrier-feedback signal; a press prints only with a car present + re-arms when
|
||||||
* it clears. COOLDOWN (fallback, no feedback): suppress repeat presses for N seconds. */
|
* it clears. COOLDOWN (fallback, no feedback): suppress repeat presses for N seconds. */
|
||||||
presenceInput?: number;
|
presenceInput?: number;
|
||||||
|
/** Sensor on the presence input: induction LOOP or a RADAR (label only). */
|
||||||
|
presenceKind?: "loop" | "radar";
|
||||||
|
/** The presence terminal is active-LOW (idles HIGH) — e.g. a radar wired opposite
|
||||||
|
* the button. Maps to the driver's per-input active-level override. */
|
||||||
|
presenceActiveLow?: boolean;
|
||||||
entryCooldownSec?: number;
|
entryCooldownSec?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A non-barrier indicator lamp wired to a spare relay (e.g. the entry button light),
|
||||||
|
* driven by the radar input vs. the camera lane status. */
|
||||||
|
export interface ButtonLightSpec {
|
||||||
|
/** 1-based spare relay the lamp is on. */
|
||||||
|
relay: number;
|
||||||
|
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
|
||||||
|
blinkOnMs?: number;
|
||||||
|
blinkOffMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TestResult {
|
export interface TestResult {
|
||||||
health: { status: string; detail?: string };
|
health: { status: string; detail?: string };
|
||||||
preconditions: {
|
preconditions: {
|
||||||
@@ -297,11 +312,13 @@ export interface TestResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test a device config (reachability + preconditions) without saving. */
|
/** Test a device config (reachability + preconditions) without saving. Pass the
|
||||||
export function testDevice(driverId: string, config: DeviceConfig): Promise<TestResult> {
|
* device `id` when editing an existing one so the server re-merges its stored
|
||||||
|
* machine secrets (e.g. the relay password redacted from the client). */
|
||||||
|
export function testDevice(driverId: string, config: DeviceConfig, id?: string): Promise<TestResult> {
|
||||||
return apiFetch<TestResult>("/api/setup/test", {
|
return apiFetch<TestResult>("/api/setup/test", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ driverId, config }),
|
body: JSON.stringify({ driverId, config, ...(id ? { id } : {}) }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -359,14 +359,39 @@ export const en: Catalog = {
|
|||||||
relaysTitle: "Relays on this controller",
|
relaysTitle: "Relays on this controller",
|
||||||
relaysHint:
|
relaysHint:
|
||||||
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
|
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
|
||||||
|
outputsTitle: "Outputs — relays (barriers + lamp)",
|
||||||
|
outputsHint:
|
||||||
|
"Relays are OUTPUTS: each opens a barrier (or drives the button lamp). Set the relay number and direction. The input terminals (button, sensor) are in the Inputs section below.",
|
||||||
|
pulseOpenMs: "Pulse open (ms)",
|
||||||
|
pulseOpenHint: "How long a barrier relay is held open (jog). Applies to all barrier relays.",
|
||||||
|
inputsTitle: "Inputs — terminals (button, sensor)",
|
||||||
|
inputsHint:
|
||||||
|
"Inputs are TERMINALS the host READS: the entry button and the presence/radar sensor. Each belongs to an entry barrier — it triggers or gates that relay.",
|
||||||
|
inputsIdleHigh: "Inputs idle HIGH",
|
||||||
|
inputsIdleHighHint: "This board idles inputs HIGH (status 1111); a press pulls LOW.",
|
||||||
|
inputsForRelay: "For relay {{relay}}",
|
||||||
|
inputsNoEntryRelay: "No entry relay — add an 'Entry' or 'Entry + exit' relay in Outputs to assign terminals.",
|
||||||
relay: "Relay",
|
relay: "Relay",
|
||||||
entryButtonTerminal: "Entry button on terminal",
|
entryButtonTerminal: "Entry button on terminal",
|
||||||
presenceInput: "Presence loop (terminal)",
|
presenceInput: "Presence sensor (terminal)",
|
||||||
presenceInputHint:
|
presenceInputHint:
|
||||||
"Input terminal the vehicle-presence loop / barrier feedback is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the loop clears (the car drove in) and a new car re-occupies it. Preferred mode.",
|
"Input terminal the vehicle-presence sensor (induction loop or radar) is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the sensor clears (the car drove in) and a new car re-occupies it. Preferred mode.",
|
||||||
entryCooldown: "Cooldown after ticket (s)",
|
entryCooldown: "Cooldown after ticket (s)",
|
||||||
entryCooldownHint:
|
entryCooldownHint:
|
||||||
"When there's no presence loop: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
|
"When there's no presence sensor: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
|
||||||
|
presenceKind: "Kind",
|
||||||
|
presenceKindLoop: "Loop",
|
||||||
|
presenceKindRadar: "Radar",
|
||||||
|
presenceActiveLow: "Active-low",
|
||||||
|
presenceActiveLowHint:
|
||||||
|
"Tick if the presence sensor (e.g. a radar) idles HIGH and goes LOW on detection — the opposite of the button. This inverts that terminal's reading so 'present' is read correctly.",
|
||||||
|
buttonLight: "Button light (spare relay)",
|
||||||
|
buttonLightRelay: "Relay",
|
||||||
|
buttonLightHint:
|
||||||
|
"The button's 12 V light on a spare relay. Blinks when the radar detects but the camera doesn't confirm a car; solid on when both confirm; off otherwise.",
|
||||||
|
buttonLightBarrierWarn: "This relay is used by a barrier — pick a spare relay.",
|
||||||
|
blinkOnMs: "Blink on (ms)",
|
||||||
|
blinkOffMs: "Blink off (ms)",
|
||||||
addRelay: "+ Add relay",
|
addRelay: "+ Add relay",
|
||||||
anpr: "Plate recognition (ANPR)",
|
anpr: "Plate recognition (ANPR)",
|
||||||
anprHint:
|
anprHint:
|
||||||
@@ -381,6 +406,19 @@ export const en: Catalog = {
|
|||||||
"anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.",
|
"anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.",
|
||||||
"anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).",
|
"anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).",
|
||||||
"anprFail.no-plate": "No plate found in the snapshot.",
|
"anprFail.no-plate": "No plate found in the snapshot.",
|
||||||
|
alarmUrlTitle: "Alarm Server settings (enter these in the camera)",
|
||||||
|
alarmUrlHint:
|
||||||
|
"Enter these in the camera at Configuration → Event → … → Alarm Settings (or Notify Surveillance Center). The camera POSTs every event here — no polling.",
|
||||||
|
alarmUrlCopy: "Copy all",
|
||||||
|
alarmUrlCopied: "Copied ✓",
|
||||||
|
alarmUrlSaveFirst:
|
||||||
|
"Save the camera first — the address is generated once the device has an ID. Re-open it for editing to see it.",
|
||||||
|
alarmUrlTestFirst:
|
||||||
|
"Click “Test connection” first — that resolves this host's IP on the camera's network (so the camera can reach it).",
|
||||||
|
alarmFieldHost: "Destination IP / Host",
|
||||||
|
alarmFieldUrl: "URL",
|
||||||
|
alarmFieldProtocol: "Protocol",
|
||||||
|
alarmFieldPort: "Port",
|
||||||
whichBarrier: "Which barrier does this device serve?",
|
whichBarrier: "Which barrier does this device serve?",
|
||||||
controller: "Controller",
|
controller: "Controller",
|
||||||
choose: "Choose…",
|
choose: "Choose…",
|
||||||
@@ -561,7 +599,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",
|
||||||
|
|||||||
+49
-10
@@ -308,13 +308,13 @@ 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",
|
||||||
@@ -322,7 +322,7 @@ export const sq = {
|
|||||||
addAnother: "+ Shto edhe një {{noun}}",
|
addAnother: "+ Shto edhe një {{noun}}",
|
||||||
addTitle: "Shto {{noun}}",
|
addTitle: "Shto {{noun}}",
|
||||||
editTitle: "Ndrysho {{noun}}",
|
editTitle: "Ndrysho {{noun}}",
|
||||||
needControllerFirst: "Shto fillimisht një kontrollues — {{noun}} drejtohet te një prej releve të tij.",
|
needControllerFirst: "Shto fillimisht një kontroller — {{noun}} drejtohet te një prej releve të tij.",
|
||||||
failedToLoad: "Ngarkimi i konfigurimit dështoi: {{error}}",
|
failedToLoad: "Ngarkimi i konfigurimit dështoi: {{error}}",
|
||||||
loadingCatalog: "Duke ngarkuar katalogun e pajisjeve…",
|
loadingCatalog: "Duke ngarkuar katalogun e pajisjeve…",
|
||||||
// Direction labels (relay direction + inherited binding).
|
// Direction labels (relay direction + inherited binding).
|
||||||
@@ -344,9 +344,9 @@ export const sq = {
|
|||||||
// Device form.
|
// Device form.
|
||||||
noDrivers: "Asnjë drejtues i regjistruar.",
|
noDrivers: "Asnjë drejtues i regjistruar.",
|
||||||
chooseDevice: "Zgjidh një pajisje…",
|
chooseDevice: "Zgjidh një pajisje…",
|
||||||
scan: "Skano për kontrollues",
|
scan: "Skano për kontroller",
|
||||||
scanning: "Duke skanuar…",
|
scanning: "Duke skanuar…",
|
||||||
noControllersFound: "Asnjë kontrollues në LAN.",
|
noControllersFound: "Asnjë kontroller në LAN.",
|
||||||
use: "Përdor",
|
use: "Përdor",
|
||||||
test: "Testo lidhjen",
|
test: "Testo lidhjen",
|
||||||
testing: "Duke testuar…",
|
testing: "Duke testuar…",
|
||||||
@@ -365,9 +365,21 @@ export const sq = {
|
|||||||
noNicOnSubnet: "⚠ asnjë NIC në subnetin e pajisjes — pajisja mund të mos arrijë backend-in",
|
noNicOnSubnet: "⚠ asnjë NIC në subnetin e pajisjes — pajisja mund të mos arrijë backend-in",
|
||||||
backendIpHint: "Adresa te e cila kjo pajisje do të dërgojë eventet e hyrjes.",
|
backendIpHint: "Adresa te e cila kjo pajisje do të dërgojë eventet e hyrjes.",
|
||||||
// Relay editor.
|
// Relay editor.
|
||||||
relaysTitle: "Relet në këtë kontrollues",
|
relaysTitle: "Relet në këtë kontroller",
|
||||||
relaysHint:
|
relaysHint:
|
||||||
"Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.",
|
"Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.",
|
||||||
|
outputsTitle: "Daljet — relet (barrierat + drita)",
|
||||||
|
outputsHint:
|
||||||
|
"Relet janë DALJE: secila hap një barrierë (ose ndez dritën e butonit). Cakto numrin e relesë dhe drejtimin. Terminalet hyrëse (butoni, sensori) janë te seksioni Hyrjet më poshtë.",
|
||||||
|
pulseOpenMs: "Kohëzgjatja e hapjes (ms)",
|
||||||
|
pulseOpenHint: "Sa kohë mbahet rele e barrierës e hapur (jog). Vlen për të gjitha relet e barrierave.",
|
||||||
|
inputsTitle: "Hyrjet — terminalet (buton, sensor)",
|
||||||
|
inputsHint:
|
||||||
|
"Hyrjet janë TERMINALE që hosti i LEXON: butoni i hyrjes dhe sensori i pranisë/radari. Secila i përket një barriere hyrëse — e gateron ose e nis atë rele.",
|
||||||
|
inputsIdleHigh: "Hyrjet në pushim HIGH",
|
||||||
|
inputsIdleHighHint: "Kjo pllakë i mban hyrjet HIGH në pushim (statusi 1111); një shtypje e ul në LOW.",
|
||||||
|
inputsForRelay: "Për rele {{relay}}",
|
||||||
|
inputsNoEntryRelay: "Asnjë rele hyrëse — shto një rele 'Hyrje' ose 'Hyrje + dalje' te Daljet që të caktosh terminalet.",
|
||||||
relay: "Rele",
|
relay: "Rele",
|
||||||
entryButtonTerminal: "Butoni i hyrjes në terminalin",
|
entryButtonTerminal: "Butoni i hyrjes në terminalin",
|
||||||
presenceInput: "Sensori i pranisë (terminali)",
|
presenceInput: "Sensori i pranisë (terminali)",
|
||||||
@@ -376,6 +388,19 @@ export const sq = {
|
|||||||
entryCooldown: "Pritje pas biletës (sek)",
|
entryCooldown: "Pritje pas biletës (sek)",
|
||||||
entryCooldownHint:
|
entryCooldownHint:
|
||||||
"Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.",
|
"Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.",
|
||||||
|
presenceKind: "Lloji",
|
||||||
|
presenceKindLoop: "Lak",
|
||||||
|
presenceKindRadar: "Radar",
|
||||||
|
presenceActiveLow: "Aktiv-ulët",
|
||||||
|
presenceActiveLowHint:
|
||||||
|
"Shëno nëse sensori i pranisë (p.sh. radari) qëndron HIGH në pushim dhe shkon LOW kur detekton — e kundërta e butonit. Kjo përmbys leximin e atij terminali që 'prania' të lexohet saktë.",
|
||||||
|
buttonLight: "Drita e butonit (rele rezervë)",
|
||||||
|
buttonLightRelay: "Rele",
|
||||||
|
buttonLightHint:
|
||||||
|
"Drita 12V e butonit e lidhur në një rele rezervë. Pulson kur radari detekton por kamera s'konfirmon makinë; ndizet fiks kur të dy konfirmojnë; përndryshe fiket.",
|
||||||
|
buttonLightBarrierWarn: "Kjo rele përdoret nga një barrierë — zgjidh një rele rezervë.",
|
||||||
|
blinkOnMs: "Pulsim ndezur (ms)",
|
||||||
|
blinkOffMs: "Pulsim fikur (ms)",
|
||||||
addRelay: "+ Shto rele",
|
addRelay: "+ Shto rele",
|
||||||
// Camera ANPR opt-in.
|
// Camera ANPR opt-in.
|
||||||
anpr: "Njohja e targave (ANPR)",
|
anpr: "Njohja e targave (ANPR)",
|
||||||
@@ -391,12 +416,26 @@ export const sq = {
|
|||||||
"anprFail.vision-disabled": "Shërbimi i vizionit është çaktivizuar — aktivizoje (VISION_ENABLED) për ta testuar ANPR.",
|
"anprFail.vision-disabled": "Shërbimi i vizionit është çaktivizuar — aktivizoje (VISION_ENABLED) për ta testuar ANPR.",
|
||||||
"anprFail.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).",
|
"anprFail.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).",
|
||||||
"anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.",
|
"anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.",
|
||||||
|
// Alarm Server push settings — generated for the camera's Event → Alarm Server form.
|
||||||
|
alarmUrlTitle: "Cilësimet e Alarm Server (vendosi te kamera)",
|
||||||
|
alarmUrlHint:
|
||||||
|
"Vendosi këto te kamera: Configuration → Event → … → Alarm Settings (ose Notify Surveillance Center). Kamera do të dërgojë çdo ngjarje këtu — pa polling.",
|
||||||
|
alarmUrlCopy: "Kopjo të gjitha",
|
||||||
|
alarmUrlCopied: "U kopjua ✓",
|
||||||
|
alarmUrlSaveFirst:
|
||||||
|
"Ruaje kamerën më parë — adresa gjenerohet pasi pajisja të marrë një ID. Hape sërish për editim që ta shohësh.",
|
||||||
|
alarmUrlTestFirst:
|
||||||
|
"Kliko “Testo lidhjen” më parë — kështu përcaktohet IP-ja e këtij hosti në rrjetin e kamerës (që kamera ta thërrasë).",
|
||||||
|
alarmFieldHost: "Destination IP / Host",
|
||||||
|
alarmFieldUrl: "URL",
|
||||||
|
alarmFieldProtocol: "Protokolli",
|
||||||
|
alarmFieldPort: "Porta",
|
||||||
// Binding picker.
|
// Binding picker.
|
||||||
whichBarrier: "Cilën barrierë shërben kjo pajisje?",
|
whichBarrier: "Cilën barrierë shërben kjo pajisje?",
|
||||||
controller: "Kontrolluesi",
|
controller: "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 +611,7 @@ export const sq = {
|
|||||||
save: "Ruaj",
|
save: "Ruaj",
|
||||||
saved: "U ruajt.",
|
saved: "U ruajt.",
|
||||||
fieldParkName: "Emri i parkimit",
|
fieldParkName: "Emri i parkimit",
|
||||||
fieldParkNamePh: "p.sh. Acme Parking",
|
fieldParkNamePh: "p.sh. Parking Aeroport",
|
||||||
fieldOperator: "Operatori (emri ligjor)",
|
fieldOperator: "Operatori (emri ligjor)",
|
||||||
fieldOperatorPh: "kompania operuese",
|
fieldOperatorPh: "kompania operuese",
|
||||||
fieldNius: "NIUS",
|
fieldNius: "NIUS",
|
||||||
|
|||||||
+54
-13
@@ -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,24 @@
|
|||||||
|
# Komodo Stack environment — reference of what a booth Stack needs and WHERE it comes
|
||||||
|
# from. Under Komodo, plain env lives in the Stack definition (komodo/resources.toml);
|
||||||
|
# the two SECRETS come from Komodo Core's secret store, PER BOOTH and UNIQUE. This file
|
||||||
|
# is documentation only — do NOT fill in real secrets here (it would be the same leak
|
||||||
|
# we're avoiding). See wiki/decisions/fleet-deployment-komodo.md.
|
||||||
|
|
||||||
|
# --- plain Stack env (lives in resources.toml; safe in git) -------------------
|
||||||
|
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||||
|
# IMMUTABLE per-commit tag. Manual + pinned. Bump per deploy. Never the moving `dev`
|
||||||
|
# on a production booth.
|
||||||
|
TAG=dev-830993b
|
||||||
|
COOKIE_SECURE=0
|
||||||
|
VISION_ENABLED=1
|
||||||
|
WS_ALLOWED_ORIGINS=
|
||||||
|
|
||||||
|
# --- secrets (Core secret store, referenced by name in resources.toml) --------
|
||||||
|
# Generated PER BOOTH (openssl rand -hex 32), registered in Core under booth-scoped
|
||||||
|
# names, never reused across sites. The user generates these; they are never typed on
|
||||||
|
# a CLI or committed.
|
||||||
|
# JWT_SECRET -> [[booth_<name>_jwt_secret]] (login)
|
||||||
|
# EVENT_SIGNING_KEY -> [[booth_<name>_event_signing_key]] (ledger signing — fraud root)
|
||||||
|
# Periphery/registry auth also live in Core:
|
||||||
|
# periphery passkey -> [[periphery_passkey_booth_<name>]]
|
||||||
|
# registry account -> [[gitea_registry_account]]
|
||||||
@@ -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,33 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { inputActive } from "./access-dingtian.js";
|
||||||
|
|
||||||
|
// Per-input active-level normalisation. The board has ONE resting level, but a radar
|
||||||
|
// can idle opposite the button — listing its terminal in `activeLow` inverts just that
|
||||||
|
// input so "present" reads correctly. See wiki/entities/hikvision-radar.md.
|
||||||
|
|
||||||
|
describe("inputActive (per-input active-level)", () => {
|
||||||
|
const none = new Set<number>();
|
||||||
|
const radarOnI2 = new Set<number>([2]);
|
||||||
|
|
||||||
|
it("default board (resting HIGH): a pull LOW is active, HIGH is rest", () => {
|
||||||
|
// Button on I1, board idles HIGH → active when LOW.
|
||||||
|
expect(inputActive(false, 1, true, none)).toBe(true); // LOW = pressed
|
||||||
|
expect(inputActive(true, 1, true, none)).toBe(false); // HIGH = rest
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resting LOW board: a pull HIGH is active", () => {
|
||||||
|
expect(inputActive(true, 1, false, none)).toBe(true);
|
||||||
|
expect(inputActive(false, 1, false, none)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("active-low override inverts ONLY the listed input", () => {
|
||||||
|
// Board idles HIGH (button on I1), radar on I2 idles HIGH and goes LOW on detect →
|
||||||
|
// mark I2 active-low so detection (LOW) reads active.
|
||||||
|
// I1 (button) keeps the board default:
|
||||||
|
expect(inputActive(false, 1, true, radarOnI2)).toBe(true); // button LOW = active
|
||||||
|
expect(inputActive(true, 1, true, radarOnI2)).toBe(false);
|
||||||
|
// I2 (radar) overridden to active-low: active when LOW.
|
||||||
|
expect(inputActive(false, 2, true, radarOnI2)).toBe(true); // radar LOW = detecting
|
||||||
|
expect(inputActive(true, 2, true, radarOnI2)).toBe(false); // radar HIGH = clear
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { createSocket } from "node:dgram";
|
|||||||
import { request as httpRequest } from "node:http";
|
import { request as httpRequest } from "node:http";
|
||||||
import type {
|
import type {
|
||||||
AccessControlDevice,
|
AccessControlDevice,
|
||||||
|
AuxOutputDevice,
|
||||||
DeviceHealth,
|
DeviceHealth,
|
||||||
HardenableDevice,
|
HardenableDevice,
|
||||||
HardenResult,
|
HardenResult,
|
||||||
@@ -166,6 +167,22 @@ interface DingtianStatus {
|
|||||||
channels: number;
|
channels: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise one input line to "active". `high` = the line is currently HIGH. An input
|
||||||
|
* whose 1-based channel is in `activeLow` is active when LOW (idles HIGH), overriding
|
||||||
|
* the board-wide `restingHigh`; otherwise active = differs from the resting level. This
|
||||||
|
* is the seam that lets a radar (wired opposite the button) read correctly. Exported for
|
||||||
|
* unit testing the bit logic without a UDP socket. See wiki/entities/hikvision-radar.md.
|
||||||
|
*/
|
||||||
|
export function inputActive(
|
||||||
|
high: boolean,
|
||||||
|
channel1Based: number,
|
||||||
|
restingHigh: boolean,
|
||||||
|
activeLow: ReadonlySet<number>,
|
||||||
|
): boolean {
|
||||||
|
return activeLow.has(channel1Based) ? !high : high !== restingHigh;
|
||||||
|
}
|
||||||
|
|
||||||
const INPUT_LINK_ISSUE = {
|
const INPUT_LINK_ISSUE = {
|
||||||
key: "input_link_relay",
|
key: "input_link_relay",
|
||||||
message:
|
message:
|
||||||
@@ -223,6 +240,7 @@ function configApi(
|
|||||||
class DingtianController
|
class DingtianController
|
||||||
implements
|
implements
|
||||||
AccessControlDevice,
|
AccessControlDevice,
|
||||||
|
AuxOutputDevice,
|
||||||
InputDevice,
|
InputDevice,
|
||||||
PreconditionDevice,
|
PreconditionDevice,
|
||||||
PushConfigurableDevice,
|
PushConfigurableDevice,
|
||||||
@@ -242,6 +260,13 @@ class DingtianController
|
|||||||
readonly #channels: number;
|
readonly #channels: number;
|
||||||
/** Input level at rest; an input is "active" when it differs from this. */
|
/** Input level at rest; an input is "active" when it differs from this. */
|
||||||
readonly #restingHigh: boolean;
|
readonly #restingHigh: boolean;
|
||||||
|
/** 1-based input terminals whose ACTIVE level is LOW, overriding the board-wide
|
||||||
|
* #restingHigh for just those inputs. A button and a radar can idle oppositely:
|
||||||
|
* the button (NO-to-GND) pulls LOW on press while the board idles HIGH, but a
|
||||||
|
* radar's dry contact may idle LOW and go HIGH on detection. Listing the radar's
|
||||||
|
* terminal here flips its edge so "active" still means "detecting". See
|
||||||
|
* wiki/entities/hikvision-radar.md. */
|
||||||
|
readonly #inputActiveLow: Set<number>;
|
||||||
readonly #pulseMs: number;
|
readonly #pulseMs: number;
|
||||||
/** Device web-UI login user (gates the browser UI only, not the CGI API). */
|
/** Device web-UI login user (gates the browser UI only, not the CGI API). */
|
||||||
readonly #webUser: string;
|
readonly #webUser: string;
|
||||||
@@ -269,6 +294,22 @@ class DingtianController
|
|||||||
this.#channels = config.channels ? Number(config.channels) : 4;
|
this.#channels = config.channels ? Number(config.channels) : 4;
|
||||||
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
|
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
|
||||||
this.#restingHigh = config.inputRestingHigh !== false;
|
this.#restingHigh = config.inputRestingHigh !== false;
|
||||||
|
// Per-input active-LOW overrides (1-based). Source of truth is each entry relay's
|
||||||
|
// `presenceActiveLow` flag (a radar terminal wired opposite the button); an explicit
|
||||||
|
// top-level `inputActiveLow` array is also honoured as an escape hatch. Both merged.
|
||||||
|
this.#inputActiveLow = new Set<number>();
|
||||||
|
if (Array.isArray(config.inputActiveLow)) {
|
||||||
|
for (const n of (config.inputActiveLow as unknown[]).map(Number)) {
|
||||||
|
if (Number.isInteger(n) && n > 0) this.#inputActiveLow.add(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(config.relays)) {
|
||||||
|
for (const r of config.relays as Array<Record<string, unknown>>) {
|
||||||
|
if (r?.presenceActiveLow === true && Number.isInteger(Number(r.presenceInput))) {
|
||||||
|
this.#inputActiveLow.add(Number(r.presenceInput));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
|
this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
|
||||||
this.#webUser = config.webUser ? String(config.webUser) : "admin";
|
this.#webUser = config.webUser ? String(config.webUser) : "admin";
|
||||||
// webPassword = the DESIRED login (admin's choice; blank → harden generates).
|
// webPassword = the DESIRED login (admin's choice; blank → harden generates).
|
||||||
@@ -319,6 +360,14 @@ class DingtianController
|
|||||||
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
|
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** AuxOutputDevice: latch a NON-barrier output (e.g. a button lamp) on a spare
|
||||||
|
* relay. Same wire op as setRelay — separated so business logic drives indicators
|
||||||
|
* through the aux capability, never the barrier relay methods. Holding/blinking an
|
||||||
|
* aux output is allowed (it is not a barrier). See button-light-indicator.md. */
|
||||||
|
async setAux(channel: number, on: boolean): Promise<void> {
|
||||||
|
await this.setRelay(channel, on);
|
||||||
|
}
|
||||||
|
|
||||||
async getDoorStatus(doorId: number): Promise<"open" | "closed"> {
|
async getDoorStatus(doorId: number): Promise<"open" | "closed"> {
|
||||||
this.#assertChannel(doorId);
|
this.#assertChannel(doorId);
|
||||||
const { relays } = await this.#status();
|
const { relays } = await this.#status();
|
||||||
@@ -672,8 +721,10 @@ class DingtianController
|
|||||||
for (let i = 0; i < this.#channels; i++) {
|
for (let i = 0; i < this.#channels; i++) {
|
||||||
const high = (inputVal & (1 << i)) !== 0;
|
const high = (inputVal & (1 << i)) !== 0;
|
||||||
relays.push((relayVal & (1 << i)) !== 0);
|
relays.push((relayVal & (1 << i)) !== 0);
|
||||||
// active = differs from the resting level (a press pulls the line).
|
// active = differs from the resting level (a press pulls the line); a terminal in
|
||||||
inputs.push(high !== this.#restingHigh);
|
// inputActiveLow is read inverted (active when LOW) — so a radar wired opposite the
|
||||||
|
// button reads right. See inputActive().
|
||||||
|
inputs.push(inputActive(high, i + 1, this.#restingHigh, this.#inputActiveLow));
|
||||||
}
|
}
|
||||||
return { relays, inputs, channels: this.#channels };
|
return { relays, inputs, channels: this.#channels };
|
||||||
}
|
}
|
||||||
@@ -728,6 +779,19 @@ export const dingtianDriver: AccessDriver = {
|
|||||||
{ key: "binaryPort", label: "Binary protocol port", type: "port", required: false, default: 60000, help: "Dingtian binary protocol UDP port — authenticated relay control (default 60000)." },
|
{ key: "binaryPort", label: "Binary protocol port", type: "port", required: false, default: 60000, help: "Dingtian binary protocol UDP port — authenticated relay control (default 60000)." },
|
||||||
{ key: "httpPort", label: "HTTP config port", type: "port", required: false, default: 80, help: "Device web/config-API port (default 80)." },
|
{ key: "httpPort", label: "HTTP config port", type: "port", required: false, default: 80, help: "Device web/config-API port (default 80)." },
|
||||||
{ key: "channels", label: "Channels (relays/inputs)", type: "number", required: true, default: 4 },
|
{ key: "channels", label: "Channels (relays/inputs)", type: "number", required: true, default: 4 },
|
||||||
|
{
|
||||||
|
// relay_pw — the BINARY-protocol control/status password (NOT the web-UI login
|
||||||
|
// below). Every relay command + the status read embeds it; with the wrong/no
|
||||||
|
// value the device silently ignores the packet → healthCheck times out → the
|
||||||
|
// controller shows "offline" even though it pings. Redacted from the client
|
||||||
|
// (SECRET_CONFIG_KEYS), so it renders as a secret: blank KEEPS the stored value
|
||||||
|
// (the server re-merges it on test/save); type a value to set/change it.
|
||||||
|
key: "relayPassword",
|
||||||
|
label: "Relay control password",
|
||||||
|
type: "secret",
|
||||||
|
required: false,
|
||||||
|
help: "Binary-protocol relay password (relay_pw). Leave blank to keep the current one; a wrong/missing value makes the device ignore commands (Test connection times out).",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "pulseMs",
|
key: "pulseMs",
|
||||||
label: "Pulse open (ms)",
|
label: "Pulse open (ms)",
|
||||||
|
|||||||
@@ -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,75 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, device, indicator, radar, camera, aux-output, barrier-not-a-door]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-24
|
||||||
|
status: settled
|
||||||
|
---
|
||||||
|
|
||||||
|
# Button-light indicator (radar × camera disagreement lamp)
|
||||||
|
|
||||||
|
The entry button has a **12 V light**. It is driven by the host on a **spare relay** of the
|
||||||
|
[[dingtian-relay|Dingtian]] controller as a 3-state indicator that combines the **[[hikvision-radar|
|
||||||
|
radar]]** input with the **camera "car in zone"** signal:
|
||||||
|
|
||||||
|
| Radar input | Camera (lane entry busy) | Button light |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| detecting | **free** — no car confirmed | **BLINK** (~1 Hz) |
|
||||||
|
| detecting | **busy** — camera confirms a car | **SOLID on** |
|
||||||
|
| clear | — | **OFF** |
|
||||||
|
|
||||||
|
It is a **disagreement indicator**: the radar sees *something* but the camera hasn't confirmed a
|
||||||
|
real vehicle → blink (attention / "pull forward"); both agree → solid; nothing there → off.
|
||||||
|
|
||||||
|
## Signals
|
||||||
|
|
||||||
|
- **Radar** = the presence input edge on the entry relay (`relays[].presenceInput`, the same edge
|
||||||
|
the [[entry-double-press|one-car-one-ticket]] gate observes — so the lamp and the gate always
|
||||||
|
agree on "a car is here").
|
||||||
|
- **Camera "car in zone"** = the existing **[[lpr-camera|lane status]]** (`LaneStatusEvent` entry
|
||||||
|
busy/free, from camera vehicle detection). Already advisory; already drives the booth's barrier
|
||||||
|
lights. No new camera plumbing.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
A controller-level `config.buttonLight = { relay, blinkOnMs?, blinkOffMs? }` (the operator picks a
|
||||||
|
**spare** relay — not a barrier relay; the setup UI warns if it overlaps one). Blink defaults to
|
||||||
|
500 ms / 500 ms.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
`apps/server/src/button-light.ts` — `ButtonLightController` subscribes to `deviceEvents.onInput`
|
||||||
|
(radar) + `onLaneStatus` (camera), computes the target state per controller, and drives the lamp via
|
||||||
|
a **device-agnostic aux-output** capability.
|
||||||
|
|
||||||
|
- **Aux-output capability.** `AuxOutputDevice { setAux(channel, on) }` on the device interface (the
|
||||||
|
Dingtian driver implements it as a latch). Business logic drives the lamp through this — **never**
|
||||||
|
the driver's barrier methods.
|
||||||
|
- **Barrier-not-a-door is preserved.** The lamp is **not a barrier**, so holding / blinking it on a
|
||||||
|
timer is fine — the [[barrier-not-a-door]] rule forbids timing a *barrier* closed, and barriers
|
||||||
|
still only ever `pulseOpen`. The lamp uses the separate `setAux` latch.
|
||||||
|
- **Fails OFF.** On host loss, shutdown, or a `setAux` error the lamp defaults OFF — a dead lamp is
|
||||||
|
"no hint", never a misleading solid "go". SOLID is only ever held while busy + present is actively
|
||||||
|
true (never latched on through a crash path).
|
||||||
|
- **Serialized sends (must — UDP is unordered).** The first cut fired fire-and-forget `setAux` every
|
||||||
|
500 ms; over **unordered UDP** the on/off packets reordered/overlapped and the relay **latched on
|
||||||
|
whichever packet the device processed last** — the lamp got stuck on/off at random (observed on
|
||||||
|
hardware). Fix: a **desired-state + serialized worker** (`#pump`). The blink timer only flips a
|
||||||
|
`desiredOn` flag; the worker guarantees **one in-flight send per lamp** and, on completion,
|
||||||
|
re-converges to the latest desired state. So the **final state is always authoritative** and a
|
||||||
|
lost/stale packet self-corrects. This also de-dupes (it skips a send when `confirmedOn === desiredOn`),
|
||||||
|
so the input stream never spams the controller.
|
||||||
|
- **Hot-reloads the config (no restart).** The lamp map is reconciled against the live device config
|
||||||
|
at start AND before each event (mirroring [[device-status-monitoring|DeviceMonitor]], which re-reads
|
||||||
|
the device set each tick) — adding/updating/dropping lamps. So a button light added or re-pointed in
|
||||||
|
the setup UI takes effect on the **next radar edge**, not after a server restart. (The first cut
|
||||||
|
loaded the map once at boot, so a just-saved lamp silently did nothing until restart.)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Built 2026-06-24 for the first booth (button I1, radar I2, lamp on a spare relay); the serialized-send
|
||||||
|
+ hot-reload fixes landed the same day after the lamp stuck on/off on hardware. Covered by
|
||||||
|
`apps/server/src/button-light.test.ts` (the truth table, blink toggling asserted on the device's
|
||||||
|
*confirmed* state, fail-OFF, de-dupe, and a lamp-added-after-start reconcile case).
|
||||||
|
Related: [[hikvision-radar]], [[entry-double-press]], [[lpr-camera]], [[dingtian-relay]],
|
||||||
|
[[barrier-not-a-door]].
|
||||||
@@ -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,12 @@ press → print → press again issued a second ticket immediately. That is not
|
|||||||
The guard lives on the entry relay's spec (`config.relays[]` — see [[entry-exit-points]]), because
|
The guard lives on the entry relay's spec (`config.relays[]` — see [[entry-exit-points]]), because
|
||||||
whether real one-car-one-ticket is *possible* depends on the hardware at that lane. Two modes:
|
whether real one-car-one-ticket is *possible* depends on the hardware at that lane. Two modes:
|
||||||
|
|
||||||
### PRESENCE mode (preferred — when a vehicle loop is wired)
|
### PRESENCE mode (preferred — when a vehicle-presence sensor is wired)
|
||||||
`relays[].presenceInput` = the 1-based input terminal of an **induction loop / barrier presence
|
`relays[].presenceInput` = the 1-based input terminal of a **vehicle-presence sensor** on the same
|
||||||
signal** on the same [[dingtian-relay|controller]] (the Dingtian's inputs are decoupled from its
|
[[dingtian-relay|controller]] (the Dingtian's inputs are decoupled from its relays). The sensor may
|
||||||
relays, and loops are already in the [[bom]]). The rule makes one-car-one-ticket **physical**:
|
be an **induction loop** OR a **[[hikvision-radar|radar]]** (`relays[].presenceKind: "loop"|"radar"`
|
||||||
|
— a label; the gate behaviour is identical). A radar wired to idle opposite the button needs
|
||||||
|
`presenceActiveLow: true` so its edge reads correctly. The rule makes one-car-one-ticket **physical**:
|
||||||
|
|
||||||
- A press prints **only while a car is present** on the loop.
|
- A press prints **only while a car is present** on the loop.
|
||||||
- After a ticket prints, the relay is **disarmed** — no second ticket — **until the loop CLEARS**
|
- After a ticket prints, the relay is **disarmed** — no second ticket — **until the loop CLEARS**
|
||||||
|
|||||||
@@ -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,28 @@ see [[dingtian-vs-mqtt]].
|
|||||||
|
|
||||||
## Driver & config API
|
## Driver & config API
|
||||||
|
|
||||||
The `dingtian` driver ([[device-registry]]) implements three capabilities:
|
The `dingtian` driver ([[device-registry]]) implements:
|
||||||
`AccessControlDevice` (relay pulse/latch over UDP), `InputDevice` (read inputs + poll-based
|
`AccessControlDevice` (relay pulse/latch over UDP), `AuxOutputDevice` (latch a NON-barrier output —
|
||||||
press/release events ~50 ms), and `PreconditionDevice` (below). Config fields include a separate
|
see below), `InputDevice` (read inputs + poll-based press/release events ~50 ms), and
|
||||||
**`httpPort`** — the device's web/config API is on a configurable HTTP port (default **80**),
|
`PreconditionDevice` (below). Config fields include a separate **`httpPort`** — the device's
|
||||||
distinct from the UDP control port 60001.
|
web/config API is on a configurable HTTP port (default **80**), distinct from the UDP control port
|
||||||
|
60001.
|
||||||
|
|
||||||
|
### Spare relays + aux outputs (`setAux`)
|
||||||
|
|
||||||
|
A 4-input board typically has spare relays once the entry/exit barriers are wired. These drive
|
||||||
|
**non-barrier indicators** — e.g. the entry button's 12 V lamp (see [[button-light-indicator]]).
|
||||||
|
Business logic drives them through the device-agnostic `AuxOutputDevice.setAux(channel, on)` (a
|
||||||
|
latch), **never** the barrier `pulseOpen`. The [[barrier-not-a-door]] rule doesn't apply to an aux
|
||||||
|
output (it never gates a vehicle), so holding/blinking it is fine.
|
||||||
|
|
||||||
|
### Per-input active level (`presenceActiveLow` / `inputActiveLow`)
|
||||||
|
|
||||||
|
Inputs are normalised against ONE board-wide resting level (`inputRestingHigh`). When a sensor (e.g.
|
||||||
|
a [[hikvision-radar|radar]]) idles **opposite** the button, list its terminal as active-LOW —
|
||||||
|
sourced from each relay's `presenceActiveLow`, merged into the driver's `inputActiveLow` set — so
|
||||||
|
that one input is read inverted while the button keeps the board default. (`inputActive()` is the
|
||||||
|
pure helper; push-mode uses the device's own `ilu.active_level` instead.)
|
||||||
|
|
||||||
### Precondition: input_link_relay must be OFF
|
### Precondition: input_link_relay must be OFF
|
||||||
|
|
||||||
@@ -140,6 +157,28 @@ On assign the driver runs `harden()` (the [[device-registry|HardenableDevice]] c
|
|||||||
> drop connections (ECONNRESET), locking out the API the driver depends on — recoverable only by
|
> drop connections (ECONNRESET), locking out the API the driver depends on — recoverable only by
|
||||||
> factory reset. `harden()` deliberately never touches it.
|
> factory reset. `harden()` deliberately never touches it.
|
||||||
|
|
||||||
|
## `relayPassword` field + the "offline despite ping" gotcha (2026-06-24)
|
||||||
|
|
||||||
|
`relay_pw` is in **every** binary frame — control AND the status read `healthCheck()` uses. With a
|
||||||
|
wrong/missing value the device **silently drops the packet** (no NAK), so the probe **times out →
|
||||||
|
the controller shows "offline" even though it pings** (ping is ICMP and never touches the binary
|
||||||
|
protocol). This bit a real bring-up: the driver read `config.relayPassword` but there was **no form
|
||||||
|
field** for it, so Test connection sent `0` → timeout → "offline", while `relay_pw` was actually a
|
||||||
|
non-zero value the harden flow had set. Diagnostic: a raw UDP status frame
|
||||||
|
(`FF AA <s> 00 <pwLo> <pwHi>`) replies *only* with the right password — `pw=N` → `ffaa…`, `pw=0` →
|
||||||
|
timeout — and binding the WSL socket to the device-facing NIC (`localAddress`) also broke the reply
|
||||||
|
(leave it unbound on WSL). Fix: a **"Relay control password"** config field (a **secret**; blank =
|
||||||
|
keep the stored value).
|
||||||
|
|
||||||
|
> 🔒 **Secret re-merge is identity-gated (don't let a redirected probe exfiltrate it).** Because
|
||||||
|
> `relayPassword`/`pushPassword` are redacted from the client ([[first-run-setup]]), the edit form
|
||||||
|
> can't resend them, so `/api/setup/test` re-merges the stored secret by device **id** — but ONLY
|
||||||
|
> when the submitted config addresses the **same device**: matching `driverId` and every
|
||||||
|
> connection-identity field it sets (`host`/`port`/`binaryPort`/`httpPort`/`serial`). A redirected
|
||||||
|
> host/port or mismatched driver returns NO secret, so an authenticated admin can't point a test at
|
||||||
|
> an attacker host and have the password sent there (the booth operator is the [[threat-model]]
|
||||||
|
> adversary). Save already merged from the stored row; this closes the same gap on test.
|
||||||
|
|
||||||
## Status — VERIFIED on hardware (DT-R004, sw V3.1.5461A, 10.0.10.172)
|
## Status — VERIFIED on hardware (DT-R004, sw V3.1.5461A, 10.0.10.172)
|
||||||
|
|
||||||
- ✅ status read (`0000:1111:4`), relay pulse, input press/release events (active-LOW, idle HIGH).
|
- ✅ status read (`0000:1111:4`), relay pulse, input press/release events (active-LOW, idle HIGH).
|
||||||
|
|||||||
@@ -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]].
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
type: entity
|
type: entity
|
||||||
tags: [parking, hardware, readers, offline-first]
|
tags: [parking, hardware, readers, offline-first]
|
||||||
sources: [parking-system-architecture]
|
sources: [parking-system-architecture]
|
||||||
updated: 2026-06-15
|
updated: 2026-06-26
|
||||||
---
|
---
|
||||||
|
|
||||||
# LPR Camera
|
# LPR Camera
|
||||||
@@ -59,6 +59,42 @@ A **Hikvision** unit ("Camera 20", MAC `94:e1:ac:…`, Hikvision OUI) at `10.0.1
|
|||||||
- Reaching it from the WSL dev box required forcing the source address (`config.localAddress`,
|
- Reaching it from the WSL dev box required forcing the source address (`config.localAddress`,
|
||||||
threaded into the driver) — see [[wsl-dev-networking]] (multi-subnet source-selection trap).
|
threaded into the driver) — see [[wsl-dev-networking]] (multi-subnet source-selection trap).
|
||||||
|
|
||||||
|
### HTTP 503 "Device Busy" — can be PERSISTENT; the real fix is stream selection (2026-06-26)
|
||||||
|
|
||||||
|
The snapshot endpoint returns **HTTP 503** with the ISAPI body `statusCode 2` / `"Device Busy"` /
|
||||||
|
`subStatusCode deviceBusy` (occasionally **500**). It comes in two flavours, and they need different
|
||||||
|
fixes — **don't assume it's a momentary blip**:
|
||||||
|
|
||||||
|
- **Transient** — the encoder is briefly occupied (another snapshot in flight, a stream starting).
|
||||||
|
Clears on retry within a frame or two.
|
||||||
|
- **Persistent** — the **MAIN-stream encoder is saturated** and 503s on EVERY main-stream snapshot.
|
||||||
|
Confirmed on hardware (**DS-2CD1047G3H-LIU**, 2026-06-26): `channels/101/picture` → 503 on five
|
||||||
|
consecutive probes 800 ms apart, while **`channels/102/picture` (the SUB stream) → 200 every time**,
|
||||||
|
a clean ~15 KB JPEG. So the path/API was correct (the camera answered with a structured Hikvision
|
||||||
|
status); the main encoder was simply never free. A retry loop **cannot** fix this — it just delays
|
||||||
|
the failure.
|
||||||
|
|
||||||
|
**The fix that actually works: snapshot from the SUB stream.** The Hikvision ISAPI channel id is
|
||||||
|
`<channel><stream>` (e.g. ch1 main = `101`, ch1 **sub = `102`**). The driver now has a **`stream`
|
||||||
|
config field** (`1` = main, default for back-compat; `2` = sub). Set the G3H camera to **Sub (02)** in
|
||||||
|
the setup form → its status flips `degraded → ready` (verified live: pulled a 14.7 KB JPEG in ~87 ms).
|
||||||
|
The sub-stream is also the better fit for snapshot/ANPR anyway (smaller/faster; doesn't contend with
|
||||||
|
live-view/recording for the main encoder).
|
||||||
|
|
||||||
|
Two more complementary mitigations (both BUILT, for the *transient* case):
|
||||||
|
1. **Don't cause concurrent busy.** On a vehicle entry two server paths used to snapshot the same
|
||||||
|
camera at once (the ANPR bridge + the advisory `snapshotAsync`); the 2nd concurrent GET drew a 503.
|
||||||
|
They now share ONE pull via `captureSnapshotShared` (deviceId-keyed, `apps/server/src/snapshot.ts`)
|
||||||
|
— the main cause of the slow 2026-06-25 subscriber entry. See [[lane-presence-and-anpr-entry]].
|
||||||
|
2. **Retry a transient one.** `HttpCamera.captureSnapshot` retries 503/500 with a short linear backoff
|
||||||
|
(250/500/750 ms, ≤4 attempts), then fails naming it `(device busy)`; it does NOT retry 401/404
|
||||||
|
(config errors won't self-heal). This recovers a momentary blip but, by design, still fails a
|
||||||
|
PERSISTENTLY-busy main stream — the cue to switch that camera to the sub-stream.
|
||||||
|
|
||||||
|
Covered by `packages/devices/src/drivers/camera.test.ts` (retry behaviour + the main/sub path
|
||||||
|
selection). `healthCheck()` deliberately reports a live 503 as `degraded` (it surfaces a genuinely
|
||||||
|
saturated main stream rather than hiding it behind a retry).
|
||||||
|
|
||||||
## Camera PUSH — "Alarm Server" event notifications (2026-06-22)
|
## Camera PUSH — "Alarm Server" event notifications (2026-06-22)
|
||||||
|
|
||||||
Separate from the **pull** snapshot path above: newer Hikvision firmware can **push** an event to
|
Separate from the **pull** snapshot path above: newer Hikvision firmware can **push** an event to
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+165
@@ -1552,3 +1552,168 @@ username chip links to it), `email` added to the session view + `SessionUser`. 7
|
|||||||
builds `.deb` + `.AppImage` on every push to dev/main and uploads them as UNSIGNED workflow artifacts
|
builds `.deb` + `.AppImage` on every push to dev/main and uploads them as UNSIGNED workflow artifacts
|
||||||
(per-commit test build); the signed/versioned release stays on `release.yml` (tag `v*`). See
|
(per-commit test build); the signed/versioned release stays on `release.yml` (tag `v*`). See
|
||||||
[[desktop-shell-tauri]] "Desktop in CI".
|
[[desktop-shell-tauri]] "Desktop in CI".
|
||||||
|
|
||||||
|
## [2026-06-24] build | Radar presence input + button-light output on the Dingtian
|
||||||
|
The first booth wired an **entry button on I1** and a **[[hikvision-radar|Hikvision radar]] on I2**
|
||||||
|
(closes a dry contact on detection), plus the **button's 12 V lamp on a spare relay**. Modelled as
|
||||||
|
children of the access controller config — no new device category. (1) The radar reuses the existing
|
||||||
|
`relays[].presenceInput` one-car-one-ticket gate; added `presenceKind: loop|radar` (label) and
|
||||||
|
`presenceActiveLow` (a radar may idle opposite the button — the Dingtian has ONE board-wide resting
|
||||||
|
level, so a per-input override `inputActiveLow` inverts just that terminal; pure helper
|
||||||
|
`inputActive()`). (2) New device-agnostic **`AuxOutputDevice.setAux(channel,on)`** capability (Dingtian
|
||||||
|
latch) so business logic drives a NON-barrier lamp through the interface — barriers still only
|
||||||
|
`pulseOpen` ([[barrier-not-a-door]] preserved). (3) New `ButtonLightController`
|
||||||
|
(`apps/server/src/button-light.ts`): subscribes to the radar input edge + the camera
|
||||||
|
[[lpr-camera|lane status]] and drives a **3-state lamp** — radar+car=SOLID, radar-only=BLINK (~1 Hz),
|
||||||
|
else OFF; **fails OFF**; de-duped. (4) SetupWizard: presence kind + active-low + a button-light relay
|
||||||
|
picker; i18n parity (sq+en). Tests: `button-light.test.ts` (truth table + blink + fail-OFF + de-dupe),
|
||||||
|
`access-dingtian.test.ts` (active-level inversion). Workspace build+lint+test green (158 server tests).
|
||||||
|
A radar detection NEVER opens a barrier on its own — it only gates the button ([[threat-model]]). See
|
||||||
|
[[hikvision-radar]], [[button-light-indicator]], [[entry-double-press]], [[dingtian-relay]].
|
||||||
|
|
||||||
|
## [2026-06-24] fix | Booth bring-up fixes — relay password, form split, lamp concurrency
|
||||||
|
Three fixes from wiring the radar/lamp on the first booth (committed 420542c, fd15988, 830993b on
|
||||||
|
top of the 2915d14 feature). (1) **"Offline despite ping"** — the Dingtian's `relay_pw` is in every
|
||||||
|
binary frame incl. the status read, but had NO form field, so Test connection sent 0 → device
|
||||||
|
silently drops the packet → "offline" (ping is ICMP, unrelated). Added a **"Relay control password"**
|
||||||
|
secret field; because the secret is redacted, the test endpoint re-merges it by device id but ONLY
|
||||||
|
when host/port/driver match the stored row (a redirected probe can't exfiltrate it — `setup-secrets.test.ts`).
|
||||||
|
(2) **Form split** — the controller editor now has separate **Outputs** (relays + pulse-open + lamp)
|
||||||
|
and **Inputs** (button + presence/radar terminals, "For relay N") sections; UI-only, storage
|
||||||
|
unchanged. `pulse open (ms)` clarified as a relay/output setting, not an input. (3) **Lamp stuck
|
||||||
|
on/off** — the blink fired fire-and-forget `setAux` over UNORDERED UDP; concurrent on/off packets
|
||||||
|
reordered and the relay latched on the last-processed one. Replaced with a serialized desired-state
|
||||||
|
worker (one in-flight send/lamp, re-converges to the latest state → final state authoritative). Also
|
||||||
|
**hot-reload**: the lamp map now reconciles against live config each event, so a button light added
|
||||||
|
in the UI works without a server restart. Workspace build+lint+test green (163 server tests). See
|
||||||
|
[[dingtian-relay]] ("offline despite ping" + secret re-merge), [[button-light-indicator]] (serialized
|
||||||
|
sends + hot-reload).
|
||||||
|
|
||||||
|
## [2026-06-24] build | Printer USB transport behind the ESC/POS render layer
|
||||||
|
The ESC/POS printer drivers were **TCP-only** (every path went through `sendRaw`/`probe` to a raw
|
||||||
|
socket on port 9100); the original BOM intended one adapter to cover "USB **or** network". Added a
|
||||||
|
**USB transport** behind the existing render layer without touching a single `render*()` function:
|
||||||
|
a discriminated `Transport` (`transportFromConfig` → `{kind:"tcp",host,port}` | `{kind:"usb",
|
||||||
|
devicePath}`) and `sendTo`/`probeTo` dispatchers in `printer-escpos.ts`; USB writes the same ESC/POS
|
||||||
|
bytes to a kernel **`usblp`** char device (`/dev/usb/lp0`) via a plain `fs` write — **no libusb/CUPS/
|
||||||
|
native dep** (keeps MIT-only + minimal-deps appliance). `cashino` + `rongta` resolve a Transport once;
|
||||||
|
both are reachability-only over USB, and the Rongta's HTTP **status page degrades to the open-the-node
|
||||||
|
probe** over USB (no guessed paper/cover — the standing honesty rule). Non-`usb` configs are unchanged
|
||||||
|
(host-only = TCP), so no migration. Setup UI gains a **Connection** select + **USB device** field;
|
||||||
|
host/port made not-required so a USB printer needs neither. Tests: `printer-escpos.test.ts` (USB writes
|
||||||
|
the exact rendered bytes; probe present/absent; `transportFromConfig` TCP back-compat) +
|
||||||
|
`printer-cashino.test.ts` (USB-configured driver prints to the node, ready/offline). Devices suite
|
||||||
|
green (29). **Flagged open-questions #14**: confirm the on-site printer is USB and bake the
|
||||||
|
**usblp + udev write-access** rule into the appliance image (provisioning, not app code; unverified on
|
||||||
|
hardware). See [[printer-usb-transport]], [[rongta-printer]].
|
||||||
|
|
||||||
|
## [2026-06-24] build | Booth operator wrapper script — scripts/booth.sh
|
||||||
|
The booth PC (Ubuntu) needs one command instead of the long
|
||||||
|
`docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env …` line over the
|
||||||
|
three compose files. Added **`scripts/booth.sh`** (+ root **`.env.example`**): **prod by default**
|
||||||
|
(`ENV=dev` for the dev override); subcommands `up`/`down`/`restart`/`status`/`logs`/`pull`/`config`/
|
||||||
|
`exec`, and the requested **`update`** = `compose pull` the moving branch tag → `up -d --remove-orphans`
|
||||||
|
(recreates only digest-changed services, **named volumes/SQLite ledger preserved**) → `docker image
|
||||||
|
prune -f`. Prod **refuses to run without `.env`** (no safe `JWT_SECRET` default); dev with no `.env`
|
||||||
|
injects the documented benign local secret (the base file makes `JWT_SECRET` shell-required via
|
||||||
|
`${JWT_SECRET:?}`, which the dev override's service-level default alone can't satisfy). `down` never
|
||||||
|
passes `-v` (would wipe the signed [[append-only-event-chain|ledger]] volume); `help`/unknown-command
|
||||||
|
short-circuit before any Docker/.env requirement. Verified: prod `config` renders Caddy:80 + internal
|
||||||
|
server + pinned images + `fast_alpr`; dev `config` renders `:dev` images + `stub` + published ports.
|
||||||
|
Documented in [[container-deployment]] ("Booth operator wrapper").
|
||||||
|
|
||||||
|
## [2026-06-25] fix | Local ANPR silently degraded — `uv run` strips the alpr extra
|
||||||
|
Diagnosed via the live DB (read-only `VACUUM INTO` copy) why entry `26799912337` recorded a snapshot
|
||||||
|
but no plate: the dev box's vision service was running **stub**, and earlier real ANPR had stopped.
|
||||||
|
Root cause (NOT the Docker/compose work, which was an innocent coincidence): the dev machine runs vision
|
||||||
|
as **bare `uv run uvicorn`** against `apps/vision/.venv`, and a plain `uv run`/`uv sync` re-resolves the
|
||||||
|
venv to the lockfile **defaults**, **stripping** fast-alpr/onnxruntime — so after any `pnpm dev` the
|
||||||
|
recognizer vanishes (weights orphaned in `~/.cache`, no module in the venv) and ANPR silently becomes
|
||||||
|
"snapshot, no plate". Evidence: 28 real reads through 06-22 (yolo-v9 model, ~99% conf), venv frozen lean
|
||||||
|
since 06-19, no other env with fast_alpr on the box. **The BOOTH was never affected** — it runs the
|
||||||
|
Docker image, which bakes `uv sync --frozen --extra alpr` at build (immutable, weights pre-warmed); a
|
||||||
|
booth `ModuleNotFoundError` is a STALE image (fix: `booth.sh update`). **Fix:** vision `package.json`
|
||||||
|
`dev`/`start`/`recognize` now `uv sync --extra alpr &&` first (self-healing), `.env` set to `fast_alpr`,
|
||||||
|
+ a `dev:stub` escape hatch. Restored real ANPR locally (`/health` → `fast_alpr` ready, model loaded from
|
||||||
|
cache, no download). Documented in [[vision-service-packaging]] ("Two runtimes, one fragile").
|
||||||
|
|
||||||
|
## [2026-06-26] fix | Hikvision snapshot 503 "Device Busy" — stream selection + retry + Alarm URL helper
|
||||||
|
Three camera fixes. (1) **503 Device Busy — the REAL fix is stream selection.** First framed as
|
||||||
|
"transient, just retry" — WRONG for this camera. Hardware probe of **DS-2CD1047G3H-LIU** (10.0.10.13):
|
||||||
|
`channels/101/picture` (MAIN) → 503 `deviceBusy` on 5 consecutive probes 800ms apart, while
|
||||||
|
`channels/102/picture` (SUB) → 200 clean JPEG every time. The main encoder is PERSISTENTLY saturated;
|
||||||
|
a retry loop can't fix it. Added a **`stream` config field** to the Hikvision driver (1=main default
|
||||||
|
for back-compat, 2=sub; ISAPI id `<channel><stream>`). Verified live: setting the camera to Sub flips
|
||||||
|
its status degraded→ready (14.7KB JPEG in ~87ms). (2) **Transient retry** (still useful for a genuine
|
||||||
|
momentary blip + the de-dup case): `HttpCamera.captureSnapshot` retries 503/500 with linear backoff
|
||||||
|
(250/500/750ms ×4), fails naming it `(device busy)`, does NOT retry 401/404. Plus the already-landed
|
||||||
|
`captureSnapshotShared` removing concurrent self-collision. `healthCheck` reports a live 503 as
|
||||||
|
`degraded` (surfaces a saturated main stream rather than hiding it). Covered by `camera.test.ts`
|
||||||
|
(10 tests: retry + main/sub path). (3) **Alarm Server URL helper:** the camera setup form now generates the camera's Alarm
|
||||||
|
Settings (Destination IP / URL / Protocol / Port) ready to paste, so the operator never hunts the
|
||||||
|
deviceId or memorises the endpoint. CRUCIAL: host/port come from the **backend address on the camera's
|
||||||
|
subnet** (`backendIpForDevice` + server port, the same probe the push-IP picker uses) — NOT
|
||||||
|
`window.location.origin` (the SPA's dev/proxy origin, which would wrongly say `localhost:5173`).
|
||||||
|
Verified live: matches the on-camera config field-for-field (10.0.10.203 / …/event / HTTP / 3000).
|
||||||
|
Shows a "save first" (needs a deviceId) then "test first" (needs the resolved backend IP) hint.
|
||||||
|
Documented in [[lpr-camera]] ("503 Device Busy"). Devices 6 new tests; server 168 green.
|
||||||
|
|
||||||
|
## [2026-06-26] fix | QR reader status was a LIE (hardcoded "ready") → real ICMP liveness
|
||||||
|
Two genuinely-OFFLINE QR readers showed GREEN in the status bar. Cause: the QR-reader adapter
|
||||||
|
(`StubReader`) had `healthCheck → { ready, "stub" }` hardcoded — it never probed anything. These are
|
||||||
|
PUSH devices (scan → GET our backend, resolve by serial) that expose **no TCP port**, so a connect
|
||||||
|
probe (cameras/printers) has nothing to hit; the stub "solved" that by lying. False-healthy is the
|
||||||
|
worst failure for a status bar. Fix: an **optional reader IP** (monitor-ONLY — scans still resolve by
|
||||||
|
serial, operation unchanged) + an **unprivileged ICMP ping** (`drivers/icmp.ts`: shells `/bin/ping`
|
||||||
|
`-c1`, exit-0 = reply; no native dep, no CAP_NET_RAW). `healthCheck`: IP replies → `ready`, no reply →
|
||||||
|
`offline`, **no IP → `degraded` ("set IP to monitor")** (never a false green). Booth compose
|
||||||
|
(`docker-compose.prod.yml`) sets `net.ipv4.ping_group_range=0 2147483647` so `/bin/ping` works
|
||||||
|
unprivileged for the non-root container user. Verified on hardware: the readers (10.0.10.7/.8) answer
|
||||||
|
ICMP on the device VLAN (eth1) — distinct MACs — and the UI Test connection shows "● ready — ping
|
||||||
|
10.0.10.7". (NB: an earlier "offline" reading was a WSL wrong-route artifact, not the readers.) Covered
|
||||||
|
by `reader.test.ts` (4 tests). Documented in [[device-status-monitoring]]. Devices +4 tests, all green.
|
||||||
|
|
||||||
|
## [2026-06-27] fix | booth.sh failed in the flat /opt layout (couldn't find compose files)
|
||||||
|
|
||||||
|
The booth deploys the compose files **flat** in `/opt/parking_systems/` with `booth.sh` next to
|
||||||
|
them, but the script assumed `<repo>/scripts/` and did `cd ..` → `REPO_DIR=/opt` (no compose
|
||||||
|
files); `usage()` then `sed`-read a now-relative `$0` → "can't read booth.sh". That's why
|
||||||
|
`sudo ./booth.sh` only printed help and `/bin/bash booth.sh` errored. Fix: **discover** the
|
||||||
|
compose files (script's own dir → `../` → `$PWD`), `usage()` reads an absolute `$SELF`. Also:
|
||||||
|
`.env.example` defaulted `TAG=main`, but the registry only has `dev`/`dev-<sha>` (no main build) →
|
||||||
|
`compose pull` 404s; default to `TAG=dev` + documented the moving-vs-immutable tag scheme.
|
||||||
|
Reproduced the booth's flat layout in a scratch dir; all forms (`./booth.sh`, `/bin/bash
|
||||||
|
booth.sh`, `config`, absolute-path) verified. Commit 83298bc.
|
||||||
|
|
||||||
|
## [2026-06-27] decision | Fleet deployment → Komodo Periphery over NetBird
|
||||||
|
|
||||||
|
booth.sh hit its ceiling: fine for one SSH-able box, but no remote/no-SSH op, no fleet view, no
|
||||||
|
deploy history, no rollback — and the fleet is **many/growing**. Decision: **Komodo Periphery**
|
||||||
|
on each appliance, driven by an existing **Komodo Core** over the **NetBird** mesh, running the
|
||||||
|
**same** compose files ([[container-deployment]] pipeline unchanged); `booth.sh` demoted to
|
||||||
|
break-glass. Three settled choices: many/growing fleet · deploys **manual + pinned** to a
|
||||||
|
`dev-<sha>` (no webhook — preserves the determinism we chose by pinning) · secrets
|
||||||
|
**Komodo-managed, per-booth + unique**. Threat-model caveats recorded: Periphery is a root agent
|
||||||
|
(bind to NetBird interface only, passkey+TLS, part of the TCB); `EVENT_SIGNING_KEY` in Core is a
|
||||||
|
fraud-root blast radius → per-booth keys + [[atecc608|ATECC608]] as the real
|
||||||
|
long-term signer; Core becomes Tier-0. GPL-3.0 OK (external ops tooling, not a shipped dep — same
|
||||||
|
boundary logic as the AGPL vision exception). New page [[fleet-deployment-komodo]]; infra-as-code
|
||||||
|
sketch in `komodo/` (`resources.toml` + README + `.env.komodo.example`). Catalogued in `index.md`;
|
||||||
|
`container-deployment` cross-linked + reframed (booth.sh = fallback).
|
||||||
|
|
||||||
|
## [2026-06-27] deploy | First Komodo booth deploy VERIFIED end-to-end (park-buzi)
|
||||||
|
|
||||||
|
Took the first booth through the whole Komodo flow on real hardware (Core v2.1.2 → agent reported
|
||||||
|
v2.2): onboarding key → Periphery installed **user-mode** (runs as `admin`, no root daemon,
|
||||||
|
**outbound** so the booth opens no inbound port) → server `park-buzi` **OK** in Core → Stack
|
||||||
|
(repo `mca/parking_solution`@`dev`, base+prod compose, registry account `komodo`, per-booth
|
||||||
|
`[[…]]` secrets) → all containers green → admin seeded via Komodo's container terminal (no SSH).
|
||||||
|
Then `komodo/resources.toml` rewritten to mirror the **working** Stack (exported from Core, v2.2
|
||||||
|
field shape, **Stack-only — no `[[server]]`** since onboarding owns the server), committed + pushed
|
||||||
|
(`dev` 9918f27); a ResourceSync reads it clean — **empty diff / Execute disabled = already in
|
||||||
|
sync** (success, not error). `booth.sh` fixed for the flat `/opt` layout earlier (83298bc).
|
||||||
|
Gotchas that bit us (now in [[appliance-provisioning]] §7 + gotchas 7–11): `core_address` is Core's
|
||||||
|
**proxy URL** not `:9120` (exposed-not-published → Connection refused); **git-auth ≠ registry-auth**
|
||||||
|
(blank registry account → `no basic auth credentials`); user-mode + `/etc/komodo` root_directory →
|
||||||
|
`Permission denied`; config key is **`core_address`** singular. [[appliance-provisioning]] §6 split:
|
||||||
|
§6 = engine, §7 = Komodo deploy (PRIMARY) with §7c manual `booth.sh` break-glass.
|
||||||
|
|||||||
Reference in New Issue
Block a user