Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cce99aadfd | |||
| f706726eeb | |||
| 6734e9815e | |||
| 38481f105f | |||
| 4418594af0 | |||
| 25a72ff20a | |||
| c2a861208f | |||
| a888125eca | |||
| 96fd97efa9 | |||
| 2a13b95da6 | |||
| 513566c89e | |||
| f77ed11782 | |||
| e4a17efd97 | |||
| 6d32e0fc0f | |||
| 3a60367232 | |||
| 045892bc94 | |||
| 916c147b4d | |||
| 1ea1aa4189 | |||
| 9c20faf8de | |||
| a68dc23393 | |||
| c87dcb2253 | |||
| 7eadf71a0b | |||
| 9918f278b2 | |||
| 83298bc0c5 | |||
| 898cf1953a | |||
| dd0f6e483a | |||
| 40de8a7467 | |||
| f0fd15bb88 | |||
| 40ffa90dac | |||
| b3cb67188e | |||
| b1c4109045 | |||
| 50dd554b43 | |||
| 6d7682ab4a | |||
| 793b8d83ee | |||
| 7366ad19cb | |||
| 5a5fedf4f4 | |||
| 830993bcb8 | |||
| fd15988a73 | |||
| 420542ce10 | |||
| 2915d141aa |
@@ -0,0 +1,37 @@
|
||||
# Booth deploy env — copy to `.env` and fill in, then run ./scripts/booth.sh up
|
||||
# (prod). Consumed by docker-compose.yml + the prod override via --env-file.
|
||||
# See wiki/decisions/container-deployment.md. Do NOT commit the filled-in .env.
|
||||
|
||||
# --- image source (prod pulls from the house Gitea registry) ------------------
|
||||
# The registry namespace; combined with the image name + TAG below.
|
||||
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||
# Image tag to deploy. CI publishes TWO tags per build: a MOVING branch tag
|
||||
# (`dev`, and `main` once that branch is built) republished on every push, and an
|
||||
# IMMUTABLE per-commit `dev-<sha>` (e.g. dev-830993b). Use the moving tag for a
|
||||
# self-updating booth (`booth.sh update` pulls the latest); pin the `<branch>-<sha>`
|
||||
# form for a reproducible, deterministic deploy. NOTE: `main` images only exist once
|
||||
# something is built on main — until then deploy from `dev`.
|
||||
TAG=dev
|
||||
|
||||
# --- secrets (NO safe defaults — the server refuses to boot without a real one) -
|
||||
# JWT signing secret. Generate yourself, never share it: openssl rand -hex 32
|
||||
# Must be 32+ chars and must NOT contain change-me / insecure / dev-only.
|
||||
JWT_SECRET=
|
||||
|
||||
# Ledger-signing key for the append-only signed event chain. Set a DISTINCT value
|
||||
# in prod (don't reuse JWT_SECRET). openssl rand -hex 32
|
||||
EVENT_SIGNING_KEY=
|
||||
|
||||
# --- booth LAN specifics ------------------------------------------------------
|
||||
# Auth cookie is HTTPS-only by default; the booth is plain HTTP behind Caddy on
|
||||
# :80, so this MUST stay 0 or operators cannot log in. Set to 1 only behind TLS.
|
||||
COOKIE_SECURE=0
|
||||
|
||||
# Remote origins the live WS feed must accept (same-origin always passes). Add any
|
||||
# address admins hit the UI from beyond the booth itself, comma-separated, e.g.
|
||||
# http://parksystems.msai.al (leave blank if only the local booth URL is used).
|
||||
WS_ALLOWED_ORIGINS=
|
||||
|
||||
# Vision/ANPR. Prod override already forces the fast_alpr engine; leave VISION_ENABLED=1
|
||||
# unless you are running without the camera. (Set 0 to disable the vision call entirely.)
|
||||
VISION_ENABLED=1
|
||||
@@ -9,5 +9,7 @@
|
||||
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
|
||||
:80 {
|
||||
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
|
||||
// (no registry, no network). The factory returns a fresh shot each call.
|
||||
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
|
||||
// The bridge now goes through captureSnapshotShared (the dedup wrapper, exercised in
|
||||
// snapshot.test.ts); here it just delegates to the fake camera's captureSnapshot so this
|
||||
// suite stays focused on the bridge's own match/debounce/emit logic.
|
||||
vi.mock("./snapshot.js", () => ({
|
||||
buildCamera: () => ({ captureSnapshot }),
|
||||
captureSnapshotShared: (_id: string, camera: { captureSnapshot: typeof captureSnapshot }, ctx: unknown) =>
|
||||
camera.captureSnapshot(ctx as never),
|
||||
}));
|
||||
|
||||
// Import AFTER the mock is registered.
|
||||
@@ -28,13 +33,22 @@ beforeEach(() => {
|
||||
captureSnapshot.mockClear();
|
||||
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
|
||||
delete process.env.ANPR_DEBOUNCE_MS;
|
||||
// Poll-until-confident loop: keep the window + interval tiny so a below-floor / no-plate
|
||||
// case gives up in ~one tick instead of the 8s production window (tests stay fast). Each
|
||||
// bridge reads these in its constructor, so set them before `new AnprBridge`.
|
||||
process.env.ANPR_POLL_MS = "1";
|
||||
process.env.ANPR_POLL_WINDOW_MS = "5";
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.ANPR_POLL_MS;
|
||||
delete process.env.ANPR_POLL_WINDOW_MS;
|
||||
delete process.env.ANPR_POLL_MAX_MS;
|
||||
});
|
||||
|
||||
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */
|
||||
function seedCamera(opts: { anpr?: boolean } = {}): string {
|
||||
/** A camera bound to an entry relay; `anpr` toggles recognition, `anprAutoTrigger` the
|
||||
* per-camera auto-open gate (absent ⇒ defaults on). */
|
||||
function seedCamera(opts: { anpr?: boolean; anprAutoTrigger?: boolean } = {}): string {
|
||||
const controllerId = randomUUID();
|
||||
db.insert(devices).values({
|
||||
id: controllerId,
|
||||
@@ -48,7 +62,13 @@ function seedCamera(opts: { anpr?: boolean } = {}): string {
|
||||
id: camId,
|
||||
category: "camera",
|
||||
driverId: "hikvision",
|
||||
config: { host: "10.0.0.9", controllerId, relay: 1, ...(opts.anpr ? { anpr: true } : {}) },
|
||||
config: {
|
||||
host: "10.0.0.9",
|
||||
controllerId,
|
||||
relay: 1,
|
||||
...(opts.anpr ? { anpr: true } : {}),
|
||||
...(opts.anprAutoTrigger === false ? { anprAutoTrigger: false } : {}),
|
||||
},
|
||||
enabled: true,
|
||||
}).run();
|
||||
return camId;
|
||||
@@ -74,8 +94,17 @@ function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: numb
|
||||
}
|
||||
|
||||
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
|
||||
function fakeSubFlow(match: SubscriptionMatch | null): SubscriptionFlow {
|
||||
return { match: vi.fn(() => match) } as unknown as SubscriptionFlow;
|
||||
function fakeSubFlow(
|
||||
match: SubscriptionMatch | null,
|
||||
// openOccurrenceCount: a constant, or a sequence consumed per call (to simulate a
|
||||
// credential closing an occurrence mid-poll → count changes).
|
||||
openCounts: number | number[] = 1,
|
||||
): SubscriptionFlow {
|
||||
const seq = Array.isArray(openCounts) ? [...openCounts] : null;
|
||||
return {
|
||||
match: vi.fn(() => match),
|
||||
openOccurrenceCount: vi.fn(() => (seq ? (seq.length > 1 ? seq.shift()! : seq[0]) : (openCounts as number))),
|
||||
} as unknown as SubscriptionFlow;
|
||||
}
|
||||
|
||||
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
|
||||
@@ -104,6 +133,18 @@ describe("AnprBridge", () => {
|
||||
expect(captureSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT auto-trigger when anprAutoTrigger=false (recognition on, auto-open off)", async () => {
|
||||
// Shared entry/exit lane: the exit cam keeps anpr (recognition) but auto-trigger off, so a
|
||||
// car driving IN isn't phantom-EXITed by its back plate. The bridge bails before snapshot.
|
||||
const cam = seedCamera({ anpr: true, anprAutoTrigger: false });
|
||||
const vision = fakeVision({ plate: "AA111BB", confidence: 0.99 });
|
||||
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||
|
||||
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||
expect(reads).toEqual([]);
|
||||
expect(captureSnapshot).not.toHaveBeenCalled(); // gated before the poll loop
|
||||
});
|
||||
|
||||
it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
|
||||
const cam = seedCamera({ anpr: true });
|
||||
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
|
||||
@@ -123,6 +164,85 @@ describe("AnprBridge", () => {
|
||||
expect(reads).toEqual([]);
|
||||
});
|
||||
|
||||
it("POLLS until confident: low-confidence approach frames, then a clean stop-at-barrier frame", async () => {
|
||||
// The car APPROACHES (garbage reads) then STOPS at the barrier (clean read) — the bridge
|
||||
// must re-pull until one frame clears the floor, not give up on the first bad frame.
|
||||
const cam = seedCamera({ anpr: true });
|
||||
// analyze escalates: 0.20, 0.20, then 0.97 on the 3rd pull → that one emits.
|
||||
const confs = [0.2, 0.2, 0.97];
|
||||
let i = 0;
|
||||
const vision = {
|
||||
enabled: true,
|
||||
analyze: vi.fn(async () => ({
|
||||
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||
plates: [],
|
||||
lowConfidence: false,
|
||||
modelVersion: "test",
|
||||
tookMs: 1,
|
||||
})),
|
||||
} as unknown as VisionClient;
|
||||
// Generous window so all 3 escalation attempts run deterministically under suite load
|
||||
// (the global beforeEach sets a tiny 5ms window for the give-up cases).
|
||||
process.env.ANPR_POLL_MS = "1";
|
||||
process.env.ANPR_POLL_WINDOW_MS = "2000";
|
||||
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||
|
||||
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||
expect(reads).toHaveLength(1);
|
||||
expect(reads[0]).toMatchObject({ value: "AA111BB", kind: "plate" });
|
||||
expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames
|
||||
});
|
||||
|
||||
it("SLIDES the window: a push mid-poll keeps the loop alive past the initial deadline", async () => {
|
||||
// A loop started by an early/far car would expire — but a NEW push (another car arriving)
|
||||
// extends the deadline, so the loop keeps polling and reads the car that settles at the
|
||||
// barrier. Here: a SHORT base window, vision stays low until attempt 5; a second push at
|
||||
// the start bumps the deadline so attempt 5's confident read still lands.
|
||||
const cam = seedCamera({ anpr: true });
|
||||
const confs = [0.2, 0.2, 0.2, 0.2, 0.97];
|
||||
let i = 0;
|
||||
const vision = {
|
||||
enabled: true,
|
||||
analyze: vi.fn(async () => ({
|
||||
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||
plates: [],
|
||||
lowConfidence: false,
|
||||
modelVersion: "test",
|
||||
tookMs: 1,
|
||||
})),
|
||||
} as unknown as VisionClient;
|
||||
process.env.ANPR_POLL_MS = "5";
|
||||
process.env.ANPR_POLL_WINDOW_MS = "12"; // tiny — would expire ~attempt 2 WITHOUT a slide
|
||||
process.env.ANPR_POLL_MAX_MS = "5000"; // ceiling far above, so the slide is what matters
|
||||
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||
|
||||
const reads = await captureReads(async () => {
|
||||
const loop = bridge.onVehicleDetected(cam); // starts the loop
|
||||
// Joining pushes keep sliding the deadline forward so the slow-to-confident read lands.
|
||||
for (let k = 0; k < 5; k++) {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
void bridge.onVehicleDetected(cam); // each bumps the deadline (loop already running)
|
||||
}
|
||||
await loop;
|
||||
});
|
||||
expect(reads).toHaveLength(1);
|
||||
expect(reads[0]).toMatchObject({ value: "AA111BB" });
|
||||
});
|
||||
|
||||
it("ABORTS if the subscriber transacts by another credential mid-poll (no double-act)", async () => {
|
||||
// The car's plate is read (identity known) but stays below the floor; meanwhile the
|
||||
// subscriber scans their card → openOccurrenceCount drops. The bridge must abort and NOT
|
||||
// emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. fleet).
|
||||
const cam = seedCamera({ anpr: true });
|
||||
const vision = fakeVision({ plate: "AA111BB", confidence: 0.5 }); // never clears the floor
|
||||
// openOccurrenceCount: 1 at baseline, then 0 (the card exit closed it) on the next check.
|
||||
const sub = fakeSubFlow(SUB_MATCH, [1, 0]);
|
||||
const bridge = new AnprBridge(db, vision, sub, silentLogger());
|
||||
|
||||
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||
expect(reads).toEqual([]); // aborted — the credential already handled it
|
||||
});
|
||||
|
||||
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
|
||||
const cam = seedCamera({ anpr: true });
|
||||
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
||||
|
||||
+151
-12
@@ -30,6 +30,10 @@ import type { VisionClient } from "./vision-client.js";
|
||||
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
|
||||
interface CameraConfig {
|
||||
readonly anpr?: boolean;
|
||||
/** Whether this camera may AUTO-OPEN the barrier (entry/exit). Absent ⇒ true (when anpr is
|
||||
* on). Set false to keep recognition but suppress auto-trigger — e.g. the exit camera on a
|
||||
* shared entry/exit lane. */
|
||||
readonly anprAutoTrigger?: boolean;
|
||||
readonly [k: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -49,6 +53,40 @@ function debounceMs(): number {
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
|
||||
}
|
||||
|
||||
/** A single alarm fires the INSTANT motion starts — the car is still approaching, so the
|
||||
* first frame often has a small/blurry/absent plate (a low-confidence misread). But the car
|
||||
* then STOPS at the barrier (waiting for it to open) — the same stationary, well-framed
|
||||
* moment the manual test reads at ~100%. So instead of one shot, we POLL fresh frames and
|
||||
* re-run ANPR until one clears the confidence floor, or the window elapses. Poll interval: */
|
||||
function pollMs(): number {
|
||||
const raw = Number(process.env.ANPR_POLL_MS ?? 1000);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 1000;
|
||||
}
|
||||
|
||||
/** How long to keep polling AFTER THE LAST vehicle push before giving up. SLIDING: each new
|
||||
* push for the camera extends the deadline by this much from now — so a loop started by a
|
||||
* far/early car keeps pulling fresh frames as the REAL car arrives and settles at the
|
||||
* barrier (the loop tracks "whoever is here now", not the car that started it). */
|
||||
function pollWindowMs(): number {
|
||||
const raw = Number(process.env.ANPR_POLL_WINDOW_MS ?? 8000);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 8000;
|
||||
}
|
||||
|
||||
/** Hard ceiling on a single loop from its START, so a continuously-busy lane (pushes never
|
||||
* stop) can't slide the window forever. The loop ends at min(lastPush + window, start + max). */
|
||||
function pollMaxMs(): number {
|
||||
const raw = Number(process.env.ANPR_POLL_MAX_MS ?? 30_000);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
/** A plate DeviceReadEvent skeleton (value filled by the caller) — for matching the
|
||||
* subscriber by plate during the poll loop without re-building the whole event. */
|
||||
function baseRead(row: { driverId: string }, deviceId: string): Omit<DeviceReadEvent, "value"> {
|
||||
return { driverId: row.driverId, deviceId, kind: "plate", at: new Date().toISOString() };
|
||||
}
|
||||
|
||||
export class AnprBridge {
|
||||
readonly #db: Db;
|
||||
readonly #vision: VisionClient | null;
|
||||
@@ -56,9 +94,19 @@ export class AnprBridge {
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
readonly #entryMinConfidence: number;
|
||||
readonly #debounceMs: number;
|
||||
readonly #pollMs: number;
|
||||
readonly #pollWindowMs: number;
|
||||
readonly #pollMaxMs: number;
|
||||
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
|
||||
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
|
||||
readonly #lastFire = new Map<string, number>();
|
||||
/** Cameras with a poll loop already in flight — a re-fired alarm (the camera pushes ~1Hz
|
||||
* while the car sits) must NOT start a second concurrent loop on the same camera. */
|
||||
readonly #polling = new Set<string>();
|
||||
/** Per-camera SLIDING deadline for the running poll loop. A push that joins a running loop
|
||||
* bumps this forward (lastPush + window, capped at start + max), so the loop keeps pulling
|
||||
* fresh frames while cars keep arriving — tracking whoever settles at the barrier. */
|
||||
readonly #pollDeadline = new Map<string, number>();
|
||||
|
||||
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
@@ -67,6 +115,9 @@ export class AnprBridge {
|
||||
this.#logger = logger;
|
||||
this.#entryMinConfidence = entryMinConfidence();
|
||||
this.#debounceMs = debounceMs();
|
||||
this.#pollMs = pollMs();
|
||||
this.#pollWindowMs = pollWindowMs();
|
||||
this.#pollMaxMs = pollMaxMs();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,15 +135,35 @@ export class AnprBridge {
|
||||
if (site && site.anprEntryEnabled === false) return;
|
||||
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||
if (!row || !row.enabled || row.category !== "camera") return;
|
||||
if ((row.config as CameraConfig)?.anpr !== true) return; // opt-in only
|
||||
const cfg = row.config as CameraConfig;
|
||||
if (cfg?.anpr !== true) return; // recognition opt-in (also gates the evidence/advisory path)
|
||||
// Per-camera AUTO-TRIGGER gate. `anpr` keeps recognition (snapshots + plate record) on;
|
||||
// this controls whether THIS camera may auto-open the barrier. A shared entry/exit lane
|
||||
// sets it false on (e.g.) the exit camera so its back-plate read doesn't phantom-exit the
|
||||
// car that just entered. Absent ⇒ true (back-compat: existing anpr cameras still trigger).
|
||||
if (cfg.anprAutoTrigger === false) return;
|
||||
|
||||
// Camera-level debounce (pre-snapshot): a car re-firing ~1Hz must not pull a
|
||||
// snapshot + analyze every second.
|
||||
// Post-success debounce: once we've emitted a read for this camera, ignore the
|
||||
// ~1Hz re-fires for #debounceMs (set on success below). A fresh alarm AFTER the
|
||||
// window is a new presentation and may start a new poll loop.
|
||||
if (this.#debounced(deviceId)) return;
|
||||
this.#stamp(deviceId);
|
||||
// One poll loop per camera. A push that arrives while a loop runs JOINs it — and
|
||||
// SLIDES the deadline forward (a different car arriving mid-loop keeps the loop alive
|
||||
// so it tracks whoever's at the barrier now, instead of giving up on the early car).
|
||||
const now = Date.now();
|
||||
if (this.#polling.has(deviceId)) {
|
||||
const cur = this.#pollDeadline.get(deviceId) ?? now;
|
||||
// Slide to lastPush + window, but never past the per-loop hard ceiling (set at start).
|
||||
this.#pollDeadline.set(deviceId, Math.max(cur, now + this.#pollWindowMs));
|
||||
return;
|
||||
}
|
||||
this.#polling.add(deviceId);
|
||||
// Initial deadline; the hard ceiling (start + max) is enforced in the loop below.
|
||||
this.#pollDeadline.set(deviceId, now + this.#pollWindowMs);
|
||||
|
||||
const camera = buildCamera(row);
|
||||
if (!camera) {
|
||||
this.#polling.delete(deviceId);
|
||||
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
|
||||
return;
|
||||
}
|
||||
@@ -100,16 +171,74 @@ export class AnprBridge {
|
||||
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
|
||||
// the gated flow infers the verb from the camera's bound relay direction).
|
||||
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
|
||||
const shot = await camera.captureSnapshot({ direction });
|
||||
const result = await this.#vision.analyze(shot.bytes, shot.contentType);
|
||||
if (!result || !result.plate) return; // nothing read
|
||||
|
||||
// Entry floor — stricter than the advisory floor (analyze() still returns the plate
|
||||
// object with its confidence even when its own lowConfidence flag is set).
|
||||
if (result.plate.confidence < this.#entryMinConfidence) {
|
||||
// POLL-UNTIL-CONFIDENT. The alarm fires as the car APPROACHES (small/blurry/absent
|
||||
// plate → low-confidence misread, e.g. '111'@0.20). But the car then STOPS at the
|
||||
// barrier — the stationary, well-framed moment the manual test reads at ~100%. So we
|
||||
// pull a FRESH frame every #pollMs and re-run ANPR until one clears the floor, or the
|
||||
// #pollWindowMs window elapses (car drove off / non-subscriber). NB: a fresh pull each
|
||||
// tick — NOT captureSnapshotShared, whose TTL would re-serve the same bad frame.
|
||||
// While polling, watch whether THIS subscriber transacts by another credential
|
||||
// (card/QR at the reader). If their open-occurrence count drops mid-poll, the
|
||||
// subscriber already exited/entered — the bridge must NOT also emit (it would act on
|
||||
// the NEXT open occurrence: a phantom double-exit, worst for a fleet sub). We learn the
|
||||
// subscription as soon as a frame reads the bound plate (identity needs no confidence),
|
||||
// snapshot the count, then keep polling for a CONFIDENT read; abort if the count moved.
|
||||
let result: Awaited<ReturnType<VisionClient["analyze"]>> = null;
|
||||
let watchedSubId: string | null = null;
|
||||
let baselineOpen = 0;
|
||||
// Hard ceiling for THIS loop (start + max); the sliding deadline (bumped by joining
|
||||
// pushes) is read from #pollDeadline each tick but never allowed past this cap.
|
||||
const hardCap = Date.now() + this.#pollMaxMs;
|
||||
let attempts = 0;
|
||||
try {
|
||||
while (Date.now() < Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap)) {
|
||||
attempts++;
|
||||
const shot = await camera.captureSnapshot({ direction });
|
||||
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
|
||||
|
||||
// Identify the subscriber from ANY readable plate (even below the barrier floor),
|
||||
// and baseline their open count once — so we can detect a credential beating us.
|
||||
if (r?.plate?.text) {
|
||||
const m0 = this.#subscription.match({ ...baseRead(row, deviceId), value: r.plate.text.trim().toUpperCase() });
|
||||
if (m0 && watchedSubId == null) {
|
||||
watchedSubId = m0.subscriptionId;
|
||||
baselineOpen = this.#subscription.openOccurrenceCount(watchedSubId);
|
||||
}
|
||||
}
|
||||
// A credential (card/QR) closed/opened an occurrence for this subscriber mid-poll →
|
||||
// they already transacted; stop polling and do NOT emit.
|
||||
if (watchedSubId && this.#subscription.openOccurrenceCount(watchedSubId) !== baselineOpen) {
|
||||
this.#logger.info(
|
||||
`anpr-bridge: subscriber ${watchedSubId} transacted by another credential mid-poll — aborting ANPR`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (r?.plate && r.plate.confidence >= this.#entryMinConfidence) {
|
||||
result = r;
|
||||
break;
|
||||
}
|
||||
if (r?.plate) {
|
||||
this.#logger.info(
|
||||
`anpr-bridge: '${r.plate.text}' (${r.plate.confidence.toFixed(3)}) below floor ` +
|
||||
`${this.#entryMinConfidence} — re-pulling (attempt ${attempts})`,
|
||||
);
|
||||
}
|
||||
// Stop if the next tick would land past the (possibly slid) deadline or the cap.
|
||||
const effDeadline = Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap);
|
||||
if (Date.now() + this.#pollMs >= effDeadline) break;
|
||||
await sleep(this.#pollMs);
|
||||
}
|
||||
} finally {
|
||||
this.#polling.delete(deviceId);
|
||||
this.#pollDeadline.delete(deviceId);
|
||||
}
|
||||
|
||||
if (!result || !result.plate) {
|
||||
this.#logger.info(
|
||||
`anpr-bridge: plate '${result.plate.text}' below entry floor ` +
|
||||
`(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`,
|
||||
`anpr-bridge: no confident plate from ${deviceId} after ${attempts} attempt(s) ` +
|
||||
`in ${this.#pollWindowMs}ms — gave up`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -133,11 +262,21 @@ export class AnprBridge {
|
||||
return;
|
||||
}
|
||||
|
||||
// Final guard against the credential-mid-poll race: if the subscriber transacted between
|
||||
// our baseline and now (e.g. a card scan in the last tick), don't double-act.
|
||||
if (watchedSubId === match.subscriptionId && this.#subscription.openOccurrenceCount(match.subscriptionId) !== baselineOpen) {
|
||||
this.#logger.info(`anpr-bridge: ${match.subscriptionId} already transacted — skipping ANPR emit`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Plate-level debounce — belt-and-suspenders against a gap that slips the
|
||||
// camera-level gate re-emitting the SAME plate.
|
||||
const plateKey = `${deviceId}:${plate}`;
|
||||
if (this.#debounced(plateKey)) return;
|
||||
this.#stamp(plateKey);
|
||||
// Camera-level debounce stamp — now that we've emitted, suppress the camera's ~1Hz
|
||||
// re-fires (and any new poll loop) for #debounceMs.
|
||||
this.#stamp(deviceId);
|
||||
|
||||
this.#logger.info(
|
||||
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq, devices, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import type { AuxOutputDevice } from "@parking/devices";
|
||||
import { ButtonLightController } from "./button-light.js";
|
||||
import { deviceEvents } from "./device-events.js";
|
||||
import { silentLogger } from "./test-helpers.js";
|
||||
|
||||
// ButtonLightController: alert (radarAlert) relays — the entry-button lamp on a spare
|
||||
// relay, driven by the lamp's trigger input vs. the camera lane status. Truth table:
|
||||
// trigger active + lane busy -> SOLID on
|
||||
// trigger active + lane free -> BLINK (~1 Hz)
|
||||
// otherwise -> OFF
|
||||
// Lamp is a non-barrier aux output; fails OFF; de-dupes redundant writes. A controller may
|
||||
// carry several alert relays (each its own row + trigger input), keyed independently.
|
||||
|
||||
let db: Db;
|
||||
const CONTROLLER = "ctl-1";
|
||||
const RADAR_INPUT = 2; // I2
|
||||
const LAMP_RELAY = 3; // spare relay R3
|
||||
|
||||
/** A fake aux device recording setAux calls (channel,on). Optionally throws. */
|
||||
function fakeAux(record: Array<{ ch: number; on: boolean }>, throwOnce = { v: false }): AuxOutputDevice {
|
||||
return {
|
||||
async setAux(channel: number, on: boolean): Promise<void> {
|
||||
if (throwOnce.v) {
|
||||
throwOnce.v = false;
|
||||
throw new Error("UDP down");
|
||||
}
|
||||
record.push({ ch: channel, on });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
vi.useFakeTimers();
|
||||
// One controller: entry relay 1 with radar on I2; lamp on spare relay 3.
|
||||
db.insert(devices).values({
|
||||
id: CONTROLLER,
|
||||
category: "access",
|
||||
driverId: "dingtian",
|
||||
config: {
|
||||
host: "10.0.0.5",
|
||||
relays: [
|
||||
{ relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" },
|
||||
{ relay: 2, direction: "exit" },
|
||||
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
|
||||
],
|
||||
},
|
||||
enabled: true,
|
||||
}).run();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/** Emit a radar (presence input) edge for the controller. */
|
||||
function radar(present: boolean): void {
|
||||
deviceEvents.emitInput({
|
||||
driverId: "dingtian",
|
||||
deviceId: CONTROLLER,
|
||||
input: RADAR_INPUT,
|
||||
edge: present ? "on" : "off",
|
||||
at: new Date().toISOString(),
|
||||
source: "poll",
|
||||
});
|
||||
}
|
||||
|
||||
/** Emit a lane status (entry busy/free). */
|
||||
function lane(entryBusy: boolean): void {
|
||||
deviceEvents.emitLaneStatus({ entry: entryBusy, exit: false });
|
||||
}
|
||||
|
||||
/** Flush the microtask queue so serialized setAux promises (and their re-pump on
|
||||
* completion) settle. The lamp worker sends ONE UDP at a time and re-pumps on resolve;
|
||||
* a few turns drain a burst. Needed because sends are now async (was synchronous). */
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 6; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("ButtonLightController truth table", () => {
|
||||
it("OFF at start (no radar, no car)", async () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||
ctl.start();
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||
// confirmedOn starts null; OFF de-dupes (null !== false → one off write), so the
|
||||
// device is confirmed OFF and at most one call was made.
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(false);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("radar present + lane busy -> SOLID on", async () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const aux = fakeAux(calls);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
ctl.start();
|
||||
await flush();
|
||||
lane(true);
|
||||
radar(true);
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // device latched ON
|
||||
// Solid = no blinking: advancing time produces no further sends.
|
||||
const n = calls.length;
|
||||
vi.advanceTimersByTime(2000);
|
||||
await flush();
|
||||
expect(calls.length).toBe(n);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("radar present + lane free -> BLINK (toggles the device over time)", async () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const aux = fakeAux(calls);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
ctl.start();
|
||||
await flush();
|
||||
radar(true); // lane still free
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // on now
|
||||
vi.advanceTimersByTime(500);
|
||||
await flush();
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // toggled off
|
||||
vi.advanceTimersByTime(500);
|
||||
await flush();
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // toggled on
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("blink -> solid when the camera confirms a car (lane busy)", async () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const aux = fakeAux(calls);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
ctl.start();
|
||||
await flush();
|
||||
radar(true); // blink
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||
lane(true); // camera confirms
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||
// No more toggles (blink torn down) — the device stays ON over time.
|
||||
vi.advanceTimersByTime(2000);
|
||||
await flush();
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("radar clears -> OFF", async () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const aux = fakeAux(calls);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
ctl.start();
|
||||
await flush();
|
||||
lane(true);
|
||||
radar(true); // solid
|
||||
await flush();
|
||||
radar(false); // car gone
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // device latched OFF
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("de-dupes redundant writes (no spam on repeat events)", async () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const aux = fakeAux(calls);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
ctl.start();
|
||||
await flush();
|
||||
lane(true);
|
||||
radar(true); // solid, on
|
||||
await flush();
|
||||
const n = calls.length;
|
||||
radar(true); // same state — no new edge (present unchanged)
|
||||
lane(true); // same lane — no change
|
||||
await flush();
|
||||
expect(calls.length).toBe(n);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("fails OFF: a setAux error does not throw or escalate", async () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const throwOnce = { v: true };
|
||||
const aux = fakeAux(calls, throwOnce);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
// First write (initial off) throws — must be swallowed.
|
||||
expect(() => ctl.start()).not.toThrow();
|
||||
await flush();
|
||||
// Subsequent writes work; driving to solid still converges to ON.
|
||||
lane(true);
|
||||
radar(true);
|
||||
await flush();
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("ignores controllers without an alert relay", () => {
|
||||
// A second controller, no alert relay.
|
||||
db.insert(devices).values({
|
||||
id: "ctl-2",
|
||||
category: "access",
|
||||
driverId: "dingtian",
|
||||
config: { host: "10.0.0.6", relays: [{ relay: 1, direction: "entry", presenceInput: 2 }] },
|
||||
enabled: true,
|
||||
}).run();
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||
ctl.start();
|
||||
expect(ctl.stateOf("ctl-2")).toBeNull();
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("picks up an alert relay ADDED after start() (no restart needed)", async () => {
|
||||
// Fresh controller with a radar input but NO alert relay yet.
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const aux = fakeAux(calls);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
// Replace the seeded controller with one that has the radar but no lamp.
|
||||
db.update(devices)
|
||||
.set({
|
||||
config: {
|
||||
host: "10.0.0.5",
|
||||
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }],
|
||||
},
|
||||
})
|
||||
.where(eq(devices.id, CONTROLLER))
|
||||
.run();
|
||||
ctl.start();
|
||||
await flush();
|
||||
// No lamp configured → an input does nothing.
|
||||
radar(true);
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER)).toBeNull();
|
||||
expect(calls.length).toBe(0);
|
||||
radar(false);
|
||||
await flush();
|
||||
|
||||
// Admin saves an alert relay (relay 3, trigger I2) — without restarting the server.
|
||||
db.update(devices)
|
||||
.set({
|
||||
config: {
|
||||
host: "10.0.0.5",
|
||||
relays: [
|
||||
{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" },
|
||||
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
|
||||
],
|
||||
},
|
||||
})
|
||||
.where(eq(devices.id, CONTROLLER))
|
||||
.run();
|
||||
|
||||
// The very next radar edge reconciles + blinks (lane still free).
|
||||
radar(true);
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("drives two alert relays on one controller independently", async () => {
|
||||
const R3 = 3;
|
||||
const R4 = 4;
|
||||
const I2 = 2;
|
||||
const I3 = 3;
|
||||
// Controller with two alert lamps, each on its own trigger input.
|
||||
db.update(devices)
|
||||
.set({
|
||||
config: {
|
||||
host: "10.0.0.5",
|
||||
relays: [
|
||||
{ relay: 1, direction: "entry", presenceInput: I2, presenceKind: "radar" },
|
||||
{ relay: R3, direction: "radarAlert", triggerInput: I2, blinkOnMs: 500, blinkOffMs: 500 },
|
||||
{ relay: R4, direction: "radarAlert", triggerInput: I3, blinkOnMs: 500, blinkOffMs: 500 },
|
||||
],
|
||||
},
|
||||
})
|
||||
.where(eq(devices.id, CONTROLLER))
|
||||
.run();
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const aux = fakeAux(calls);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
ctl.start();
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
|
||||
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
|
||||
|
||||
// I2 active → only R3 blinks; R4 stays off (different trigger).
|
||||
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
|
||||
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
|
||||
|
||||
// I3 active → R4 blinks too, independently.
|
||||
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I3, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
|
||||
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||
|
||||
// Camera confirms a car → BOTH lock solid (lane-busy is site-wide).
|
||||
lane(true);
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER, R3)).toBe("solid");
|
||||
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||
|
||||
// I2 clears → R3 off, R4 still solid (its trigger still active).
|
||||
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "off", at: new Date().toISOString(), source: "poll" });
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
|
||||
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("an EXIT alert lamp locks on the EXIT camera, not entry", async () => {
|
||||
const R4 = 4;
|
||||
const I5 = 5; // exit radar
|
||||
db.update(devices)
|
||||
.set({
|
||||
config: {
|
||||
host: "10.0.0.5",
|
||||
relays: [
|
||||
{ relay: 1, direction: "entry" },
|
||||
{ relay: 2, direction: "exit" },
|
||||
// Exit alert lamp: triggers on the exit radar, locks on the EXIT camera.
|
||||
{ relay: R4, direction: "radarAlert", triggerInput: I5, lockLane: "exit", blinkOnMs: 500, blinkOffMs: 500 },
|
||||
],
|
||||
},
|
||||
})
|
||||
.where(eq(devices.id, CONTROLLER))
|
||||
.run();
|
||||
const aux = fakeAux([]);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
ctl.start();
|
||||
await flush();
|
||||
|
||||
// Exit radar active → blink.
|
||||
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I5, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||
|
||||
// ENTRY camera busy must NOT lock this exit lamp — it still blinks.
|
||||
deviceEvents.emitLaneStatus({ entry: true, exit: false });
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||
|
||||
// EXIT camera busy → SOLID.
|
||||
deviceEvents.emitLaneStatus({ entry: true, exit: true });
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||
ctl.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import { eq, devices, type Db, type DeviceRow } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { hasAuxOutput, registry, type AuxOutputDevice } from "@parking/devices";
|
||||
import { deviceEvents, type DeviceInputEvent, type LaneStatusEvent } from "./device-events.js";
|
||||
import { alertRelaysOf, relayForPresence, type RelaySpec } from "./device-resolve.js";
|
||||
|
||||
// Alert (radarAlert) relays — non-barrier indicator lamps, e.g. the entry button's 12 V
|
||||
// light. Each lamp is a `relays[]` row with event `radarAlert`, driven by ITS trigger
|
||||
// input vs. the camera "car in zone" signal (the advisory lane-status). A disagreement
|
||||
// indicator:
|
||||
// trigger active + lane busy (camera confirms a car) → SOLID on
|
||||
// trigger active + lane free (radar sees something, no car) → BLINK (~1 Hz)
|
||||
// otherwise → OFF
|
||||
// The lamp is a NON-barrier aux output (setAux latch), so holding/blinking it is fine
|
||||
// — barrier-not-a-door applies only to barriers, which still only pulseOpen. The lamp
|
||||
// FAILS OFF: any error / shutdown leaves it off, so a dead lamp is "no hint", never a
|
||||
// misleading solid "go". A controller may have several alert relays (each its own row +
|
||||
// trigger input), keyed independently. See wiki/concepts/button-light-indicator.md.
|
||||
|
||||
type LightState = "off" | "solid" | "blink";
|
||||
|
||||
const DEFAULT_BLINK_MS = 500;
|
||||
|
||||
/** Per-lamp live state for the alert rule (one per radarAlert relay). */
|
||||
interface LampState {
|
||||
/** The controller this lamp lives on (its deviceId) — for resolving the aux adapter. */
|
||||
readonly controllerId: string;
|
||||
/** Alert relay row (relay #, triggerInput, blink ms). Mutable: #reconcile updates it in
|
||||
* place when the admin changes the alert config without a restart. */
|
||||
spec: RelaySpec;
|
||||
/** Is the lamp's trigger input (the radar) currently active? */
|
||||
present: boolean;
|
||||
/** The high-level state we're rendering (to avoid restarting a running blink). */
|
||||
rendered: LightState | null;
|
||||
/** Active blink timer, if blinking. */
|
||||
blink: ReturnType<typeof setInterval> | null;
|
||||
/** Blink phase (true = currently on). */
|
||||
blinkOn: boolean;
|
||||
/** The output we WANT the relay to be in. The serialized worker drives the device
|
||||
* toward this. The blink timer only flips this flag — it never sends directly. */
|
||||
desiredOn: boolean;
|
||||
/** The output we last CONFIRMED on the device (after a successful send). null = unknown. */
|
||||
confirmedOn: boolean | null;
|
||||
/** True while a send is in flight for this lamp — serializes UDP so on/off can't
|
||||
* overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */
|
||||
sending: boolean;
|
||||
}
|
||||
|
||||
/** Resolves a controller's live aux-output adapter. The default goes through the
|
||||
* driver registry; tests inject a spy. Returns null when the controller has no
|
||||
* aux-output capability (or won't build). */
|
||||
export type AuxResolver = (controllerId: string) => AuxOutputDevice | null;
|
||||
|
||||
export class ButtonLightController {
|
||||
readonly #db: Db;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
readonly #resolveAux: AuxResolver;
|
||||
/** Per-lamp state, keyed by `${controllerId}:${relay}` (a controller may have several). */
|
||||
readonly #lamps = new Map<string, LampState>();
|
||||
/** Latest lane status — a camera-confirmed car in the entry / exit zone. A lamp locks
|
||||
* SOLID off its OWN lane's camera (`spec.lockLane`), so an exit radar's lamp tracks the
|
||||
* exit camera, not the entry one. */
|
||||
#entryBusy = false;
|
||||
#exitBusy = false;
|
||||
/** Controllers we've already warned lack the aux-output capability (warn once). */
|
||||
readonly #warned = new Set<string>();
|
||||
#unsubInput: (() => void) | null = null;
|
||||
#unsubLane: (() => void) | null = null;
|
||||
|
||||
constructor(db: Db, logger: FastifyBaseLogger, resolveAux?: AuxResolver) {
|
||||
this.#db = db;
|
||||
this.#logger = logger;
|
||||
this.#resolveAux = resolveAux ?? ((id) => this.#auxFromRegistry(id));
|
||||
}
|
||||
|
||||
/** Subscribe to radar input edges + lane status, and initialise every lamp OFF. */
|
||||
start(): void {
|
||||
this.#reconcile();
|
||||
// All lamps start OFF (known-safe baseline) regardless of prior device state.
|
||||
for (const lamp of this.#lamps.values()) this.#apply(lamp);
|
||||
|
||||
this.#unsubInput = deviceEvents.onInput((e) => this.#onInput(e));
|
||||
this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s));
|
||||
}
|
||||
|
||||
/** Reconcile the lamp map with the CURRENT device config (the booth can add/change a
|
||||
* button light without a server restart). Mirrors DeviceMonitor, which re-reads the
|
||||
* device set each tick. Adds lamps for newly-configured controllers, updates the spec
|
||||
* (relay #, blink ms) in place — preserving live `present`/blink state — and drops
|
||||
* lamps whose controller lost its buttonLight or was disabled. Called at start() and
|
||||
* before handling each event, so a just-saved lamp takes effect immediately. */
|
||||
#reconcile(): void {
|
||||
const rows = this.#db.select().from(devices).where(eq(devices.category, "access")).all();
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (!row.enabled) continue;
|
||||
for (const spec of alertRelaysOf(row)) {
|
||||
const key = lampKey(row.id, spec.relay);
|
||||
seen.add(key);
|
||||
const existing = this.#lamps.get(key);
|
||||
if (existing) {
|
||||
existing.spec = spec; // pick up a changed trigger input / blink cadence
|
||||
} else {
|
||||
this.#lamps.set(key, {
|
||||
controllerId: row.id,
|
||||
spec,
|
||||
present: false,
|
||||
rendered: null,
|
||||
blink: null,
|
||||
blinkOn: false,
|
||||
desiredOn: false,
|
||||
confirmedOn: null,
|
||||
sending: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Drop lamps whose controller no longer declares one (or was disabled/removed).
|
||||
for (const [key, lamp] of this.#lamps) {
|
||||
if (seen.has(key)) continue;
|
||||
if (lamp.blink) {
|
||||
clearInterval(lamp.blink);
|
||||
lamp.blink = null;
|
||||
}
|
||||
this.#finalOff(lamp); // best-effort fail-OFF before forgetting it
|
||||
this.#lamps.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** A radar (presence) edge updates that controller's `present` flag. We resolve the
|
||||
* edge the SAME way the entry flow does (relayForPresence on an entry/both relay),
|
||||
* so the lamp and the one-car-one-ticket gate always agree on "a car is here". */
|
||||
#onInput(e: DeviceInputEvent): void {
|
||||
// Reconcile first so a lamp added/changed since boot (no restart) is picked up.
|
||||
this.#reconcile();
|
||||
const present = e.edge === "on";
|
||||
for (const lamp of this.#lamps.values()) {
|
||||
if (lamp.controllerId !== e.deviceId) continue;
|
||||
// A lamp's trigger is its own `triggerInput`; if unset, fall back to the controller's
|
||||
// entry-relay presence terminal (resolved the SAME way the entry flow does) so the
|
||||
// lamp and the one-car-one-ticket gate always agree on "a car is here".
|
||||
const trigger =
|
||||
lamp.spec.triggerInput ?? relayForPresence(this.#db, e.deviceId, e.input)?.presenceInput;
|
||||
if (trigger !== e.input) continue; // not this lamp's trigger terminal
|
||||
if (present === lamp.present) continue;
|
||||
lamp.present = present;
|
||||
this.#apply(lamp);
|
||||
}
|
||||
}
|
||||
|
||||
/** Lane status changed: a camera-confirmed car in the entry and/or exit zone. */
|
||||
#onLane(s: LaneStatusEvent): void {
|
||||
if (s.entry === this.#entryBusy && s.exit === this.#exitBusy) return;
|
||||
this.#entryBusy = s.entry;
|
||||
this.#exitBusy = s.exit;
|
||||
// Re-render every lamp (each picks its own lane's camera in #apply).
|
||||
for (const lamp of this.#lamps.values()) this.#apply(lamp);
|
||||
}
|
||||
|
||||
/** Compute + render the target state for one lamp. Drives are fire-and-forget (the
|
||||
* timer/state machine is synchronous; the UDP write resolves on its own). */
|
||||
#apply(lamp: LampState): void {
|
||||
// SOLID only once THIS lamp's lane camera confirms a car (default entry).
|
||||
const laneBusy = lamp.spec.lockLane === "exit" ? this.#exitBusy : this.#entryBusy;
|
||||
const target: LightState = !lamp.present ? "off" : laneBusy ? "solid" : "blink";
|
||||
if (target === lamp.rendered) return; // already rendering this state
|
||||
|
||||
// Tear down any running blink before switching states.
|
||||
if (lamp.blink) {
|
||||
clearInterval(lamp.blink);
|
||||
lamp.blink = null;
|
||||
}
|
||||
lamp.rendered = target;
|
||||
|
||||
if (target === "off") {
|
||||
lamp.desiredOn = false;
|
||||
this.#pump(lamp);
|
||||
} else if (target === "solid") {
|
||||
lamp.desiredOn = true;
|
||||
this.#pump(lamp);
|
||||
} else {
|
||||
// BLINK: a wall-clock timer flips ONLY the desired flag; #pump does the actual
|
||||
// (serialized) UDP send. A symmetric cadence uses one interval; an asymmetric one
|
||||
// re-arms each phase with its own duration. Sends never overlap or reorder, so the
|
||||
// relay can't get stuck on a stale packet.
|
||||
const onMs = lamp.spec.blinkOnMs && lamp.spec.blinkOnMs > 0 ? lamp.spec.blinkOnMs : DEFAULT_BLINK_MS;
|
||||
const offMs = lamp.spec.blinkOffMs && lamp.spec.blinkOffMs > 0 ? lamp.spec.blinkOffMs : DEFAULT_BLINK_MS;
|
||||
lamp.blinkOn = true;
|
||||
lamp.desiredOn = true;
|
||||
const tick = () => {
|
||||
lamp.blinkOn = !lamp.blinkOn;
|
||||
lamp.desiredOn = lamp.blinkOn;
|
||||
this.#pump(lamp);
|
||||
if (onMs !== offMs && lamp.blink) {
|
||||
clearInterval(lamp.blink);
|
||||
lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs);
|
||||
lamp.blink.unref?.();
|
||||
}
|
||||
};
|
||||
lamp.blink = setInterval(tick, onMs);
|
||||
lamp.blink.unref?.();
|
||||
this.#pump(lamp);
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialized per-lamp worker: drive the relay toward `desiredOn`, one UDP send at a
|
||||
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
|
||||
* the relay stuck on a stale packet. Here a single in-flight send is guaranteed
|
||||
* (`sending` guard); when it resolves, if the desired state moved on we send again —
|
||||
* so the LAST desired state is always the one finally asserted on the device. */
|
||||
#pump(lamp: LampState): void {
|
||||
if (lamp.sending) return; // a send is already in flight; it'll re-check on completion
|
||||
if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP
|
||||
const aux = this.#resolveAux(lamp.controllerId);
|
||||
if (!aux) return;
|
||||
const target = lamp.desiredOn;
|
||||
lamp.sending = true;
|
||||
void aux
|
||||
.setAux(lamp.spec.relay, target)
|
||||
.then(() => {
|
||||
lamp.confirmedOn = target;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// Leave confirmedOn unchanged so the next pump retries this state. Never escalates.
|
||||
this.#logger.error(`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
lamp.sending = false;
|
||||
// Desired state may have changed (or the send failed) while we were busy —
|
||||
// re-pump to converge. This is what makes the final state authoritative.
|
||||
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(lamp);
|
||||
});
|
||||
}
|
||||
|
||||
/** Build the live aux-output adapter for a controller, or null (logged once). */
|
||||
#auxFromRegistry(controllerId: string): AuxOutputDevice | null {
|
||||
const row = this.#db.select().from(devices).where(eq(devices.id, controllerId)).get();
|
||||
if (!row) return null;
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) return null;
|
||||
let device: unknown;
|
||||
try {
|
||||
device = driver.create(row.config as never);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!hasAuxOutput(device)) {
|
||||
if (!this.#warned.has(controllerId)) {
|
||||
this.#warned.add(controllerId);
|
||||
this.#logger.warn(`button-light: controller ${controllerId} (${row.driverId}) has no aux-output — lamp ignored`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
/** Unsubscribe, stop all blink timers, and best-effort drive every lamp OFF. */
|
||||
stop(): void {
|
||||
this.#unsubInput?.();
|
||||
this.#unsubLane?.();
|
||||
this.#unsubInput = null;
|
||||
this.#unsubLane = null;
|
||||
for (const lamp of this.#lamps.values()) {
|
||||
if (lamp.blink) {
|
||||
clearInterval(lamp.blink);
|
||||
lamp.blink = null;
|
||||
}
|
||||
// Best-effort fail-OFF on shutdown.
|
||||
this.#finalOff(lamp);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired
|
||||
* OFF and pump. The serialized worker still applies, so this can't collide with an
|
||||
* in-flight send — it converges to OFF. */
|
||||
#finalOff(lamp: LampState): void {
|
||||
lamp.desiredOn = false;
|
||||
this.#pump(lamp);
|
||||
}
|
||||
|
||||
/** Test seam: current high-level state being rendered for a lamp (controller + relay).
|
||||
* `relay` defaults to the controller's only/first alert relay for single-lamp tests. */
|
||||
stateOf(controllerId: string, relay?: number): LightState | null {
|
||||
return this.#lamp(controllerId, relay)?.rendered ?? null;
|
||||
}
|
||||
|
||||
/** Test seam: the state last CONFIRMED on the device for a lamp (after a successful
|
||||
* send). null = unknown / nothing sent yet. `relay` defaults to the only alert relay. */
|
||||
confirmedOf(controllerId: string, relay?: number): boolean | null {
|
||||
return this.#lamp(controllerId, relay)?.confirmedOn ?? null;
|
||||
}
|
||||
|
||||
/** Resolve a lamp by controller + relay. When `relay` is omitted, returns the
|
||||
* controller's single lamp (the common single-alert case); ambiguous if several. */
|
||||
#lamp(controllerId: string, relay?: number): LampState | undefined {
|
||||
if (relay != null) return this.#lamps.get(lampKey(controllerId, relay));
|
||||
for (const lamp of this.#lamps.values()) if (lamp.controllerId === controllerId) return lamp;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Composite key for the lamp map (a controller may carry several alert relays). */
|
||||
function lampKey(controllerId: string, relay: number): string {
|
||||
return `${controllerId}:${relay}`;
|
||||
}
|
||||
|
||||
/** Build a controller row's live aux device (exported for reuse/tests). */
|
||||
export function buildAux(db: Db, row: DeviceRow): AuxOutputDevice | null {
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) return null;
|
||||
try {
|
||||
const device = driver.create(row.config as never);
|
||||
return hasAuxOutput(device) ? device : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,28 @@ export interface LaneStatusEvent {
|
||||
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
|
||||
}
|
||||
|
||||
/** A plate was RECOGNIZED for a session AFTER its entry/exit event already shipped. Plate
|
||||
* recognition is async/advisory (a vision round-trip off the snapshot), so it lands a
|
||||
* moment after the signed event — too late for the event's own WS push to carry it. This
|
||||
* notifies the booth so it can fill in the plate badge on the already-rendered feed row /
|
||||
* active session in place, no refresh. Advisory; never touches the signed ledger. See
|
||||
* snapshot.ts (recognizePlate) + event-enrich.ts. */
|
||||
export interface PlateRecognizedEvent {
|
||||
readonly identity: string; // the session identity the plate is tied to
|
||||
readonly plate: string; // normalized plate text (trimmed, upper)
|
||||
readonly direction: "entry" | "exit";
|
||||
}
|
||||
|
||||
/** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the
|
||||
* entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has
|
||||
* confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink:
|
||||
* radar-present + camera-not-busy. Drives the booth's barrier light blink. Advisory only —
|
||||
* it gates nothing. See wiki/concepts/button-light-indicator.md. */
|
||||
export interface LanePresenceEvent {
|
||||
readonly entry: boolean; // true = a presence input on an entry barrier is active
|
||||
readonly exit: boolean; // true = a presence input on an exit barrier is active
|
||||
}
|
||||
|
||||
class DeviceEventBus extends EventEmitter {
|
||||
emitInput(event: DeviceInputEvent): void {
|
||||
this.emit("input", event);
|
||||
@@ -148,6 +170,26 @@ class DeviceEventBus extends EventEmitter {
|
||||
this.on("lane-status", cb);
|
||||
return () => this.off("lane-status", cb);
|
||||
}
|
||||
|
||||
/** Emitted whenever a lane's RADAR presence CHANGES (a presence input shorted/cleared
|
||||
* at an entry/exit barrier). Drives the booth barrier light's blink. Advisory only. */
|
||||
emitLanePresence(event: LanePresenceEvent): void {
|
||||
this.emit("lane-presence", event);
|
||||
}
|
||||
onLanePresence(cb: (event: LanePresenceEvent) => void): () => void {
|
||||
this.on("lane-presence", cb);
|
||||
return () => this.off("lane-presence", cb);
|
||||
}
|
||||
|
||||
/** Emitted when an async plate recognition completes for a session (after its event
|
||||
* already shipped). Lets the booth backfill the plate badge in place. Advisory only. */
|
||||
emitPlateRecognized(event: PlateRecognizedEvent): void {
|
||||
this.emit("plate-recognized", event);
|
||||
}
|
||||
onPlateRecognized(cb: (event: PlateRecognizedEvent) => void): () => void {
|
||||
this.on("plate-recognized", cb);
|
||||
return () => this.off("plate-recognized", cb);
|
||||
}
|
||||
}
|
||||
|
||||
/** Process-wide device event bus. */
|
||||
|
||||
@@ -39,7 +39,12 @@ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
|
||||
return d;
|
||||
}
|
||||
case "access": {
|
||||
const dirs = new Set(relaysOf(row).map((r) => r.direction));
|
||||
// Only barrier relays carry a role direction; alert (radarAlert) relays don't.
|
||||
const dirs = new Set(
|
||||
relaysOf(row)
|
||||
.map((r) => r.direction)
|
||||
.filter((d): d is "entry" | "exit" | "both" => d !== "radarAlert"),
|
||||
);
|
||||
if (dirs.size === 0) return null;
|
||||
if (dirs.size > 1) return "mixed";
|
||||
const only = [...dirs][0]; // entry | exit | both
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { devices, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { inputsOf, relayForButton, relayForPresence } from "./device-resolve.js";
|
||||
|
||||
// device-resolve: the input resolution layer. Inputs live in config.inputs[] (the first-class
|
||||
// model); a pre-inputs[] controller is back-compat-synthesized from the legacy per-relay
|
||||
// button/presenceInput fields. relayForButton/relayForPresence must resolve IDENTICALLY from
|
||||
// either shape, so an exit radar = just another presence row.
|
||||
|
||||
let db: Db;
|
||||
const CTL = "ctl-1";
|
||||
|
||||
function seed(config: Record<string, unknown>): void {
|
||||
({ db } = createTestDb());
|
||||
db.insert(devices).values({ id: CTL, category: "access", driverId: "dingtian", config, enabled: true }).run();
|
||||
}
|
||||
|
||||
describe("inputsOf back-compat synth", () => {
|
||||
it("synthesizes inputs[] from legacy relay button/presence fields", () => {
|
||||
seed({
|
||||
relays: [
|
||||
{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "radar", presenceActiveLow: true },
|
||||
{ relay: 2, direction: "exit" },
|
||||
],
|
||||
});
|
||||
const row = db.select().from(devices).get()!;
|
||||
const inputs = inputsOf(row);
|
||||
expect(inputs).toEqual([
|
||||
{ input: 1, role: "button", relay: 1, cooldownSec: undefined },
|
||||
{ input: 2, role: "presence", relay: 1, kind: "radar", activeLow: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers an explicit inputs[] over the legacy fields", () => {
|
||||
seed({
|
||||
relays: [{ relay: 1, direction: "entry", button: 9 /* legacy ignored */ }],
|
||||
inputs: [{ input: 1, role: "button", relay: 1 }],
|
||||
});
|
||||
const row = db.select().from(devices).get()!;
|
||||
expect(inputsOf(row)).toEqual([{ input: 1, role: "button", relay: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("relayForButton / relayForPresence", () => {
|
||||
it("resolves a button + presence from inputs[]", () => {
|
||||
seed({
|
||||
relays: [{ relay: 1, direction: "entry" }],
|
||||
inputs: [
|
||||
{ input: 1, role: "button", relay: 1 },
|
||||
{ input: 2, role: "presence", relay: 1, kind: "radar" },
|
||||
],
|
||||
});
|
||||
const byBtn = relayForButton(db, CTL, 1);
|
||||
expect(byBtn).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2, presenceKind: "radar" });
|
||||
const byPres = relayForPresence(db, CTL, 2);
|
||||
expect(byPres).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2 });
|
||||
});
|
||||
|
||||
it("resolves IDENTICALLY from the legacy shape (no inputs[])", () => {
|
||||
seed({ relays: [{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "loop" }] });
|
||||
expect(relayForButton(db, CTL, 1)).toMatchObject({ relay: 1, presenceInput: 2, presenceKind: "loop" });
|
||||
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1, presenceInput: 2 });
|
||||
});
|
||||
|
||||
it("resolves an EXIT presence row to the exit relay (the exit radar)", () => {
|
||||
seed({
|
||||
relays: [
|
||||
{ relay: 1, direction: "entry" },
|
||||
{ relay: 2, direction: "exit" },
|
||||
],
|
||||
inputs: [
|
||||
{ input: 2, role: "presence", relay: 1, kind: "radar" }, // entry radar
|
||||
{ input: 5, role: "presence", relay: 2, kind: "radar" }, // exit radar
|
||||
],
|
||||
});
|
||||
// NOTE: relayForPresence only gates entry/both relays (transient entry). The exit radar
|
||||
// resolves to null HERE (the exit barrier has no entry gate) — but it's still a valid
|
||||
// inputs[] row the lamp can trigger on. The entry radar resolves to relay 1.
|
||||
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1 });
|
||||
expect(relayForPresence(db, CTL, 5)).toBeNull(); // exit relay isn't a transient-entry gate
|
||||
});
|
||||
|
||||
it("a button on an exit-only relay is not a transient-entry trigger", () => {
|
||||
seed({
|
||||
relays: [{ relay: 2, direction: "exit" }],
|
||||
inputs: [{ input: 1, role: "button", relay: 2 }],
|
||||
});
|
||||
expect(relayForButton(db, CTL, 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -10,35 +10,74 @@ export type Direction = "entry" | "exit" | "both";
|
||||
/** A concrete flow a credential/button drives (never "both"). */
|
||||
export type FlowDirection = "entry" | "exit";
|
||||
|
||||
/** One relay on an access controller: which barrier it opens, in which direction,
|
||||
* and (optionally) the input terminals its entry button + presence loop are wired to. */
|
||||
/** The EVENT a relay reacts to. The barrier events (entry/exit/both) `pulseOpen`; the
|
||||
* `radarAlert` event drives a non-barrier alert lamp (blink while the trigger input is
|
||||
* active, locked SOLID by the camera). A relay is "when EVENT X happens, do its action" —
|
||||
* the action is implied by the event. See wiki/concepts/button-light-indicator.md. */
|
||||
export type RelayEvent = Direction | "radarAlert";
|
||||
|
||||
/** What a controller input terminal MEANS. `button` = a transient-entry button; `presence`
|
||||
* = a one-car-one-ticket sensor (induction loop or radar); `alertTrigger` = the edge that
|
||||
* starts a `radarAlert` lamp blinking. See wiki/concepts/entry-double-press.md. */
|
||||
export type InputRole = "button" | "presence" | "alertTrigger";
|
||||
|
||||
/** One INPUT terminal the host reads, as a first-class citizen (the twin of RelaySpec).
|
||||
* An exit radar is just another `presence` row serving the exit relay. */
|
||||
export interface InputSpec {
|
||||
/** 1-based input terminal the host reads. */
|
||||
readonly input: number;
|
||||
readonly role: InputRole;
|
||||
/** The barrier relay this input serves. Required for `button`/`presence` (the gate is
|
||||
* keyed per relay); optional for `alertTrigger` (a standalone lamp trigger). */
|
||||
readonly relay?: number;
|
||||
/** `presence` only — induction LOOP or RADAR. Label only (gate is identical). Default loop. */
|
||||
readonly kind?: "loop" | "radar";
|
||||
/** This terminal is ACTIVE-LOW (idles HIGH) — e.g. a radar wired opposite the button.
|
||||
* Maps to the driver's per-input `inputActiveLow`. See wiki/entities/hikvision-radar.md. */
|
||||
readonly activeLow?: boolean;
|
||||
/** `button` only — presence-less fallback: suppress repeat presses for N seconds after a
|
||||
* ticket. A timer (mitigation, not a guarantee); used when no `presence` row serves this relay. */
|
||||
readonly cooldownSec?: number;
|
||||
}
|
||||
|
||||
/** One relay on an access controller: the event it reacts to. Input wiring (button,
|
||||
* presence) lives in `config.inputs[]`; the LEGACY per-relay fields below are still read
|
||||
* (back-compat) but no longer written by the UI. */
|
||||
export interface RelaySpec {
|
||||
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
|
||||
readonly relay: number;
|
||||
readonly direction: Direction;
|
||||
/** 1-based input terminal of the entry button that fires this relay (transient
|
||||
* entry). Absent = no button at this barrier (subscriber/reader-driven only). */
|
||||
/** The event this relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` →
|
||||
* drive an alert lamp (blink + camera-lock) via `setAux`, NEVER pulseOpen. */
|
||||
readonly direction: RelayEvent;
|
||||
|
||||
// ── LEGACY input fields (read-only back-compat; superseded by config.inputs[]) ──
|
||||
// Pre-inputs[] configs wired the entry button + presence sensor here. `inputsOf()`
|
||||
// synthesizes InputSpec rows from these when a controller has no `inputs[]` yet.
|
||||
readonly button?: number;
|
||||
/**
|
||||
* Anti-double-press for the transient entry button (one car must yield ONE ticket).
|
||||
* Two modes, chosen by what barrier feedback exists at this lane:
|
||||
* - PRESENCE (preferred, when a vehicle loop is wired): `presenceInput` = the
|
||||
* 1-based input terminal of an induction loop / barrier presence signal on THIS
|
||||
* controller. A press prints only while a car is present, and no second ticket
|
||||
* issues until the loop CLEARS (car drove in) and a new car re-occupies it. This
|
||||
* makes one-car-one-ticket physical.
|
||||
* - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses
|
||||
* on this relay for N seconds after a ticket prints. A pure timer — mitigation,
|
||||
* not a guarantee. Used when `presenceInput` is unset (or as a secondary guard).
|
||||
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
|
||||
*/
|
||||
readonly presenceInput?: number;
|
||||
readonly presenceKind?: "loop" | "radar";
|
||||
readonly presenceActiveLow?: boolean;
|
||||
readonly entryCooldownSec?: number;
|
||||
|
||||
// ── radarAlert-only (direction === "radarAlert") ──
|
||||
// A non-barrier indicator lamp wired to this (spare) relay — e.g. the entry button's
|
||||
// 12 V light. Driven by the server ButtonLightController off its trigger input vs. the
|
||||
// camera lane status: blink while the trigger is active + lane free, SOLID once the
|
||||
// camera confirms a car, OFF otherwise. NOT a barrier (uses setAux, never pulseOpen).
|
||||
/** 1-based input terminal whose active edge starts the blink (the radar). */
|
||||
readonly triggerInput?: number;
|
||||
/** Which lane's camera locks this lamp SOLID — the entry or the exit camera. Default
|
||||
* "entry". An exit radar's lamp must lock on the EXIT camera. */
|
||||
readonly lockLane?: FlowDirection;
|
||||
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
|
||||
readonly blinkOnMs?: number;
|
||||
readonly blinkOffMs?: number;
|
||||
}
|
||||
|
||||
/** Access controller config (the `relays[]` map + connection fields). */
|
||||
/** Access controller config (the `relays[]` + `inputs[]` maps + connection fields). */
|
||||
interface AccessConfig {
|
||||
readonly relays?: RelaySpec[];
|
||||
readonly inputs?: InputSpec[];
|
||||
readonly [k: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -60,9 +99,11 @@ export interface ResolvedRelay {
|
||||
readonly controller: DeviceRow;
|
||||
readonly relay: number;
|
||||
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;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -83,9 +124,49 @@ export function relaysOf(row: DeviceRow): RelaySpec[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a button press to the relay it fires: the access controller with this
|
||||
* deviceId, and the relay whose `button` terminal matches the pressed input. Only
|
||||
* an ENTRY (or both) relay is a transient-entry trigger. Returns null otherwise.
|
||||
* The INPUT terminals declared on an access controller — the back-compat keystone. Returns
|
||||
* `config.inputs[]` when present; otherwise SYNTHESIZES InputSpec rows from the LEGACY
|
||||
* per-relay fields (`relays[].button` → a `button` row; `relays[].presenceInput` → a
|
||||
* `presence` row) so a pre-inputs[] controller resolves identically. Everything that reads
|
||||
* inputs goes through here, so the legacy fold lives in exactly one place.
|
||||
*/
|
||||
export function inputsOf(row: DeviceRow): InputSpec[] {
|
||||
const cfg = row.config as AccessConfig;
|
||||
if (Array.isArray(cfg.inputs) && cfg.inputs.length > 0) return cfg.inputs;
|
||||
const synth: InputSpec[] = [];
|
||||
for (const r of relaysOf(row)) {
|
||||
if (typeof r.button === "number") {
|
||||
synth.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec });
|
||||
}
|
||||
if (typeof r.presenceInput === "number") {
|
||||
synth.push({
|
||||
input: r.presenceInput,
|
||||
role: "presence",
|
||||
relay: r.relay,
|
||||
kind: r.presenceKind ?? "loop",
|
||||
activeLow: r.presenceActiveLow,
|
||||
});
|
||||
}
|
||||
}
|
||||
return synth;
|
||||
}
|
||||
|
||||
/** The barrier RelaySpec a `button`/`presence` input row serves (its `relay`), or null —
|
||||
* only entry/both relays gate transient entry. Narrows `direction` to a barrier Direction. */
|
||||
function barrierForInput(row: DeviceRow, spec: InputSpec): (RelaySpec & { direction: Direction }) | null {
|
||||
if (typeof spec.relay !== "number") return null;
|
||||
const relay = relaysOf(row).find((r) => r.relay === spec.relay);
|
||||
if (!relay) return null;
|
||||
if (relay.direction !== "entry" && relay.direction !== "both") return null;
|
||||
return { ...relay, direction: relay.direction };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a button press to the relay it fires: the access controller with this deviceId,
|
||||
* and the relay served by the `button` input on this terminal (via inputsOf). Only an
|
||||
* ENTRY (or both) relay is a transient-entry trigger. Carries the one-car-one-ticket
|
||||
* config (presence input + cooldown) for that relay so the entry flow can enforce it.
|
||||
* Returns null otherwise.
|
||||
*/
|
||||
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
||||
const row = db
|
||||
@@ -94,23 +175,28 @@ export function relayForButton(db: Db, controllerId: string, terminal: number):
|
||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||
.get();
|
||||
if (!row || !row.enabled) return null;
|
||||
const spec = relaysOf(row).find((r) => r.button === terminal);
|
||||
if (!spec) return null;
|
||||
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
||||
const inputs = inputsOf(row);
|
||||
const btn = inputs.find((i) => i.role === "button" && i.input === terminal);
|
||||
if (!btn) return null;
|
||||
const relay = barrierForInput(row, btn);
|
||||
if (!relay) return null;
|
||||
// The presence sensor (if any) serving the SAME relay supplies the gate.
|
||||
const presence = inputs.find((i) => i.role === "presence" && i.relay === relay.relay);
|
||||
return {
|
||||
controller: row,
|
||||
relay: spec.relay,
|
||||
direction: spec.direction,
|
||||
presenceInput: spec.presenceInput,
|
||||
entryCooldownSec: spec.entryCooldownSec,
|
||||
relay: relay.relay,
|
||||
direction: relay.direction,
|
||||
presenceInput: presence?.input,
|
||||
presenceKind: presence?.kind ?? "loop",
|
||||
entryCooldownSec: btn.cooldownSec,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PRESENCE-LOOP input edge to the entry relay it gates: the controller with
|
||||
* this deviceId, and the relay whose `presenceInput` terminal matches the fired input.
|
||||
* Lets the entry flow track "a car is physically at this entry barrier" so it issues
|
||||
* exactly one ticket per car. Only entry/both relays gate transient entry. Null otherwise.
|
||||
* Resolve a PRESENCE input edge to the entry relay it gates: the controller with this
|
||||
* deviceId, and the relay served by the `presence` input on this terminal. Lets the entry
|
||||
* flow track "a car is physically at this entry barrier" so it issues exactly one ticket
|
||||
* per car. Only entry/both relays gate transient entry. Null otherwise.
|
||||
*/
|
||||
export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
||||
const row = db
|
||||
@@ -119,10 +205,43 @@ export function relayForPresence(db: Db, controllerId: string, terminal: number)
|
||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||
.get();
|
||||
if (!row || !row.enabled) return null;
|
||||
const spec = relaysOf(row).find((r) => r.presenceInput === terminal);
|
||||
if (!spec) return null;
|
||||
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
||||
return { controller: row, relay: spec.relay, direction: spec.direction };
|
||||
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
|
||||
if (!presence) return null;
|
||||
const relay = barrierForInput(row, presence);
|
||||
if (!relay) return null;
|
||||
return {
|
||||
controller: row,
|
||||
relay: relay.relay,
|
||||
direction: relay.direction,
|
||||
presenceInput: presence.input,
|
||||
presenceKind: presence.kind ?? "loop",
|
||||
};
|
||||
}
|
||||
|
||||
/** The alert (radarAlert) relay rows declared on an access controller — the lamps the
|
||||
* ButtonLightController drives. Each is a `relays[]` row whose event is `radarAlert`. */
|
||||
export function alertRelaysOf(row: DeviceRow): RelaySpec[] {
|
||||
return relaysOf(row).filter((r) => r.direction === "radarAlert" && typeof r.relay === "number");
|
||||
}
|
||||
|
||||
/**
|
||||
* Which LANE a presence input belongs to — for the booth's barrier-light blink (advisory).
|
||||
* Unlike `relayForPresence` (entry-gated, for the one-car-one-ticket gate), this resolves a
|
||||
* presence input on ANY barrier: entry/both → "entry", exit → "exit". Returns null if the
|
||||
* terminal isn't a presence input on a barrier relay. See lane-presence.ts.
|
||||
*/
|
||||
export function presenceLaneOf(db: Db, controllerId: string, terminal: number): FlowDirection | null {
|
||||
const row = db
|
||||
.select()
|
||||
.from(devices)
|
||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||
.get();
|
||||
if (!row || !row.enabled) return null;
|
||||
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
|
||||
if (!presence || typeof presence.relay !== "number") return null;
|
||||
const relay = relaysOf(row).find((r) => r.relay === presence.relay);
|
||||
if (!relay) return null;
|
||||
return relay.direction === "exit" ? "exit" : relay.direction === "radarAlert" ? null : "entry";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,7 +263,10 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
|
||||
.get();
|
||||
if (controller && controller.enabled) {
|
||||
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
|
||||
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
||||
// Only a barrier relay opens; an alert (radarAlert) relay is never a barrier.
|
||||
if (spec && spec.direction !== "radarAlert") {
|
||||
return { controller, relay: spec.relay, direction: spec.direction };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -164,7 +286,8 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
|
||||
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
|
||||
for (const controller of accessRows(db)) {
|
||||
const spec = relaysOf(controller).find(
|
||||
(r) => r.direction === direction || r.direction === "both",
|
||||
(r): r is RelaySpec & { direction: Direction } =>
|
||||
r.direction === direction || r.direction === "both",
|
||||
);
|
||||
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { eq, devices, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { LanePresence } from "./lane-presence.js";
|
||||
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
|
||||
import { silentLogger } from "./test-helpers.js";
|
||||
|
||||
// LanePresence: a vehicle-presence INPUT edge (loop/radar) on an entry/exit barrier marks
|
||||
// that lane "present" — the same signal that blinks the physical button lamp (relay 3). It
|
||||
// resolves the edge via relayForPresence (the SAME path relay 3 + the entry gate use), and
|
||||
// emits a lane-presence change only when a lane's present/clear state actually flips.
|
||||
|
||||
let db: Db;
|
||||
const CTL = "ctl-1";
|
||||
const ENTRY_RADAR = 2;
|
||||
const EXIT_RADAR = 5;
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
// Entry relay 1 with a radar on I2; exit relay 2 with a radar on I5.
|
||||
db.insert(devices).values({
|
||||
id: CTL,
|
||||
category: "access",
|
||||
driverId: "dingtian",
|
||||
config: {
|
||||
host: "10.0.0.5",
|
||||
relays: [
|
||||
{ relay: 1, direction: "entry" },
|
||||
{ relay: 2, direction: "exit" },
|
||||
],
|
||||
inputs: [
|
||||
{ input: ENTRY_RADAR, role: "presence", relay: 1, kind: "radar" },
|
||||
{ input: EXIT_RADAR, role: "presence", relay: 2, kind: "radar" },
|
||||
],
|
||||
},
|
||||
enabled: true,
|
||||
}).run();
|
||||
});
|
||||
|
||||
function edge(input: number, on: boolean): void {
|
||||
const e: DeviceInputEvent = {
|
||||
driverId: "dingtian",
|
||||
deviceId: CTL,
|
||||
input,
|
||||
edge: on ? "on" : "off",
|
||||
at: new Date().toISOString(),
|
||||
source: "poll",
|
||||
};
|
||||
deviceEvents.emitInput(e);
|
||||
}
|
||||
|
||||
/** Collect lane-presence emissions while running `fn`. */
|
||||
function capture(fn: () => void): LanePresenceEvent[] {
|
||||
const seen: LanePresenceEvent[] = [];
|
||||
const off = deviceEvents.onLanePresence((p) => seen.push(p));
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe("LanePresence", () => {
|
||||
it("starts clear and snapshots clear", () => {
|
||||
const lp = new LanePresence(db, silentLogger());
|
||||
lp.start();
|
||||
expect(lp.snapshot()).toEqual({ entry: false, exit: false });
|
||||
lp.stop();
|
||||
});
|
||||
|
||||
it("an ENTRY radar edge marks the entry lane present, then clears", () => {
|
||||
const lp = new LanePresence(db, silentLogger());
|
||||
lp.start();
|
||||
const events = capture(() => {
|
||||
edge(ENTRY_RADAR, true);
|
||||
edge(ENTRY_RADAR, false);
|
||||
});
|
||||
expect(events).toEqual([
|
||||
{ entry: true, exit: false },
|
||||
{ entry: false, exit: false },
|
||||
]);
|
||||
lp.stop();
|
||||
});
|
||||
|
||||
it("an EXIT radar edge marks the exit lane independently", () => {
|
||||
const lp = new LanePresence(db, silentLogger());
|
||||
lp.start();
|
||||
const events = capture(() => {
|
||||
edge(EXIT_RADAR, true);
|
||||
});
|
||||
expect(events).toEqual([{ entry: false, exit: true }]);
|
||||
expect(lp.snapshot()).toEqual({ entry: false, exit: true });
|
||||
lp.stop();
|
||||
});
|
||||
|
||||
it("de-dupes: a second 'on' from another presence input on the same lane emits once", () => {
|
||||
// Two radars both serving the entry lane.
|
||||
db.update(devices)
|
||||
.set({
|
||||
config: {
|
||||
host: "10.0.0.5",
|
||||
relays: [{ relay: 1, direction: "entry" }],
|
||||
inputs: [
|
||||
{ input: 2, role: "presence", relay: 1, kind: "radar" },
|
||||
{ input: 3, role: "presence", relay: 1, kind: "radar" },
|
||||
],
|
||||
},
|
||||
})
|
||||
.where(eq(devices.id, CTL))
|
||||
.run();
|
||||
const lp = new LanePresence(db, silentLogger());
|
||||
lp.start();
|
||||
const events = capture(() => {
|
||||
edge(2, true); // entry → present (emit)
|
||||
edge(3, true); // still present (no emit — same lane)
|
||||
edge(2, false); // still present via I3 (no emit)
|
||||
edge(3, false); // now clear (emit)
|
||||
});
|
||||
expect(events).toEqual([
|
||||
{ entry: true, exit: false },
|
||||
{ entry: false, exit: false },
|
||||
]);
|
||||
lp.stop();
|
||||
});
|
||||
|
||||
it("ignores a non-presence input (e.g. a button terminal)", () => {
|
||||
db.update(devices)
|
||||
.set({
|
||||
config: {
|
||||
host: "10.0.0.5",
|
||||
relays: [{ relay: 1, direction: "entry" }],
|
||||
inputs: [{ input: 1, role: "button", relay: 1 }],
|
||||
},
|
||||
})
|
||||
.where(eq(devices.id, CTL))
|
||||
.run();
|
||||
const lp = new LanePresence(db, silentLogger());
|
||||
lp.start();
|
||||
const events = capture(() => edge(1, true));
|
||||
expect(events).toEqual([]);
|
||||
lp.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Db } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
|
||||
import { presenceLaneOf } from "./device-resolve.js";
|
||||
|
||||
// Per-lane RADAR presence for the booth's barrier lights. A vehicle-presence INPUT
|
||||
// (loop/radar) shorted at an entry/exit barrier means "something is in the lane vicinity"
|
||||
// BEFORE the camera confirms a vehicle. This is the SAME signal that makes the physical
|
||||
// button lamp (relay 3) blink — see button-light.ts (#onInput) — so the on-screen light
|
||||
// and the lamp stay in lockstep: both react to a presence edge resolved the SAME way
|
||||
// (relayForPresence, on an entry/both relay). ADVISORY ONLY: it gates nothing.
|
||||
//
|
||||
// A radar serving an entry (or "both") barrier marks the ENTRY lane present; an exit radar
|
||||
// marks EXIT. The lane is resolved via `presenceLaneOf` (direction-agnostic — unlike the
|
||||
// entry-gated `relayForPresence` the one-car-one-ticket gate uses), so both lanes blink.
|
||||
|
||||
export class LanePresence {
|
||||
readonly #db: Db;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
/** Active presence terminals per lane, keyed `${deviceId}:${input}` (several radars may
|
||||
* serve one lane). A lane is "present" while its set is non-empty. */
|
||||
readonly #entry = new Set<string>();
|
||||
readonly #exit = new Set<string>();
|
||||
#unsub: (() => void) | null = null;
|
||||
|
||||
constructor(db: Db, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Subscribe to presence input edges. */
|
||||
start(): void {
|
||||
this.#unsub = deviceEvents.onInput((e) => this.#onInput(e));
|
||||
}
|
||||
|
||||
/** Current snapshot (for the WS hello). */
|
||||
snapshot(): LanePresenceEvent {
|
||||
return { entry: this.#entry.size > 0, exit: this.#exit.size > 0 };
|
||||
}
|
||||
|
||||
#onInput(e: DeviceInputEvent): void {
|
||||
const lane = presenceLaneOf(this.#db, e.deviceId, e.input);
|
||||
if (!lane) return; // not a presence terminal on a barrier relay
|
||||
const key = `${e.deviceId}:${e.input}`;
|
||||
const set = lane === "entry" ? this.#entry : this.#exit;
|
||||
const before = set.size > 0;
|
||||
if (e.edge === "on") set.add(key);
|
||||
else set.delete(key);
|
||||
const after = set.size > 0;
|
||||
if (before !== after) {
|
||||
this.#logger.info(`lane-presence: ${lane} -> ${after ? "present" : "clear"}`);
|
||||
deviceEvents.emitLanePresence(this.snapshot());
|
||||
}
|
||||
}
|
||||
|
||||
/** Unsubscribe on shutdown. */
|
||||
stop(): void {
|
||||
this.#unsub?.();
|
||||
this.#unsub = null;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,13 @@ interface ThemeBody {
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
// UI font scale: percent of base, clamped to [80, 160] in steps of 10. Integer percent.
|
||||
const FONT_SCALE_MIN = 80;
|
||||
const FONT_SCALE_MAX = 160;
|
||||
interface FontScaleBody {
|
||||
fontScale: number;
|
||||
}
|
||||
|
||||
// Self-service profile: a signed-in user edits their OWN display name + email. This is
|
||||
// NOT the admin user-management path (routes/users.ts) — it only ever touches the caller
|
||||
// (req.user.sub), needs no `user:*` permission, and can't change username, role, or any
|
||||
@@ -67,6 +74,7 @@ function sessionView(
|
||||
roleId: string;
|
||||
language: string;
|
||||
theme: string;
|
||||
fontScale: number;
|
||||
fullName?: string | null;
|
||||
email?: string | null;
|
||||
},
|
||||
@@ -81,6 +89,7 @@ function sessionView(
|
||||
permissions,
|
||||
language: user.language,
|
||||
theme: user.theme,
|
||||
fontScale: user.fontScale,
|
||||
fullName: user.fullName ?? null,
|
||||
email: user.email ?? null,
|
||||
};
|
||||
@@ -171,6 +180,23 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
},
|
||||
);
|
||||
|
||||
// Change MY own UI font scale (any signed-in user). Percent of base, clamped to
|
||||
// [80, 160] in steps of 10. Persisted like `theme`, restored on the next login.
|
||||
app.put<{ Body: FontScaleBody }>(
|
||||
"/api/auth/font-scale",
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const raw = req.body?.fontScale;
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return reply.code(400).send({ error: "fontScale must be a number" });
|
||||
}
|
||||
// Snap to a 10-step and clamp to the allowed band (defensive — the UI already does).
|
||||
const fontScale = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round(raw / 10) * 10));
|
||||
await db.update(users).set({ fontScale }).where(eq(users.id, req.user.sub)).run();
|
||||
return { fontScale };
|
||||
},
|
||||
);
|
||||
|
||||
// Edit MY own display name / email (any signed-in user; no permission needed — it only
|
||||
// touches the caller). Cannot change username or role — those stay admin-only (users.ts).
|
||||
app.put<{ Body: ProfileBody }>(
|
||||
|
||||
@@ -128,3 +128,57 @@ describe("PUT /api/auth/password (self-service)", () => {
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/font-scale (self-service)", () => {
|
||||
it("persists a valid scale and returns it on the next session", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "f1", roleId: "viewer", permissions: [] });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/auth/font-scale",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { fontScale: 120 },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().fontScale).toBe(120);
|
||||
// Persisted to the caller's row…
|
||||
expect(db.select().from(users).where(eq(users.username, "f1")).get()?.fontScale).toBe(120);
|
||||
// …and surfaced on /me (the session bootstrap).
|
||||
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
|
||||
expect(me.json().fontScale).toBe(120);
|
||||
});
|
||||
|
||||
it("clamps + snaps out-of-band / off-step values", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "f2", roleId: "viewer", permissions: [] });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const tooBig = await app.inject({
|
||||
method: "PUT", url: "/api/auth/font-scale",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { fontScale: 999 },
|
||||
});
|
||||
expect(tooBig.json().fontScale).toBe(160); // clamped to max
|
||||
const offStep = await app.inject({
|
||||
method: "PUT", url: "/api/auth/font-scale",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { fontScale: 113 },
|
||||
});
|
||||
expect(offStep.json().fontScale).toBe(110); // snapped to the 10-step
|
||||
});
|
||||
|
||||
it("rejects a non-numeric scale (400)", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "f3", roleId: "viewer", permissions: [] });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/auth/font-scale",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { fontScale: "big" },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("defaults to 100 for a fresh user", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "f4", roleId: "viewer", permissions: [] });
|
||||
const { cookie } = await login(app, username, password);
|
||||
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
|
||||
expect(me.json().fontScale).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { devices, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { storedSecrets } from "./setup.js";
|
||||
|
||||
// storedSecrets re-merges a device's machine-only secrets (relayPassword/pushPassword)
|
||||
// into a test/save — but ONLY when the submitted config addresses the SAME device at the
|
||||
// SAME host/port. This guards against a redirected probe exfiltrating the secret to an
|
||||
// attacker host (an admin keeps a real device id but swaps the host). The booth operator
|
||||
// is the threat-model adversary, so an authenticated-admin redirect must NOT leak.
|
||||
|
||||
let db: Db;
|
||||
const ID = "ctl-secret";
|
||||
const HOST = "10.0.10.5";
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
db.insert(devices).values({
|
||||
id: ID,
|
||||
category: "access",
|
||||
driverId: "dingtian",
|
||||
config: { host: HOST, binaryPort: 60000, relayPassword: 1996, pushPassword: "p-secret" },
|
||||
enabled: true,
|
||||
}).run();
|
||||
});
|
||||
|
||||
describe("storedSecrets identity guard", () => {
|
||||
it("re-merges secrets when host/port/driver match the stored device", () => {
|
||||
const out = storedSecrets(db, ID, "dingtian", { host: HOST, binaryPort: 60000 });
|
||||
expect(out.relayPassword).toBe(1996);
|
||||
expect(out.pushPassword).toBe("p-secret");
|
||||
});
|
||||
|
||||
it("re-merges when identity fields are OMITTED (fall back to the stored device)", () => {
|
||||
const out = storedSecrets(db, ID, "dingtian", {});
|
||||
expect(out.relayPassword).toBe(1996);
|
||||
});
|
||||
|
||||
it("REFUSES secrets when the host is redirected (exfiltration attempt)", () => {
|
||||
const out = storedSecrets(db, ID, "dingtian", { host: "10.66.66.66", binaryPort: 60000 });
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
|
||||
it("REFUSES secrets when a control port is changed", () => {
|
||||
const out = storedSecrets(db, ID, "dingtian", { host: HOST, binaryPort: 9999 });
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
|
||||
it("REFUSES secrets when the driver doesn't match the stored row", () => {
|
||||
const out = storedSecrets(db, ID, "stub-access", { host: HOST });
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
|
||||
it("returns nothing for an unknown device id", () => {
|
||||
expect(storedSecrets(db, randomUUID(), "dingtian", { host: HOST })).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
isCamera,
|
||||
isDiscoverable,
|
||||
isHardenable,
|
||||
isPrinter,
|
||||
registerBuiltinDrivers,
|
||||
registry,
|
||||
setDeviceLogSink,
|
||||
@@ -36,6 +37,11 @@ interface AssignBody {
|
||||
interface TestBody {
|
||||
driverId: string;
|
||||
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.
|
||||
@@ -54,6 +60,41 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
||||
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
|
||||
* HTTP error to send back. Shared by assign (create) and patch (edit). */
|
||||
type ConfigureOutcome =
|
||||
@@ -249,13 +290,30 @@ export async function setupRoutes(
|
||||
"/api/setup/test",
|
||||
{ preHandler: adminGuard },
|
||||
async (req, reply) => {
|
||||
const { driverId, config } = req.body;
|
||||
const { driverId, config, id } = req.body;
|
||||
const driver = registry.get(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;
|
||||
try {
|
||||
device = registry.create(driverId, config);
|
||||
device = registry.create(driverId, merged as Record<string, string | number | boolean>);
|
||||
} catch (err) {
|
||||
return reply.code(400).send({ error: (err as Error).message });
|
||||
}
|
||||
@@ -334,6 +392,70 @@ export async function setupRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// Print a TEST SLIP on a printer config WITHOUT saving. healthCheck only opens the
|
||||
// transport (TCP connect / USB open) — it proves reachability, NOT that paper feeds
|
||||
// and the head fires. This pushes a real short slip through the device-agnostic
|
||||
// printReport(), so the admin can physically confirm the printer is live (the USB
|
||||
// /dev/usb/lpN path or the network printer). Fail-soft like test-anpr: a print error
|
||||
// is reported, never a 500. Mirrors /test's stored-secret re-merge so an edited
|
||||
// network printer still authenticates.
|
||||
app.post<{ Body: TestBody }>(
|
||||
"/api/setup/test-print",
|
||||
{ preHandler: adminGuard },
|
||||
async (req, reply) => {
|
||||
const { driverId, config, id } = req.body;
|
||||
const driver = registry.get(driverId);
|
||||
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
||||
if (driver.category !== "printer") {
|
||||
return reply.code(400).send({ error: `driver ${driverId} is not a printer` });
|
||||
}
|
||||
|
||||
const merged: Record<string, string | number | boolean | undefined> = { ...config };
|
||||
if (id) {
|
||||
for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) {
|
||||
const sent = merged[k];
|
||||
if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number;
|
||||
}
|
||||
}
|
||||
|
||||
let device;
|
||||
try {
|
||||
device = registry.create(driverId, merged as Record<string, string | number | boolean>);
|
||||
} catch (err) {
|
||||
return reply.code(400).send({ error: (err as Error).message });
|
||||
}
|
||||
if (!isPrinter(device)) {
|
||||
return reply.code(400).send({ error: `driver ${driverId} cannot print` });
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await device.printReport({
|
||||
title: "TEST PRINT",
|
||||
lines: [
|
||||
"Parking System",
|
||||
"Printer test slip",
|
||||
new Date().toLocaleString("sv"), // YYYY-MM-DD HH:MM:SS, locale-stable
|
||||
"",
|
||||
"If you can read this, the",
|
||||
"printer is connected and",
|
||||
"printing correctly.",
|
||||
],
|
||||
});
|
||||
} catch (err) {
|
||||
// The failure we're testing for (paper out, head fault, transport drop) —
|
||||
// report it, don't 500.
|
||||
return reply.send({
|
||||
ok: false,
|
||||
reason: "print-failed",
|
||||
detail: (err as Error).message,
|
||||
tookMs: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
return reply.send({ ok: true, tookMs: Date.now() - startedAt });
|
||||
},
|
||||
);
|
||||
|
||||
// Candidate backend IPs the device can push to, for a given device host. The
|
||||
// wizard pre-fills with the on-subnet one and lets the admin override (matters
|
||||
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
||||
|
||||
@@ -2,10 +2,16 @@ import type { FastifyInstance } from "fastify";
|
||||
import type { Db } from "@parking/db";
|
||||
import type { LedgerEvent } from "@parking/shared";
|
||||
import { roleHasPermissions } from "../auth.js";
|
||||
import { deviceEvents, type LaneStatusEvent } from "../device-events.js";
|
||||
import {
|
||||
deviceEvents,
|
||||
type LaneStatusEvent,
|
||||
type LanePresenceEvent,
|
||||
type PlateRecognizedEvent,
|
||||
} from "../device-events.js";
|
||||
import { enrichEvent } from "../event-enrich.js";
|
||||
import type { DeviceMonitor } from "../device-monitor.js";
|
||||
import type { LaneStatus } from "../lane-status.js";
|
||||
import type { LanePresence } from "../lane-presence.js";
|
||||
import { getOccupancy } from "../occupancy.js";
|
||||
|
||||
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
|
||||
@@ -53,17 +59,26 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
|
||||
}
|
||||
|
||||
type OutMsg =
|
||||
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown; lanes: LaneStatusEvent }
|
||||
| {
|
||||
kind: "hello";
|
||||
occupancy: ReturnType<typeof getOccupancy>;
|
||||
devices: unknown;
|
||||
lanes: LaneStatusEvent;
|
||||
radar: LanePresenceEvent;
|
||||
}
|
||||
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
||||
| { kind: "printer-status"; event: unknown }
|
||||
| { kind: "device-status"; event: unknown }
|
||||
| { kind: "lane-status"; lanes: LaneStatusEvent };
|
||||
| { kind: "lane-status"; lanes: LaneStatusEvent }
|
||||
| { kind: "lane-presence"; radar: LanePresenceEvent }
|
||||
| { kind: "plate-recognized"; plate: PlateRecognizedEvent };
|
||||
|
||||
export async function wsRoutes(
|
||||
app: FastifyInstance,
|
||||
db: Db,
|
||||
deviceMonitor: DeviceMonitor,
|
||||
laneStatus: LaneStatus,
|
||||
lanePresence: LanePresence,
|
||||
): Promise<void> {
|
||||
app.get(
|
||||
"/api/ws",
|
||||
@@ -96,7 +111,13 @@ export async function wsRoutes(
|
||||
|
||||
// Initial snapshot so the client renders immediately, before any event:
|
||||
// occupancy AND the current device-status set (for the footer).
|
||||
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot(), lanes: laneStatus.snapshot() });
|
||||
send({
|
||||
kind: "hello",
|
||||
occupancy: getOccupancy(db),
|
||||
devices: deviceMonitor.snapshot(),
|
||||
lanes: laneStatus.snapshot(),
|
||||
radar: lanePresence.snapshot(),
|
||||
});
|
||||
|
||||
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
||||
// ledger (cheap fold) so the pushed count is always authoritative.
|
||||
@@ -117,12 +138,22 @@ export async function wsRoutes(
|
||||
const offLane = deviceEvents.onLaneStatus((lanes) => {
|
||||
send({ kind: "lane-status", lanes });
|
||||
});
|
||||
// Lane RADAR presence (presence-input edge → barrier-light blink). Advisory.
|
||||
const offPresence = deviceEvents.onLanePresence((radar) => {
|
||||
send({ kind: "lane-presence", radar });
|
||||
});
|
||||
// A late async plate recognition → backfill the badge on the matching feed row. Advisory.
|
||||
const offPlate = deviceEvents.onPlateRecognized((plate) => {
|
||||
send({ kind: "plate-recognized", plate });
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
offLedger();
|
||||
offPrinter();
|
||||
offDevice();
|
||||
offLane();
|
||||
offPresence();
|
||||
offPlate();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
|
||||
import { deviceEvents } from "./device-events.js";
|
||||
import { ButtonLightController } from "./button-light.js";
|
||||
import { EntryFlow } from "./entry-flow.js";
|
||||
import { EventLog } from "./event-log.js";
|
||||
import { ExitFlow } from "./exit-flow.js";
|
||||
@@ -27,6 +28,7 @@ import { roleRoutes } from "./routes/roles.js";
|
||||
import { deviceRoutes } from "./routes/devices.js";
|
||||
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
|
||||
import { LaneStatus } from "./lane-status.js";
|
||||
import { LanePresence } from "./lane-presence.js";
|
||||
import { AnprBridge } from "./anpr-entry.js";
|
||||
import { eventRoutes } from "./routes/events.js";
|
||||
import { reportRoutes } from "./routes/reports.js";
|
||||
@@ -124,6 +126,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
const laneStatus = new LaneStatus(db, app.log);
|
||||
app.addHook("onClose", async () => laneStatus.stop());
|
||||
|
||||
// Per-lane RADAR presence (presence-input edges → barrier-light blink). Mirrors the
|
||||
// physical button lamp (relay 3): the SAME presence signal, surfaced to the booth UI.
|
||||
const lanePresence = new LanePresence(db, app.log);
|
||||
lanePresence.start();
|
||||
app.addHook("onClose", async () => lanePresence.stop());
|
||||
|
||||
// NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read
|
||||
// flows are constructed — because the ANPR bridge they carry depends on the
|
||||
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
|
||||
@@ -168,7 +176,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
|
||||
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
||||
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
||||
await wsRoutes(app, db, deviceMonitor, laneStatus);
|
||||
await wsRoutes(app, db, deviceMonitor, laneStatus, lanePresence);
|
||||
|
||||
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
||||
await snapshotRoutes(app, db);
|
||||
@@ -188,6 +196,13 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
});
|
||||
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
|
||||
// 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,
|
||||
|
||||
@@ -548,8 +548,9 @@ export class ShiftService {
|
||||
"",
|
||||
"-- Arkëtime sipas burimit --",
|
||||
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
|
||||
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
|
||||
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
||||
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
||||
` shitje: ${money(r.subscriptionSalesMinor)} ${cur}`,
|
||||
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
||||
"",
|
||||
"-- Arka --",
|
||||
|
||||
@@ -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,8 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
||||
import { registry, type CameraDevice } from "@parking/devices";
|
||||
import { registry, type CameraDevice, type Snapshot } from "@parking/devices";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
||||
import { deviceEvents } from "./device-events.js";
|
||||
import type { VisionClient } from "./vision-client.js";
|
||||
|
||||
// Camera snapshot capture, fired AFTER the barrier opens and never awaited on the
|
||||
@@ -62,7 +63,9 @@ export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const shot = await camera.captureSnapshot({ direction });
|
||||
// Shared capture: if the ANPR bridge just pulled this camera's frame for the
|
||||
// same vehicle, reuse it instead of a 2nd concurrent GET (which 503s).
|
||||
const shot = await captureSnapshotShared(row.id, camera, { direction });
|
||||
const id: string = randomUUID();
|
||||
db.insert(snapshots)
|
||||
.values({
|
||||
@@ -136,6 +139,9 @@ async function recognizePlate(
|
||||
})
|
||||
.run();
|
||||
logger.info(`anpr plate '${plate}' (${result.plate.confidence.toFixed(3)}) for ${identity}`);
|
||||
// The session's entry/exit event already shipped without this (async) plate — tell the
|
||||
// booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched.
|
||||
deviceEvents.emitPlateRecognized({ identity, plate, direction });
|
||||
} catch (err) {
|
||||
logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`);
|
||||
}
|
||||
@@ -153,6 +159,70 @@ export function buildCamera(row: { driverId: string; config: unknown }): CameraD
|
||||
}
|
||||
}
|
||||
|
||||
// --- shared snapshot capture (one HTTP pull per camera per vehicle) -----------
|
||||
// A Hikvision camera serves /ISAPI/.../picture SINGLE-THREADED: two concurrent
|
||||
// snapshot GETs to the same unit return HTTP 503 "service busy". On a vehicle entry
|
||||
// TWO paths capture the SAME camera within ~1s — the ANPR bridge (barrier-driving,
|
||||
// anpr-entry.ts) and the advisory snapshotAsync (evidence + telemetry, below). They
|
||||
// each `buildCamera()` a SEPARATE adapter instance, so a per-instance cache can't
|
||||
// dedupe them. This module-level, deviceId-keyed cache does: it coalesces in-flight
|
||||
// captures (the 2nd caller awaits the 1st's pull) AND serves a result captured within
|
||||
// SNAPSHOT_TTL_MS, so the bridge + advisory share ONE frame instead of colliding into
|
||||
// a 503 (which then burned the bridge's 12s debounce → the slow entry observed
|
||||
// 2026-06-25; see wiki/concepts/lane-presence-and-anpr-entry.md).
|
||||
|
||||
/** How long a fresh capture is reused for the same camera. A car is one event for a
|
||||
* couple of seconds; 1.5s comfortably spans the bridge→advisory gap without ever
|
||||
* serving a stale frame for a *different* vehicle (entries are seconds apart). */
|
||||
const SNAPSHOT_TTL_MS = 1500;
|
||||
|
||||
interface CacheEntry {
|
||||
/** A capture in flight — concurrent callers await this instead of issuing a 2nd GET. */
|
||||
inflight?: Promise<Snapshot>;
|
||||
/** The last SUCCESSFUL capture + when it resolved, for the freshness window. */
|
||||
last?: { shot: Snapshot; at: number };
|
||||
}
|
||||
|
||||
const snapshotCache = new Map<string, CacheEntry>();
|
||||
|
||||
/**
|
||||
* Capture a snapshot for a camera, sharing ONE HTTP pull across concurrent/near-
|
||||
* simultaneous callers (the ANPR bridge and the advisory snapshot). Same contract as
|
||||
* `camera.captureSnapshot` (throws on failure) — a failed pull is NOT cached, so the
|
||||
* next caller retries rather than inheriting the error. Key by the stable `deviceId`.
|
||||
*/
|
||||
export function captureSnapshotShared(
|
||||
deviceId: string,
|
||||
camera: CameraDevice,
|
||||
ctx: { direction: FlowDirection },
|
||||
): Promise<Snapshot> {
|
||||
const now = Date.now();
|
||||
let entry = snapshotCache.get(deviceId);
|
||||
if (!entry) {
|
||||
entry = {};
|
||||
snapshotCache.set(deviceId, entry);
|
||||
}
|
||||
// Fresh enough → reuse the last frame (same vehicle, no second hardware hit).
|
||||
if (entry.last && now - entry.last.at < SNAPSHOT_TTL_MS) {
|
||||
return Promise.resolve(entry.last.shot);
|
||||
}
|
||||
// A capture is already running → join it (this is what prevents the 503 collision).
|
||||
if (entry.inflight) return entry.inflight;
|
||||
// Otherwise issue the single real pull; record it as the in-flight promise.
|
||||
const pull = camera
|
||||
.captureSnapshot(ctx)
|
||||
.then((shot) => {
|
||||
entry.last = { shot, at: Date.now() };
|
||||
return shot;
|
||||
})
|
||||
.finally(() => {
|
||||
// Clear the in-flight slot whether it resolved or threw; a failure is never cached.
|
||||
if (entry.inflight === pull) entry.inflight = undefined;
|
||||
});
|
||||
entry.inflight = pull;
|
||||
return pull;
|
||||
}
|
||||
|
||||
function recordFailure(
|
||||
db: Db,
|
||||
direction: FlowDirection,
|
||||
|
||||
@@ -310,6 +310,15 @@ export class SubscriptionFlow {
|
||||
* subscription, (b) pick which occurrence a read closes, and (c) enforce
|
||||
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
|
||||
*/
|
||||
/** How many occurrences this subscription currently has OPEN (entries not yet exited).
|
||||
* Public so the ANPR bridge can detect a credential (card/QR) exit landing mid-poll — if
|
||||
* the count drops while it's polling, the subscriber already transacted and the bridge must
|
||||
* NOT also emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. for
|
||||
* a fleet sub). See anpr-entry.ts. */
|
||||
openOccurrenceCount(subscriptionId: string): number {
|
||||
return this.#openOccurrences(subscriptionId).length;
|
||||
}
|
||||
|
||||
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
|
||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||
// Net entries−exits per occurrence identity, keeping the entry order (oldest first).
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"//": "Thin shim so this Python service is a first-class node in the Turbo task graph (it is NOT a JS package — deps are managed by uv/pyproject.toml). Each script shells to Python tooling. See wiki/decisions/vision-service-packaging.md.",
|
||||
"//alpr": "DEV self-heals real ANPR: `dev`/`start` run `uv sync --extra alpr` FIRST, because a plain `uv run` re-resolves the venv to the lockfile DEFAULTS and STRIPS fast-alpr (the cause of silent 'snapshot but no plate' after a prior pnpm dev). Syncing the extra here guarantees the recognizer survives every run. Use `dev:stub` for a lean, model-free local run. The BOOTH is unaffected — it runs the Docker image, which bakes `--extra alpr` at build (see Dockerfile + docker-compose.prod.yml).",
|
||||
"scripts": {
|
||||
"dev": "uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
|
||||
"start": "uv run uvicorn vision_service.app:app --host 0.0.0.0 --port 8089",
|
||||
"dev": "uv sync --extra alpr && uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
|
||||
"dev:stub": "uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
|
||||
"start": "uv sync --extra alpr && uv run uvicorn vision_service.app:app --host 0.0.0.0 --port 8089",
|
||||
"lint": "uv run ruff check .",
|
||||
"format": "uv run ruff format .",
|
||||
"typecheck": "uv run mypy vision_service",
|
||||
"test": "uv run pytest -q",
|
||||
"recognize": "uv run python -m vision_service.cli",
|
||||
"recognize": "uv sync --extra alpr && uv run python -m vision_service.cli",
|
||||
"build": "echo 'no build step (Python service; models fetched at deploy)'"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
# Production build env for the SPA (auto-loaded by `vite build`, which the Tauri
|
||||
# desktop bundle runs via beforeBuildCommand). NOT loaded by `vite` dev.
|
||||
# Production build env for the SPA (auto-loaded by `vite build`). NOT loaded by `vite` dev.
|
||||
#
|
||||
# The desktop shell serves the bundled SPA from tauri://localhost (no proxy, not
|
||||
# same-origin), so the SPA must reach Fastify by absolute origin. This is the
|
||||
# appliance's local Fastify address. Not a secret — committed for reproducible
|
||||
# desktop builds. Override per-deployment if Fastify binds elsewhere.
|
||||
# RELATIVE /api base (empty value). The booth serves the SPA same-origin (Fastify serves
|
||||
# dist/, reached via Caddy on :80), so requests must stay relative — baking an absolute
|
||||
# origin here would point the browser at the wrong host. This matches the deploy
|
||||
# (wiki/decisions/container-deployment.md "Web access"; the 77b2acb fix).
|
||||
#
|
||||
# NOTE: a plain browser prod build (Fastify serving dist/ same-origin) does NOT
|
||||
# want this set. If you build the SPA for that, override VITE_API_BASE="" .
|
||||
VITE_API_BASE=http://127.0.0.1:3000
|
||||
# DESKTOP (Tauri) NOTE: the desktop shell serves the SPA from tauri://localhost (no proxy,
|
||||
# not same-origin) and DOES need an absolute Fastify origin — but the desktop app is a
|
||||
# DEFERRED, separate task (it's currently hardcoded to localhost:3000; see apps/desktop +
|
||||
# the desktop-app-hardcoded-localhost note). When that work resumes, set VITE_API_BASE to
|
||||
# the appliance's Fastify origin for the desktop build only (e.g. via apps/desktop or an
|
||||
# exported override), NOT here.
|
||||
VITE_API_BASE=
|
||||
|
||||
@@ -26,26 +26,8 @@ import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
||||
// reconciles via the pay/exit modal — never a free barrier open.
|
||||
// See wiki/concepts/booth-exit-flow.md.
|
||||
|
||||
type StatusFilter = "unpaid" | "paid" | "exiting" | "overstay";
|
||||
type KindFilter = "transient" | "subscription";
|
||||
|
||||
function statusOf(s: ActiveSession): StatusFilter | "subscription" {
|
||||
if (s.subscription) return "subscription";
|
||||
if (s.overstay) return "overstay";
|
||||
if (!s.open && s.withinGrace) return "exiting";
|
||||
if (s.paidAt) return "paid";
|
||||
return "unpaid";
|
||||
}
|
||||
|
||||
function statusBadge(s: ActiveSession): { key: string; titleKey?: string; cls: string } {
|
||||
if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" };
|
||||
if (s.overstay)
|
||||
return { key: "booth.badgeOverstay", titleKey: "booth.badgeOverstayTitle", cls: "text-term-red" };
|
||||
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
|
||||
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
|
||||
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
|
||||
}
|
||||
|
||||
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
@@ -70,9 +52,9 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
});
|
||||
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
|
||||
|
||||
// Filters: free-text search, status, and transient-vs-subscriber.
|
||||
// Filters: free-text search + transient-vs-subscriber. (No status filter — the status
|
||||
// column was dropped; an unpaid transient is normal and a subscriber is marked ★.)
|
||||
const [search, setSearch] = useState("");
|
||||
const [status, setStatus] = useState<StatusFilter | "">("");
|
||||
const [kind, setKind] = useState<KindFilter | "">("");
|
||||
|
||||
const sessions = useMemo(() => data?.sessions ?? [], [data]);
|
||||
@@ -81,21 +63,15 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
return sessions.filter((s) => {
|
||||
if (kind === "transient" && s.subscription) return false;
|
||||
if (kind === "subscription" && !s.subscription) return false;
|
||||
if (status && statusOf(s) !== status) return false;
|
||||
if (q) {
|
||||
const hay = `${s.identity} ${s.subscriptionHolder ?? ""}`.toLowerCase();
|
||||
// Include the enriched plate (`s.plate`, the displayed badge) so a plate search hits.
|
||||
const hay = `${s.identity} ${s.subscriptionHolder ?? ""} ${s.plate ?? ""}`.toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [sessions, search, status, kind]);
|
||||
}, [sessions, search, kind]);
|
||||
|
||||
const statusOpts: SegOption<StatusFilter>[] = [
|
||||
{ value: "unpaid", label: t("booth.fStatusUnpaid") },
|
||||
{ value: "paid", label: t("booth.fStatusPaid") },
|
||||
{ value: "exiting", label: t("booth.fStatusExiting") },
|
||||
{ value: "overstay", label: t("booth.fStatusOverstay") },
|
||||
];
|
||||
const kindOpts: SegOption<KindFilter>[] = [
|
||||
{ value: "transient", label: t("booth.fKindTransient") },
|
||||
{ value: "subscription", label: t("booth.fKindSubscription") },
|
||||
@@ -119,7 +95,7 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
<Panel
|
||||
title={t("booth.activeSessions")}
|
||||
right={
|
||||
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||
{filtered.length}
|
||||
{filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")}
|
||||
</span>
|
||||
@@ -128,7 +104,6 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<FilterBar search={search} onSearch={setSearch} searchPlaceholder={t("booth.filterSearchSessions")}>
|
||||
<SegGroup value={status} options={statusOpts} onChange={setStatus} allLabel={t("booth.filterAll")} />
|
||||
<SegGroup value={kind} options={kindOpts} onChange={setKind} allLabel={t("booth.filterAll")} />
|
||||
</FilterBar>
|
||||
|
||||
@@ -142,70 +117,87 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
: t("booth.noMatch")}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((s) => {
|
||||
const badge = statusBadge(s);
|
||||
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
|
||||
return (
|
||||
<div
|
||||
key={s.identity}
|
||||
className="flex items-center gap-3 border-b border-term-border/50 py-1.5 text-[12px] tabular-nums"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(s.identity)}
|
||||
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
|
||||
title={t("booth.openPayExit")}
|
||||
>
|
||||
<span className="text-term-text">
|
||||
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
|
||||
</span>
|
||||
{s.plate && (
|
||||
<span
|
||||
className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber"
|
||||
title={t("booth.plateTitle")}
|
||||
>
|
||||
{s.plate}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span>
|
||||
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
|
||||
<span
|
||||
className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}
|
||||
title={badge.titleKey ? t(badge.titleKey) : undefined}
|
||||
// A real table — aligned columns (who · plate · entry · elapsed · action). No
|
||||
// status column: an unpaid transient is the normal case, and a subscriber is
|
||||
// already marked with ★ + holder name. Overstay (a top-up is owed) keeps a row
|
||||
// tint so that fraud-relevant signal isn't lost. The whole row is clickable
|
||||
// (→ pay/exit modal); the trailing cell holds the audited Open-barrier action.
|
||||
<table className="w-full text-[0.75rem] tabular-nums">
|
||||
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("booth.colWho")}</th>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("booth.colPlate")}</th>
|
||||
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colEntry")}</th>
|
||||
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colElapsed")}</th>
|
||||
<th className="px-2 py-1.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((s) => {
|
||||
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
|
||||
// Paid-and-in-grace TRANSIENT only: an audited re-pulse for a car that paid
|
||||
// but the barrier didn't confirm. NOT overstay (owes a top-up → modal) and
|
||||
// NOT a subscription (assist-open lives in the modal). An unpaid transient
|
||||
// gets no button (no-unpaid-bypass). Mirrors reopenBarrier's server guard.
|
||||
const canReopen = s.paidAt && !s.overstay && !s.subscription;
|
||||
return (
|
||||
<tr
|
||||
key={s.identity}
|
||||
onClick={() => onPick(s.identity)}
|
||||
className={`cursor-pointer border-t border-term-border/50 hover:bg-term-panel-2 ${
|
||||
s.overstay ? "bg-term-red/5" : ""
|
||||
}`}
|
||||
title={t("booth.openPayExit")}
|
||||
>
|
||||
{t(badge.key)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Open barrier — PAID-and-still-in-grace TRANSIENT only: an audited
|
||||
re-pulse for a car that paid but the barrier didn't confirm. NOT an
|
||||
OVERSTAY (grace expired → owes a top-up; routes to the pay/exit modal)
|
||||
and NOT a SUBSCRIPTION (the assist-open, and any out-of-window payment,
|
||||
live in the pay/exit modal — the list must not offer a one-click open,
|
||||
which would bypass an unpaid window charge). An unpaid transient has no
|
||||
button either (no-unpaid-bypass). Mirrors reopenBarrier's server guard. */}
|
||||
{s.paidAt && !s.overstay && !s.subscription ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={reopen.isPending || !shiftReady}
|
||||
onClick={() => handleReopen(s)}
|
||||
className="btn btn-pay btn-sm shrink-0"
|
||||
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
|
||||
>
|
||||
{t("booth.openBarrier")}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-[88px] shrink-0" />
|
||||
)}
|
||||
|
||||
{msg && (
|
||||
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
||||
{msg.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
<td className="px-2 py-1.5 text-term-text">
|
||||
{s.subscription ? (
|
||||
<span className="text-term-cyan">★ {s.subscriptionHolder ?? t("subs.unnamed")}</span>
|
||||
) : (
|
||||
s.identity
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
{s.plate && (
|
||||
<span
|
||||
className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber"
|
||||
title={t("booth.plateTitle")}
|
||||
>
|
||||
{s.plate}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
|
||||
{formatRelativeDateTime(s.enteredAt, t)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
|
||||
{formatDuration(s.enteredAt, new Date().toISOString())}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{canReopen && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={reopen.isPending || !shiftReady}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // don't also open the pay/exit modal
|
||||
void handleReopen(s);
|
||||
}}
|
||||
className="btn btn-pay btn-sm"
|
||||
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
|
||||
>
|
||||
{t("booth.openBarrier")}
|
||||
</button>
|
||||
)}
|
||||
{msg && (
|
||||
<span className={`ml-2 text-[0.625rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
||||
{msg.text}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fetchMe, type SessionUser } from "./api.js";
|
||||
import { Login } from "./Login.js";
|
||||
import { queryClient } from "./lib/query.js";
|
||||
import { setLanguage } from "./lib/i18n/index.js";
|
||||
import { applyTheme } from "./lib/theme.js";
|
||||
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||
import { router } from "./router.js";
|
||||
|
||||
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
||||
@@ -24,15 +24,17 @@ export function App() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Apply the signed-in user's preferred language + theme whenever they resolve/
|
||||
// change (login, bootstrap, or a toggle). Albanian + dark are the defaults before
|
||||
// auth resolves; on logout, fall back to dark so the Login screen is consistent.
|
||||
// Apply the signed-in user's preferred language + theme + font scale whenever they
|
||||
// resolve/change (login, bootstrap, or a toggle). Albanian + dark + 100% are the defaults
|
||||
// before auth resolves; on logout, fall back so the Login screen is consistent.
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setLanguage(user.language);
|
||||
applyTheme(user.theme);
|
||||
applyFontScale(user.fontScale);
|
||||
} else {
|
||||
applyTheme("dark");
|
||||
applyFontScale(100);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
|
||||
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
<Dialog.Title className="m-0 text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{isSubscription
|
||||
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
|
||||
: `${t("pay.ticket")} ${identity}`}
|
||||
@@ -244,19 +244,19 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
<div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2">
|
||||
{blockedByOther ? (
|
||||
<>
|
||||
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{t("shift.gateOtherTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-term-text">
|
||||
<div className="mt-1 text-[0.75rem] text-term-text">
|
||||
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{t("shift.gateTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-term-text">{t("shift.gateBody")}</div>
|
||||
<div className="mt-1 text-[0.75rem] text-term-text">{t("shift.gateBody")}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenShift}
|
||||
@@ -321,7 +321,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
out-of-window window charge; then show that amount. For an overstay the
|
||||
amount is the TOP-UP delta, not the whole stay. */}
|
||||
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">
|
||||
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
{subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")}
|
||||
</span>
|
||||
<span className="text-3xl font-bold text-term-cyan">
|
||||
@@ -341,22 +341,22 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
gate; once paid, prompt the operator to open the barrier; a prepaid
|
||||
subscriber sees the assist explanation only after revealing it. */}
|
||||
{subWindowDue && !windowPaid ? (
|
||||
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[12px] text-term-text">
|
||||
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||||
{t("pay.windowChargeHint")}
|
||||
</div>
|
||||
) : isSubscription && windowPaid ? (
|
||||
<div className="rounded-term border border-term-green/40 bg-term-green/5 px-3 py-2 text-[12px] text-term-text">
|
||||
<div className="rounded-term border border-term-green/40 bg-term-green/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||||
{t("pay.windowPaidHint")}
|
||||
</div>
|
||||
) : isSubscription && assistRevealed ? (
|
||||
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text">
|
||||
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||||
{t("pay.subAssistHint")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* For an overstay, explain why a top-up is required (no free exit). */}
|
||||
{isOverstay && (
|
||||
<div className="rounded-term border border-term-red/40 bg-term-red/5 px-3 py-2 text-[12px] text-term-text">
|
||||
<div className="rounded-term border border-term-red/40 bg-term-red/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||||
{t("pay.overstayHint")}
|
||||
</div>
|
||||
)}
|
||||
@@ -368,7 +368,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
subscriber window charge that's still unpaid). */}
|
||||
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
|
||||
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
|
||||
{(["cash", "card"] as const).map((tn) => (
|
||||
<button
|
||||
key={tn}
|
||||
@@ -384,7 +384,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
|
||||
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */}
|
||||
{phase !== "done" && !isSubscription && (
|
||||
<label className="flex items-center gap-2 text-[12px]">
|
||||
<label className="flex items-center gap-2 text-[0.75rem]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
@@ -401,10 +401,10 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
signed `void` event — the entry is never edited. */}
|
||||
{voiding && phase !== "done" && (
|
||||
<div className="rounded-term border border-term-amber/50 bg-term-amber/5 px-3 py-2">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
<div className="text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{t("pay.cancelTicketTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-term-text">{t("pay.cancelTicketHint")}</div>
|
||||
<div className="mt-1 text-[0.75rem] text-term-text">{t("pay.cancelTicketHint")}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{(["misprint", "test", "wrongVehicle"] as const).map((k) => (
|
||||
<button
|
||||
@@ -560,7 +560,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
|
||||
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</span>
|
||||
<span className={`text-sm ${valueClass}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -49,13 +49,13 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
|
||||
<div className="pb-1 text-term-muted">
|
||||
<div className="text-[11px] uppercase tracking-wider">{t("booth.inside")}</div>
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider">{t("booth.inside")}</div>
|
||||
<div className="text-sm tabular-nums">
|
||||
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto text-right">
|
||||
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("booth.free")}</div>
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("booth.free")}</div>
|
||||
<div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
|
||||
{occ.free == null ? "∞" : occ.free}
|
||||
</div>
|
||||
@@ -67,7 +67,7 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
||||
</div>
|
||||
)}
|
||||
{occ.full && (
|
||||
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[11px] font-bold uppercase tracking-widest text-term-red">
|
||||
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[0.6875rem] font-bold uppercase tracking-widest text-term-red">
|
||||
{t("booth.lotFull")}
|
||||
</div>
|
||||
)}
|
||||
@@ -106,46 +106,51 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
||||
className="input h-11 flex-1 px-3 text-lg tabular-nums"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-lg">
|
||||
{t("booth.open")}
|
||||
{t("booth.openTicket")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** One barrier light — green = free, red = busy (a vehicle is at the lane vicinity,
|
||||
* from camera detection). Advisory only; it gates nothing. */
|
||||
function BarrierLight({ label, busy }: { label: string; busy: boolean }) {
|
||||
/** One barrier light — a 3-state indicator mirroring the physical button lamp (relay 3):
|
||||
* - radar present + camera NOT busy → BLINK green↔red (~1 Hz): "detected, not yet confirmed"
|
||||
* - camera busy → SOLID red: a vehicle is confirmed at the lane vicinity
|
||||
* - otherwise → SOLID green: free
|
||||
* Advisory only; it gates nothing. The blink uses the `.lane-blink` keyframe (index.css),
|
||||
* whose children inherit the alternating colour via `currentColor`. */
|
||||
function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; radar: boolean }) {
|
||||
// Blink only when the radar sees something the camera hasn't confirmed.
|
||||
const blinking = radar && !busy;
|
||||
const solid = busy ? "border-term-red bg-term-red/10 text-term-red" : "border-term-green bg-term-green/10 text-term-green";
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${
|
||||
busy ? "border-term-red bg-term-red/10" : "border-term-green bg-term-green/10"
|
||||
}`}
|
||||
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${blinking ? "lane-blink" : solid}`}
|
||||
title={label}
|
||||
>
|
||||
{/* Barrier glyph: a post + an arm. Colour carries the state. */}
|
||||
<svg viewBox="0 0 24 24" className={`h-5 w-5 ${busy ? "text-term-red" : "text-term-green"}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
{/* Barrier glyph: a post + an arm. `currentColor` follows the (possibly blinking) state. */}
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="5" y1="21" x2="5" y2="9" />
|
||||
<line x1="5" y1="10" x2="21" y2="6" />
|
||||
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
<div className="leading-tight">
|
||||
<div className="text-[10px] uppercase tracking-wider text-term-muted">{label}</div>
|
||||
<div className={`text-xs font-bold ${busy ? "text-term-red" : "text-term-green"}`}>
|
||||
{busy ? "●" : "○"}
|
||||
</div>
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-term-muted">{label}</div>
|
||||
<div className="text-xs font-bold">{busy ? "●" : blinking ? "◐" : "○"}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The two lane barrier lights (entry / exit) fed by the live lane-status. */
|
||||
/** The two lane barrier lights (entry / exit) fed by the live lane-status (camera busy/free)
|
||||
* and lane-presence (radar). */
|
||||
function LaneIndicators() {
|
||||
const { t } = useTranslation();
|
||||
const lanes = useLiveStore((s) => s.lanes);
|
||||
const radar = useLiveStore((s) => s.radar);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} />
|
||||
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} />
|
||||
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} radar={radar?.entry ?? false} />
|
||||
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} radar={radar?.exit ?? false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -176,10 +181,10 @@ export function BoothScreen() {
|
||||
// abandon an in-progress payment (the operator finishes/closes, then scans the next).
|
||||
useScanner({ onScan: setActiveTicket, paused: activeTicket != null || detailEvent != null });
|
||||
|
||||
// Live-feed filters: free-text search, event category, and direction/source.
|
||||
// Live-feed filters: free-text search, event type, and source. (No direction filter —
|
||||
// HYRJE/DALJE there just duplicated the entry/exit options already in the Type filter.)
|
||||
const [feedSearch, setFeedSearch] = useState("");
|
||||
const [feedType, setFeedType] = useState<FeedCat | "">("");
|
||||
const [feedDir, setFeedDir] = useState<"entry" | "exit" | "">("");
|
||||
const [feedSrc, setFeedSrc] = useState<"booth" | "reader" | "">("");
|
||||
|
||||
// Live overlays from the WS store.
|
||||
@@ -202,17 +207,17 @@ export function BoothScreen() {
|
||||
|
||||
// Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
|
||||
// vs reader (device-initiated: wiegand/lpr/qr/ticket). Search spans identity,
|
||||
// subscriber label, and any advisory plate on the payload.
|
||||
// subscriber label, and the enriched advisory plate (`e.plate` — the displayed field;
|
||||
// the plate is NOT in the signed payload, so `payload.plate` would never match).
|
||||
const fq = feedSearch.trim().toLowerCase();
|
||||
const events = scoped.filter((e) => {
|
||||
if (feedType && feedCat(e.type) !== feedType) return false;
|
||||
if (feedDir && e.direction !== feedDir) return false;
|
||||
if (feedSrc) {
|
||||
const isBooth = e.source === "manual";
|
||||
if (feedSrc === "booth" ? !isBooth : isBooth) return false;
|
||||
}
|
||||
if (fq) {
|
||||
const hay = `${e.identity ?? ""} ${e.subscriberLabel ?? ""} ${e.payload?.plate ?? ""}`.toLowerCase();
|
||||
const hay = `${e.identity ?? ""} ${e.subscriberLabel ?? ""} ${e.plate ?? ""}`.toLowerCase();
|
||||
if (!hay.includes(fq)) return false;
|
||||
}
|
||||
return true;
|
||||
@@ -225,10 +230,6 @@ export function BoothScreen() {
|
||||
{ value: "void", label: t("booth.fEvtVoid") },
|
||||
{ value: "anomaly", label: t("booth.fEvtAnomaly") },
|
||||
];
|
||||
const feedDirOpts: SegOption<"entry" | "exit">[] = [
|
||||
{ value: "entry", label: t("booth.fDirEntry") },
|
||||
{ value: "exit", label: t("booth.fDirExit") },
|
||||
];
|
||||
const feedSrcOpts: SegOption<"booth" | "reader">[] = [
|
||||
{ value: "booth", label: t("booth.fSrcBooth") },
|
||||
{ value: "reader", label: t("booth.fSrcReader") },
|
||||
@@ -266,7 +267,7 @@ export function BoothScreen() {
|
||||
<Panel
|
||||
title={t("booth.liveFeed")}
|
||||
right={
|
||||
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||
{events.length}
|
||||
{events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")}
|
||||
</span>
|
||||
@@ -282,7 +283,6 @@ export function BoothScreen() {
|
||||
onChange={setFeedType}
|
||||
allLabel={t("booth.filterAll")}
|
||||
/>
|
||||
<SegGroup value={feedDir} options={feedDirOpts} onChange={setFeedDir} allLabel={t("booth.filterAll")} />
|
||||
<SegGroup value={feedSrc} options={feedSrcOpts} onChange={setFeedSrc} allLabel={t("booth.filterAll")} />
|
||||
</FilterBar>
|
||||
)}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="mb-3 text-[12px] text-term-red">{error}</p>}
|
||||
{error && <p className="mb-3 text-[0.75rem] text-term-red">{error}</p>}
|
||||
<button type="submit" className="btn btn-primary btn-lg w-full" disabled={busy || !username || !password}>
|
||||
{busy ? t("auth.signingIn") : t("auth.signIn")}
|
||||
</button>
|
||||
|
||||
@@ -31,7 +31,7 @@ function LogRow({ log }: { log: AppLogRecord }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hasDetail && setOpen((v) => !v)}
|
||||
className={`grid w-full grid-cols-[auto_4rem_5rem_1fr_auto] items-center gap-x-3 px-1 py-1 text-left text-[12px] ${
|
||||
className={`grid w-full grid-cols-[auto_4rem_5rem_1fr_auto] items-center gap-x-3 px-1 py-1 text-left text-[0.75rem] ${
|
||||
hasDetail ? "hover:bg-term-panel-2" : "cursor-default"
|
||||
}`}
|
||||
>
|
||||
@@ -44,17 +44,17 @@ function LogRow({ log }: { log: AppLogRecord }) {
|
||||
{open && hasDetail && (
|
||||
<div className="border-t border-term-border/40 bg-term-bg px-3 py-2">
|
||||
{log.path && (
|
||||
<div className="mb-1 text-[11px] text-term-muted">
|
||||
<div className="mb-1 text-[0.6875rem] text-term-muted">
|
||||
{t("logs.path")}: <code className="text-term-text">{log.path}</code>
|
||||
</div>
|
||||
)}
|
||||
{log.context && Object.keys(log.context).length > 0 && (
|
||||
<pre className="mb-2 overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[11px] text-term-text">
|
||||
<pre className="mb-2 overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[0.6875rem] text-term-text">
|
||||
{JSON.stringify(log.context, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{log.stack && (
|
||||
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[11px] text-term-red/90">
|
||||
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[0.6875rem] text-term-red/90">
|
||||
{log.stack}
|
||||
</pre>
|
||||
)}
|
||||
@@ -136,12 +136,12 @@ export function LogsViewer() {
|
||||
|
||||
<div className="card p-2">
|
||||
{q.isLoading ? (
|
||||
<div className="p-3 text-[12px] text-term-muted">{t("common.loading")}</div>
|
||||
<div className="p-3 text-[0.75rem] text-term-muted">{t("common.loading")}</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="p-3 text-[12px] text-term-muted">{t("logs.empty")}</div>
|
||||
<div className="p-3 text-[0.75rem] text-term-muted">{t("logs.empty")}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[auto_4rem_5rem_1fr_auto] gap-x-3 border-b border-term-border px-1 pb-1 text-[10px] uppercase tracking-wider text-term-muted">
|
||||
<div className="grid grid-cols-[auto_4rem_5rem_1fr_auto] gap-x-3 border-b border-term-border px-1 pb-1 text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||
<span>{t("logs.time")}</span>
|
||||
<span>{t("logs.level")}</span>
|
||||
<span>{t("logs.source")}</span>
|
||||
|
||||
@@ -82,7 +82,7 @@ export function Profile({
|
||||
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
||||
{t("profile.accountSection")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-3 text-[11px] text-term-muted">
|
||||
<div className="grid grid-cols-2 gap-3 text-[0.6875rem] text-term-muted">
|
||||
<div>
|
||||
<span className="block">{t("profile.username")}</span>
|
||||
<span className="text-sm text-term-text">{user.username}</span>
|
||||
@@ -92,7 +92,7 @@ export function Profile({
|
||||
<span className="text-sm text-term-text">{user.roleName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||
{t("profile.fullName")}
|
||||
<input
|
||||
className="input"
|
||||
@@ -101,7 +101,7 @@ export function Profile({
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||
{t("profile.email")}
|
||||
<input
|
||||
className="input"
|
||||
@@ -115,7 +115,7 @@ export function Profile({
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}>
|
||||
{t("profile.saveProfile")}
|
||||
</button>
|
||||
{accountMsg && <span className="text-[11px] text-term-muted">{accountMsg}</span>}
|
||||
{accountMsg && <span className="text-[0.6875rem] text-term-muted">{accountMsg}</span>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -124,7 +124,7 @@ export function Profile({
|
||||
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
||||
{t("profile.passwordSection")}
|
||||
</h2>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||
{t("profile.currentPassword")}
|
||||
<input
|
||||
className="input"
|
||||
@@ -134,7 +134,7 @@ export function Profile({
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||
{t("profile.newPassword")}
|
||||
<input
|
||||
className="input"
|
||||
@@ -144,7 +144,7 @@ export function Profile({
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||
{t("profile.confirmPassword")}
|
||||
<input
|
||||
className="input"
|
||||
@@ -163,7 +163,7 @@ export function Profile({
|
||||
>
|
||||
{t("profile.changePassword")}
|
||||
</button>
|
||||
{pwMsg && <span className="text-[11px] text-term-muted">{pwMsg}</span>}
|
||||
{pwMsg && <span className="text-[0.6875rem] text-term-muted">{pwMsg}</span>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -76,13 +76,13 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
|
||||
{t("recycleBin.title")}
|
||||
</h1>
|
||||
{retentionDays > 0 && (
|
||||
<span className="text-[12px] text-term-muted">
|
||||
<span className="text-[0.75rem] text-term-muted">
|
||||
{t("recycleBin.retentionNote", { days: retentionDays })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-2 text-[12px] text-term-red">{error}</p>}
|
||||
{error && <p className="mb-2 text-[0.75rem] text-term-red">{error}</p>}
|
||||
{binQ.isLoading && <p className="text-term-muted">{t("common.loading")}</p>}
|
||||
|
||||
{!binQ.isLoading && items.length === 0 ? (
|
||||
@@ -90,9 +90,9 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
|
||||
{t("recycleBin.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-[13px]">
|
||||
<table className="w-full text-[0.8125rem]">
|
||||
<thead>
|
||||
<tr className="border-b border-term-border text-left text-[11px] uppercase tracking-wider text-term-muted">
|
||||
<tr className="border-b border-term-border text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
<th className="py-1.5 pr-3">{t("recycleBin.col.type")}</th>
|
||||
<th className="py-1.5 pr-3">{t("recycleBin.col.item")}</th>
|
||||
<th className="py-1.5 pr-3">{t("recycleBin.col.deleted")}</th>
|
||||
@@ -103,7 +103,7 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
|
||||
{items.map((it) => (
|
||||
<tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50">
|
||||
<td className="py-1.5 pr-3">
|
||||
<span className="rounded-term border border-term-border px-1.5 py-0.5 text-[11px] text-term-muted">
|
||||
<span className="rounded-term border border-term-border px-1.5 py-0.5 text-[0.6875rem] text-term-muted">
|
||||
{t(KIND_KEY[it.kind])}
|
||||
</span>
|
||||
</td>
|
||||
@@ -146,10 +146,10 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
|
||||
|
||||
{purging && (
|
||||
<Modal open onClose={() => setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}>
|
||||
<p className="text-[13px] text-term-text">
|
||||
<p className="text-[0.8125rem] text-term-text">
|
||||
{t("recycleBin.purgeConfirmBody", { label: purging.label })}
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-term-red">{t("recycleBin.purgeIrreversible")}</p>
|
||||
<p className="mt-1 text-[0.75rem] text-term-red">{t("recycleBin.purgeIrreversible")}</p>
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}>
|
||||
{t("common.cancel")}
|
||||
|
||||
@@ -95,7 +95,7 @@ export function Reports() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-2 flex items-center gap-1 text-[12px] text-term-muted">
|
||||
<div className="ml-2 flex items-center gap-1 text-[0.75rem] text-term-muted">
|
||||
<span>{t("reports.groupBy")}</span>
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
@@ -247,7 +247,7 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
||||
|
||||
{/* Cash / card + duration + subscription breakdown (numbers). */}
|
||||
<Panel title={t("reports.chart.breakdown")}>
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-[13px]">
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-[0.8125rem]">
|
||||
<Row label={t("reports.row.cash")} value={money(tot.cashMinor)} />
|
||||
<Row label={t("reports.row.card")} value={money(tot.cardMinor)} />
|
||||
<Row label={t("reports.mix.ticket")} value={money(tot.ticketMinor)} />
|
||||
@@ -262,7 +262,7 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-term-muted">
|
||||
<p className="text-[0.6875rem] text-term-muted">
|
||||
{t("reports.footnote", { tz: data.tz })}
|
||||
</p>
|
||||
</div>
|
||||
@@ -290,7 +290,7 @@ function Kpi({ label, value, accent }: { label: string; value: string; accent?:
|
||||
: "text-term-text";
|
||||
return (
|
||||
<div className="rounded-term border border-term-border bg-term-panel p-2.5">
|
||||
<div className="text-[11px] uppercase tracking-wider text-term-muted">{label}</div>
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</div>
|
||||
<div className={`mt-0.5 text-lg font-bold tabular-nums ${color}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -299,7 +299,7 @@ function Kpi({ label, value, accent }: { label: string; value: string; accent?:
|
||||
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-term border border-term-border bg-term-panel p-3">
|
||||
<h2 className="mb-2 text-[11px] uppercase tracking-wider text-term-muted">{title}</h2>
|
||||
<h2 className="mb-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -315,5 +315,5 @@ function Row({ label, value }: { label: string; value: string }) {
|
||||
}
|
||||
|
||||
function Empty({ t }: { t: TFunction }) {
|
||||
return <p className="py-12 text-center text-[12px] text-term-muted">{t("reports.noData")}</p>;
|
||||
return <p className="py-12 text-center text-[0.75rem] text-term-muted">{t("reports.noData")}</p>;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>}
|
||||
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[0.75rem] text-term-red">{error}</div>}
|
||||
|
||||
<Modal
|
||||
open={editing != null}
|
||||
@@ -93,13 +93,13 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
|
||||
<div key={r.id} className="rounded-term border border-term-border bg-term-panel p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-term-text">{r.name}</span>
|
||||
<span className="text-[0.8125rem] font-semibold text-term-text">{r.name}</span>
|
||||
{r.builtin && (
|
||||
<span className="rounded-term border border-term-amber/50 px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-term-amber">
|
||||
<span className="rounded-term border border-term-amber/50 px-1.5 py-0.5 text-[0.625rem] uppercase tracking-wider text-term-amber">
|
||||
{t("roles.builtin")}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] text-term-muted">
|
||||
<span className="text-[0.6875rem] text-term-muted">
|
||||
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
|
||||
</span>
|
||||
</div>
|
||||
@@ -155,11 +155,11 @@ function RoleEditor({
|
||||
<div className="mt-1 grid grid-cols-1 gap-1">
|
||||
{Object.entries(grouped).map(([resource, list]) => (
|
||||
<div key={resource} className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-term-border py-1.5">
|
||||
<span className="w-28 shrink-0 text-[12px] font-semibold text-term-text">{resource}</span>
|
||||
<span className="w-28 shrink-0 text-[0.75rem] font-semibold text-term-text">{resource}</span>
|
||||
{list.map((p) => {
|
||||
const action = p.split(":")[1]!;
|
||||
return (
|
||||
<label key={p} className="flex items-center gap-1 text-[12px] text-term-text">
|
||||
<label key={p} className="flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||
<input type="checkbox" className="accent-term-amber" checked={perms.has(p)} onChange={() => toggle(p)} />
|
||||
{action}
|
||||
</label>
|
||||
|
||||
+612
-105
File diff suppressed because it is too large
Load Diff
@@ -175,7 +175,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
||||
</div>
|
||||
|
||||
{q.isError && (
|
||||
<div className="mb-2 shrink-0 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{t("shifts.loadFailed")}</div>
|
||||
<div className="mb-2 shrink-0 rounded-term border border-term-red px-3 py-2 text-[0.75rem] text-term-red">{t("shifts.loadFailed")}</div>
|
||||
)}
|
||||
|
||||
{/* Two-pane: shift list (left) + selected shift's activity log (right). Both
|
||||
@@ -183,7 +183,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
||||
<div className="grid min-h-0 flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
|
||||
<div className="flex min-h-0 flex-col gap-1.5 overflow-y-auto pr-1">
|
||||
{!q.isLoading && list.length === 0 && (
|
||||
<p className="rounded-term border border-term-border px-3 py-3 text-[12px] text-term-muted">{t("shifts.none")}</p>
|
||||
<p className="rounded-term border border-term-border px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.none")}</p>
|
||||
)}
|
||||
{list.map((s) => (
|
||||
<ShiftCard key={s.id} s={s} showOperator={isAdmin} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
|
||||
@@ -202,7 +202,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
||||
onChanged={refreshAll}
|
||||
/>
|
||||
) : (
|
||||
<p className="px-3 py-6 text-center text-[12px] text-term-muted">{t("shifts.selectAShift")}</p>
|
||||
<p className="px-3 py-6 text-center text-[0.75rem] text-term-muted">{t("shifts.selectAShift")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,7 +228,7 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{err && <span className="text-[12px] text-term-red">{err}</span>}
|
||||
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
|
||||
{busy ? t("shift.starting") : t("shift.startShift")}
|
||||
</button>
|
||||
@@ -244,11 +244,11 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`card w-full p-2.5 text-left text-[12px] transition-colors ${selected ? "border-term-amber bg-term-panel-2" : "hover:bg-term-panel-2"}`}
|
||||
className={`card w-full p-2.5 text-left text-[0.75rem] transition-colors ${selected ? "border-term-amber bg-term-panel-2" : "hover:bg-term-panel-2"}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2 font-semibold text-term-text">
|
||||
{open && <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("shifts.current")}</span>}
|
||||
{open && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
|
||||
{showOperator ? s.operator : when(s.startedAt)}
|
||||
</span>
|
||||
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
||||
@@ -300,9 +300,9 @@ function ShiftActivityLog({
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="shrink-0 border-b border-term-border bg-term-panel-2 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[12px]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[0.75rem]">
|
||||
<span className="flex items-center gap-2 font-semibold text-term-text">
|
||||
{isCurrent && <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("shifts.current")}</span>}
|
||||
{isCurrent && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
|
||||
{showOperator && `${shift.operator} · `}
|
||||
{formatRelativeDateTime(shift.startedAt, t)}
|
||||
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
|
||||
@@ -316,10 +316,10 @@ function ShiftActivityLog({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[11px] tabular-nums sm:grid-cols-4">
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[0.6875rem] tabular-nums sm:grid-cols-4">
|
||||
<Figure label={t("shifts.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
|
||||
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
||||
<Figure label={t("shifts.srcSubSales")} value={money(shift.subscriptionSalesMinor, cur)} sub />
|
||||
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||
<Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
||||
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
||||
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
||||
@@ -331,8 +331,8 @@ function ShiftActivityLog({
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-1">
|
||||
{q.isLoading && <p className="px-3 py-3 text-[12px] text-term-muted">{t("common.loading")}</p>}
|
||||
{!q.isLoading && events.length === 0 && <p className="px-3 py-3 text-[12px] text-term-muted">{t("shifts.noActivity")}</p>}
|
||||
{q.isLoading && <p className="px-3 py-3 text-[0.75rem] text-term-muted">{t("common.loading")}</p>}
|
||||
{!q.isLoading && events.length === 0 && <p className="px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.noActivity")}</p>}
|
||||
{events.map((e) => (
|
||||
<EventRow key={e.id} e={e} onOpen={setDetailEvent} />
|
||||
))}
|
||||
@@ -372,14 +372,15 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
||||
<Modal open onClose={onClose} title={t("shift.endShift")} width="max-w-md">
|
||||
{report ? (
|
||||
// Result — the signed Z-report.
|
||||
<div className="text-[13px] tabular-nums">
|
||||
<div className="text-[0.8125rem] tabular-nums">
|
||||
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||
<Figure label={t("shift.payments")} value={String(report.paymentCount)} />
|
||||
<span />
|
||||
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} />
|
||||
<Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} />
|
||||
<Figure label={t("shift.srcSubSales")} value={money(report.subscriptionSalesMinor, report.currency)} sub />
|
||||
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||
<span />
|
||||
<Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub />
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
||||
@@ -399,20 +400,24 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
||||
</div>
|
||||
) : (
|
||||
// Confirm — show the live takings (split by source) + drawer before closing.
|
||||
<div className="text-[13px] tabular-nums">
|
||||
<div className="text-[0.8125rem] tabular-nums">
|
||||
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||
<Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
|
||||
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
||||
<Figure label={t("shift.srcSubSales")} value={money(shift.subscriptionSalesMinor, cur)} sub />
|
||||
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||
<span />
|
||||
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||
<Figure label={t("shift.cash")} value={money(shift.cashTotalMinor, cur)} />
|
||||
<Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />
|
||||
{/* Drawer math made explicit: opening cash + cash taken = expected drawer. */}
|
||||
<Figure label={t("shift.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
||||
<span />
|
||||
<Figure label={t("shift.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
||||
</div>
|
||||
{err && <p className="mt-2 text-[12px] text-term-red">{err}</p>}
|
||||
{err && <p className="mt-2 text-[0.75rem] text-term-red">{err}</p>}
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={onClose}>{t("subs.cancel")}</button>
|
||||
<button type="button" className="btn btn-sm btn-danger" onClick={confirm} disabled={busy}>
|
||||
@@ -452,7 +457,7 @@ function VoucherModal({ currency, onClose, onDone }: { currency: string | null;
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={t("shift.drawerVoucher")} width="max-w-md">
|
||||
<div className="flex flex-col gap-2 text-[13px]">
|
||||
<div className="flex flex-col gap-2 text-[0.8125rem]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input className="input w-28" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder={t("shift.amount")} inputMode="decimal" />
|
||||
<input className="input min-w-36 flex-1" value={reason} onChange={(e) => setReason(e.target.value)} placeholder={t("shift.reasonPlaceholder")} />
|
||||
@@ -461,8 +466,8 @@ function VoucherModal({ currency, onClose, onDone }: { currency: string | null;
|
||||
<input className="input w-36" value={authName} onChange={(e) => setAuthName(e.target.value)} placeholder={t("shift.authName")} autoComplete="off" />
|
||||
<input className="input w-36" type="password" value={authPassword} onChange={(e) => setAuthPassword(e.target.value)} placeholder={t("shift.authPassword")} autoComplete="off" />
|
||||
</div>
|
||||
<div className="text-[11px] text-term-muted">{t("shift.voucherHint")}</div>
|
||||
{msg && <div className="text-[12px] text-term-muted">{msg}</div>}
|
||||
<div className="text-[0.6875rem] text-term-muted">{t("shift.voucherHint")}</div>
|
||||
{msg && <div className="text-[0.75rem] text-term-muted">{msg}</div>}
|
||||
<div className="mt-1 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={() => submit("cash_in")}>{t("shift.mandatArketimi")}</button>
|
||||
@@ -480,16 +485,17 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md">
|
||||
{!x ? (
|
||||
<p className="text-[12px] text-term-muted">{t("common.loading")}</p>
|
||||
<p className="text-[0.75rem] text-term-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<div className="text-[13px] tabular-nums">
|
||||
<div className="text-[0.8125rem] tabular-nums">
|
||||
<div className="text-term-muted">{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
|
||||
<span />
|
||||
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} />
|
||||
<Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} />
|
||||
<Figure label={t("shift.srcSubSales")} value={money(x.subscriptionSalesMinor, x.currency)} sub />
|
||||
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||
<span />
|
||||
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
||||
@@ -500,7 +506,7 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
||||
<Figure label={t("shift.cashRemoved")} value={money(x.cashRemovedMinor, x.currency)} />
|
||||
<Figure label={t("shift.expectedDrawer")} value={money(x.expectedDrawerMinor, x.currency)} bold />
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-term-muted">{t("shift.xReportHint")}</div>
|
||||
<div className="mt-2 text-[0.6875rem] text-term-muted">{t("shift.xReportHint")}</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
|
||||
</div>
|
||||
@@ -513,8 +519,9 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
||||
function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
||||
return (
|
||||
<div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
||||
<span className={sub ? "text-term-muted/70" : "text-term-muted"}>{label}</span>
|
||||
<span className={bold ? "font-semibold text-term-text" : "text-term-text"}>{value}</span>
|
||||
<span className={`whitespace-nowrap ${sub ? "text-term-muted/70" : "text-term-muted"}`}>{label}</span>
|
||||
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
|
||||
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
|
||||
return (
|
||||
<section className="card mt-6 max-w-md p-4">
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[13px]">
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[0.8125rem]">
|
||||
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
||||
{occ == null ? (
|
||||
<span className="text-term-muted">…</span>
|
||||
@@ -93,7 +93,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
<span className="label">{t("site.capacityLabel")}</span>
|
||||
<input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-[12px] text-term-text">
|
||||
<label className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
@@ -103,7 +103,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
{t("site.printExitDefault")}
|
||||
<span className="hint">{t("site.printExitHint")}</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-[12px] text-term-text">
|
||||
<label className="flex items-start gap-2 text-[0.75rem] text-term-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 accent-term-amber"
|
||||
@@ -115,7 +115,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
<span className="hint block">{t("site.reserveSubsHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-[12px] text-term-text">
|
||||
<label className="flex items-start gap-2 text-[0.75rem] text-term-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 accent-term-amber"
|
||||
@@ -127,7 +127,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
<span className="hint block">{t("site.anprEntryHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
||||
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
{t("site.parkDetails")}
|
||||
</div>
|
||||
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
|
||||
@@ -153,7 +153,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
))}
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("site.save")}</button>
|
||||
{msg && <span className="text-[12px] text-term-muted">{msg}</span>}
|
||||
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -412,7 +412,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
<h2 className="mb-3 text-h4 font-semibold text-term-text">{t("subs.title")}</h2>
|
||||
<ul className="mb-3 list-none p-0">
|
||||
{subs.map((s) => (
|
||||
<li key={s.id} className="flex flex-wrap items-center gap-2 border-b border-term-border/60 py-2 text-[12px]">
|
||||
<li key={s.id} className="flex flex-wrap items-center gap-2 border-b border-term-border/60 py-2 text-[0.75rem]">
|
||||
<strong className="text-term-text">{s.holderName ?? t("subs.unnamed")}</strong>
|
||||
<span className={s.status === "active" ? "text-term-green" : "text-term-amber"}>{t(STATUS_KEY[s.status])}</span>
|
||||
<span className="tabular-nums text-term-cyan">{priceLabel(s, t)}</span>
|
||||
@@ -464,13 +464,13 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{plans.length === 0 && <span className="text-[12px] text-term-amber">{t("subs.planNoneAvail")}</span>}
|
||||
{plans.length === 0 && <span className="text-[0.75rem] text-term-amber">{t("subs.planNoneAvail")}</span>}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="label">{t("subs.plan")}</label>
|
||||
<span className="text-[13px] text-term-text">{form.planId || t("subs.noPrice")}</span>
|
||||
<span className="text-[0.8125rem] text-term-text">{form.planId || t("subs.noPrice")}</span>
|
||||
{/* VERSION CORRECTION (admins). The plan itself is frozen, but an admin may
|
||||
move the sub to a different VERSION of that same plan (e.g. one with
|
||||
different timeframes). Price stays as billed. Only shown when the sub has
|
||||
@@ -486,7 +486,15 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
if (cur) versions.unshift(cur);
|
||||
}
|
||||
if (versions.length < 2 && versions.some((v) => v.id === form.planVersionId)) {
|
||||
return <span className="text-[12px] text-term-muted">{t("subs.versionOnlyOne")}</span>;
|
||||
// Keep the 2-col grid flow intact: a lone cell here would shift every
|
||||
// following row by one column (label↔input swap). Emit a full row —
|
||||
// the version label + the "only one version" hint as its control.
|
||||
return (
|
||||
<>
|
||||
<label className="label">{t("subs.version")}</label>
|
||||
<span className="text-[0.75rem] text-term-muted">{t("subs.versionOnlyOne")}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
@@ -504,7 +512,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-[12px] text-term-muted">{t("subs.versionHint")}</span>
|
||||
<span className="text-[0.75rem] text-term-muted">{t("subs.versionHint")}</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
@@ -523,7 +531,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
inputMode="numeric"
|
||||
onChange={(e) => setForm((f) => ({ ...f, quantity: e.target.value, maxConcurrent: e.target.value }))}
|
||||
/>
|
||||
<span className="text-[12px] text-term-muted">{t("subs.quantityHint")}</span>
|
||||
<span className="text-[0.75rem] text-term-muted">{t("subs.quantityHint")}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -533,7 +541,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
<>
|
||||
<label className="label">{t("subs.tender")}</label>
|
||||
<span className="flex items-center gap-3">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-text">
|
||||
<input
|
||||
type="radio"
|
||||
name="tender"
|
||||
@@ -543,7 +551,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
/>
|
||||
{t("subs.tenderCash")}
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-text">
|
||||
<input
|
||||
type="radio"
|
||||
name="tender"
|
||||
@@ -553,13 +561,13 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
/>
|
||||
{t("subs.tenderCard")}
|
||||
</label>
|
||||
<span className="text-[12px] text-term-muted">{t("subs.tenderHint")}</span>
|
||||
<span className="text-[0.75rem] text-term-muted">{t("subs.tenderHint")}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<label className="label">{t("subs.carLimit")}</label>
|
||||
<span className="flex items-center gap-3">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-text">
|
||||
<input type="checkbox" className="accent-term-amber" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
|
||||
</label>
|
||||
{form.carBound && (
|
||||
@@ -580,7 +588,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
inputMode="numeric"
|
||||
onChange={(e) => setForm((f) => ({ ...f, count: e.target.value }))}
|
||||
/>
|
||||
<span className="text-[12px] text-term-muted">
|
||||
<span className="text-[0.75rem] text-term-muted">
|
||||
× {t(PERIOD_KEY[selectedPlan.period])}
|
||||
</span>
|
||||
</span>
|
||||
@@ -592,7 +600,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
{/* Live SERVER quote: ceil(periods) × per-period price. The operator can't
|
||||
override it — this is exactly what will be charged + signed. */}
|
||||
{editing === "new" && form.planId.trim() !== "" && (
|
||||
<span className="text-[12px] text-term-cyan">
|
||||
<span className="text-[0.75rem] text-term-cyan">
|
||||
{quoting
|
||||
? t("subs.quoting")
|
||||
: quote
|
||||
@@ -610,7 +618,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
<input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
|
||||
</div>
|
||||
|
||||
<h4 className="mt-4 mb-1 text-[12px] font-semibold uppercase tracking-wider text-term-muted">{t("subs.credentials")}</h4>
|
||||
<h4 className="mt-4 mb-1 text-[0.75rem] font-semibold uppercase tracking-wider text-term-muted">{t("subs.credentials")}</h4>
|
||||
{form.credentials.map((c, i) => (
|
||||
<div key={i} className="mb-1.5 flex items-center gap-2">
|
||||
{/* Operator chooses the credential type: QR (auto-generated) or RFID
|
||||
@@ -625,7 +633,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
c.value.trim() ? (
|
||||
<input className="input input-sm flex-1 opacity-70" value={c.value} readOnly />
|
||||
) : (
|
||||
<span className="flex-1 self-center text-[12px] italic text-term-muted">{t("subs.qrAutoGen")}</span>
|
||||
<span className="flex-1 self-center text-[0.75rem] italic text-term-muted">{t("subs.qrAutoGen")}</span>
|
||||
)
|
||||
) : (
|
||||
// RFID: the value is read off a physical card (or typed). "Read card"
|
||||
@@ -643,7 +651,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
{/* Capture panel: pick a reader, present the card; the captured value fills
|
||||
the credential. The OTHER reader keeps serving the live flow. */}
|
||||
{capture && (
|
||||
<div className="mt-3 rounded-term border border-term-cyan/50 bg-term-cyan/5 p-3 text-[12px]">
|
||||
<div className="mt-3 rounded-term border border-term-cyan/50 bg-term-cyan/5 p-3 text-[0.75rem]">
|
||||
{capture.phase === "pick" ? (
|
||||
<>
|
||||
<div className="mb-1.5 text-term-text">{t("subs.captureChooseReader")}</div>
|
||||
@@ -673,7 +681,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
<button type="button" className="btn btn-sm" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{msg && <p className={msg.kind === "ok" ? "mt-3 text-[12px] text-term-green" : "mt-3 text-[12px] text-term-red"}>{msg.text}</p>}
|
||||
{msg && <p className={msg.kind === "ok" ? "mt-3 text-[0.75rem] text-term-green" : "mt-3 text-[0.75rem] text-term-red"}>{msg.text}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,19 +237,19 @@ export function SubscriptionPlansManager() {
|
||||
return (
|
||||
<section className="px-4 py-6">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-[13px] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
|
||||
<h3 className="text-[0.8125rem] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
|
||||
{t("plans.add")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mb-3 text-[12px] text-term-muted">{t("plans.intro")}</p>
|
||||
<p className="mb-3 text-[0.75rem] text-term-muted">{t("plans.intro")}</p>
|
||||
|
||||
{msg && (
|
||||
<div className={`mb-3 text-[12px] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
|
||||
<div className={`mb-3 text-[0.75rem] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
|
||||
)}
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-[13px] text-term-muted">{t("plans.noneYet")}</p>
|
||||
<p className="text-[0.8125rem] text-term-muted">{t("plans.noneYet")}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{groups.map(({ planId, head: p, active, versions }) => {
|
||||
@@ -263,15 +263,15 @@ export function SubscriptionPlansManager() {
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-semibold text-term-text">{p.name}</span>
|
||||
{active ? (
|
||||
<span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("plans.inForce")}</span>
|
||||
<span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("plans.inForce")}</span>
|
||||
) : (
|
||||
<span className="rounded border border-term-border px-1 text-[10px] text-term-muted">{t("plans.retired")}</span>
|
||||
<span className="rounded border border-term-border px-1 text-[0.625rem] text-term-muted">{t("plans.retired")}</span>
|
||||
)}
|
||||
{versions > 1 && <span className="text-[10px] text-term-muted">{t("plans.versionCount", { count: versions })}</span>}
|
||||
{versions > 1 && <span className="text-[0.625rem] text-term-muted">{t("plans.versionCount", { count: versions })}</span>}
|
||||
</div>
|
||||
|
||||
{/* Details: price · hours · effective */}
|
||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[12px] text-term-muted">
|
||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[0.75rem] text-term-muted">
|
||||
<span className="tabular-nums text-term-text">
|
||||
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
||||
</span>
|
||||
@@ -280,7 +280,7 @@ export function SubscriptionPlansManager() {
|
||||
</div>
|
||||
|
||||
{/* Used by */}
|
||||
<div className="mt-1 text-[12px]">
|
||||
<div className="mt-1 text-[0.75rem]">
|
||||
{users.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -295,12 +295,12 @@ export function SubscriptionPlansManager() {
|
||||
)}
|
||||
</div>
|
||||
{isOpen && users.length > 0 && (
|
||||
<ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1 rounded-term bg-term-bg px-3 py-2 text-[12px]">
|
||||
<ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1 rounded-term bg-term-bg px-3 py-2 text-[0.75rem]">
|
||||
{users.map((s) => (
|
||||
<li key={s.id} className={s.status === "active" ? "text-term-text" : "text-term-muted"}>
|
||||
{s.holderName || t("subs.unnamed")}
|
||||
{s.quantity > 1 && <span className="text-term-muted"> ×{s.quantity}</span>}
|
||||
{s.status !== "active" && <span className="ml-1 text-[10px]">({t(STATUS_KEY[s.status])})</span>}
|
||||
{s.status !== "active" && <span className="ml-1 text-[0.625rem]">({t(STATUS_KEY[s.status])})</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -349,14 +349,14 @@ export function SubscriptionPlansManager() {
|
||||
<span className="flex items-center gap-2">
|
||||
<input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
|
||||
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })} />
|
||||
<span className="text-[12px] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
|
||||
<span className="text-[0.75rem] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the
|
||||
window they're charged the transient tariff for the gap. Off = 24/7. */}
|
||||
<div className="mt-3 border-t border-term-border pt-3">
|
||||
<label className="flex items-center gap-2 text-[12px] text-term-text">
|
||||
<label className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
@@ -370,7 +370,7 @@ export function SubscriptionPlansManager() {
|
||||
<label className="label">{t("plans.days")}</label>
|
||||
<span className="flex flex-wrap gap-2">
|
||||
{DOW_ORDER.map((d) => (
|
||||
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text">
|
||||
<label key={d} className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
@@ -386,7 +386,7 @@ export function SubscriptionPlansManager() {
|
||||
))}
|
||||
</span>
|
||||
<label className="label">{t("plans.window")}</label>
|
||||
<span className="flex flex-wrap items-center gap-2 text-[12px] text-term-muted">
|
||||
<span className="flex flex-wrap items-center gap-2 text-[0.75rem] text-term-muted">
|
||||
{t("plans.enterAfter")}
|
||||
<input type="time" className="input w-28" value={form.winFrom} onChange={(e) => setForm((f) => f && { ...f, winFrom: e.target.value })} />
|
||||
{t("plans.exitBefore")}
|
||||
@@ -395,14 +395,14 @@ export function SubscriptionPlansManager() {
|
||||
<label className="label">{t("plans.grace")}</label>
|
||||
<span className="flex items-center gap-2">
|
||||
<input className="input w-16" value={form.graceMin} inputMode="numeric" onChange={(e) => setForm((f) => f && { ...f, graceMin: e.target.value })} />
|
||||
<span className="text-[12px] text-term-muted">{t("plans.graceHint")}</span>
|
||||
<span className="text-[0.75rem] text-term-muted">{t("plans.graceHint")}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1.5 text-[11px] text-term-muted">{t("plans.timeframesHint")}</p>
|
||||
<p className="mt-1.5 text-[0.6875rem] text-term-muted">{t("plans.timeframesHint")}</p>
|
||||
</div>
|
||||
|
||||
{form.planId && <p className="mt-2 text-[11px] text-term-amber">{t("plans.newVersionHint")}</p>}
|
||||
{form.planId && <p className="mt-2 text-[0.6875rem] text-term-amber">{t("plans.newVersionHint")}</p>}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={save}>{t("subs.save")}</button>
|
||||
|
||||
@@ -354,11 +354,11 @@ export function TariffComposer() {
|
||||
<section className="px-4 py-6">
|
||||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2>
|
||||
{!state?.active ? (
|
||||
<p className="mb-4 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[12px] text-term-amber">
|
||||
<p className="mb-4 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[0.75rem] text-term-amber">
|
||||
{t("tariff.noRateCard")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mb-4 text-[12px] text-term-muted">
|
||||
<p className="mb-4 text-[0.75rem] text-term-muted">
|
||||
{t("tariff.activeSince", {
|
||||
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
||||
count: state.versions.length,
|
||||
@@ -407,7 +407,7 @@ export function TariffComposer() {
|
||||
{/* A stepped ("up-to") base rate cannot be combined with time tiers — the
|
||||
engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
|
||||
{form.base.mode === "stepped" && form.tiers.length > 0 && (
|
||||
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[12px] text-term-red">
|
||||
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[0.75rem] text-term-red">
|
||||
{t("tariff.steppedTiersConflict")}
|
||||
</p>
|
||||
)}
|
||||
@@ -432,7 +432,7 @@ export function TariffComposer() {
|
||||
<label className="label">{t("tariff.tierDays")}</label>
|
||||
<span className="flex flex-wrap gap-2">
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
||||
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text">
|
||||
<label key={d} className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
|
||||
{t(`tariff.dow${d}`)}
|
||||
</label>
|
||||
@@ -444,7 +444,7 @@ export function TariffComposer() {
|
||||
<span className="text-term-muted">–</span>
|
||||
<input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" />
|
||||
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
|
||||
<span className="text-[11px] text-term-muted">{t("tariff.tierOvernight")}</span>
|
||||
<span className="text-[0.6875rem] text-term-muted">{t("tariff.tierOvernight")}</span>
|
||||
)}
|
||||
</span>
|
||||
<label className="label">{t("tariff.tierDates")}</label>
|
||||
@@ -478,7 +478,7 @@ export function TariffComposer() {
|
||||
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
||||
</button>
|
||||
{msg && (
|
||||
<span className={msg.kind === "ok" ? "text-[12px] text-term-green" : "text-[12px] text-term-red"}>{msg.text}</span>
|
||||
<span className={msg.kind === "ok" ? "text-[0.75rem] text-term-green" : "text-[0.75rem] text-term-red"}>{msg.text}</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -504,7 +504,7 @@ function PricingEditor(props: {
|
||||
const { t, pricing: p } = props;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex gap-4 text-[12px]">
|
||||
<div className="mb-3 flex gap-4 text-[0.75rem]">
|
||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
|
||||
{t("tariff.modeLadder")}
|
||||
@@ -538,7 +538,7 @@ function PricingEditor(props: {
|
||||
<td className="px-2 py-1">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input className="input w-20" value={s.hours} onChange={(e) => props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
||||
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
@@ -587,7 +587,7 @@ function PricingEditor(props: {
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
||||
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -131,7 +131,7 @@ export function TariffLab() {
|
||||
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}>
|
||||
{t("lab.load")}
|
||||
</button>
|
||||
{loadMsg && <span className="text-[12px] text-term-green">{loadMsg}</span>}
|
||||
{loadMsg && <span className="text-[0.75rem] text-term-green">{loadMsg}</span>}
|
||||
</div>
|
||||
|
||||
{/* Hypothetical session inputs */}
|
||||
@@ -167,7 +167,7 @@ export function TariffLab() {
|
||||
|
||||
<label className="label">{t("lab.payment")}</label>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex items-center gap-1 text-[12px] text-term-text">
|
||||
<label className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||
<input type="checkbox" className="accent-term-amber" checked={paid} onChange={(e) => setPaid(e.target.checked)} />
|
||||
{t("lab.paid")}
|
||||
</label>
|
||||
@@ -190,7 +190,7 @@ export function TariffLab() {
|
||||
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
|
||||
{busy ? t("lab.pricing") : t("lab.price")}
|
||||
</button>
|
||||
{err && <span className="text-[12px] text-term-red">{err}</span>}
|
||||
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
@@ -198,19 +198,19 @@ export function TariffLab() {
|
||||
{/* Outcome */}
|
||||
<div className="card card-body">
|
||||
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[13px]">
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[0.8125rem]">
|
||||
<dt className="text-term-muted">{t("lab.amountDue")}</dt>
|
||||
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
|
||||
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
|
||||
<dd className="text-term-text">
|
||||
{formatDuration(result.pricing.periodStart, fromLocalInput(asOf))}
|
||||
{result.pricing.overstay && (
|
||||
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[10px] uppercase text-term-red">
|
||||
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-red">
|
||||
{t("lab.overstay")}
|
||||
</span>
|
||||
)}
|
||||
{result.pricing.withinGrace && (
|
||||
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[10px] uppercase text-term-green">
|
||||
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-green">
|
||||
{t("lab.settled")}
|
||||
</span>
|
||||
)}
|
||||
@@ -230,7 +230,7 @@ export function TariffLab() {
|
||||
<div className="card card-body">
|
||||
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
|
||||
<p className="hint mb-2">{t("lab.curveHint")}</p>
|
||||
<table className="w-full text-[12px] tabular-nums">
|
||||
<table className="w-full text-[0.75rem] tabular-nums">
|
||||
<tbody>
|
||||
{result.curve.map((c) => (
|
||||
<tr key={c.minutes} className="border-b border-term-border/40">
|
||||
|
||||
@@ -51,7 +51,7 @@ export function UsersManager({ user }: { user: SessionUser | null }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>}
|
||||
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[0.75rem] text-term-red">{error}</div>}
|
||||
|
||||
<Modal open={adding} onClose={() => setAdding(false)} title={t("users.new")} width="max-w-2xl">
|
||||
<UserForm
|
||||
@@ -100,8 +100,8 @@ export function UsersManager({ user }: { user: SessionUser | null }) {
|
||||
</Modal>
|
||||
|
||||
<div className="overflow-hidden rounded-term border border-term-border">
|
||||
<table className="w-full text-[12px]">
|
||||
<thead className="bg-term-panel-2 text-[11px] uppercase tracking-wider text-term-muted">
|
||||
<table className="w-full text-[0.75rem]">
|
||||
<thead className="bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
<th className="px-3 py-1.5 text-left">{t("users.username")}</th>
|
||||
<th className="px-3 py-1.5 text-left">{t("users.role")}</th>
|
||||
@@ -291,7 +291,7 @@ function UserForm({
|
||||
{!isEdit && <div className="hint mt-1">{t("users.passwordHint")}</div>}
|
||||
|
||||
{/* Optional profile metadata. */}
|
||||
<div className="mt-4 mb-2 text-[11px] uppercase tracking-wider text-term-muted">{t("users.detailsSection")}</div>
|
||||
<div className="mt-4 mb-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("users.detailsSection")}</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="field">
|
||||
<span className="label">{t("users.fullName")}</span>
|
||||
|
||||
+76
-10
@@ -73,6 +73,8 @@ export interface SessionUser {
|
||||
language: Lang;
|
||||
/** Preferred UI theme (loaded from the server on login). */
|
||||
theme: Theme;
|
||||
/** Preferred UI font scale, percent of base (100 = base; clamped 80–160). */
|
||||
fontScale: number;
|
||||
/** Optional display name (profile metadata); null if unset. */
|
||||
fullName: string | null;
|
||||
/** Optional contact email (profile metadata); null if unset. */
|
||||
@@ -105,6 +107,16 @@ export function setThemePref(theme: Theme): Promise<{ theme: Theme }> {
|
||||
return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) });
|
||||
}
|
||||
|
||||
/** Allowed font-scale band (percent of base) + step. The header control clamps to these. */
|
||||
export const FONT_SCALE_MIN = 80;
|
||||
export const FONT_SCALE_MAX = 160;
|
||||
export const FONT_SCALE_STEP = 10;
|
||||
|
||||
/** Persist the current user's UI font scale (percent; restored on next login). */
|
||||
export function setFontScalePref(fontScale: number): Promise<{ fontScale: number }> {
|
||||
return apiFetch("/api/auth/font-scale", { method: "PUT", body: JSON.stringify({ fontScale }) });
|
||||
}
|
||||
|
||||
/** Edit MY own profile (display name / email). Returns the refreshed session.
|
||||
* Self-service — touches only the signed-in user; no `user:*` permission needed. */
|
||||
export function updateMyProfile(patch: {
|
||||
@@ -275,18 +287,52 @@ export type DeviceConfig = Record<string, ConfigValue>;
|
||||
/** Direction a barrier/relay (or a device bound to it) serves. */
|
||||
export type Direction = "entry" | "exit" | "both";
|
||||
|
||||
/** One relay on an access controller: which barrier it opens, in which direction,
|
||||
* and (optionally) the input terminal its entry button is wired to. */
|
||||
/** The EVENT a relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` → drive a
|
||||
* non-barrier alert lamp (blink while its trigger input is active, SOLID once the camera
|
||||
* confirms a car). The action is implied by the event. */
|
||||
export type RelayEvent = Direction | "radarAlert";
|
||||
|
||||
/** What a controller input terminal means: a transient-entry `button`, a one-car-one-ticket
|
||||
* `presence` sensor (loop/radar), or an `alertTrigger` for a radarAlert lamp. */
|
||||
export type InputRole = "button" | "presence" | "alertTrigger";
|
||||
|
||||
/** One INPUT terminal the host reads (the twin of RelaySpec). An exit radar is just another
|
||||
* `presence` row serving the exit relay. */
|
||||
export interface InputSpec {
|
||||
input: number;
|
||||
role: InputRole;
|
||||
/** The barrier relay this input serves (required for button/presence; optional for
|
||||
* alertTrigger). */
|
||||
relay?: number;
|
||||
/** presence only — induction LOOP or RADAR (label only). */
|
||||
kind?: "loop" | "radar";
|
||||
/** This terminal idles HIGH / is active-LOW (e.g. a radar wired opposite the button). */
|
||||
activeLow?: boolean;
|
||||
/** button only — presence-less fallback cooldown (seconds). */
|
||||
cooldownSec?: number;
|
||||
}
|
||||
|
||||
/** One relay on an access controller: the event it reacts to. Input wiring lives in
|
||||
* `config.inputs[]`; the legacy per-relay button/presence fields are still read for
|
||||
* back-compat but no longer written. */
|
||||
export interface RelaySpec {
|
||||
relay: number;
|
||||
direction: Direction;
|
||||
/** Input terminal of the entry button that fires this relay (transient entry). */
|
||||
/** The event this relay reacts to (UI label: "Event"). */
|
||||
direction: RelayEvent;
|
||||
// ── legacy input fields (read-only back-compat; superseded by config.inputs[]) ──
|
||||
button?: number;
|
||||
/** Anti-double-press (one car = one ticket). PRESENCE: input terminal of a vehicle
|
||||
* loop/barrier-feedback signal; a press prints only with a car present + re-arms when
|
||||
* it clears. COOLDOWN (fallback, no feedback): suppress repeat presses for N seconds. */
|
||||
presenceInput?: number;
|
||||
presenceKind?: "loop" | "radar";
|
||||
presenceActiveLow?: boolean;
|
||||
entryCooldownSec?: number;
|
||||
// ── radarAlert-only ──
|
||||
/** Input terminal whose active edge starts the blink (the radar). */
|
||||
triggerInput?: number;
|
||||
/** Which lane's camera locks this lamp SOLID (default entry). An exit radar locks on exit. */
|
||||
lockLane?: "entry" | "exit";
|
||||
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
|
||||
blinkOnMs?: number;
|
||||
blinkOffMs?: number;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
@@ -297,11 +343,13 @@ export interface TestResult {
|
||||
};
|
||||
}
|
||||
|
||||
/** Test a device config (reachability + preconditions) without saving. */
|
||||
export function testDevice(driverId: string, config: DeviceConfig): Promise<TestResult> {
|
||||
/** Test a device config (reachability + preconditions) without saving. Pass the
|
||||
* 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", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ driverId, config }),
|
||||
body: JSON.stringify({ driverId, config, ...(id ? { id } : {}) }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -333,6 +381,24 @@ export function testAnpr(driverId: string, config: DeviceConfig): Promise<AnprTe
|
||||
});
|
||||
}
|
||||
|
||||
/** Result of a physical print test: a real test slip is pushed to the printer. */
|
||||
export type PrintTestResult =
|
||||
| { ok: true; tookMs: number }
|
||||
| { ok: false; reason: string; detail?: string; tookMs?: number };
|
||||
|
||||
/** Print a real test slip on the printer — without saving. Confirms the printer
|
||||
* actually feeds paper + fires the head (healthCheck only opens the transport). */
|
||||
export function testPrint(
|
||||
driverId: string,
|
||||
config: DeviceConfig,
|
||||
id?: string,
|
||||
): Promise<PrintTestResult> {
|
||||
return apiFetch<PrintTestResult>("/api/setup/test-print", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ driverId, config, id }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Admin reports -------------------------------------------------------
|
||||
export type ReportBucket = "hour" | "day" | "month";
|
||||
|
||||
|
||||
+35
-8
@@ -180,7 +180,7 @@ body {
|
||||
background: var(--color-term-bg);
|
||||
color: var(--color-term-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* Crisp text and no rubber-banding on the fixed appliance display. */
|
||||
@@ -266,23 +266,23 @@ body {
|
||||
/* Small / dense variant for inline table cells */
|
||||
.input-sm {
|
||||
height: var(--control-h-sm);
|
||||
@apply px-2 text-[12px];
|
||||
@apply px-2 text-[0.75rem];
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
.label {
|
||||
@apply text-[11px] uppercase tracking-wider text-term-muted;
|
||||
@apply text-[0.6875rem] uppercase tracking-wider text-term-muted;
|
||||
}
|
||||
.hint {
|
||||
@apply text-[11px] leading-snug text-term-muted;
|
||||
@apply text-[0.6875rem] leading-snug text-term-muted;
|
||||
}
|
||||
|
||||
/* ---- Buttons: a button must look pressable, never like a field ---- */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-1.5 rounded-term border
|
||||
px-3 text-[12px] font-semibold uppercase tracking-wider
|
||||
px-3 text-[0.75rem] font-semibold uppercase tracking-wider
|
||||
transition-colors select-none;
|
||||
height: var(--control-h-md);
|
||||
/* Neutral default: a filled grey body, not a bare outline. */
|
||||
@@ -302,11 +302,11 @@ body {
|
||||
}
|
||||
.btn-sm {
|
||||
height: var(--control-h-sm);
|
||||
@apply px-2.5 text-[11px];
|
||||
@apply px-2.5 text-[0.6875rem];
|
||||
}
|
||||
.btn-lg {
|
||||
height: var(--control-h-lg);
|
||||
@apply px-5 text-[13px];
|
||||
@apply px-5 text-[0.8125rem];
|
||||
}
|
||||
|
||||
/* Primary: FILLED amber, dark text — the unmistakable main action. */
|
||||
@@ -366,7 +366,7 @@ body {
|
||||
}
|
||||
.card-head {
|
||||
@apply flex items-center justify-between border-b border-term-border
|
||||
bg-term-panel-2 px-4 py-2 text-[12px] uppercase tracking-wider text-term-muted;
|
||||
bg-term-panel-2 px-4 py-2 text-[0.75rem] uppercase tracking-wider text-term-muted;
|
||||
}
|
||||
.card-body {
|
||||
@apply p-4;
|
||||
@@ -428,3 +428,30 @@ html.theme-light .btn:hover:not(:disabled) {
|
||||
html.theme-light .btn-primary {
|
||||
color: #fafaf7;
|
||||
}
|
||||
|
||||
/* ── Barrier-light blink ────────────────────────────────────────────────────
|
||||
The booth Entry/Exit indicator blinks green↔red (~1 Hz) when the radar/presence
|
||||
input is active but the camera hasn't confirmed a vehicle yet — mirroring the
|
||||
physical button lamp (relay 3). Toggles a CSS var the component maps onto its
|
||||
border / tint / glyph, so green and red alternate every 500 ms. */
|
||||
@keyframes lane-blink {
|
||||
0%, 49% { --lane-c: var(--color-term-green); --lane-tint: color-mix(in srgb, var(--color-term-green) 10%, transparent); }
|
||||
50%, 100% { --lane-c: var(--color-term-red); --lane-tint: color-mix(in srgb, var(--color-term-red) 10%, transparent); }
|
||||
}
|
||||
.lane-blink {
|
||||
animation: lane-blink 1s steps(1, end) infinite;
|
||||
border-color: var(--lane-c);
|
||||
background: var(--lane-tint);
|
||||
color: var(--lane-c);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* No flashing for motion-sensitive users — hold the "attention" (red) state. */
|
||||
.lane-blink {
|
||||
animation: none;
|
||||
--lane-c: var(--color-term-red);
|
||||
--lane-tint: color-mix(in srgb, var(--color-term-red) 10%, transparent);
|
||||
border-color: var(--lane-c);
|
||||
background: var(--lane-tint);
|
||||
color: var(--lane-c);
|
||||
}
|
||||
}
|
||||
|
||||
+72
-18
@@ -14,6 +14,9 @@ export const en: Catalog = {
|
||||
themeDark: "dark",
|
||||
themeLight: "light",
|
||||
theme: "Theme",
|
||||
fontSmaller: "Smaller text",
|
||||
fontLarger: "Larger text",
|
||||
fontSize: "Text size",
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
months: [
|
||||
@@ -118,7 +121,7 @@ export const en: Catalog = {
|
||||
scanPlaceholder: "Scan or type ticket number…",
|
||||
laneEntry: "Entry",
|
||||
laneExit: "Exit",
|
||||
open: "Open",
|
||||
openTicket: "Read",
|
||||
occupancy: "Occupancy",
|
||||
occUnavailable: "occupancy unavailable",
|
||||
inside: "inside",
|
||||
@@ -133,6 +136,11 @@ export const en: Catalog = {
|
||||
insideCount: "inside",
|
||||
noActiveSessions: "No active sessions.",
|
||||
noMatch: "No sessions match the filter.",
|
||||
// Active-sessions table column headers.
|
||||
colWho: "Ticket / subscriber",
|
||||
colPlate: "Plate",
|
||||
colEntry: "Entry",
|
||||
colElapsed: "Elapsed",
|
||||
badgeOverstay: "overstay",
|
||||
badgeOverstayTitle:
|
||||
"Paid session. The customer failed to exit during the grace period. A new period began.",
|
||||
@@ -141,14 +149,8 @@ export const en: Catalog = {
|
||||
filterSearchSessions: "Search ticket / subscriber / plate…",
|
||||
filterSearchFeed: "Search event / identity / plate…",
|
||||
filterAll: "All",
|
||||
fStatusUnpaid: "Unpaid",
|
||||
fStatusPaid: "Paid",
|
||||
fStatusExiting: "Exiting",
|
||||
fStatusOverstay: "Overstay",
|
||||
fKindTransient: "Transient",
|
||||
fKindSubscription: "Subscribers",
|
||||
fDirEntry: "Entry",
|
||||
fDirExit: "Exit",
|
||||
fSrcBooth: "Booth",
|
||||
fSrcReader: "Reader",
|
||||
fEvtEntry: "Entry",
|
||||
@@ -359,18 +361,49 @@ export const en: Catalog = {
|
||||
relaysTitle: "Relays on this controller",
|
||||
relaysHint:
|
||||
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
|
||||
outputsTitle: "Outputs — relays (barriers + lamp)",
|
||||
outputsHint:
|
||||
"Relays are OUTPUTS: each opens a barrier (or drives the button lamp). Set the relay number and direction. The input terminals (button, sensor) are in the Inputs section below.",
|
||||
pulseOpenMs: "Pulse open (ms)",
|
||||
pulseOpenHint: "How long a barrier relay is held open (jog). Applies to all barrier relays.",
|
||||
inputsTitle: "Inputs — terminals (button, sensor)",
|
||||
inputsHint:
|
||||
"Inputs are TERMINALS the host READS: the entry button and the presence/radar sensor. Each belongs to an entry barrier — it triggers or gates that relay.",
|
||||
inputsIdleHigh: "Inputs idle HIGH",
|
||||
inputsIdleHighHint: "This board idles inputs HIGH (status 1111); a press pulls LOW.",
|
||||
relay: "Relay",
|
||||
entryButtonTerminal: "Entry button on terminal",
|
||||
presenceInput: "Presence loop (terminal)",
|
||||
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.",
|
||||
// Generic input rows: terminal + role + the relay it serves.
|
||||
inputTerminal: "Terminal",
|
||||
inputServesRelay: "Serves relay",
|
||||
roleButton: "Entry button",
|
||||
rolePresenceLoop: "Presence (loop)",
|
||||
rolePresenceRadar: "Presence (radar)",
|
||||
roleAlertTrigger: "Alert trigger",
|
||||
addInput: "+ Add input",
|
||||
entryCooldown: "Cooldown after ticket (s)",
|
||||
entryCooldownHint:
|
||||
"When there's no presence loop: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
|
||||
"When there's no presence sensor: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
|
||||
activeLow: "Active-low",
|
||||
activeLowHint:
|
||||
"Tick if the presence sensor (e.g. a radar) idles HIGH and goes LOW on detection — the opposite of the button. This inverts that terminal's reading so 'present' is read correctly.",
|
||||
eventRadarAlert: "Radar alert (lamp)",
|
||||
triggerInput: "Trigger input",
|
||||
triggerInputHint:
|
||||
"The input terminal (the radar) that starts this relay blinking. Blinks while the trigger is active but the camera doesn't confirm a car; solid on once the camera confirms; off otherwise.",
|
||||
lockLane: "Lock from",
|
||||
lockLaneHint:
|
||||
"Which camera locks the lamp solid: the entry or the exit camera. An exit radar must lock on the EXIT camera.",
|
||||
lockLaneEntry: "Entry camera",
|
||||
lockLaneExit: "Exit camera",
|
||||
blinkOnMs: "Blink on (ms)",
|
||||
blinkOffMs: "Blink off (ms)",
|
||||
addRelay: "+ Add relay",
|
||||
anpr: "Plate recognition (ANPR)",
|
||||
anprHint:
|
||||
"Enable to scan plates on this camera: the vision service reads the plate from a snapshot and feeds it as a read (advisory only — it never opens a barrier on its own). Requires the vision service running.",
|
||||
"Read plates on this camera: the vision service reads the plate from each snapshot and records it (both entry and exit). Requires the vision service running.",
|
||||
anprAuto: "Auto open/close on subscriber plate",
|
||||
anprAutoHint:
|
||||
"Let THIS camera auto-open the barrier when it recognises a subscriber's plate. Turn OFF on a shared entry/exit lane's exit camera, so a car driving IN isn't auto-EXITed by its back plate (recognition still runs — only the auto-trigger is off).",
|
||||
testAnpr: "Test ANPR",
|
||||
anprTesting: "Testing ANPR…",
|
||||
testAnprHint:
|
||||
@@ -381,6 +414,29 @@ export const en: Catalog = {
|
||||
"anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.",
|
||||
"anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).",
|
||||
"anprFail.no-plate": "No plate found in the snapshot.",
|
||||
// Printer test slip — pushes a real slip so the admin can confirm it physically prints.
|
||||
testPrint: "Print test slip",
|
||||
printTesting: "Printing…",
|
||||
testPrintHint:
|
||||
"Sends a test slip to the printer now. ‘Connected’ only opens the link — this confirms the printer actually feeds paper.",
|
||||
printOk: "✓ Test slip sent ({{ms}} ms). Check the printer.",
|
||||
"printFail.print-failed": "The printer rejected the job (out of paper, cover open, or the link dropped).",
|
||||
// Reveal/hide toggle for a secret field (e.g. the device web password).
|
||||
revealSecret: "Show password",
|
||||
hideSecret: "Hide password",
|
||||
alarmUrlTitle: "Alarm Server settings (enter these in the camera)",
|
||||
alarmUrlHint:
|
||||
"Enter these in the camera at Configuration → Event → … → Alarm Settings (or Notify Surveillance Center). The camera POSTs every event here — no polling.",
|
||||
alarmUrlCopy: "Copy all",
|
||||
alarmUrlCopied: "Copied ✓",
|
||||
alarmUrlSaveFirst:
|
||||
"Save the camera first — the address is generated once the device has an ID. Re-open it for editing to see it.",
|
||||
alarmUrlTestFirst:
|
||||
"Click “Test connection” first — that resolves this host's IP on the camera's network (so the camera can reach it).",
|
||||
alarmFieldHost: "Destination IP / Host",
|
||||
alarmFieldUrl: "URL",
|
||||
alarmFieldProtocol: "Protocol",
|
||||
alarmFieldPort: "Port",
|
||||
whichBarrier: "Which barrier does this device serve?",
|
||||
controller: "Controller",
|
||||
choose: "Choose…",
|
||||
@@ -561,7 +617,7 @@ export const en: Catalog = {
|
||||
save: "Save",
|
||||
saved: "Saved.",
|
||||
fieldParkName: "Park name",
|
||||
fieldParkNamePh: "e.g. Acme Parking",
|
||||
fieldParkNamePh: "e.g. Airport Parking",
|
||||
fieldOperator: "Operator (legal name)",
|
||||
fieldOperatorPh: "operating company",
|
||||
fieldNius: "NIUS",
|
||||
@@ -645,10 +701,9 @@ export const en: Catalog = {
|
||||
card: "Card:",
|
||||
srcTickets: "Tickets:",
|
||||
srcSubscriptions: "Subscriptions:",
|
||||
srcSubSales: "sales",
|
||||
srcSubWindow: "out-of-window",
|
||||
drawerSection: "— Drawer —",
|
||||
openingFloat: "Opening float:",
|
||||
openingFloat: "Opening cash:",
|
||||
cashTaken: "Cash taken:",
|
||||
cashAdded: "Cash added:",
|
||||
cashRemoved: "Cash removed:",
|
||||
@@ -682,7 +737,6 @@ export const en: Catalog = {
|
||||
card: "Card",
|
||||
srcTickets: "Tickets",
|
||||
srcSubscriptions: "Subscriptions",
|
||||
srcSubSales: "subs sales",
|
||||
srcSubWindow: "out-of-window",
|
||||
expectedDrawer: "Expected drawer",
|
||||
filterFrom: "From",
|
||||
@@ -700,7 +754,7 @@ export const en: Catalog = {
|
||||
noActivity: "No activity in this shift.",
|
||||
current: "current",
|
||||
drawerSection: "Drawer",
|
||||
openingFloat: "Opening float",
|
||||
openingFloat: "Opening cash",
|
||||
cashTaken: "Cash taken",
|
||||
cashAdded: "Cash added",
|
||||
cashRemoved: "Cash removed",
|
||||
|
||||
+83
-28
@@ -14,6 +14,9 @@ export const sq = {
|
||||
themeDark: "errët",
|
||||
themeLight: "çelët",
|
||||
theme: "Tema",
|
||||
fontSmaller: "Zvogëlo tekstin",
|
||||
fontLarger: "Rrit tekstin",
|
||||
fontSize: "Madhësia e tekstit",
|
||||
today: "Sot",
|
||||
yesterday: "Dje",
|
||||
// Month names (index 0 = January) — kept in the catalog because the appliance's
|
||||
@@ -120,7 +123,7 @@ export const sq = {
|
||||
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
|
||||
laneEntry: "Hyrje",
|
||||
laneExit: "Dalje",
|
||||
open: "Hap",
|
||||
openTicket: "Lexo",
|
||||
occupancy: "Prania",
|
||||
occUnavailable: "zënia e padisponueshme",
|
||||
inside: "brenda",
|
||||
@@ -135,6 +138,11 @@ export const sq = {
|
||||
insideCount: "brenda",
|
||||
noActiveSessions: "Asnjë sesion aktiv.",
|
||||
noMatch: "Asnjë rezultat për filtrin.",
|
||||
// Kokat e kolonave të tabelës së sesioneve aktive.
|
||||
colWho: "Biletë / abonent",
|
||||
colPlate: "Targa",
|
||||
colEntry: "Hyrja",
|
||||
colElapsed: "Koha brenda",
|
||||
badgeOverstay: "tej afatit",
|
||||
badgeOverstayTitle:
|
||||
"Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.",
|
||||
@@ -143,14 +151,8 @@ export const sq = {
|
||||
filterSearchSessions: "Kërko biletë / abonent / targë…",
|
||||
filterSearchFeed: "Kërko event / identitet / targë…",
|
||||
filterAll: "Të gjitha",
|
||||
fStatusUnpaid: "Papaguar",
|
||||
fStatusPaid: "Paguar",
|
||||
fStatusExiting: "Duke dalë",
|
||||
fStatusOverstay: "Tej afatit",
|
||||
fKindTransient: "Kalimtarë",
|
||||
fKindSubscription: "Abonentë",
|
||||
fDirEntry: "Hyrje",
|
||||
fDirExit: "Dalje",
|
||||
fSrcBooth: "Kabinë",
|
||||
fSrcReader: "Lexues",
|
||||
fEvtEntry: "Hyrje",
|
||||
@@ -308,21 +310,21 @@ export const sq = {
|
||||
setup: {
|
||||
title: "Konfigurimi",
|
||||
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.
|
||||
catControllers: "Kontrolluesit (barrierat + butoni i hyrjes)",
|
||||
catControllers: "Kontrollerat (barrierat + butoni i hyrjes)",
|
||||
catReaders: "Lexuesit (QR / RFID)",
|
||||
catCameras: "Kamerat (foto + targë)",
|
||||
catPrinters: "Printerat (bileta / vouchera)",
|
||||
nounController: "kontrollues",
|
||||
nounController: "kontroller",
|
||||
nounReader: "lexues",
|
||||
nounCamera: "kamerë",
|
||||
nounPrinter: "printer",
|
||||
add: "+ Shto {{noun}}",
|
||||
addAnother: "+ Shto edhe një {{noun}}",
|
||||
addAnother: "+ Shto {{noun}}",
|
||||
addTitle: "Shto {{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}}",
|
||||
loadingCatalog: "Duke ngarkuar katalogun e pajisjeve…",
|
||||
// Direction labels (relay direction + inherited binding).
|
||||
@@ -344,9 +346,9 @@ export const sq = {
|
||||
// Device form.
|
||||
noDrivers: "Asnjë drejtues i regjistruar.",
|
||||
chooseDevice: "Zgjidh një pajisje…",
|
||||
scan: "Skano për kontrollues",
|
||||
scan: "Skano për kontroller",
|
||||
scanning: "Duke skanuar…",
|
||||
noControllersFound: "Asnjë kontrollues në LAN.",
|
||||
noControllersFound: "Asnjë kontroller në LAN.",
|
||||
use: "Përdor",
|
||||
test: "Testo lidhjen",
|
||||
testing: "Duke testuar…",
|
||||
@@ -365,22 +367,53 @@ export const sq = {
|
||||
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.",
|
||||
// Relay editor.
|
||||
relaysTitle: "Relet në këtë kontrollues",
|
||||
relaysTitle: "Relet në këtë kontroller",
|
||||
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.",
|
||||
outputsTitle: "Daljet — relet (barrierat + drita)",
|
||||
outputsHint:
|
||||
"Relet janë DALJE: secila hap një barrierë (ose ndez dritën e butonit). Cakto numrin e relesë dhe drejtimin. Terminalet hyrëse (butoni, sensori) janë te seksioni Hyrjet më poshtë.",
|
||||
pulseOpenMs: "Kohëzgjatja e hapjes (ms)",
|
||||
pulseOpenHint: "Sa kohë mbahet rele e barrierës e hapur (jog). Vlen për të gjitha relet e barrierave.",
|
||||
inputsTitle: "Hyrjet — terminalet (buton, sensor)",
|
||||
inputsHint:
|
||||
"Hyrjet janë TERMINALE që hosti i LEXON: butoni i hyrjes dhe sensori i pranisë/radari. Secila i përket një barriere hyrëse — e gateron ose e nis atë rele.",
|
||||
inputsIdleHigh: "Hyrjet në pushim HIGH",
|
||||
inputsIdleHighHint: "Kjo pllakë i mban hyrjet HIGH në pushim (statusi 1111); një shtypje e ul në LOW.",
|
||||
relay: "Rele",
|
||||
entryButtonTerminal: "Butoni i hyrjes në terminalin",
|
||||
presenceInput: "Sensori i pranisë (terminali)",
|
||||
presenceInputHint:
|
||||
"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.",
|
||||
// Generic input rows: terminal + role + the relay it serves.
|
||||
inputTerminal: "Terminali",
|
||||
inputServesRelay: "I shërben reles",
|
||||
roleButton: "Butoni i hyrjes",
|
||||
rolePresenceLoop: "Prania (lak induktiv)",
|
||||
rolePresenceRadar: "Prania (radar)",
|
||||
roleAlertTrigger: "Trigger alarmi",
|
||||
addInput: "+ Shto hyrje",
|
||||
entryCooldown: "Pritje pas biletës (sek)",
|
||||
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.",
|
||||
activeLow: "Aktiv-ulët",
|
||||
activeLowHint:
|
||||
"Shëno nëse sensori i pranisë (p.sh. radari) qëndron HIGH në pushim dhe shkon LOW kur detekton — e kundërta e butonit. Kjo përmbys leximin e atij terminali që 'prania' të lexohet saktë.",
|
||||
eventRadarAlert: "Alarm radar (dritë)",
|
||||
triggerInput: "Trigger input",
|
||||
triggerInputHint:
|
||||
"Terminali i hyrjes (radari) që nis pulsimin e kësaj rele. Pulson kur Trigger input është aktiv por kamera s'konfirmon makinë; ndizet fiks kur kamera konfirmon; përndryshe fiket.",
|
||||
lockLane: "Bllokimi nga",
|
||||
lockLaneHint:
|
||||
"Cila kamerë e ndez dritën fiks: hyrja apo dalja. Një radar i daljes duhet të bllokohet nga kamera e DALJES.",
|
||||
lockLaneEntry: "Kamera e hyrjes",
|
||||
lockLaneExit: "Kamera e daljes",
|
||||
blinkOnMs: "Pulsim ndezur (ms)",
|
||||
blinkOffMs: "Pulsim fikur (ms)",
|
||||
addRelay: "+ Shto rele",
|
||||
// Camera ANPR opt-in.
|
||||
anpr: "Njohja e targave (ANPR)",
|
||||
anprHint:
|
||||
"Aktivizo që ky aparat të skanojë targat: shërbimi i vizionit lexon targën nga pamja dhe e dërgon si lexim (vetëm këshillues — nuk hap vetë barrierën). Kërkon shërbimin e vizionit aktiv.",
|
||||
"Lexo targat në këtë aparat: shërbimi i vizionit lexon targën nga çdo pamje dhe e regjistron (hyrje dhe dalje). Kërkon shërbimin e vizionit aktiv.",
|
||||
anprAuto: "Hapje/mbyllje automatike me targën e abonentit",
|
||||
anprAutoHint:
|
||||
"Lejo që KY aparat të hapë vetë barrierën kur njeh targën e një abonenti. ÇAKTIVIZOJE te aparati i daljes në një korsi të përbashkët hyrje/dalje, që një makinë që HYN të mos DALË automatikisht nga targa e pasme (njohja vazhdon — fiket vetëm hapja automatike).",
|
||||
testAnpr: "Testo ANPR",
|
||||
anprTesting: "Duke testuar ANPR…",
|
||||
testAnprHint:
|
||||
@@ -391,12 +424,36 @@ export const sq = {
|
||||
"anprFail.vision-disabled": "Shërbimi i vizionit është çaktivizuar — aktivizoje (VISION_ENABLED) për ta testuar ANPR.",
|
||||
"anprFail.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).",
|
||||
"anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.",
|
||||
// Printer test slip — pushes a real slip so the admin can confirm it physically prints.
|
||||
testPrint: "Printo provë",
|
||||
printTesting: "Duke printuar…",
|
||||
testPrintHint:
|
||||
"Dërgon një fletë prove te printeri tani. ‘I lidhur’ vetëm hap lidhjen — kjo konfirmon se printeri vërtet nxjerr letër.",
|
||||
printOk: "✓ Fleta e provës u dërgua ({{ms}} ms). Kontrollo printerin.",
|
||||
"printFail.print-failed": "Printeri nuk pranoi punën (pa letër, kapaku hapur, ose lidhja ra).",
|
||||
// Reveal/hide toggle for a secret field (e.g. the device web password).
|
||||
revealSecret: "Shfaq fjalëkalimin",
|
||||
hideSecret: "Fshih fjalëkalimin",
|
||||
// Alarm Server push settings — generated for the camera's Event → Alarm Server form.
|
||||
alarmUrlTitle: "Cilësimet e Alarm Server (vendosi te kamera)",
|
||||
alarmUrlHint:
|
||||
"Vendosi këto te kamera: Configuration → Event → … → Alarm Settings (ose Notify Surveillance Center). Kamera do të dërgojë çdo ngjarje këtu — pa polling.",
|
||||
alarmUrlCopy: "Kopjo të gjitha",
|
||||
alarmUrlCopied: "U kopjua ✓",
|
||||
alarmUrlSaveFirst:
|
||||
"Ruaje kamerën më parë — adresa gjenerohet pasi pajisja të marrë një ID. Hape sërish për editim që ta shohësh.",
|
||||
alarmUrlTestFirst:
|
||||
"Kliko “Testo lidhjen” më parë — kështu përcaktohet IP-ja e këtij hosti në rrjetin e kamerës (që kamera ta thërrasë).",
|
||||
alarmFieldHost: "Destination IP / Host",
|
||||
alarmFieldUrl: "URL",
|
||||
alarmFieldProtocol: "Protokolli",
|
||||
alarmFieldPort: "Porta",
|
||||
// Binding picker.
|
||||
whichBarrier: "Cilën barrierë shërben kjo pajisje?",
|
||||
controller: "Kontrolluesi",
|
||||
controller: "Kontrolleri",
|
||||
choose: "Zgjidh…",
|
||||
relayLabel: "Rele {{relay}} ({{direction}})",
|
||||
noRelaysConfigured: "Ky kontrollues nuk ka rele të konfiguruar.",
|
||||
noRelaysConfigured: "Ky kontroller nuk ka rele të konfiguruar.",
|
||||
},
|
||||
lab: {
|
||||
title: "Lab Tarife",
|
||||
@@ -572,7 +629,7 @@ export const sq = {
|
||||
save: "Ruaj",
|
||||
saved: "U ruajt.",
|
||||
fieldParkName: "Emri i parkimit",
|
||||
fieldParkNamePh: "p.sh. Acme Parking",
|
||||
fieldParkNamePh: "p.sh. Parking Aeroport",
|
||||
fieldOperator: "Operatori (emri ligjor)",
|
||||
fieldOperatorPh: "kompania operuese",
|
||||
fieldNius: "NIUS",
|
||||
@@ -629,7 +686,7 @@ export const sq = {
|
||||
starting: "Duke filluar…",
|
||||
endShift: "Mbyll turnin",
|
||||
ending: "Duke mbyllur…",
|
||||
endConfirm: "Të mbyllet ky turn? Regjistrohet dhe printohet një Raport Z i nënshkruar.",
|
||||
endConfirm: "",
|
||||
drawer: "Arka:",
|
||||
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
|
||||
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
|
||||
@@ -657,10 +714,9 @@ export const sq = {
|
||||
card: "Kartë:",
|
||||
srcTickets: "Bileta:",
|
||||
srcSubscriptions: "Abonime:",
|
||||
srcSubSales: "shitje",
|
||||
srcSubWindow: "jashtë orarit",
|
||||
drawerSection: "— Arka —",
|
||||
openingFloat: "Bilanci fillestar:",
|
||||
openingFloat: "Arka fillestare:",
|
||||
cashTaken: "Para të marra:",
|
||||
cashAdded: "Para të shtuara:",
|
||||
cashRemoved: "Para të hequra:",
|
||||
@@ -694,7 +750,6 @@ export const sq = {
|
||||
card: "Kartë",
|
||||
srcTickets: "Bileta",
|
||||
srcSubscriptions: "Abonime",
|
||||
srcSubSales: "shitje abonimesh",
|
||||
srcSubWindow: "jashtë orarit",
|
||||
expectedDrawer: "Gjëndje arke",
|
||||
// Filter (admin only).
|
||||
@@ -714,7 +769,7 @@ export const sq = {
|
||||
current: "aktual",
|
||||
// Expanded drawer detail.
|
||||
drawerSection: "Arka",
|
||||
openingFloat: "Bilanci fillestar",
|
||||
openingFloat: "Arka fillestare",
|
||||
cashTaken: "Para të marra",
|
||||
cashAdded: "Para të shtuara",
|
||||
cashRemoved: "Para të hequra",
|
||||
|
||||
@@ -16,6 +16,14 @@ export interface LaneStatus {
|
||||
exit: boolean; // true = busy
|
||||
}
|
||||
|
||||
/** Per-lane RADAR presence — a presence input (loop/radar) is shorted at the barrier,
|
||||
* i.e. "something is in the lane" BEFORE the camera confirms a vehicle. Drives the
|
||||
* barrier light's BLINK (the same signal as the physical button lamp / relay 3). */
|
||||
export interface LanePresence {
|
||||
entry: boolean; // true = a radar/presence input on an entry barrier is active
|
||||
exit: boolean; // true = … on an exit barrier
|
||||
}
|
||||
|
||||
/** Cap the in-memory live feed so a long-running booth session can't grow it
|
||||
* unbounded — the full history is always available via the /api/events query. */
|
||||
const MAX_FEED = 200;
|
||||
@@ -31,6 +39,8 @@ interface LiveState {
|
||||
devices: Record<string, DeviceStatus>;
|
||||
/** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */
|
||||
lanes: LaneStatus | null;
|
||||
/** Per-lane radar presence (advisory blink). Null until the first WS hello. */
|
||||
radar: LanePresence | null;
|
||||
setStatus: (s: WsStatus) => void;
|
||||
setOccupancy: (o: Occupancy) => void;
|
||||
pushEvent: (e: LedgerEvent) => void;
|
||||
@@ -40,6 +50,11 @@ interface LiveState {
|
||||
upsertDevice: (d: DeviceStatus) => void;
|
||||
/** Set lane busy/free (WS hello + each lane-status push). */
|
||||
setLanes: (l: LaneStatus) => void;
|
||||
/** Set lane radar presence (WS hello + each lane-presence push). */
|
||||
setRadar: (r: LanePresence) => void;
|
||||
/** Backfill the enriched plate on every feed event matching `identity` (a late async
|
||||
* recognition that landed after the event's own push). No-op if no row matches. */
|
||||
patchPlate: (identity: string, plate: string) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -56,6 +71,7 @@ export const useLiveStore = create<LiveState>((set) => ({
|
||||
feed: [],
|
||||
devices: {},
|
||||
lanes: null,
|
||||
radar: null,
|
||||
setStatus: (status) => set({ status }),
|
||||
setOccupancy: (occupancy) => set({ occupancy }),
|
||||
pushEvent: (e) =>
|
||||
@@ -66,5 +82,11 @@ export const useLiveStore = create<LiveState>((set) => ({
|
||||
setDevices: (list) => set({ devices: byId(list) }),
|
||||
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
|
||||
setLanes: (lanes) => set({ lanes }),
|
||||
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null }),
|
||||
setRadar: (radar) => set({ radar }),
|
||||
patchPlate: (identity, plate) =>
|
||||
set((s) => {
|
||||
if (!s.feed.some((e) => e.identity === identity && !e.plate)) return s; // nothing to fill
|
||||
return { feed: s.feed.map((e) => (e.identity === identity && !e.plate ? { ...e, plate } : e)) };
|
||||
}),
|
||||
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null, radar: null }),
|
||||
}));
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import type { Theme } from "../api.js";
|
||||
import { FONT_SCALE_MAX, FONT_SCALE_MIN } from "../api.js";
|
||||
|
||||
// Theme application. The whole UI reads colour through the --color-term-* tokens;
|
||||
// the light palette lives in index.css under `html.theme-light`. Applying a theme is
|
||||
// just toggling that class on <html>. The active theme is the LOGGED-IN USER's stored
|
||||
// preference (users.theme), applied via applyTheme() after auth resolves — mirroring
|
||||
// how language works. Dark is the default before auth resolves. Printed tickets are
|
||||
// unaffected (always Albanian, dark-agnostic).
|
||||
// Theme + font-scale application. The whole UI reads colour through the --color-term-*
|
||||
// tokens; the light palette lives in index.css under `html.theme-light`. Applying a theme
|
||||
// is just toggling that class on <html>. Both are the LOGGED-IN USER's stored preferences
|
||||
// (users.theme / users.font_scale), applied after auth resolves — mirroring how language
|
||||
// works. Defaults (dark, 100%) apply before auth resolves. Printed tickets are unaffected.
|
||||
|
||||
/** Apply a theme by toggling `theme-light` on <html>. Dark is the absence of the
|
||||
* class (the base tokens). No-op-safe to call repeatedly. */
|
||||
export function applyTheme(theme: Theme): void {
|
||||
document.documentElement.classList.toggle("theme-light", theme === "light");
|
||||
}
|
||||
|
||||
/** Apply a font scale by setting the ROOT font-size (percent). The app's text is sized in
|
||||
* rem (the `text-[…rem]` utilities + the .label/.input/.hint/.btn component classes all
|
||||
* derive from the root), so only TEXT scales — viewport-locked layout (h-screen frame,
|
||||
* max-h-[90vh] modals, vh units) is unaffected, so headers/footers never clip; taller
|
||||
* content just scrolls its own container. NOT `zoom` (which scaled those vh boxes too and
|
||||
* pushed modal chrome out of view). Clamped to the band; no-op-safe to call repeatedly. */
|
||||
export function applyFontScale(pct: number): void {
|
||||
const clamped = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round(pct)));
|
||||
// 100% = the browser's 16px root. The app's rem units scale off this.
|
||||
document.documentElement.style.fontSize = clamped === 100 ? "" : `${clamped}%`;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
||||
import { qk } from "./query.js";
|
||||
import { useLiveStore, type LaneStatus } from "./live-store.js";
|
||||
import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js";
|
||||
import { wsUrl } from "./origin.js";
|
||||
|
||||
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
||||
@@ -14,16 +14,19 @@ import { wsUrl } from "./origin.js";
|
||||
|
||||
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
||||
type WsMessage =
|
||||
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus }
|
||||
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus; radar: LanePresence }
|
||||
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
||||
| { kind: "printer-status"; event: unknown }
|
||||
| { kind: "device-status"; event: DeviceStatus }
|
||||
| { kind: "lane-status"; lanes: LaneStatus };
|
||||
| { kind: "lane-status"; lanes: LaneStatus }
|
||||
| { kind: "lane-presence"; radar: LanePresence }
|
||||
| { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } };
|
||||
|
||||
|
||||
export function useLiveFeed(): void {
|
||||
const qc = useQueryClient();
|
||||
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes } = useLiveStore();
|
||||
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } =
|
||||
useLiveStore();
|
||||
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
||||
// double-invoke and unmount.
|
||||
const sockRef = useRef<WebSocket | null>(null);
|
||||
@@ -56,10 +59,18 @@ export function useLiveFeed(): void {
|
||||
// Initial device-status snapshot for the footer.
|
||||
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
||||
if (msg.lanes) setLanes(msg.lanes);
|
||||
if (msg.radar) setRadar(msg.radar);
|
||||
} else if (msg.kind === "device-status") {
|
||||
upsertDevice(msg.event);
|
||||
} else if (msg.kind === "lane-status") {
|
||||
setLanes(msg.lanes);
|
||||
} else if (msg.kind === "lane-presence") {
|
||||
setRadar(msg.radar);
|
||||
} else if (msg.kind === "plate-recognized") {
|
||||
// Backfill the badge on the already-rendered feed row, and refetch the
|
||||
// Query-owned active-sessions list (re-runs enrichEvents → the now-written plate).
|
||||
patchPlate(msg.plate.identity, msg.plate.plate);
|
||||
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||
} else if (msg.kind === "ledger") {
|
||||
setOccupancy(msg.occupancy);
|
||||
pushEvent(msg.event);
|
||||
|
||||
+66
-14
@@ -10,11 +10,23 @@ import { lazy, Suspense, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
||||
import { can, closeShift, fetchShiftReport, logout, openShift, setLanguagePref, setThemePref } from "./api.js";
|
||||
import {
|
||||
can,
|
||||
closeShift,
|
||||
fetchShiftReport,
|
||||
logout,
|
||||
openShift,
|
||||
setLanguagePref,
|
||||
setThemePref,
|
||||
setFontScalePref,
|
||||
FONT_SCALE_MIN,
|
||||
FONT_SCALE_MAX,
|
||||
FONT_SCALE_STEP,
|
||||
} from "./api.js";
|
||||
import { qk, queryClient } from "./lib/query.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
import { setLanguage } from "./lib/i18n/index.js";
|
||||
import { applyTheme } from "./lib/theme.js";
|
||||
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
||||
@@ -56,7 +68,7 @@ function NavLink({ to, label }: { to: string; label: string }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="px-2 py-1 text-[11px] uppercase tracking-wider text-term-muted rounded-term hover:text-term-text [&.active]:text-term-amber [&.active]:bg-term-panel-2"
|
||||
className="px-2 py-1 text-[0.6875rem] uppercase tracking-wider text-term-muted rounded-term hover:text-term-text [&.active]:text-term-amber [&.active]:bg-term-panel-2"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
@@ -70,7 +82,7 @@ function SetupTab({ to, label, exact = false }: { to: string; label: string; exa
|
||||
<Link
|
||||
to={to}
|
||||
activeOptions={{ exact }}
|
||||
className="border-b-2 border-transparent px-3 py-2 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text [&.active]:border-term-amber [&.active]:text-term-amber"
|
||||
className="border-b-2 border-transparent px-3 py-2 text-[0.75rem] uppercase tracking-wider text-term-muted hover:text-term-text [&.active]:border-term-amber [&.active]:text-term-amber"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
@@ -147,7 +159,7 @@ function LanguageToggle({
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider">
|
||||
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
|
||||
{(["sq", "en"] as const).map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
@@ -193,7 +205,7 @@ function ThemeToggle({
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider">
|
||||
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
|
||||
{(["dark", "light"] as const).map((th) => (
|
||||
<button
|
||||
key={th}
|
||||
@@ -210,6 +222,37 @@ function ThemeToggle({
|
||||
);
|
||||
}
|
||||
|
||||
/** Header font-size control: A−/value/A+ scaling the whole UI (root font-size). Persisted
|
||||
* to the user profile like the theme, restored on next login. Local `active` state seeded
|
||||
* from the prop (the router context doesn't re-render on setUser); App's effect keeps the
|
||||
* DOM in sync with the persisted user on (re)login. */
|
||||
function FontScaleToggle({ user, setUser }: { user: SessionUser; setUser: (u: SessionUser | null) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [active, setActive] = useState<number>(user.fontScale);
|
||||
function step(delta: number) {
|
||||
const next = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, active + delta));
|
||||
if (next === active) return;
|
||||
setActive(next);
|
||||
applyFontScale(next); // instant UI
|
||||
setUser({ ...user, fontScale: next });
|
||||
void setFontScalePref(next).catch(() => {
|
||||
/* non-fatal — the choice still applies this session */
|
||||
});
|
||||
}
|
||||
const btn = "rounded-term px-1.5 py-0.5 text-term-muted hover:text-term-text disabled:opacity-40";
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
|
||||
<button type="button" className={btn} onClick={() => step(-FONT_SCALE_STEP)} disabled={active <= FONT_SCALE_MIN} title={t("common.fontSmaller")} aria-label={t("common.fontSmaller")}>
|
||||
A−
|
||||
</button>
|
||||
<span className="min-w-[2.5rem] text-center text-term-muted" title={t("common.fontSize")}>{active}%</span>
|
||||
<button type="button" className={btn} onClick={() => step(FONT_SCALE_STEP)} disabled={active >= FONT_SCALE_MAX} title={t("common.fontLarger")} aria-label={t("common.fontLarger")}>
|
||||
A+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Header shift control — the site-wide single-open shift expressed as one button:
|
||||
* - no shift open → "Open shift" (enabled; opens this operator's shift)
|
||||
@@ -273,14 +316,14 @@ function ShiftButton() {
|
||||
disabled={busy || blockedByOther}
|
||||
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
||||
onClick={onClick}
|
||||
className={`rounded-term border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${tone}`}
|
||||
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider ${tone}`}
|
||||
>
|
||||
{busy ? t("shift.opening") : label}
|
||||
</button>
|
||||
{!isOpen && (
|
||||
<span className="text-[10px] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
||||
)}
|
||||
{err && <span className="text-[10px] text-term-red">{err}</span>}
|
||||
{err && <span className="text-[0.625rem] text-term-red">{err}</span>}
|
||||
{confirmingClose && (
|
||||
<CloseShiftConfirm
|
||||
busy={busy}
|
||||
@@ -315,7 +358,7 @@ function CloseShiftConfirm({
|
||||
|
||||
return (
|
||||
<Modal open onClose={onCancel} title={t("shift.endShift")} width="max-w-md">
|
||||
<div className="text-[13px] tabular-nums">
|
||||
<div className="text-[0.8125rem] tabular-nums">
|
||||
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
||||
{!x ? (
|
||||
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
|
||||
@@ -327,12 +370,17 @@ function CloseShiftConfirm({
|
||||
{/* Split by source — the operator's ask: subscription money apart from tickets. */}
|
||||
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
|
||||
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
|
||||
<ConfirmFigure label={t("shift.srcSubSales")} value={fmt(x.subscriptionSalesMinor)} sub />
|
||||
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
|
||||
part is broken out below it; subscription SALES is not (it's the remainder). */}
|
||||
<span />
|
||||
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
|
||||
<ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />
|
||||
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
|
||||
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
|
||||
<span />
|
||||
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
|
||||
</div>
|
||||
</>
|
||||
@@ -353,10 +401,13 @@ function CloseShiftConfirm({
|
||||
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
||||
return (
|
||||
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
||||
<span className={`text-[11px] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}>
|
||||
<span
|
||||
className={`whitespace-nowrap text-[0.6875rem] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className={bold ? "font-semibold text-term-text" : "text-term-text"}>{value}</span>
|
||||
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
|
||||
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -399,12 +450,13 @@ function RootLayout() {
|
||||
{user && <ShiftButton />}
|
||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||
{user && <FontScaleToggle user={user} setUser={setUser} />}
|
||||
<StatusDot />
|
||||
{user && (
|
||||
<Link
|
||||
to="/profile"
|
||||
title={t("nav.profile")}
|
||||
className="text-[11px] text-term-muted hover:text-term-text [&.active]:text-term-amber"
|
||||
className="text-[0.6875rem] text-term-muted hover:text-term-text [&.active]:text-term-amber"
|
||||
>
|
||||
{user.username} · {user.roleName}
|
||||
</Link>
|
||||
|
||||
@@ -98,7 +98,7 @@ export function DeviceFooter() {
|
||||
return (
|
||||
<footer
|
||||
ref={rootRef}
|
||||
className="relative flex shrink-0 items-center gap-2 overflow-visible border-t border-term-border bg-term-panel px-3 py-1.5 text-[11px]"
|
||||
className="relative flex shrink-0 items-center gap-2 overflow-visible border-t border-term-border bg-term-panel px-3 py-1.5 text-[0.6875rem]"
|
||||
>
|
||||
<span className="shrink-0 font-semibold uppercase tracking-wider text-term-muted">
|
||||
{t("devices.footerTitle")}
|
||||
@@ -156,7 +156,7 @@ export function DeviceFooter() {
|
||||
{open && problems.length > 0 && (
|
||||
<div className="absolute bottom-full right-2 z-50 mb-1 w-[360px] max-w-[95vw] rounded-term border border-term-border bg-term-panel shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-3 py-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
<span className="text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{t("devices.issuesTitle")}
|
||||
</span>
|
||||
<button
|
||||
@@ -177,13 +177,13 @@ export function DeviceFooter() {
|
||||
<span className={`mt-1 inline-block h-2 w-2 shrink-0 rounded-full ${DOT[d.state]}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className={`text-[12px] font-semibold ${TEXT[d.state]}`}>{label(d)}</span>
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wider text-term-muted">
|
||||
<span className={`text-[0.75rem] font-semibold ${TEXT[d.state]}`}>{label(d)}</span>
|
||||
<span className="shrink-0 text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||
{t(`devices.state.${d.state}`)}
|
||||
</span>
|
||||
</div>
|
||||
{d.detail && <div className="mt-0.5 break-words text-[11px] text-term-muted">{d.detail}</div>}
|
||||
<div className="mt-0.5 text-[10px] tabular-nums text-term-muted/70">
|
||||
{d.detail && <div className="mt-0.5 break-words text-[0.6875rem] text-term-muted">{d.detail}</div>}
|
||||
<div className="mt-0.5 text-[0.625rem] tabular-nums text-term-muted/70">
|
||||
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function SegGroup<V extends string>({
|
||||
key={v || "all"}
|
||||
type="button"
|
||||
onClick={() => onChange(v)}
|
||||
className={`px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${
|
||||
className={`px-2 py-0.5 text-[0.625rem] uppercase tracking-wider transition-colors ${
|
||||
value === v ? "bg-term-border text-term-text" : "text-term-muted hover:text-term-text"
|
||||
}`}
|
||||
>
|
||||
@@ -61,7 +61,7 @@ export function FilterBar({
|
||||
value={search}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="min-w-[8rem] flex-1 rounded border border-term-border/60 bg-transparent px-2 py-0.5 text-[12px] text-term-text placeholder:text-term-muted focus:border-term-amber focus:outline-none"
|
||||
className="min-w-[8rem] flex-1 rounded border border-term-border/60 bg-transparent px-2 py-0.5 text-[0.75rem] text-term-text placeholder:text-term-muted focus:border-term-amber focus:outline-none"
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function Modal({
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<div className="sticky top-0 flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
|
||||
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
<Dialog.Title className="m-0 text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close">
|
||||
|
||||
@@ -21,7 +21,7 @@ export function Panel({
|
||||
>
|
||||
{title && (
|
||||
<header className="flex items-center justify-between px-3 py-1.5 bg-term-panel-2 border-b border-term-border">
|
||||
<h2 className="m-0 text-[11px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
<h2 className="m-0 text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{title}
|
||||
</h2>
|
||||
{right}
|
||||
|
||||
@@ -37,9 +37,9 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
||||
const dirLabel = (dir: "entry" | "exit" | null): string =>
|
||||
dir === "entry" ? t("pay.snapEntry") : dir === "exit" ? t("pay.snapExit") : "—";
|
||||
|
||||
if (isLoading) return <div className="text-[11px] text-term-muted">{t("pay.loadingSnapshots")}</div>;
|
||||
if (isLoading) return <div className="text-[0.6875rem] text-term-muted">{t("pay.loadingSnapshots")}</div>;
|
||||
if (shots.length === 0 && failures.length === 0 && plates.length === 0)
|
||||
return <div className="text-[11px] text-term-muted">{t("pay.noSnapshots")}</div>;
|
||||
return <div className="text-[0.6875rem] text-term-muted">{t("pay.noSnapshots")}</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -50,15 +50,15 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
||||
{dedupePlates(plates).map((p, i) => (
|
||||
<span
|
||||
key={`${p.plate}-${p.direction}-${i}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[11px]"
|
||||
className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[0.6875rem]"
|
||||
title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${
|
||||
p.at ? ` · ${new Date(p.at).toLocaleString()}` : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-[9px] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
|
||||
<span className="text-[0.5625rem] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
|
||||
<span className="font-mono font-semibold text-term-cyan">{p.plate}</span>
|
||||
{typeof p.confidence === "number" && (
|
||||
<span className="text-[10px] text-term-muted">{(p.confidence * 100).toFixed(0)}%</span>
|
||||
<span className="text-[0.625rem] text-term-muted">{(p.confidence * 100).toFixed(0)}%</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
@@ -81,7 +81,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
||||
loading="lazy"
|
||||
/>
|
||||
<span
|
||||
className={`text-[9px] uppercase tracking-wider ${
|
||||
className={`text-[0.5625rem] uppercase tracking-wider ${
|
||||
s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted"
|
||||
}`}
|
||||
>
|
||||
@@ -100,8 +100,8 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
||||
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${new Date(f.occurredAt).toLocaleString()}` : ""}`}
|
||||
>
|
||||
<span className="text-lg leading-none text-term-amber">⚠</span>
|
||||
<span className="text-[9px] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
|
||||
<span className="px-1 text-[9px] leading-tight text-term-muted">{t("pay.snapFailed")}</span>
|
||||
<span className="text-[0.5625rem] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
|
||||
<span className="px-1 text-[0.5625rem] leading-tight text-term-muted">{t("pay.snapFailed")}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ export function StatusDot() {
|
||||
const { t } = useTranslation();
|
||||
const status = useLiveStore((s) => s.status);
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-term-muted">
|
||||
<span className="flex items-center gap-1.5 text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`}
|
||||
/>
|
||||
|
||||
@@ -95,9 +95,9 @@ export function displayIdentity(e: LedgerEvent): string {
|
||||
return e.subscriberLabel ?? e.identity ?? "—";
|
||||
}
|
||||
|
||||
/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps
|
||||
* the time/label/identity/index columns aligned across rows; the detail line lives in
|
||||
* its own row, indented under the identity column. */
|
||||
/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps the
|
||||
* time/label/#index columns aligned across rows; the identity, plate, badges and reason flow
|
||||
* inline in the middle column and wrap there only when they run out of width. */
|
||||
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const style = eventStyleFor(e);
|
||||
@@ -110,52 +110,44 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
|
||||
const reason = renderReason(p, t);
|
||||
const amount = paymentSummary(p);
|
||||
const badges = eventBadges(p);
|
||||
const via = viaKey(p);
|
||||
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
|
||||
const showDetail = detail != null || badges.length > 0 || via != null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(e)}
|
||||
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
|
||||
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-start gap-x-3 border-b border-term-border/50 px-1 py-1 text-left text-[0.75rem] tabular-nums hover:bg-term-panel-2 ${
|
||||
isAnomaly ? "bg-term-red/5" : refusedWarning ? "bg-term-amber/5" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||
<span className={`shrink-0 font-semibold ${style.color}`}>{label}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
||||
<span className="py-px text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||
<span className={`py-px shrink-0 font-semibold ${style.color}`}>{label}</span>
|
||||
{/* Identity + plate + detail all flow in ONE wrapping line — they fill the available
|
||||
width and only wrap to a second line when this cell actually runs out of room (no
|
||||
forced second row). Keeps time/label/#index in their columns. */}
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 py-px">
|
||||
<span className="break-all text-term-text">{displayIdentity(e)}</span>
|
||||
{e.plate && (
|
||||
<span
|
||||
className="shrink-0 rounded border border-term-border px-1 text-[11px] font-semibold tracking-wide text-term-amber"
|
||||
className="shrink-0 rounded border border-term-border px-1 text-[0.6875rem] font-semibold tracking-wide text-term-amber"
|
||||
title={t("booth.plateTitle")}
|
||||
>
|
||||
{e.plate}
|
||||
</span>
|
||||
)}
|
||||
{badges.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="shrink-0 rounded-sm bg-term-red/15 px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide text-term-red"
|
||||
>
|
||||
{t(k)}
|
||||
</span>
|
||||
))}
|
||||
{detail && (
|
||||
<span className={`${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-term-muted">#{e.index}</span>
|
||||
{showDetail && (
|
||||
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{badges.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
||||
>
|
||||
{t(k)}
|
||||
</span>
|
||||
))}
|
||||
{via && (
|
||||
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
|
||||
{t(via)}
|
||||
</span>
|
||||
)}
|
||||
{detail && (
|
||||
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="py-px text-term-muted">#{e.index}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -163,8 +155,8 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
|
||||
/** One label/value line in the event-detail modal. */
|
||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[12px]">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
|
||||
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[0.75rem]">
|
||||
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</span>
|
||||
<span className="min-w-0 break-words text-term-text">{children}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -203,19 +195,19 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
||||
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
|
||||
<div className={`text-sm font-bold uppercase tracking-widest ${style.color}`}>{label}</div>
|
||||
{(reason || money) && (
|
||||
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
||||
<div className={`mt-1 text-[0.8125rem] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
||||
{reason ?? money}
|
||||
</div>
|
||||
)}
|
||||
{!reason && !money && isAnomaly && (
|
||||
<div className="mt-1 text-[13px] text-term-red/90">{t("booth.evtNoReason")}</div>
|
||||
<div className="mt-1 text-[0.8125rem] text-term-red/90">{t("booth.evtNoReason")}</div>
|
||||
)}
|
||||
{badges.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{badges.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
||||
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide text-term-red"
|
||||
>
|
||||
{t(k)}
|
||||
</span>
|
||||
@@ -235,7 +227,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
||||
(the SUBSESS-… session key) for traceability against the ledger. */}
|
||||
{e.subscriberLabel && e.identity && (
|
||||
<DetailRow label={t("booth.edOccurrence")}>
|
||||
<code className="text-[11px] text-term-muted">{e.identity}</code>
|
||||
<code className="text-[0.6875rem] text-term-muted">{e.identity}</code>
|
||||
</DetailRow>
|
||||
)}
|
||||
{money && (
|
||||
@@ -257,7 +249,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
||||
)}
|
||||
{tariffVersionId && (
|
||||
<DetailRow label={t("booth.edTariffVersion")}>
|
||||
<code className="text-[11px] text-term-muted">{tariffVersionId}</code>
|
||||
<code className="text-[0.6875rem] text-term-muted">{tariffVersionId}</code>
|
||||
</DetailRow>
|
||||
)}
|
||||
</div>
|
||||
@@ -265,7 +257,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
||||
{/* The entry/exit evidence images for this session's identity. */}
|
||||
{e.identity && (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
|
||||
<div className="mb-1.5 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
|
||||
<SnapshotStrip identity={e.identity} />
|
||||
</div>
|
||||
)}
|
||||
@@ -275,28 +267,28 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
||||
operator's; tucking them behind a disclosure keeps the common view clean
|
||||
while preserving the tamper-evidence trail on demand. */}
|
||||
<details className="rounded-term border border-term-border bg-term-panel-2">
|
||||
<summary className="cursor-pointer select-none px-3 py-2 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">
|
||||
<summary className="cursor-pointer select-none px-3 py-2 text-[0.6875rem] uppercase tracking-wider text-term-muted hover:text-term-text">
|
||||
{t("booth.edAuditData")}
|
||||
</summary>
|
||||
<div className="border-t border-term-border px-3 pb-3 pt-1">
|
||||
<DetailRow label={t("booth.edSignature")}>
|
||||
<code className="break-all text-[11px] text-term-muted">{e.signature}</code>
|
||||
<code className="break-all text-[0.6875rem] text-term-muted">{e.signature}</code>
|
||||
</DetailRow>
|
||||
<DetailRow label={t("booth.edKeyId")}>
|
||||
<code className="text-[11px] text-term-muted">{e.keyId}</code>
|
||||
<code className="text-[0.6875rem] text-term-muted">{e.keyId}</code>
|
||||
</DetailRow>
|
||||
<DetailRow label={t("booth.edPrevHash")}>
|
||||
<code className="break-all text-[11px] text-term-muted">{e.prevHash ?? "—"}</code>
|
||||
<code className="break-all text-[0.6875rem] text-term-muted">{e.prevHash ?? "—"}</code>
|
||||
</DetailRow>
|
||||
<div className="mb-1.5 mt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
||||
<div className="mb-1.5 mt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
{t("booth.edRawPayload")}
|
||||
</div>
|
||||
{p && Object.keys(p).length > 0 ? (
|
||||
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[11px] text-term-text">
|
||||
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[0.6875rem] text-term-text">
|
||||
{JSON.stringify(p, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="text-[12px] text-term-muted">{t("booth.edNoPayload")}</div>
|
||||
<div className="text-[0.75rem] text-term-muted">{t("booth.edNoPayload")}</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
+54
-13
@@ -7,24 +7,25 @@
|
||||
# See wiki/decisions/container-deployment.md.
|
||||
|
||||
services:
|
||||
# Reverse proxy: :80 → server:3000 (WebSocket /api/ws upgrades pass through natively).
|
||||
# Caddy is a single static binary with a one-line proxy config; swapping http:// for the
|
||||
# site's real hostname later enables automatic HTTPS. The booth is reached at
|
||||
# http://<name-or-ip>/ (the name set via hosts/DNS on-site — NOT baked into any image).
|
||||
# Reverse proxy: :80 → server (127.0.0.1:3000). On the HOST network (see the server note),
|
||||
# so it reaches the host-net server over loopback and publishes :80 directly on the host.
|
||||
# WebSocket /api/ws upgrades pass through natively. Swapping http:// for the site's real
|
||||
# hostname later enables automatic HTTPS. Reached at http://<name-or-ip>/ (name via hosts/DNS
|
||||
# on-site — NOT baked into any image).
|
||||
proxy:
|
||||
image: caddy:2-alpine
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80"
|
||||
# - "443:443" # uncomment when moving to TLS (and set a real hostname in Caddyfile)
|
||||
# Host network: Caddy listens on the host's :80 and proxies the host-net server on
|
||||
# 127.0.0.1:3000. (No `ports:` mapping — host mode publishes directly.)
|
||||
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:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
depends_on:
|
||||
- server
|
||||
networks:
|
||||
- parking
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -33,9 +34,45 @@ services:
|
||||
|
||||
server:
|
||||
restart: always
|
||||
# No published port — only the proxy reaches the server, over the private network.
|
||||
expose:
|
||||
- "3000"
|
||||
# HOST NETWORK — the crux of the appliance. The server is the ONLY container doing device
|
||||
# I/O (camera ISAPI snapshots, relay control, receiving reader/alarm pushes), all on the
|
||||
# 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:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -45,9 +82,13 @@ services:
|
||||
vision:
|
||||
restart: always
|
||||
# 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:
|
||||
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:
|
||||
driver: json-file
|
||||
options:
|
||||
|
||||
+5
-2
@@ -13,8 +13,11 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: /data/parking.sqlite
|
||||
# Reach the vision service over the private compose network by service name.
|
||||
VISION_URL: http://vision:8089
|
||||
# Reach the vision service. DEV: the private compose-network service name (`vision`).
|
||||
# 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}
|
||||
# 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}
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
# i18n long-sentence review — EN vs SQ
|
||||
|
||||
Generated 2026-06-24. Source: `apps/web/src/lib/i18n/en.ts` + `sq.ts`. Threshold: strings ≥ 80 chars in either language (35 keys).
|
||||
|
||||
Review each pair for MEANING (does SQ say the same as EN?). Mark your decision in the **Verdict** line: `OK` / `FIX: <new text>` / `?`.
|
||||
|
||||
Keys with an automated note are tagged **⚑ NOTE**.
|
||||
|
||||
---
|
||||
|
||||
## 1. `badgeOverstayTitle` ⚑
|
||||
|
||||
- **EN** (en.ts:138, 86 ch): Paid session. The customer failed to exit during the grace period. A new period began.
|
||||
- **SQ** (sq.ts:140, 93 ch): Sesion i paguar. Klienti nuk doli brenda afatit kohor. Ka filluar një periudhë e re tarifimi.
|
||||
- **⚑ NOTE:** Spelling: "brënda" → standard is "brenda" (no ë).
|
||||
- **Verdict:**
|
||||
|
||||
## 2. `activeSince`
|
||||
|
||||
- **EN** (en.ts:254, 133 ch): Active since {{date}} · {{count}} version(s) in history. Publishing creates a new version; past sessions keep their original pricing.
|
||||
- **SQ** (sq.ts:257, 137 ch): Aktive që nga {{date}} · {{count}} version(e) në histori. Publikimi krijon një version të ri; sesionet e kaluara ruajnë çmimin origjinal.
|
||||
- **Verdict:**
|
||||
|
||||
## 3. `rateBlocksHint`
|
||||
|
||||
- **EN** (en.ts:263, 256 ch): Each band lasts a number of hours and bills at its own price; bands are consumed in order (the first hours, then the next hours). The last band is \"thereafter\" (open-ended) — its price applies once the ladder is exhausted. Price is per billing increment.
|
||||
- **SQ** (sq.ts:266, 251 ch): Çdo brez zgjat një numër orësh dhe faturohet me çmimin e tij; brezat konsumohen me radhë (orët e para, pastaj orët në vijim). Brezi i fundit është \"më pas\" (i hapur) — çmimi i tij zbatohet pas mbarimit të shkallës. Çmimi është për interval faturimi.
|
||||
- **Verdict:**
|
||||
|
||||
## 4. `defaultCardHint`
|
||||
|
||||
- **EN** (en.ts:275, 98 ch): The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.
|
||||
- **SQ** (sq.ts:278, 116 ch): Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.
|
||||
- **Verdict:**
|
||||
|
||||
## 5. `steppedHint`
|
||||
|
||||
- **EN** (en.ts:280, 210 ch): Set the TOTAL price for a stay up to a given time (e.g. up to 3h = 500). The first row whose limit ≥ the duration wins (the limit is inclusive). The last row's total repeats as a per-day price for longer stays.
|
||||
- **SQ** (sq.ts:283, 233 ch): Vendos çmimin TOTAL për një qëndrim deri në një kohë të caktuar (p.sh. deri 3 orë = 500). Fiton rreshti i parë me kufi ≥ kohëzgjatjes (kufiri përfshihet). Totali i rreshtit të fundit përsëritet si çmim ditor për qëndrime më të gjata.
|
||||
- **Verdict:**
|
||||
|
||||
## 6. `steppedTiersConflict`
|
||||
|
||||
- **EN** (en.ts:285, 235 ch): ⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price'. Publishing is blocked until this is fixed.
|
||||
- **SQ** (sq.ts:288, 244 ch): ⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks'. Publikimi bllokohet derisa kjo të rregullohet.
|
||||
- **Verdict:**
|
||||
|
||||
## 7. `tiersHint`
|
||||
|
||||
- **EN** (en.ts:287, 174 ch): Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.
|
||||
- **SQ** (sq.ts:290, 182 ch): Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.
|
||||
- **Verdict:**
|
||||
|
||||
## 8. `intro`
|
||||
|
||||
- **EN** (en.ts:526, 160 ch): Admin-defined plans the operator sells from — the price is looked up, never typed. Editing a plan publishes a new version; past sales keep their recorded price.
|
||||
- **SQ** (sq.ts:537, 193 ch): Planet i përcakton admini; operatori vetëm shet prej tyre — çmimi merret automatikisht, nuk shkruhet. Ndryshimi i një plani publikon një version të ri; shitjet e mëparshme ruajnë çmimin e tyre.
|
||||
- **Verdict:**
|
||||
|
||||
## 9. `relaysHint`
|
||||
|
||||
- **EN** (en.ts:361, 124 ch): Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.
|
||||
- **SQ** (sq.ts:370, 130 ch): Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.
|
||||
- **Verdict:**
|
||||
|
||||
## 10. `outputsHint`
|
||||
|
||||
- **EN** (en.ts:364, 175 ch): Relays are OUTPUTS: each opens a barrier (or drives the button lamp). Set the relay number and direction. The input terminals (button, sensor) are in the Inputs section below.
|
||||
- **SQ** (sq.ts:373, 178 ch): Relet janë DALJE: secila hap një barrierë (ose ndez dritën e butonit). Cakto numrin e relesë dhe drejtimin. Terminalet hyrëse (butoni, sensori) janë te seksioni Hyrjet më poshtë.
|
||||
- **Verdict:**
|
||||
|
||||
## 11. `pulseOpenHint`
|
||||
|
||||
- **EN** (en.ts:366, 75 ch): How long a barrier relay is held open (jog). Applies to all barrier relays.
|
||||
- **SQ** (sq.ts:375, 85 ch): Sa kohë mbahet rele e barrierës e hapur (jog). Vlen për të gjitha relet e barrierave.
|
||||
- **Verdict:**
|
||||
|
||||
## 12. `inputsHint` ⚑
|
||||
|
||||
- **EN** (en.ts:369, 152 ch): Inputs are TERMINALS the host READS: the entry button and the presence/radar sensor. Each belongs to an entry barrier — it triggers or gates that relay.
|
||||
- **SQ** (sq.ts:378, 153 ch): Hyrjet janë TERMINALE që hosti i LEXON: butoni i hyrjes dhe sensori i pranisë/radari. Secila i përket një barriere hyrëse — e gateron ose e nis atë rele.
|
||||
- **⚑ NOTE:** "e gateron" is an anglicism ("gates it"). Native: "e kushtëzon" / "e lejon".
|
||||
- **Verdict:**
|
||||
|
||||
## 13. `inputsIdleHighHint`
|
||||
|
||||
- **EN** (en.ts:371, 62 ch): This board idles inputs HIGH (status 1111); a press pulls LOW.
|
||||
- **SQ** (sq.ts:380, 80 ch): Kjo pllakë i mban hyrjet HIGH në pushim (statusi 1111); një shtypje e ul në LOW.
|
||||
- **Verdict:**
|
||||
|
||||
## 14. `inputsNoEntryRelay`
|
||||
|
||||
- **EN** (en.ts:373, 87 ch): No entry relay — add an 'Entry' or 'Entry + exit' relay in Outputs to assign terminals.
|
||||
- **SQ** (sq.ts:382, 97 ch): Asnjë rele hyrëse — shto një rele 'Hyrje' ose 'Hyrje + dalje' te Daljet që të caktosh terminalet.
|
||||
- **Verdict:**
|
||||
|
||||
## 15. `presenceInputHint` ⚑
|
||||
|
||||
- **EN** (en.ts:378, 290 ch): Input terminal the vehicle-presence sensor (induction loop or radar) is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the sensor clears (the car drove in) and a new car re-occupies it. Preferred mode.
|
||||
- **SQ** (sq.ts:387, 275 ch): Terminali hyrës ku është lidhur sensori/laku i pranisë së automjetit. Kur vendoset, lëshohet vetëm NJË biletë për automjet: butoni printon vetëm kur ka makinë, dhe nuk lëshon biletë të dytë derisa laku të lirohet (makina hyri) dhe një makinë e re ta zërë. Mënyra e preferuar.
|
||||
- **⚑ NOTE:** EN names "induction loop OR radar"; SQ "sensori/laku i pranisë" omits radar explicitly.
|
||||
- **Verdict:**
|
||||
|
||||
## 16. `entryCooldownHint`
|
||||
|
||||
- **EN** (en.ts:381, 175 ch): When there's no presence sensor: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.
|
||||
- **SQ** (sq.ts:390, 165 ch): Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.
|
||||
- **Verdict:**
|
||||
|
||||
## 17. `presenceActiveLowHint`
|
||||
|
||||
- **EN** (en.ts:387, 178 ch): Tick if the presence sensor (e.g. a radar) idles HIGH and goes LOW on detection — the opposite of the button. This inverts that terminal's reading so 'present' is read correctly.
|
||||
- **SQ** (sq.ts:396, 184 ch): Shëno nëse sensori i pranisë (p.sh. radari) qëndron HIGH në pushim dhe shkon LOW kur detekton — e kundërta e butonit. Kjo përmbys leximin e atij terminali që 'prania' të lexohet saktë.
|
||||
- **Verdict:**
|
||||
|
||||
## 18. `buttonLightHint`
|
||||
|
||||
- **EN** (en.ts:391, 152 ch): The button's 12 V light on a spare relay. Blinks when the radar detects but the camera doesn't confirm a car; solid on when both confirm; off otherwise.
|
||||
- **SQ** (sq.ts:400, 160 ch): Drita 12V e butonit e lidhur në një rele rezervë. Pulson kur radari detekton por kamera s'konfirmon makinë; ndizet fiks kur të dy konfirmojnë; përndryshe fiket.
|
||||
- **Verdict:**
|
||||
|
||||
## 19. `anprHint` ⚑
|
||||
|
||||
- **EN** (en.ts:398, 203 ch): Enable to scan plates on this camera: the vision service reads the plate from a snapshot and feeds it as a read (advisory only — it never opens a barrier on its own). Requires the vision service running.
|
||||
- **SQ** (sq.ts:408, 185 ch): Aktivizo që ky aparat të skanojë targat: shërbimi i vizionit lexon targën nga pamja dhe e dërgon si lexim (vetëm këshillues — nuk hap vetë barrierën). Kërkon shërbimin e vizionit aktiv.
|
||||
- **⚑ NOTE:** SQ uses "aparat" for camera; elsewhere camera = "kamerë" (see testAnprHint). Inconsistent.
|
||||
- **Verdict:**
|
||||
|
||||
## 20. `testAnprHint`
|
||||
|
||||
- **EN** (en.ts:402, 143 ch): Takes a live snapshot from this camera and tries to read a plate, reporting the result and the time it took. Point a plate at the camera first.
|
||||
- **SQ** (sq.ts:412, 169 ch): Merr një pamje të drejtpërdrejtë nga kjo kamerë dhe përpiqet të lexojë një targë, duke raportuar rezultatin dhe kohën e nevojshme. Vendos një targë para kamerës më parë.
|
||||
- **Verdict:**
|
||||
|
||||
## 21. `curveHint`
|
||||
|
||||
- **EN** (en.ts:443, 88 ch): Fee from entry at several durations — see where the daily cap flattens or windows shift.
|
||||
- **SQ** (sq.ts:454, 96 ch): Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.
|
||||
- **Verdict:**
|
||||
|
||||
## 22. `versionHint`
|
||||
|
||||
- **EN** (en.ts:465, 128 ch): Move this subscriber to another version of the same plan. The price stays as billed; only the access hours change going forward.
|
||||
- **SQ** (sq.ts:476, 139 ch): Zhvendos këtë abonent në një version tjetër të të njëjtit plan. Çmimi mbetet siç u faturua; ndryshon vetëm orari i lejuar nga këtu e tutje.
|
||||
- **Verdict:**
|
||||
|
||||
## 23. `deleteInUse`
|
||||
|
||||
- **EN** (en.ts:550, 68 ch): Can't delete — subscriptions still use this plan. Retire it instead.
|
||||
- **SQ** (sq.ts:561, 80 ch): S'mund të fshihet — abonime ende e përdorin këtë plan. Tërhiqe në vend të kësaj.
|
||||
- **Verdict:**
|
||||
|
||||
## 24. `newVersionHint`
|
||||
|
||||
- **EN** (en.ts:553, 84 ch): This publishes a NEW version of the plan — existing sales keep their original price.
|
||||
- **SQ** (sq.ts:564, 86 ch): Kjo publikon një version TË RI të planit — shitjet ekzistuese ruajnë çmimin origjinal.
|
||||
- **Verdict:**
|
||||
|
||||
## 25. `timeframesHint`
|
||||
|
||||
- **EN** (en.ts:570, 168 ch): A scan outside the allowed window is charged the normal transient tariff for the out-of-window minutes (early entry is deferred to exit; late exit is gated until paid).
|
||||
- **SQ** (sq.ts:581, 184 ch): Një skanim jashtë intervalit të lejuar tarifohet me tarifën normale kalimtare për minutat jashtë intervalit (hyrja e hershme shtyhet në dalje; dalja e vonuar bllokohet derisa paguhet).
|
||||
- **Verdict:**
|
||||
|
||||
## 26. `reserveSubsHint` ⚑
|
||||
|
||||
- **EN** (en.ts:582, 164 ch): Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).
|
||||
- **SQ** (sq.ts:593, 198 ch): Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).
|
||||
- **⚑ NOTE:** EN "handle overflow by valet" → SQ "parkim manual" (manual parking) — meaning shift; valet ≠ manual parking.
|
||||
- **Verdict:**
|
||||
|
||||
## 27. `anprEntryHint`
|
||||
|
||||
- **EN** (en.ts:584, 195 ch): When on, a subscriber's plate read by a lane camera opens the barrier through the normal subscription gate. Off: subscribers must use their card/QR. Plate snapshots are still recorded either way.
|
||||
- **SQ** (sq.ts:595, 207 ch): Kur është aktiv, targa e një abonenti e lexuar nga kamera e korsisë hap barrierën përmes portës normale të abonimit. Joaktiv: abonentët duhet të përdorin kartën/QR-në. Fotot e targave regjistrohen gjithsesi.
|
||||
- **Verdict:**
|
||||
|
||||
## 28. `voucherHint`
|
||||
|
||||
- **EN** (en.ts:659, 128 ch): A receipt (Mandat Arkëtimi) adds cash; a disbursement (Mandat Pagese) removes it. The float only moves with an admin's sign-off.
|
||||
- **SQ** (sq.ts:671, 97 ch): Mandat Arkëtimi shton para; Mandat Pagese heq para. Arka lëviz vetëm me autorizimin e një admini.
|
||||
- **Verdict:**
|
||||
|
||||
## 29. `xReportHint`
|
||||
|
||||
- **EN** (en.ts:667, 83 ch): View only — nothing is recorded. These figures are signed when the shift is closed.
|
||||
- **SQ** (sq.ts:679, 85 ch): Vetëm për shikim — asgjë nuk regjistrohet. Këto shifra nënshkruhen kur mbyllet turni.
|
||||
- **Verdict:**
|
||||
|
||||
## 30. `gateBody`
|
||||
|
||||
- **EN** (en.ts:691, 80 ch): No shift is open. Open your shift so payments and exits are recorded against it.
|
||||
- **SQ** (sq.ts:703, 95 ch): Asnjë turn nuk është i hapur. Hap turnin tënd që pagesat dhe daljet të regjistrohen te ky turn.
|
||||
- **Verdict:**
|
||||
|
||||
## 31. `gateOtherBody`
|
||||
|
||||
- **EN** (en.ts:694, 120 ch): {{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.
|
||||
- **SQ** (sq.ts:706, 140 ch): {{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.
|
||||
- **Verdict:**
|
||||
|
||||
## 32. `overstayHint` ⚑
|
||||
|
||||
- **EN** (en.ts:813, 155 ch): Earlier session paid. The customer failed to exit during the grace period. Payment for the new period is required. The total below is the new period's fee.
|
||||
- **SQ** (sq.ts:827, 151 ch): Sesion i mëparshëm i paguar. Klienti nuk doli brënda afatit kohor. Kërkohet pagesë për periudhën e re. Totali më poshtë është tarifa e periudhës së re.
|
||||
- **⚑ NOTE:** Spelling: "brënda" → standard is "brenda" (no ë).
|
||||
- **Verdict:**
|
||||
|
||||
## 33. `subAssistHint`
|
||||
|
||||
- **EN** (en.ts:837, 101 ch): Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.
|
||||
- **SQ** (sq.ts:851, 108 ch): Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.
|
||||
- **Verdict:**
|
||||
|
||||
## 34. `windowChargeHint`
|
||||
|
||||
- **EN** (en.ts:842, 154 ch): This subscriber parked outside their plan's allowed hours. They owe the transient tariff for the out-of-window time — take payment, then open the barrier.
|
||||
- **SQ** (sq.ts:856, 153 ch): Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën, pastaj hap barrierën.
|
||||
- **Verdict:**
|
||||
|
||||
## 35. `cancelTicketHint`
|
||||
|
||||
- **EN** (en.ts:852, 115 ch): Cancels a wrongly-printed ticket. A signed record is kept (operator + reason); the original entry is never deleted.
|
||||
- **SQ** (sq.ts:866, 129 ch): Anulon një biletë të printuar gabimisht. Ruhet një gjurmë e nënshkruar (operatori + arsyeja); hyrja origjinale nuk fshihet kurrë.
|
||||
- **Verdict:**
|
||||
|
||||
---
|
||||
|
||||
## Summary of flagged items
|
||||
|
||||
- `badgeOverstayTitle` — Spelling: "brënda" → standard is "brenda" (no ë).
|
||||
- `inputsHint` — "e gateron" is an anglicism ("gates it"). Native: "e kushtëzon" / "e lejon".
|
||||
- `presenceInputHint` — EN names "induction loop OR radar"; SQ "sensori/laku i pranisë" omits radar explicitly.
|
||||
- `anprHint` — SQ uses "aparat" for camera; elsewhere camera = "kamerë" (see testAnprHint). Inconsistent.
|
||||
- `reserveSubsHint` — EN "handle overflow by valet" → SQ "parkim manual" (manual parking) — meaning shift; valet ≠ manual parking.
|
||||
- `overstayHint` — Spelling: "brënda" → standard is "brenda" (no ë).
|
||||
|
||||
_All other pairs: meaning judged faithful in automated review; confirm during your read._
|
||||
@@ -0,0 +1,99 @@
|
||||
# Komodo Stack environment — the COMPLETE reference of every env the booth stack reads:
|
||||
# required, image-selection, set-by-compose (don't override), and the optional tunables
|
||||
# with their code defaults. Under Komodo, plain env lives in the Stack definition
|
||||
# (komodo/resources.toml); the two SECRETS come from Core's secret store, PER BOOTH and
|
||||
# UNIQUE. This file is DOCUMENTATION — never fill in real secrets here. See
|
||||
# wiki/decisions/fleet-deployment-komodo.md.
|
||||
#
|
||||
# A minimal working Stack only needs: REGISTRY, TAG, the two secrets, COOKIE_SECURE=0,
|
||||
# VISION_ENABLED=1, WS_ALLOWED_ORIGINS. Everything under "OPTIONAL TUNABLES" has a safe
|
||||
# default in code — set one only to override it.
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# IMAGE SELECTION (picks which container image to pull — not server runtime env)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||
# IMMUTABLE per-commit tag. Manual + pinned. Bump per deploy. Never the moving `dev`
|
||||
# on a PRODUCTION booth (a staging booth may track `dev`).
|
||||
TAG=dev-830993b
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# REQUIRED (no safe default — the server refuses to boot / login breaks without)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# Login signing secret (>=32 chars, no change-me/insecure/dev-only). openssl rand -hex 32.
|
||||
JWT_SECRET=[[booth_<name>_jwt_secret]]
|
||||
# Ledger-signing HMAC key — the anti-fraud root. DISTINCT per booth; never reuse. If unset
|
||||
# it falls back to JWT_SECRET (warned). openssl rand -hex 32.
|
||||
EVENT_SIGNING_KEY=[[booth_<name>_event_signing_key]]
|
||||
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT, so
|
||||
# without =0 the auth cookie never sends and operators CANNOT log in. Set 1 only behind TLS.
|
||||
COOKIE_SECURE=0
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# COMMONLY SET (have defaults, but you usually want these explicit on a booth)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# Turn the ANPR/vision call on. Default "" (off-ish). Set 1 to enable. (Prod compose also
|
||||
# forces VISION_RECOGNIZER=fast_alpr on the vision container.)
|
||||
VISION_ENABLED=1
|
||||
# Extra origins the booth WebSocket (/api/ws) accepts beyond same-origin. Comma-separated,
|
||||
# e.g. http://parksystems.msai.al. Blank = only the same-origin booth URL. Default "".
|
||||
WS_ALLOWED_ORIGINS=
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# SET BY COMPOSE — do NOT put these in the Stack (the compose files own them)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# VISION_URL=http://127.0.0.1:8089 # prod override (host-net server → loopback vision)
|
||||
# DATABASE_URL=/data/parking.sqlite # the mounted volume (the signed ledger)
|
||||
# VISION_RECOGNIZER=fast_alpr # prod override on the vision container
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# OPTIONAL TUNABLES — all have code defaults; set only to override. (defaults shown)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# --- networking / process ---
|
||||
# PORT=3000 # server listen port
|
||||
# HOST=0.0.0.0 # bind interface (127.0.0.1 = loopback only)
|
||||
# BACKEND_HOST_IP= # override the auto-picked IP devices push back to
|
||||
# # (multi-NIC hosts; usually auto-detected fine)
|
||||
# WEB_DIST_DIR= # where the built SPA lives (the image sets it)
|
||||
# --- logging ---
|
||||
# LOG_LEVEL=info # debug|info|warn|error
|
||||
# LOG_RETENTION_DAYS=30 # app_logs auto-purge age
|
||||
# LOG_RETENTION_MAX_ROWS=50000 # app_logs row cap
|
||||
# RECYCLE_BIN_RETENTION_DAYS=30 # soft-deleted items auto-purge age (0 = keep forever)
|
||||
# --- device monitor / lane ---
|
||||
# DEVICE_POLL_MS=8000 # device health poll interval
|
||||
# PRINTER_POLL_MS=5000 # printer status poll interval
|
||||
# LANE_BUSY_TTL_MS=30000 # how long a lane stays "busy" after a vehicle push
|
||||
# CAPTURE_TTL_MS=30000 # snapshot evidence cache TTL
|
||||
# --- ANPR / vision (server side) ---
|
||||
# VISION_URL is compose-set (above). These are the knobs you may tweak per booth:
|
||||
# VISION_TIMEOUT_MS=1500 # per /analyze call timeout
|
||||
# VISION_MIN_CONFIDENCE=0.5 # advisory floor (telemetry/lane); below = low_confidence
|
||||
# VISION_ENTRY_MIN_CONFIDENCE=0.85 # STRICT barrier-driving floor (auto entry/exit). A
|
||||
# # read below this is ignored (falls back to card/QR).
|
||||
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this = one presentation
|
||||
# ANPR_POLL_MS=1000 # poll-until-confident: re-pull a fresh frame every N ms
|
||||
# ANPR_POLL_WINDOW_MS=8000 # ...for this long AFTER THE LAST vehicle push (sliding:
|
||||
# # a car arriving mid-loop extends it). RAISE if a slow
|
||||
# # barrier means the car waits >8s before reading clean.
|
||||
# ANPR_POLL_MAX_MS=30000 # hard ceiling on one loop from start (so a continuously
|
||||
# # busy lane can't slide the window forever)
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# VISION CONTAINER env (the Python ANPR service — its OWN process, prefix VISION_)
|
||||
# Mostly compose-set; documented here for completeness. (defaults shown)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# VISION_RECOGNIZER=fast_alpr # stub | fast_alpr (prod compose forces fast_alpr)
|
||||
# VISION_HOST=0.0.0.0 # bind (prod publishes 127.0.0.1 only — see compose)
|
||||
# VISION_PORT=8089
|
||||
# VISION_DETECTOR_MODEL=yolo-v9-t-384-license-plate-end2end
|
||||
# VISION_OCR_MODEL=cct-xs-v2-global-model
|
||||
# VISION_MIN_CONFIDENCE=0.5 # the Python service's own floor (keep ~in sync w/ server)
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# SECRETS — referenced by name in resources.toml, stored in Core (never inline here)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# JWT_SECRET -> [[booth_<name>_jwt_secret]] (login)
|
||||
# EVENT_SIGNING_KEY -> [[booth_<name>_event_signing_key]] (ledger signing — fraud root)
|
||||
# periphery passkey -> [[periphery_passkey_booth_<name>]] (agent onboarding)
|
||||
# registry account -> [[gitea_registry_account]] (image pull)
|
||||
@@ -0,0 +1,60 @@
|
||||
# `komodo/` — fleet deployment as code
|
||||
|
||||
Infra-as-code for the **Komodo Core** control plane that deploys the parking appliance to the
|
||||
booth fleet over the **NetBird** mesh. See `wiki/decisions/fleet-deployment-komodo.md` for the
|
||||
rationale, threat-model analysis, and the three settled choices (many/growing fleet · deploys
|
||||
are **manual + pinned** · secrets are **Komodo-managed, per-booth**).
|
||||
|
||||
This directory does **not** change how images are built or how the app runs — it's only the
|
||||
control plane. The booth still runs the same `docker-compose.yml` + `docker-compose.prod.yml`
|
||||
([[container-deployment]]); Komodo just drives them remotely instead of someone SSH-ing in to
|
||||
run `booth.sh`.
|
||||
|
||||
## Files
|
||||
|
||||
- **`resources.toml`** — the Komodo resource definitions (Servers, Stacks, optional Builders/
|
||||
Procedures), synced into Core via a **ResourceSync**. This is the reviewable, version-
|
||||
controlled source of truth for *which booth runs what*.
|
||||
- **`.env.komodo.example`** — the variables a Stack expects, documenting what comes from Core's
|
||||
**secret store** (per-booth `JWT_SECRET` / `EVENT_SIGNING_KEY`) vs. plain Stack env.
|
||||
|
||||
## How Core consumes this (one-time)
|
||||
|
||||
In Komodo Core, create a **ResourceSync** pointing at this repo + path (`komodo/resources.toml`),
|
||||
on the branch you manage from (e.g. `main`). Core reads the file and reconciles Servers/Stacks to
|
||||
match. Thereafter, a PR to this directory + a sync is how you change the fleet — no clicking.
|
||||
|
||||
> Komodo's TOML schema evolves across releases. Treat `resources.toml` as a **starting sketch**:
|
||||
> `resources.toml` mirrors the **working `park-buzi` Stack** (built by hand in the Core UI, then
|
||||
> exported to TOML — so field names match the running Komodo version, v2.2). Import it into the
|
||||
> sync **Unmanaged** first and review the diff; it should be ~empty against the live Stack.
|
||||
|
||||
## How servers get created — NOT here
|
||||
|
||||
There is **no `[[server]]` block** in `resources.toml`. Servers are created by the **Periphery
|
||||
agent onboarding outbound**: in Core, create a one-time **Onboarding Key** (Settings →
|
||||
Onboarding), then install Periphery on the booth passing `--onboarding-key` + `--core-address`
|
||||
(Core's reverse-proxy URL, reached over the NetBird mesh) + `--connect-as=<booth-name>`. The
|
||||
agent self-registers, generates its own auto-rotating key pair (private key never leaves the
|
||||
booth), and connects **outbound** — the booth opens **no inbound port**. The sync owns only the
|
||||
**Stack**, which references the server by the name it onboarded as (`server = "park-buzi"`). See
|
||||
`wiki/decisions/fleet-deployment-komodo.md`.
|
||||
|
||||
## Adding booth N
|
||||
|
||||
Copy the `[[stack]]` block, change `name`, `server` (its onboarded name), and the per-booth
|
||||
secret references (`[[park_<site>_jwt_secret]]`, `[[park_<site>_event_signing_key]]`). Create
|
||||
those secrets in Core's store first.
|
||||
|
||||
## Hard rules encoded here (do not relax without updating the decision page)
|
||||
|
||||
1. **No deploy webhook on a booth Stack.** Deploys are a human action; pin `TAG=dev-<sha>` before
|
||||
a production booth goes live. A moving `:dev` on a production booth is the non-determinism we
|
||||
rejected. (`TAG=dev` here is fine while staging.)
|
||||
2. **Onboarding, outbound, mesh-only.** Servers self-register via an onboarding key; Periphery
|
||||
connects outbound to Core's mesh URL and exposes no inbound port. Never a LAN/WAN address.
|
||||
3. **Secrets are per-booth and unique.** `EVENT_SIGNING_KEY` signs the anti-fraud ledger — one
|
||||
leak must taint one booth, never the fleet. Reference Core secrets by name; never inline a
|
||||
real value in this file (it's in git).
|
||||
4. **Volumes preserved.** The Stack must never run `compose down -v` — that would wipe the
|
||||
`parking-data` volume (the signed ledger). Komodo's "destroy" is gated for the same reason.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Komodo resources — parking appliance fleet (control plane as code)
|
||||
#
|
||||
# Synced into Komodo Core via a ResourceSync pointing at this file. Drives the SAME
|
||||
# compose files the booth runs locally (docker-compose.yml + docker-compose.prod.yml);
|
||||
# Komodo Periphery on each booth executes them. See:
|
||||
# wiki/decisions/fleet-deployment-komodo.md (rationale + threat model)
|
||||
# wiki/decisions/container-deployment.md (image build/tag/registry — unchanged)
|
||||
#
|
||||
# This file mirrors the WORKING park-buzi Stack (built by hand in the Core UI, then
|
||||
# exported to TOML). Field names match the running Komodo version (v2.2).
|
||||
#
|
||||
# NO [[server]] block: servers are created by the AGENT onboarding outbound (a one-time
|
||||
# onboarding key → Periphery self-registers with auto-rotating key pairs). The sync owns
|
||||
# only the Stack; it references the server by the name it onboarded as (`connect_as`).
|
||||
#
|
||||
# Secrets ([[park_buzi_jwt_secret]] etc.) are REFERENCES to Komodo Core's secret store —
|
||||
# per-booth + unique, never inlined here (this file is in git). JWT_SECRET gates login;
|
||||
# EVENT_SIGNING_KEY signs the append-only anti-fraud ledger.
|
||||
#
|
||||
# Deploys are MANUAL + PINNED in spirit: bump TAG to an immutable dev-<sha> before a
|
||||
# production booth goes live (TAG=dev here is the moving tag, fine while staging). NO
|
||||
# deploy webhook is attached to a booth Stack.
|
||||
|
||||
##############################################################################
|
||||
# Stack — the deployable unit for booth "park-buzi". One Stack per booth; add a
|
||||
# new [[stack]] block per site (unique name, its own per-booth secret refs).
|
||||
##############################################################################
|
||||
|
||||
[[stack]]
|
||||
name = "park-buzi"
|
||||
[stack.config]
|
||||
server = "park-buzi"
|
||||
git_provider = "git.infra.msai.al"
|
||||
git_account = "komodo"
|
||||
repo = "mca/parking_solution"
|
||||
branch = "dev"
|
||||
file_paths = [
|
||||
"docker-compose.yml",
|
||||
"docker-compose.prod.yml"
|
||||
]
|
||||
registry_provider = "git.infra.msai.al"
|
||||
registry_account = "komodo"
|
||||
environment = """
|
||||
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||
TAG=dev
|
||||
COOKIE_SECURE=0
|
||||
VISION_ENABLED=1
|
||||
WS_ALLOWED_ORIGINS=
|
||||
JWT_SECRET=[[park_buzi_jwt_secret]]
|
||||
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
||||
"""
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Per-user UI font scale (PERCENT of base; 100 = base). Persisted like `theme`/`language`
|
||||
-- so an operator's chosen text size is restored on the next login from any booth. Additive
|
||||
-- ALTER ADD COLUMN — backward-compatible; existing users default to 100 (base). The client
|
||||
-- clamps to 80–160 in steps of 10. Printed tickets are unaffected (server-rendered).
|
||||
ALTER TABLE `users` ADD `font_scale` integer DEFAULT 100 NOT NULL;
|
||||
@@ -99,6 +99,13 @@
|
||||
"when": 1781885600000,
|
||||
"tag": "0013_anpr_entry_toggle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1781885700000,
|
||||
"tag": "0014_user_font_scale",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -73,6 +73,11 @@ export const users = sqliteTable("users", {
|
||||
theme: text("theme", { enum: ["dark", "light"] })
|
||||
.notNull()
|
||||
.default("dark"),
|
||||
// Preferred UI font scale (PERCENT of base, e.g. 100 = base, 120 = 20% larger).
|
||||
// Persisted like `theme` (read on login, restored from any booth). Integer percent
|
||||
// avoids float drift; the client clamps to 80–160 in steps of 10. Printed tickets are
|
||||
// unaffected (server-rendered).
|
||||
fontScale: integer("font_scale").notNull().default(100),
|
||||
// Optional operator profile metadata — display name + contact details. All
|
||||
// nullable; only username/password/role are required to create a user. fullName
|
||||
// (when set) is the human label for audit/Z-report display.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { activeLowFrom, inputActive } from "./access-dingtian.js";
|
||||
|
||||
// Per-input active-level normalisation. The board has ONE resting level, but a radar
|
||||
// can idle opposite the button — listing its terminal in `activeLow` inverts just that
|
||||
// input so "present" reads correctly. See wiki/entities/hikvision-radar.md.
|
||||
|
||||
describe("inputActive (per-input active-level)", () => {
|
||||
const none = new Set<number>();
|
||||
const radarOnI2 = new Set<number>([2]);
|
||||
|
||||
it("default board (resting HIGH): a pull LOW is active, HIGH is rest", () => {
|
||||
// Button on I1, board idles HIGH → active when LOW.
|
||||
expect(inputActive(false, 1, true, none)).toBe(true); // LOW = pressed
|
||||
expect(inputActive(true, 1, true, none)).toBe(false); // HIGH = rest
|
||||
});
|
||||
|
||||
it("resting LOW board: a pull HIGH is active", () => {
|
||||
expect(inputActive(true, 1, false, none)).toBe(true);
|
||||
expect(inputActive(false, 1, false, none)).toBe(false);
|
||||
});
|
||||
|
||||
it("active-low override inverts ONLY the listed input", () => {
|
||||
// Board idles HIGH (button on I1), radar on I2 idles HIGH and goes LOW on detect →
|
||||
// mark I2 active-low so detection (LOW) reads active.
|
||||
// I1 (button) keeps the board default:
|
||||
expect(inputActive(false, 1, true, radarOnI2)).toBe(true); // button LOW = active
|
||||
expect(inputActive(true, 1, true, radarOnI2)).toBe(false);
|
||||
// I2 (radar) overridden to active-low: active when LOW.
|
||||
expect(inputActive(false, 2, true, radarOnI2)).toBe(true); // radar LOW = detecting
|
||||
expect(inputActive(true, 2, true, radarOnI2)).toBe(false); // radar HIGH = clear
|
||||
});
|
||||
});
|
||||
|
||||
describe("activeLowFrom (config → active-low terminal set)", () => {
|
||||
it("reads a config.inputs[] presence row with activeLow", () => {
|
||||
const set = activeLowFrom({
|
||||
inputs: [
|
||||
{ input: 1, role: "button", relay: 1 },
|
||||
{ input: 2, role: "presence", relay: 1, kind: "radar", activeLow: true },
|
||||
{ input: 5, role: "presence", relay: 2, kind: "radar", activeLow: true }, // exit radar
|
||||
],
|
||||
});
|
||||
expect([...set].sort()).toEqual([2, 5]); // both radars inverted; the button is not
|
||||
});
|
||||
|
||||
it("still reads the LEGACY relays[].presenceActiveLow (back-compat)", () => {
|
||||
const set = activeLowFrom({
|
||||
relays: [{ relay: 1, direction: "entry", presenceInput: 2, presenceActiveLow: true }],
|
||||
});
|
||||
expect([...set]).toEqual([2]);
|
||||
});
|
||||
|
||||
it("honours an explicit top-level inputActiveLow escape hatch + merges all sources", () => {
|
||||
const set = activeLowFrom({
|
||||
inputActiveLow: [3],
|
||||
inputs: [{ input: 2, role: "presence", activeLow: true }],
|
||||
relays: [{ relay: 1, direction: "entry", presenceInput: 4, presenceActiveLow: true }],
|
||||
});
|
||||
expect([...set].sort()).toEqual([2, 3, 4]);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { createSocket } from "node:dgram";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import type {
|
||||
AccessControlDevice,
|
||||
AuxOutputDevice,
|
||||
DeviceHealth,
|
||||
HardenableDevice,
|
||||
HardenResult,
|
||||
@@ -166,6 +167,49 @@ interface DingtianStatus {
|
||||
channels: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise one input line to "active". `high` = the line is currently HIGH. An input
|
||||
* whose 1-based channel is in `activeLow` is active when LOW (idles HIGH), overriding
|
||||
* the board-wide `restingHigh`; otherwise active = differs from the resting level. This
|
||||
* is the seam that lets a radar (wired opposite the button) read correctly. Exported for
|
||||
* unit testing the bit logic without a UDP socket. See wiki/entities/hikvision-radar.md.
|
||||
*/
|
||||
export function inputActive(
|
||||
high: boolean,
|
||||
channel1Based: number,
|
||||
restingHigh: boolean,
|
||||
activeLow: ReadonlySet<number>,
|
||||
): boolean {
|
||||
return activeLow.has(channel1Based) ? !high : high !== restingHigh;
|
||||
}
|
||||
|
||||
/** Build the set of 1-based ACTIVE-LOW input terminals from a controller config. Three
|
||||
* sources, all merged: (a) `config.inputs[]` presence rows with `activeLow:true` (the
|
||||
* first-class model); (b) LEGACY per-relay `presenceActiveLow` (pre-inputs[] configs);
|
||||
* (c) an explicit top-level `inputActiveLow` array (escape hatch). A radar terminal wired
|
||||
* opposite the button idles HIGH, so it must be read inverted. */
|
||||
export function activeLowFrom(config: Record<string, unknown>): Set<number> {
|
||||
const set = new Set<number>();
|
||||
const add = (n: unknown) => {
|
||||
const v = Number(n);
|
||||
if (Number.isInteger(v) && v > 0) set.add(v);
|
||||
};
|
||||
if (Array.isArray(config.inputActiveLow)) {
|
||||
for (const n of config.inputActiveLow as unknown[]) add(n);
|
||||
}
|
||||
if (Array.isArray(config.inputs)) {
|
||||
for (const i of config.inputs as Array<Record<string, unknown>>) {
|
||||
if (i?.role === "presence" && i?.activeLow === true) add(i.input);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(config.relays)) {
|
||||
for (const r of config.relays as Array<Record<string, unknown>>) {
|
||||
if (r?.presenceActiveLow === true) add(r.presenceInput);
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
const INPUT_LINK_ISSUE = {
|
||||
key: "input_link_relay",
|
||||
message:
|
||||
@@ -223,6 +267,7 @@ function configApi(
|
||||
class DingtianController
|
||||
implements
|
||||
AccessControlDevice,
|
||||
AuxOutputDevice,
|
||||
InputDevice,
|
||||
PreconditionDevice,
|
||||
PushConfigurableDevice,
|
||||
@@ -242,6 +287,13 @@ class DingtianController
|
||||
readonly #channels: number;
|
||||
/** Input level at rest; an input is "active" when it differs from this. */
|
||||
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;
|
||||
/** Device web-UI login user (gates the browser UI only, not the CGI API). */
|
||||
readonly #webUser: string;
|
||||
@@ -269,6 +321,7 @@ class DingtianController
|
||||
this.#channels = config.channels ? Number(config.channels) : 4;
|
||||
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
|
||||
this.#restingHigh = config.inputRestingHigh !== false;
|
||||
this.#inputActiveLow = activeLowFrom(config);
|
||||
this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
|
||||
this.#webUser = config.webUser ? String(config.webUser) : "admin";
|
||||
// webPassword = the DESIRED login (admin's choice; blank → harden generates).
|
||||
@@ -319,6 +372,14 @@ class DingtianController
|
||||
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
|
||||
}
|
||||
|
||||
/** 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"> {
|
||||
this.#assertChannel(doorId);
|
||||
const { relays } = await this.#status();
|
||||
@@ -672,8 +733,10 @@ class DingtianController
|
||||
for (let i = 0; i < this.#channels; i++) {
|
||||
const high = (inputVal & (1 << i)) !== 0;
|
||||
relays.push((relayVal & (1 << i)) !== 0);
|
||||
// active = differs from the resting level (a press pulls the line).
|
||||
inputs.push(high !== this.#restingHigh);
|
||||
// active = differs from the resting level (a press pulls the line); a terminal in
|
||||
// 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 };
|
||||
}
|
||||
@@ -728,6 +791,19 @@ export const dingtianDriver: AccessDriver = {
|
||||
{ key: "binaryPort", label: "Binary protocol port", type: "port", required: false, default: 60000, help: "Dingtian binary protocol UDP port — authenticated relay control (default 60000)." },
|
||||
{ key: "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 },
|
||||
{
|
||||
// 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",
|
||||
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 { hostField, passwordField, portField, usernameField, stubLog } from "./common.js";
|
||||
import {
|
||||
hostField,
|
||||
passwordField,
|
||||
portField,
|
||||
usernameField,
|
||||
stubLog,
|
||||
} from "./common.js";
|
||||
import { digestGet } from "./http-digest.js";
|
||||
|
||||
// Camera drivers — entry/exit snapshot-on-event. The host pulls a still over
|
||||
@@ -15,12 +26,32 @@ import { digestGet } from "./http-digest.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 8000;
|
||||
|
||||
// Hikvision returns HTTP 503 (statusCode 2 / "Device Busy" / subStatus deviceBusy) —
|
||||
// and occasionally 500 — when its snapshot encoder is momentarily occupied (another
|
||||
// snapshot in flight, a stream starting, on-camera VCA). It is TRANSIENT: a retry a
|
||||
// few hundred ms later succeeds. The newer G3H sensors (e.g. DS-2CD1047G3H) hit it
|
||||
// more readily. So a standalone capture retries a few times before giving up; we do
|
||||
// NOT retry config errors (401 auth, 404 path/channel) — those won't self-heal.
|
||||
// (Concurrent same-camera hits are separately de-duped by captureSnapshotShared in
|
||||
// the server.) See wiki/entities/lpr-camera.md ("503 Device Busy").
|
||||
const SNAPSHOT_RETRY_STATUSES = new Set([500, 503]);
|
||||
const SNAPSHOT_MAX_ATTEMPTS = 4;
|
||||
const SNAPSHOT_RETRY_BASE_MS = 250;
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
class HttpCamera implements CameraDevice {
|
||||
readonly #host: string;
|
||||
readonly #port: number;
|
||||
readonly #user: string;
|
||||
readonly #password: string;
|
||||
readonly #channel: number;
|
||||
/** Hikvision stream within the channel: 1 = main (high-res), 2 = sub (lighter).
|
||||
* Some models (e.g. the G3H) keep the MAIN encoder saturated and return a
|
||||
* persistent 503 deviceBusy on the main-stream snapshot, while the sub-stream
|
||||
* serves fine — so this is selectable. Ignored by drivers (Dahua) that don't
|
||||
* encode a stream in the path. See wiki/entities/lpr-camera.md ("503 Device Busy"). */
|
||||
readonly #stream: number;
|
||||
readonly #timeout: number;
|
||||
// Source outbound from the device-facing NIC on a multi-homed host (the
|
||||
// multi-subnet source-address trap — see wiki/concepts/wsl-dev-networking.md).
|
||||
@@ -29,16 +60,20 @@ class HttpCamera implements CameraDevice {
|
||||
constructor(
|
||||
readonly driverId: string,
|
||||
config: DeviceConfig,
|
||||
/** Builds the snapshot path from the configured channel. */
|
||||
private readonly snapshotPath: (channel: number) => string,
|
||||
/** Builds the snapshot path from the configured channel + stream (1=main, 2=sub). */
|
||||
private readonly snapshotPath: (channel: number, stream: number) => string,
|
||||
) {
|
||||
this.#host = String(config.host);
|
||||
this.#port = Number(config.port ?? 80);
|
||||
this.#user = String(config.username ?? "");
|
||||
this.#password = String(config.password ?? "");
|
||||
this.#channel = Number(config.channel ?? 1);
|
||||
// 1 = main, 2 = sub. Clamp to those two; default main for back-compat.
|
||||
this.#stream = Number(config.stream) === 2 ? 2 : 1;
|
||||
this.#timeout = Number(config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
this.#localAddress = config.localAddress ? String(config.localAddress) : undefined;
|
||||
this.#localAddress = config.localAddress
|
||||
? String(config.localAddress)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {}
|
||||
@@ -49,8 +84,13 @@ class HttpCamera implements CameraDevice {
|
||||
// frame: it exercises reachability + auth + the path/channel in one shot.
|
||||
try {
|
||||
const res = await this.#get();
|
||||
if (res.status === 200) return { status: "ready", detail: `${res.body.length} bytes` };
|
||||
if (res.status === 401) return { status: "degraded", detail: "auth rejected (check username/password)" };
|
||||
if (res.status === 200)
|
||||
return { status: "ready", detail: `${res.body.length} bytes` };
|
||||
if (res.status === 401)
|
||||
return {
|
||||
status: "degraded",
|
||||
detail: "auth rejected (check username/password)",
|
||||
};
|
||||
return { status: "degraded", detail: `HTTP ${res.status}` };
|
||||
} catch (err) {
|
||||
return { status: "offline", detail: (err as Error).message };
|
||||
@@ -58,13 +98,37 @@ class HttpCamera implements CameraDevice {
|
||||
}
|
||||
|
||||
async captureSnapshot(ctx: SnapshotContext): Promise<Snapshot> {
|
||||
const res = await this.#get();
|
||||
// Retry transient "Device Busy" (503/500); a config error (401/404) fails fast.
|
||||
let res = await this.#get();
|
||||
for (
|
||||
let attempt = 1;
|
||||
res.status !== 200 &&
|
||||
SNAPSHOT_RETRY_STATUSES.has(res.status) &&
|
||||
attempt < SNAPSHOT_MAX_ATTEMPTS;
|
||||
attempt++
|
||||
) {
|
||||
// Linear backoff (250/500/750ms) — the encoder frees within a frame or two.
|
||||
await sleep(SNAPSHOT_RETRY_BASE_MS * attempt);
|
||||
stubLog(
|
||||
this.driverId,
|
||||
`captureSnapshot ${ctx.direction} retry ${attempt} (was HTTP ${res.status})`,
|
||||
);
|
||||
res = await this.#get();
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
// Name the busy case so the operator/telemetry can tell "camera busy" from a
|
||||
// real fault (offline / auth / wrong path).
|
||||
const busy = SNAPSHOT_RETRY_STATUSES.has(res.status)
|
||||
? " (device busy)"
|
||||
: "";
|
||||
throw new Error(
|
||||
`${this.driverId} snapshot failed (${ctx.direction}): HTTP ${res.status}`,
|
||||
`${this.driverId} snapshot failed (${ctx.direction}): HTTP ${res.status}${busy}`,
|
||||
);
|
||||
}
|
||||
stubLog(this.driverId, `captureSnapshot ${ctx.direction} (${res.body.length} bytes)`);
|
||||
stubLog(
|
||||
this.driverId,
|
||||
`captureSnapshot ${ctx.direction} (${res.body.length} bytes)`,
|
||||
);
|
||||
return {
|
||||
bytes: res.body,
|
||||
contentType: res.contentType || "image/jpeg",
|
||||
@@ -76,7 +140,7 @@ class HttpCamera implements CameraDevice {
|
||||
return digestGet({
|
||||
host: this.#host,
|
||||
port: this.#port,
|
||||
path: this.snapshotPath(this.#channel),
|
||||
path: this.snapshotPath(this.#channel, this.#stream),
|
||||
user: this.#user,
|
||||
password: this.#password,
|
||||
timeoutMs: this.#timeout,
|
||||
@@ -93,7 +157,32 @@ const channelField: ConfigField = {
|
||||
default: 1,
|
||||
};
|
||||
|
||||
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, channelField];
|
||||
// Hikvision stream-within-channel for the snapshot: main (01) is full-res; sub (02)
|
||||
// is lighter. Default MAIN (back-compat). Switch to SUB when the main encoder is
|
||||
// saturated and returns a persistent 503 deviceBusy (seen on DS-2CD1047G3H-LIU) —
|
||||
// the sub-stream is also the better fit for snapshot/ANPR (smaller, faster, doesn't
|
||||
// contend with live-view/recording). See wiki/entities/lpr-camera.md.
|
||||
const streamField: ConfigField = {
|
||||
key: "stream",
|
||||
label: "Snapshot stream",
|
||||
type: "select",
|
||||
required: false,
|
||||
default: "1",
|
||||
options: [
|
||||
{ value: "1", label: "Main (01)" },
|
||||
{ value: "2", label: "Sub (02)" },
|
||||
],
|
||||
};
|
||||
|
||||
// Dahua has no stream selector (its CGI snapshot isn't stream-encoded in the path).
|
||||
const cameraConfigFields = [
|
||||
hostField,
|
||||
portField(80),
|
||||
usernameField,
|
||||
passwordField,
|
||||
channelField,
|
||||
];
|
||||
const hikvisionConfigFields = [...cameraConfigFields, streamField];
|
||||
|
||||
// Hikvision "Alarm Server" PUSH config. The newer firmware (Event → Smart/VCA →
|
||||
// "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm Settings →
|
||||
@@ -138,15 +227,21 @@ export const hikvisionDriver: CameraDriver = {
|
||||
id: "hikvision",
|
||||
category: "camera",
|
||||
label: "Hikvision camera",
|
||||
description: "Hikvision snapshot via ISAPI (HTTP Digest) + optional Alarm Server event push.",
|
||||
description:
|
||||
"Hikvision snapshot via ISAPI (HTTP Digest) + optional Alarm Server event push.",
|
||||
transports: ["tcp-ip"],
|
||||
// The camera PULLS snapshots, but with Alarm Server on it ALSO pushes events to us —
|
||||
// so it may need the backend push IP at assign time (like the Dingtian).
|
||||
pushesToBackend: true,
|
||||
configFields: [...cameraConfigFields, ...alarmPushFields],
|
||||
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch2 main = 201.
|
||||
configFields: [...hikvisionConfigFields, ...alarmPushFields],
|
||||
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch1 sub = 102, ch2 main = 201.
|
||||
// stream 1 → "01" (main), 2 → "02" (sub).
|
||||
create: (c) =>
|
||||
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 = {
|
||||
@@ -156,7 +251,12 @@ export const dahuaDriver: CameraDriver = {
|
||||
description: "Dahua snapshot via CGI (HTTP Digest).",
|
||||
transports: ["tcp-ip"],
|
||||
configFields: cameraConfigFields,
|
||||
// Dahua channels are 0-based on the CGI; the admin enters 1-based.
|
||||
// Dahua channels are 0-based on the CGI; the admin enters 1-based. No stream in the
|
||||
// path (the second arg is ignored — Dahua has no main/sub snapshot distinction here).
|
||||
create: (c) =>
|
||||
new HttpCamera("dahua", c, (ch) => `/cgi-bin/snapshot.cgi?channel=${Math.max(0, ch - 1)}`),
|
||||
new HttpCamera(
|
||||
"dahua",
|
||||
c,
|
||||
(ch) => `/cgi-bin/snapshot.cgi?channel=${Math.max(0, ch - 1)}`,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { execFile } from "node:child_process";
|
||||
|
||||
// Unprivileged ICMP liveness check for PUSH-only devices that expose no TCP port —
|
||||
// e.g. the Dingtian/GEE QR readers, which GET our backend on each scan but listen on
|
||||
// nothing. For those a TCP connect probe (what cameras/printers use) has nothing to
|
||||
// connect to; ICMP echo is the only honest "powered + on-network" signal.
|
||||
//
|
||||
// We shell to the system `ping` rather than open a raw socket: Node's `dgram` is
|
||||
// UDP-only (no IPPROTO_ICMP), and a raw socket needs CAP_NET_RAW. `/bin/ping` in
|
||||
// SOCK_DGRAM mode runs WITHOUT NET_RAW when the kernel's `net.ipv4.ping_group_range`
|
||||
// includes the runtime user's gid — which the booth compose sets as a sysctl (see
|
||||
// docker-compose.prod.yml). So: no native dep, no NET_RAW. A ping only proves the box
|
||||
// answers ICMP (not that the scan head works) — but it correctly flips red when the
|
||||
// reader is unplugged/dead, which the old hardcoded "ready" never did.
|
||||
// See wiki/entities/dingtian-qr-reader.md / device-status-monitoring.md.
|
||||
|
||||
/**
|
||||
* Send ONE ICMP echo to `host` and resolve true if it replied within `timeoutMs`.
|
||||
* Never throws — any spawn/permission/timeout failure resolves false (treated as
|
||||
* "not reachable"). Linux `ping` flags: `-n` numeric (no DNS), `-c 1` one packet,
|
||||
* `-w`/`-W` deadline. We pass the host as a fixed arg (execFile, not a shell) so a
|
||||
* crafted "host" can't inject a command.
|
||||
*/
|
||||
export function icmpPing(host: string, timeoutMs = 2000): Promise<boolean> {
|
||||
const deadlineSec = Math.max(1, Math.ceil(timeoutMs / 1000));
|
||||
return new Promise((resolve) => {
|
||||
const child = execFile(
|
||||
"ping",
|
||||
["-n", "-c", "1", "-w", String(deadlineSec), "-W", String(deadlineSec), host],
|
||||
{ timeout: timeoutMs + 500 },
|
||||
(err) => resolve(err == null), // exit 0 = a reply; anything else = no reply
|
||||
);
|
||||
// If the binary is missing entirely, execFile emits 'error' (callback also fires).
|
||||
child.on("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { cashinoDriver } from "./printer-cashino.js";
|
||||
import { renderTicket } from "./printer-escpos.js";
|
||||
|
||||
// End-to-end transport routing through the real driver: a USB-configured Cashino must
|
||||
// resolve to the char-device transport and write the SAME ESC/POS bytes the TCP path
|
||||
// would. (The TCP path is exercised by the routing/escpos suites and on hardware.)
|
||||
|
||||
describe("cashinoDriver — USB transport", () => {
|
||||
let dir: string;
|
||||
let devicePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "cashino-usb-"));
|
||||
devicePath = join(dir, "lp0");
|
||||
// Stand in for an enumerated usblp node (the kernel creates it; we only open it).
|
||||
writeFileSync(devicePath, "");
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("prints a ticket to the configured USB device path", async () => {
|
||||
const printer = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
|
||||
const data = { ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" };
|
||||
await printer.printTicket(data);
|
||||
const written = readFileSync(devicePath);
|
||||
expect(written.equals(renderTicket(data))).toBe(true);
|
||||
});
|
||||
|
||||
it("healthCheck reports ready when the node exists, offline when it doesn't", async () => {
|
||||
const present = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
|
||||
expect((await present.healthCheck()).status).toBe("ready");
|
||||
// An absent device node (printer unplugged / not enumerated) → offline.
|
||||
const absent = cashinoDriver.create({
|
||||
transport: "usb",
|
||||
devicePath: join(dir, "absent-lp0"),
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect((await absent.healthCheck()).status).toBe("offline");
|
||||
});
|
||||
|
||||
it("advertises both transports", () => {
|
||||
expect(cashinoDriver.transports).toContain("usb");
|
||||
expect(cashinoDriver.transports).toContain("tcp-ip");
|
||||
});
|
||||
});
|
||||
@@ -10,20 +10,31 @@ import type {
|
||||
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
||||
import { hostField, portField, stubLog } from "./common.js";
|
||||
import {
|
||||
probe,
|
||||
devicePathField,
|
||||
probeTo,
|
||||
renderReceipt,
|
||||
renderReport,
|
||||
renderSubscriptionCard,
|
||||
renderTicket,
|
||||
renderWindowChargeNotice,
|
||||
sendRaw,
|
||||
sendTo,
|
||||
transportField,
|
||||
transportFromConfig,
|
||||
type Transport,
|
||||
} from "./printer-escpos.js";
|
||||
|
||||
// Cashino 80mm network thermal printer driver. The Cashino is an ESC/POS clone:
|
||||
// it PRINTS identically to the Rongta (same byte stream — see ./printer-escpos.ts),
|
||||
// so tickets, reports and subscription cards render the same. What it does NOT
|
||||
// have is the Rongta board's decoded status web page (/prn_stat.htm). It cannot
|
||||
// report paper-out / cover-open / cutter faults in a form we trust.
|
||||
// Cashino 80mm thermal printer driver (network OR USB). The Cashino is an ESC/POS
|
||||
// clone: it PRINTS identically to the Rongta (same byte stream — see
|
||||
// ./printer-escpos.ts), so tickets, reports and subscription cards render the same,
|
||||
// over either transport. What it does NOT have is the Rongta board's decoded status
|
||||
// web page (/prn_stat.htm). It cannot report paper-out / cover-open / cutter faults
|
||||
// in a form we trust.
|
||||
//
|
||||
// TRANSPORT: a single `config.transport` ("tcp-ip" | "usb") picks the wire; the
|
||||
// driver resolves it ONCE into a Transport and every print/probe stays transport-
|
||||
// blind (see transportFromConfig/sendTo/probeTo). USB writes the same bytes to a
|
||||
// local usblp char device (/dev/usb/lp0); TCP writes to the raw print socket. This
|
||||
// clone is the natural USB candidate — reachability-only, no status page to lose.
|
||||
//
|
||||
// Therefore this driver deliberately does NOT implement MonitorableDevice
|
||||
// (no readStatus). The device monitor then falls back to the generic
|
||||
@@ -40,13 +51,11 @@ import {
|
||||
|
||||
class CashinoPrinter implements PrinterDevice {
|
||||
readonly driverId = "cashino";
|
||||
readonly #host: string;
|
||||
readonly #port: number;
|
||||
readonly #transport: Transport;
|
||||
readonly #timeout: number;
|
||||
|
||||
constructor(config: DeviceConfig) {
|
||||
this.#host = String(config.host);
|
||||
this.#port = config.port ? Number(config.port) : 9100;
|
||||
this.#transport = transportFromConfig(config);
|
||||
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
|
||||
}
|
||||
|
||||
@@ -59,15 +68,15 @@ class CashinoPrinter implements PrinterDevice {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reachability only — a TCP connect probe of the raw print socket. The Cashino
|
||||
* has no trustworthy status protocol, so this is the floor and the ceiling of
|
||||
* what we report: reachable → ready, unreachable → offline. Deliberately NO
|
||||
* readStatus(): the monitor uses this for the traffic-light, never a guessed
|
||||
* paper/cover state.
|
||||
* Reachability only — a connect probe (TCP) or char-device open probe (USB) of
|
||||
* the print path. The Cashino has no trustworthy status protocol, so this is the
|
||||
* floor and the ceiling of what we report: reachable → ready, unreachable →
|
||||
* offline. Deliberately NO readStatus(): the monitor uses this for the
|
||||
* traffic-light, never a guessed paper/cover state.
|
||||
*/
|
||||
async healthCheck(): Promise<DeviceHealth> {
|
||||
try {
|
||||
await probe(this.#host, this.#port, this.#timeout);
|
||||
await probeTo(this.#transport, this.#timeout);
|
||||
return { status: "ready" };
|
||||
} catch (err) {
|
||||
return { status: "offline", detail: (err as Error).message };
|
||||
@@ -75,12 +84,12 @@ class CashinoPrinter implements PrinterDevice {
|
||||
}
|
||||
|
||||
async printTicket(data: TicketData): Promise<void> {
|
||||
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
|
||||
await sendTo(this.#transport, renderTicket(data), this.#timeout);
|
||||
stubLog(this.driverId, `printed ticket ${data.ticketId}`);
|
||||
}
|
||||
|
||||
async printReport(report: PrintReport): Promise<void> {
|
||||
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout);
|
||||
await sendTo(this.#transport, renderReport(report), this.#timeout);
|
||||
stubLog(
|
||||
this.driverId,
|
||||
`printed report "${report.title}" (${report.lines.length} lines)`,
|
||||
@@ -88,17 +97,12 @@ class CashinoPrinter implements PrinterDevice {
|
||||
}
|
||||
|
||||
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
|
||||
await sendRaw(
|
||||
this.#host,
|
||||
this.#port,
|
||||
renderSubscriptionCard(data),
|
||||
this.#timeout,
|
||||
);
|
||||
await sendTo(this.#transport, renderSubscriptionCard(data), this.#timeout);
|
||||
stubLog(this.driverId, `printed subscription card ${data.code}`);
|
||||
}
|
||||
|
||||
async printReceipt(data: ReceiptData): Promise<void> {
|
||||
await sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout);
|
||||
await sendTo(this.#transport, renderReceipt(data), this.#timeout);
|
||||
stubLog(
|
||||
this.driverId,
|
||||
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
|
||||
@@ -106,7 +110,7 @@ class CashinoPrinter implements PrinterDevice {
|
||||
}
|
||||
|
||||
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
|
||||
await sendRaw(this.#host, this.#port, renderWindowChargeNotice(data), this.#timeout);
|
||||
await sendTo(this.#transport, renderWindowChargeNotice(data), this.#timeout);
|
||||
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
|
||||
}
|
||||
}
|
||||
@@ -141,14 +145,17 @@ export const cashinoDriver: PrinterDriver = {
|
||||
category: "printer",
|
||||
label: "Cashino 80mm thermal printer",
|
||||
description:
|
||||
"Cashino 80mm thermal printer (ESC/POS over raw TCP, port 9100). Prints like the Rongta but has no status page — monitored by reachability ping only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
|
||||
transports: ["tcp-ip"],
|
||||
"Cashino 80mm thermal printer (ESC/POS over raw TCP port 9100, OR local USB /dev/usb/lp0). Prints like the Rongta but has no status page — monitored by reachability only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
|
||||
transports: ["tcp-ip", "usb"],
|
||||
configFields: [
|
||||
hostField,
|
||||
transportField,
|
||||
devicePathField,
|
||||
// host/port are TCP-only; not required because a USB printer needs neither.
|
||||
{ ...hostField, required: false, help: `${hostField.help} Leave blank for a USB printer.` },
|
||||
{
|
||||
...portField(9100),
|
||||
required: false,
|
||||
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).",
|
||||
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100). TCP only.",
|
||||
},
|
||||
roleField,
|
||||
rankField,
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
renderTicket,
|
||||
renderReceipt,
|
||||
renderWindowChargeNotice,
|
||||
renderSubscriptionCard,
|
||||
probeUsb,
|
||||
sendRawUsb,
|
||||
transportFromConfig,
|
||||
stamp,
|
||||
} from "./printer-escpos.js";
|
||||
|
||||
@@ -103,6 +109,69 @@ describe("CP852 character mapping (the misprint fixes)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("USB transport (sendRawUsb / probeUsb / transportFromConfig)", () => {
|
||||
// A regular file stands in for the usblp character device: open(O_WRONLY) + write
|
||||
// is the same syscall path. This proves the transport is byte-blind — the EXACT
|
||||
// ESC/POS stream renderTicket produces lands at the device path, with no transport
|
||||
// touching a rendered byte (the whole point of the seam).
|
||||
let dir: string;
|
||||
let devicePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "escpos-usb-"));
|
||||
devicePath = join(dir, "lp0");
|
||||
// A real usblp node already EXISTS (created by the kernel on enumeration); we open
|
||||
// it O_WRONLY without O_CREAT, never create it. Pre-create the stand-in file so the
|
||||
// test mirrors that — opening an ABSENT path means "printer not present" (offline).
|
||||
writeFileSync(devicePath, "");
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("writes the exact rendered ESC/POS bytes to the device path", async () => {
|
||||
const payload = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" });
|
||||
await sendRawUsb(devicePath, payload, 1000);
|
||||
const written = readFileSync(devicePath);
|
||||
expect(written.equals(payload)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when the device path can't be opened (printer not present)", async () => {
|
||||
await expect(
|
||||
sendRawUsb(join(dir, "absent-lp0"), Buffer.from([0x1b, 0x40]), 1000),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("probeUsb resolves for an existing node, rejects for a missing one", async () => {
|
||||
await expect(probeUsb(devicePath, 1000)).resolves.toBeUndefined();
|
||||
await expect(probeUsb(join(dir, "nope"), 1000)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("transportFromConfig: transport=usb selects the char device (default /dev/usb/lp0)", () => {
|
||||
expect(transportFromConfig({ transport: "usb", devicePath: "/dev/usb/lp1" })).toEqual({
|
||||
kind: "usb",
|
||||
devicePath: "/dev/usb/lp1",
|
||||
});
|
||||
expect(transportFromConfig({ transport: "usb" })).toEqual({
|
||||
kind: "usb",
|
||||
devicePath: "/dev/usb/lp0",
|
||||
});
|
||||
});
|
||||
|
||||
it("transportFromConfig: anything else is TCP (back-compat with host-only configs)", () => {
|
||||
expect(transportFromConfig({ host: "10.0.0.9" })).toEqual({
|
||||
kind: "tcp",
|
||||
host: "10.0.0.9",
|
||||
port: 9100,
|
||||
});
|
||||
expect(transportFromConfig({ host: "10.0.0.9", port: 9101 })).toEqual({
|
||||
kind: "tcp",
|
||||
host: "10.0.0.9",
|
||||
port: 9101,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("stamp (Albanian date format)", () => {
|
||||
it("formats an ISO time as '<day> <Month> <year> HH:MM:SS'", () => {
|
||||
// Local-time dependent, so assert the structure + the Albanian month name.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Socket } from "node:net";
|
||||
import { open } from "node:fs/promises";
|
||||
import { constants as FS } from "node:fs";
|
||||
import type {
|
||||
PrintReport,
|
||||
ReceiptData,
|
||||
@@ -567,7 +569,150 @@ export function probe(
|
||||
});
|
||||
}
|
||||
|
||||
// --- USB transport (kernel usblp character device) ----------------------------
|
||||
// An ESC/POS USB printer plugged into the appliance enumerates as a character
|
||||
// device (e.g. /dev/usb/lp0) via the in-box `usblp` kernel driver. We deliver the
|
||||
// SAME ESC/POS byte stream there as over TCP — only the transport differs, not a
|
||||
// single rendered byte. No libusb / CUPS / native addon: a plain file write keeps
|
||||
// the MIT-only + offline-first, minimal-deps appliance constraints, and the path is
|
||||
// a LOCAL char device the booth operator (the threat model's adversary) can't reach
|
||||
// over the network. Paper/cover is NOT sensed here — same honesty floor as the
|
||||
// Cashino TCP probe. usblp + a udev rule granting the server write access to the
|
||||
// node are a provisioning dependency. See wiki/concepts/printer-usb-transport.md.
|
||||
|
||||
/** Bound a promise with a timeout — a wedged USB printer can block a write (or even
|
||||
* the open) indefinitely, and a stuck print must surface as a failure rather than
|
||||
* hang the entry flow. The underlying handle leaks on timeout, but the process is
|
||||
* the appliance server; a failed print is logged and retried/failed-over upstream. */
|
||||
function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error(msg)), ms);
|
||||
p.then(
|
||||
(v) => {
|
||||
clearTimeout(t);
|
||||
resolve(v);
|
||||
},
|
||||
(e) => {
|
||||
clearTimeout(t);
|
||||
reject(e as Error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
|
||||
* is a RAW character device: a single open + write delivers the job — there is no
|
||||
* FIN/half-close dance (that was a TCP concern, where an early destroy() could
|
||||
* truncate the stream). We always close the handle (even on a failed write). */
|
||||
export async function sendRawUsb(
|
||||
devicePath: string,
|
||||
payload: Buffer,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
const handle = await withTimeout(
|
||||
open(devicePath, FS.O_WRONLY | FS.O_NONBLOCK),
|
||||
timeoutMs,
|
||||
"usb open timeout",
|
||||
);
|
||||
try {
|
||||
await withTimeout(handle.write(payload), timeoutMs, "usb write timeout");
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Reachability for a USB printer: the floor is "does the char device exist and
|
||||
* open writable". A present, openable /dev/usb/lp0 means usblp bound a powered,
|
||||
* enumerated printer — the USB analogue of the TCP connect probe. (Like the Cashino
|
||||
* TCP probe, this reports reachability only, never a guessed paper/cover state.) */
|
||||
export async function probeUsb(devicePath: string, timeoutMs: number): Promise<void> {
|
||||
const handle = await withTimeout(
|
||||
open(devicePath, FS.O_WRONLY | FS.O_NONBLOCK),
|
||||
timeoutMs,
|
||||
"usb open timeout",
|
||||
);
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
// --- transport dispatch -------------------------------------------------------
|
||||
// A discriminated transport so each driver resolves the wire ONCE (from config) and
|
||||
// every print/probe call site stays transport-blind. Adding a transport = one more
|
||||
// arm here + the render layer is untouched.
|
||||
|
||||
/** Where a printer's bytes go: a TCP raw-print socket, or a local USB char device. */
|
||||
export type Transport =
|
||||
| { kind: "tcp"; host: string; port: number }
|
||||
| { kind: "usb"; devicePath: string };
|
||||
|
||||
/** Build a Transport from a driver's flat config. `transport: "usb"` selects the
|
||||
* USB char device (`devicePath`, default /dev/usb/lp0); anything else is TCP
|
||||
* (host + port, default 9100) — so existing network configs with no `transport`
|
||||
* key keep working unchanged. */
|
||||
export function transportFromConfig(config: {
|
||||
transport?: unknown;
|
||||
host?: unknown;
|
||||
port?: unknown;
|
||||
devicePath?: unknown;
|
||||
}): Transport {
|
||||
if (config.transport === "usb") {
|
||||
return { kind: "usb", devicePath: String(config.devicePath ?? "/dev/usb/lp0") };
|
||||
}
|
||||
return {
|
||||
kind: "tcp",
|
||||
host: String(config.host),
|
||||
port: config.port ? Number(config.port) : 9100,
|
||||
};
|
||||
}
|
||||
|
||||
/** Send an ESC/POS payload over whichever transport the printer is configured for. */
|
||||
export function sendTo(t: Transport, payload: Buffer, timeoutMs: number): Promise<void> {
|
||||
return t.kind === "usb"
|
||||
? sendRawUsb(t.devicePath, payload, timeoutMs)
|
||||
: sendRaw(t.host, t.port, payload, timeoutMs);
|
||||
}
|
||||
|
||||
/** Reachability probe over whichever transport the printer is configured for. */
|
||||
export function probeTo(t: Transport, timeoutMs: number): Promise<void> {
|
||||
return t.kind === "usb"
|
||||
? probeUsb(t.devicePath, timeoutMs)
|
||||
: probe(t.host, t.port, timeoutMs);
|
||||
}
|
||||
|
||||
/** Human label for a transport, for status detail / logs. */
|
||||
export function transportLabel(t: Transport): string {
|
||||
return t.kind === "usb" ? t.devicePath : `${t.host}:${t.port}`;
|
||||
}
|
||||
|
||||
// --- shared driver config fields ----------------------------------------------
|
||||
// Role + failover are identical across ESC/POS printers; defined here so each
|
||||
// driver shares them. See wiki/concepts/printer-roles-failover.md.
|
||||
export type PrinterRole = "entry-dispenser" | "booth-receipt";
|
||||
|
||||
// --- shared printer config fields (transport) ---------------------------------
|
||||
// TCP-or-USB is offered identically across the ESC/POS drivers; defined here so each
|
||||
// shares the exact field set. The setup wizard renders these generically.
|
||||
import type { ConfigField } from "../registry.js";
|
||||
|
||||
/** Connection-transport select: network (raw TCP 9100) or local USB char device. */
|
||||
export const transportField: ConfigField = {
|
||||
key: "transport",
|
||||
label: "Connection",
|
||||
type: "select",
|
||||
required: true,
|
||||
default: "tcp-ip",
|
||||
options: [
|
||||
{ value: "tcp-ip", label: "Network (raw TCP, port 9100)" },
|
||||
{ value: "usb", label: "USB (local /dev/usb/lp0)" },
|
||||
],
|
||||
help: "USB drives a printer plugged into the appliance (usblp); Network drives one on the isolated device VLAN.",
|
||||
};
|
||||
|
||||
/** USB character-device path; used only when transport=usb (ignored for TCP). */
|
||||
export const devicePathField: ConfigField = {
|
||||
key: "devicePath",
|
||||
label: "USB device",
|
||||
type: "string",
|
||||
required: false,
|
||||
default: "/dev/usb/lp0",
|
||||
help: "Character device for a USB printer (usblp), e.g. /dev/usb/lp0. Only used when Connection is USB.",
|
||||
};
|
||||
|
||||
@@ -13,22 +13,28 @@ import type {
|
||||
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
||||
import { hostField, portField, stubLog } from "./common.js";
|
||||
import {
|
||||
probe,
|
||||
devicePathField,
|
||||
probeTo,
|
||||
renderReceipt,
|
||||
renderReport,
|
||||
renderSubscriptionCard,
|
||||
renderTicket,
|
||||
renderWindowChargeNotice,
|
||||
sendRaw,
|
||||
sendTo,
|
||||
transportField,
|
||||
transportFromConfig,
|
||||
type Transport,
|
||||
} from "./printer-escpos.js";
|
||||
|
||||
// Rongta 80mm network thermal printer driver. Rongta RP-series printers (and the
|
||||
// many OEM clones that share their firmware) speak ESC/POS over a raw TCP socket
|
||||
// on port 9100 — the JetDirect/RAW convention. The ESC/POS rendering + transport
|
||||
// are shared with the other ESC/POS clones in ./printer-escpos.ts; what is unique
|
||||
// to Rongta — and lives here — is LIVE STATUS via the board's own status web page.
|
||||
// There is no auth on the print socket; like the other field devices it lives on
|
||||
// the isolated device VLAN.
|
||||
// Rongta 80mm thermal printer driver (network OR USB). Rongta RP-series printers
|
||||
// (and the many OEM clones that share their firmware) speak ESC/POS over a raw TCP
|
||||
// socket on port 9100 — the JetDirect/RAW convention — or over a local USB usblp
|
||||
// char device. The ESC/POS rendering + transport are shared with the other ESC/POS
|
||||
// clones in ./printer-escpos.ts (config.transport picks the wire); what is unique to
|
||||
// Rongta — and lives here — is LIVE STATUS via the board's own status web page. That
|
||||
// page is a NETWORK feature: a USB Rongta degrades to reachability-only monitoring
|
||||
// (see readStatus). There is no auth on the print socket; like the other field
|
||||
// devices a networked unit lives on the isolated device VLAN.
|
||||
// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md.
|
||||
//
|
||||
// ROLES + FAILOVER: a lane has more than one printer. Each instance declares a
|
||||
@@ -128,14 +134,15 @@ function parseStatusPage(html: string): StatusFlags {
|
||||
|
||||
class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||
readonly driverId = "rongta";
|
||||
readonly #transport: Transport;
|
||||
readonly #host: string;
|
||||
readonly #port: number;
|
||||
readonly #httpPort: number;
|
||||
readonly #timeout: number;
|
||||
|
||||
constructor(config: DeviceConfig) {
|
||||
this.#host = String(config.host);
|
||||
this.#port = config.port ? Number(config.port) : 9100;
|
||||
this.#transport = transportFromConfig(config);
|
||||
// Kept for the HTTP status page (TCP only); empty on a USB printer.
|
||||
this.#host = config.host ? String(config.host) : "";
|
||||
this.#httpPort = config.httpPort ? Number(config.httpPort) : 80;
|
||||
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
|
||||
}
|
||||
@@ -150,7 +157,7 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||
|
||||
async healthCheck(): Promise<DeviceHealth> {
|
||||
try {
|
||||
await probe(this.#host, this.#port, this.#timeout);
|
||||
await probeTo(this.#transport, this.#timeout);
|
||||
return { status: "ready" };
|
||||
} catch (err) {
|
||||
return { status: "offline", detail: (err as Error).message };
|
||||
@@ -158,12 +165,12 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||
}
|
||||
|
||||
async printTicket(data: TicketData): Promise<void> {
|
||||
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
|
||||
await sendTo(this.#transport, renderTicket(data), this.#timeout);
|
||||
stubLog(this.driverId, `printed ticket ${data.ticketId}`);
|
||||
}
|
||||
|
||||
async printReport(report: PrintReport): Promise<void> {
|
||||
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout);
|
||||
await sendTo(this.#transport, renderReport(report), this.#timeout);
|
||||
stubLog(
|
||||
this.driverId,
|
||||
`printed report "${report.title}" (${report.lines.length} lines)`,
|
||||
@@ -171,17 +178,12 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||
}
|
||||
|
||||
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
|
||||
await sendRaw(
|
||||
this.#host,
|
||||
this.#port,
|
||||
renderSubscriptionCard(data),
|
||||
this.#timeout,
|
||||
);
|
||||
await sendTo(this.#transport, renderSubscriptionCard(data), this.#timeout);
|
||||
stubLog(this.driverId, `printed subscription card ${data.code}`);
|
||||
}
|
||||
|
||||
async printReceipt(data: import("../interfaces.js").ReceiptData): Promise<void> {
|
||||
await sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout);
|
||||
await sendTo(this.#transport, renderReceipt(data), this.#timeout);
|
||||
stubLog(
|
||||
this.driverId,
|
||||
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
|
||||
@@ -189,7 +191,7 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||
}
|
||||
|
||||
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
|
||||
await sendRaw(this.#host, this.#port, renderWindowChargeNotice(data), this.#timeout);
|
||||
await sendTo(this.#transport, renderWindowChargeNotice(data), this.#timeout);
|
||||
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
|
||||
}
|
||||
|
||||
@@ -206,6 +208,18 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||
*/
|
||||
async readStatus(): Promise<PrinterStatus> {
|
||||
const checkedAt = new Date().toISOString();
|
||||
// The status page is an HTTP feature of the network board; a USB printer has no
|
||||
// such page. Degrade to the reachability floor (open the char device) and report
|
||||
// ready/offline only — never a guessed paper/cover state, same honesty rule as
|
||||
// the Cashino. (A USB Rongta is effectively a Cashino for monitoring purposes.)
|
||||
if (this.#transport.kind === "usb") {
|
||||
try {
|
||||
await probeTo(this.#transport, this.#timeout);
|
||||
return { status: "ready", checkedAt };
|
||||
} catch (err) {
|
||||
return { status: "offline", detail: (err as Error).message, checkedAt };
|
||||
}
|
||||
}
|
||||
let html: string;
|
||||
try {
|
||||
html = await fetchStatusPage(this.#host, this.#httpPort, this.#timeout);
|
||||
@@ -281,14 +295,17 @@ export const rongtaDriver: PrinterDriver = {
|
||||
category: "printer",
|
||||
label: "Rongta 80mm thermal printer",
|
||||
description:
|
||||
"Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones that serve the /prn_stat.htm status page) over raw TCP (port 9100). No auth on the print socket — isolate the VLAN.",
|
||||
transports: ["tcp-ip"],
|
||||
"Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones that serve the /prn_stat.htm status page) over raw TCP (port 9100), OR local USB /dev/usb/lp0. The decoded status page is a network feature — a USB Rongta is monitored by reachability only. No auth on the print socket — isolate the VLAN.",
|
||||
transports: ["tcp-ip", "usb"],
|
||||
configFields: [
|
||||
hostField,
|
||||
transportField,
|
||||
devicePathField,
|
||||
// host/port/status-page are TCP-only; not required for a USB printer.
|
||||
{ ...hostField, required: false, help: `${hostField.help} Leave blank for a USB printer.` },
|
||||
{
|
||||
...portField(9100),
|
||||
required: false,
|
||||
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).",
|
||||
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100). TCP only.",
|
||||
},
|
||||
{
|
||||
key: "httpPort",
|
||||
@@ -296,7 +313,7 @@ export const rongtaDriver: PrinterDriver = {
|
||||
type: "port",
|
||||
required: false,
|
||||
default: 80,
|
||||
help: "Device status page (/prn_stat.htm) port for live monitoring (default 80).",
|
||||
help: "Device status page (/prn_stat.htm) port for live monitoring (default 80). TCP only.",
|
||||
},
|
||||
roleField,
|
||||
rankField,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Reader health: push-only QR readers expose no TCP port, so liveness is an ICMP
|
||||
// ping of the (optional) configured IP. With no IP we must NOT claim "ready" (the old
|
||||
// stub did, hiding offline readers behind a green dot) — we report degraded instead.
|
||||
// icmpPing is mocked so the test is deterministic + offline.
|
||||
|
||||
const icmpPing = vi.fn<(host: string, timeoutMs?: number) => Promise<boolean>>();
|
||||
vi.mock("./icmp.js", () => ({ icmpPing: (...a: [string, number?]) => icmpPing(...a) }));
|
||||
|
||||
const { geeQrReaderDriver } = await import("./reader.js");
|
||||
|
||||
afterEach(() => {
|
||||
icmpPing.mockReset();
|
||||
});
|
||||
|
||||
describe("QR reader healthCheck (ICMP liveness)", () => {
|
||||
it("with an IP that replies → ready", async () => {
|
||||
icmpPing.mockResolvedValue(true);
|
||||
const r = geeQrReaderDriver.create({ serial: "H05M2AFA", host: "10.0.10.7" });
|
||||
expect(await r.healthCheck()).toEqual({ status: "ready", detail: "ping 10.0.10.7" });
|
||||
expect(icmpPing).toHaveBeenCalledWith("10.0.10.7");
|
||||
});
|
||||
|
||||
it("with an IP that does NOT reply → offline (this is the bug fix)", async () => {
|
||||
icmpPing.mockResolvedValue(false);
|
||||
const r = geeQrReaderDriver.create({ serial: "H05M2AFA", host: "10.0.10.7" });
|
||||
expect(await r.healthCheck()).toEqual({ status: "offline", detail: "no ping reply from 10.0.10.7" });
|
||||
});
|
||||
|
||||
it("with NO IP → degraded (never a false 'ready')", async () => {
|
||||
const r = geeQrReaderDriver.create({ serial: "H05M2AFA" });
|
||||
const h = await r.healthCheck();
|
||||
expect(h.status).toBe("degraded");
|
||||
expect(icmpPing).not.toHaveBeenCalled(); // nothing to ping
|
||||
});
|
||||
|
||||
it("exposes an optional host field for monitoring", () => {
|
||||
const hostField = geeQrReaderDriver.configFields.find((f) => f.key === "host");
|
||||
expect(hostField).toBeDefined();
|
||||
expect(hostField!.required).toBe(false); // operation is push-by-serial; IP is monitor-only
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DeviceHealth, ReaderDevice, ReaderEvent } from "../interfaces.js";
|
||||
import type { DeviceConfig, ReaderDriver } from "../registry.js";
|
||||
import { hostField, portField, stubLog } from "./common.js";
|
||||
import { icmpPing } from "./icmp.js";
|
||||
|
||||
// Reader drivers (RF / optical). Two integration paths: Wiegand reads reach the
|
||||
// access controller directly (autonomous); TCP-IP readers are seen host-side.
|
||||
@@ -18,8 +19,23 @@ class StubReader implements ReaderDevice {
|
||||
async disconnect(): Promise<void> {
|
||||
stubLog(this.driverId, "disconnect");
|
||||
}
|
||||
/**
|
||||
* Liveness. These readers PUSH (scan → GET our backend) and expose no TCP port, so
|
||||
* there's nothing to connect-probe. If the admin gave the reader's IP we ICMP-ping
|
||||
* it (powered + on-network); a reply → ready, no reply → offline. With NO IP we
|
||||
* report `degraded` ("set IP to monitor") rather than a false `ready` — a push
|
||||
* device that's silent is indistinguishable from a dead one, so claiming `ready`
|
||||
* unconditionally (the old behaviour) hid offline readers behind a green dot.
|
||||
*/
|
||||
async healthCheck(): Promise<DeviceHealth> {
|
||||
return { status: "ready", detail: "stub" };
|
||||
const host = this.config.host ? String(this.config.host) : "";
|
||||
if (!host) {
|
||||
return { status: "degraded", detail: "push device — set IP to monitor" };
|
||||
}
|
||||
const alive = await icmpPing(host);
|
||||
return alive
|
||||
? { status: "ready", detail: `ping ${host}` }
|
||||
: { status: "offline", detail: `no ping reply from ${host}` };
|
||||
}
|
||||
onRead(cb: (r: ReaderEvent) => void): void {
|
||||
this.#cb = cb;
|
||||
@@ -80,6 +96,14 @@ export const geeQrReaderDriver: ReaderDriver = {
|
||||
required: true,
|
||||
help: "The reader's serial as it reports in each scan (the `cjihao` field). Used to map scans to this lane.",
|
||||
},
|
||||
{
|
||||
// OPTIONAL: the reader pushes by serial (operation needs no IP), but giving its
|
||||
// IP lets the status monitor ICMP-ping it for a real online/offline dot instead
|
||||
// of an always-green stub. Leave blank to skip monitoring (shows "set IP").
|
||||
...hostField,
|
||||
required: false,
|
||||
help: "Optional: the reader's IP, used ONLY to monitor it (ping). Scans still resolve by serial. Leave blank to skip liveness monitoring.",
|
||||
},
|
||||
],
|
||||
create: (c) => new StubReader("gee-qr-reader", c),
|
||||
};
|
||||
|
||||
@@ -35,6 +35,23 @@ export interface AccessControlDevice extends Device {
|
||||
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) -------------------------------------
|
||||
// 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-
|
||||
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# booth.sh — operate the parking stack on the booth PC (Ubuntu).
|
||||
#
|
||||
# Wraps the three compose files (base + a dev/prod override) so the operator runs
|
||||
# one command instead of a long `docker compose -f … -f … --env-file …` line.
|
||||
#
|
||||
# ./booth.sh up # start the stack (detached)
|
||||
# ./booth.sh update # pull newer images + recreate (the "there are new
|
||||
# # images" case) — see `update` below
|
||||
# ./booth.sh down # stop the stack
|
||||
# ./booth.sh restart # restart without pulling
|
||||
# ./booth.sh status # what's running
|
||||
# ./booth.sh logs # follow logs (Ctrl-C to stop)
|
||||
# ./booth.sh ps|pull|config|exec …
|
||||
#
|
||||
# Runs from wherever it sits next to the compose files (the booth deploys them
|
||||
# flat, e.g. /opt/parking_systems/) or from the repo at scripts/booth.sh.
|
||||
#
|
||||
# Environment is PROD by default (the booth runs prod: pull pinned registry images,
|
||||
# Caddy on :80, fast_alpr). Override with ENV=dev for a local build/dev run:
|
||||
# ENV=dev ./booth.sh up
|
||||
#
|
||||
# Config comes from an .env file next to the compose files (REGISTRY, TAG,
|
||||
# JWT_SECRET, …). Copy .env.example → .env and fill it in. See
|
||||
# wiki/decisions/container-deployment.md.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- locate the compose files -------------------------------------------------
|
||||
# The script must work in BOTH layouts: in the repo at <repo>/scripts/booth.sh
|
||||
# (files one level up), AND deployed flat on the booth (booth.sh sits next to the
|
||||
# compose files, e.g. /opt/parking_systems/). So we don't assume a `scripts/`
|
||||
# parent — we look for docker-compose.yml in the script's own dir, then ../,
|
||||
# then $PWD, and cd there. (An absolute SELF is also kept for usage()/sed.)
|
||||
SELF="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/$(basename -- "${BASH_SOURCE[0]}")"
|
||||
SCRIPT_DIR="$(dirname -- "$SELF")"
|
||||
REPO_DIR=""
|
||||
for d in "$SCRIPT_DIR" "$SCRIPT_DIR/.." "$PWD"; do
|
||||
if [ -f "$d/docker-compose.yml" ]; then REPO_DIR="$(cd -- "$d" && pwd)"; break; fi
|
||||
done
|
||||
[ -n "$REPO_DIR" ] || {
|
||||
printf 'ERROR: docker-compose.yml not found (looked in %s, its parent, and %s).\n' \
|
||||
"$SCRIPT_DIR" "$PWD" >&2
|
||||
exit 1
|
||||
}
|
||||
cd "$REPO_DIR"
|
||||
|
||||
# --- environment selection (prod by default; the booth is prod) ---------------
|
||||
ENV="${ENV:-prod}"
|
||||
case "$ENV" in
|
||||
prod|production) ENV=prod; OVERRIDE="docker-compose.prod.yml" ;;
|
||||
dev|development) ENV=dev; OVERRIDE="docker-compose.dev.yml" ;;
|
||||
*) echo "ERROR: ENV must be 'prod' or 'dev' (got '$ENV')." >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
BASE="docker-compose.yml"
|
||||
ENV_FILE="${ENV_FILE:-.env}"
|
||||
|
||||
# --- colours (only when attached to a terminal) -------------------------------
|
||||
if [ -t 1 ]; then
|
||||
R="$(printf '\033[31m')"; G="$(printf '\033[32m')"; Y="$(printf '\033[33m')"
|
||||
B="$(printf '\033[1m')"; N="$(printf '\033[0m')"
|
||||
else
|
||||
R=""; G=""; Y=""; B=""; N=""
|
||||
fi
|
||||
info() { printf '%s==>%s %s\n' "$B" "$N" "$*"; }
|
||||
warn() { printf '%s!! %s%s\n' "$Y" "$*" "$N" >&2; }
|
||||
die() { printf '%sERROR:%s %s\n' "$R" "$N" "$*" >&2; exit 1; }
|
||||
|
||||
usage() {
|
||||
sed -n '3,26p' "$SELF" | sed 's/^# \{0,1\}//'
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
# --- preflight (only for commands that actually talk to Docker) ---------------
|
||||
# Deferred into a function so `help`/usage works with no Docker and no .env.
|
||||
ENV_ARGS=()
|
||||
DC=()
|
||||
preflight() {
|
||||
command -v docker >/dev/null 2>&1 || die "docker is not installed or not on PATH."
|
||||
# Prefer the v2 plugin (`docker compose`); fall back to legacy `docker-compose`.
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
DC=(docker compose)
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
DC=(docker-compose)
|
||||
else
|
||||
die "Docker Compose v2 plugin not found ('docker compose'). Install docker-compose-plugin."
|
||||
fi
|
||||
|
||||
[ -f "$BASE" ] || die "missing $BASE in $REPO_DIR"
|
||||
[ -f "$OVERRIDE" ] || die "missing $OVERRIDE in $REPO_DIR"
|
||||
|
||||
# An .env is required for prod (JWT_SECRET et al. have no safe default); optional
|
||||
# for dev (we inject a benign local secret below). Pass --env-file only when it
|
||||
# exists so dev works without one.
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
ENV_ARGS=(--env-file "$ENV_FILE")
|
||||
elif [ "$ENV" = "prod" ]; then
|
||||
die "no $ENV_FILE found. Copy .env.example to $ENV_FILE and set JWT_SECRET/REGISTRY/TAG. (prod has no safe defaults.)"
|
||||
else
|
||||
# The BASE compose file makes JWT_SECRET shell-required (${JWT_SECRET:?}), which
|
||||
# the dev override's service-level default can't satisfy. For a dev run with no
|
||||
# .env, inject the same benign 32-char local secret the dev override documents so
|
||||
# `up`/`config` work out of the box. NEVER do this for prod (the die above).
|
||||
warn "no $ENV_FILE found — injecting the documented local-dev JWT_SECRET (dev only)."
|
||||
: "${JWT_SECRET:=localdevsecret0123456789abcdef0123}"
|
||||
export JWT_SECRET
|
||||
fi
|
||||
}
|
||||
|
||||
# The assembled compose invocation every subcommand builds on (runs preflight once).
|
||||
compose() { "${DC[@]}" -f "$BASE" -f "$OVERRIDE" "${ENV_ARGS[@]}" "$@"; }
|
||||
|
||||
# --- subcommands --------------------------------------------------------------
|
||||
cmd="${1:-}"; [ "$#" -gt 0 ] && shift || true
|
||||
|
||||
# Help/usage short-circuits before any Docker or .env requirement.
|
||||
case "$cmd" in ""|-h|--help|help) usage 0 ;; esac
|
||||
|
||||
# Reject an unknown command up front (before preflight) so a typo gets a clear
|
||||
# "unknown command" rather than a confusing "no .env" from the prod env check.
|
||||
case "$cmd" in
|
||||
up|start|update|upgrade|down|stop|restart|pull|status|ps|logs|config|exec) ;;
|
||||
*) warn "unknown command: $cmd"; usage 1 ;;
|
||||
esac
|
||||
|
||||
preflight
|
||||
|
||||
case "$cmd" in
|
||||
up|start)
|
||||
info "Starting the parking stack ($B$ENV$N) …"
|
||||
compose up -d "$@"
|
||||
info "Up. ${G}$(compose ps --services 2>/dev/null | tr '\n' ' ')${N}"
|
||||
info "Booth UI: prod → http://<booth-ip>/ · dev → http://<booth-ip>:3000/"
|
||||
;;
|
||||
|
||||
update|upgrade)
|
||||
# The "I know there are new images" path: pull the moving branch tag, then
|
||||
# recreate only what changed. Compose recreates a service whose image digest
|
||||
# moved; unchanged services (and the named volumes — the SQLite DB!) are left
|
||||
# alone. Old image layers are pruned afterwards to reclaim disk.
|
||||
[ "$ENV" = "prod" ] || warn "update on ENV=$ENV: dev builds locally, so 'pull' may be a no-op. Use 'up --build' to rebuild dev."
|
||||
info "Pulling newer images for the ${B}$ENV_FILE${N} TAG …"
|
||||
compose pull
|
||||
info "Recreating changed services (volumes/DB preserved) …"
|
||||
compose up -d --remove-orphans
|
||||
info "Pruning dangling image layers …"
|
||||
docker image prune -f >/dev/null || true
|
||||
info "${G}Update complete.${N} Running:"
|
||||
compose ps
|
||||
;;
|
||||
|
||||
down|stop)
|
||||
info "Stopping the parking stack ($ENV) …"
|
||||
# NOTE: never pass -v here — that would delete the parking-data volume (the
|
||||
# signed event ledger). Volumes are intentionally preserved across down/up.
|
||||
compose down "$@"
|
||||
;;
|
||||
|
||||
restart)
|
||||
info "Restarting (no pull) …"
|
||||
compose restart "$@"
|
||||
;;
|
||||
|
||||
pull)
|
||||
info "Pulling images only (no recreate) …"
|
||||
compose pull "$@"
|
||||
;;
|
||||
|
||||
status|ps)
|
||||
compose ps "$@"
|
||||
;;
|
||||
|
||||
logs)
|
||||
# Follow by default; pass a service name to scope, e.g. `logs server`.
|
||||
compose logs -f --tail=200 "$@"
|
||||
;;
|
||||
|
||||
config)
|
||||
# Render the merged, variable-substituted compose config (debugging).
|
||||
compose config "$@"
|
||||
;;
|
||||
|
||||
exec)
|
||||
[ "$#" -ge 1 ] || die "usage: $0 exec <service> [cmd…] (e.g. exec server sh)"
|
||||
compose exec "$@"
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8099
|
||||
|
||||
|
||||
class Sink(BaseHTTPRequestHandler):
|
||||
def _log(self, method: str) -> None:
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
src = self.client_address[0]
|
||||
clen = int(self.headers.get("Content-Length", 0) or 0)
|
||||
body = self.rfile.read(clen) if clen else b""
|
||||
ctype = self.headers.get("Content-Type", "-")
|
||||
print(f"\n=== {ts} {method} {self.path} from {src} ===", flush=True)
|
||||
print(f" Content-Type: {ctype} ({clen} bytes)", flush=True)
|
||||
text = body.decode("utf-8", "replace")
|
||||
cut = text.find('Content-Type: image/jpeg')
|
||||
if cut != -1:
|
||||
text = text[:cut] + "\n [...JPEG image part omitted...]"
|
||||
print(" body:\n" + text, flush=True)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", "2")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"OK")
|
||||
|
||||
def do_POST(self):
|
||||
self._log("POST")
|
||||
|
||||
def do_GET(self):
|
||||
self._log("GET")
|
||||
|
||||
def do_PUT(self):
|
||||
self._log("PUT")
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"camera-event post test listening on 0.0.0.0:{PORT}", flush=True)
|
||||
print("Ctrl-C to stop.\n", flush=True)
|
||||
ThreadingHTTPServer(("0.0.0.0", PORT), Sink).serve_forever()
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, device, indicator, radar, camera, aux-output, barrier-not-a-door, event-relay]
|
||||
sources: []
|
||||
updated: 2026-06-28
|
||||
status: settled
|
||||
---
|
||||
|
||||
# Alert relays (radar × camera disagreement lamp)
|
||||
|
||||
A relay on the [[dingtian-relay|Dingtian]] controller is uniformly **"when EVENT X happens, do
|
||||
action Y"** — see [[entry-exit-points|relays carry an event]]. The barrier events (`entry`/`exit`/
|
||||
`both`) **pulse** a barrier; the **`radarAlert`** event drives a non-barrier **indicator lamp**
|
||||
(blink + camera-lock) on a spare relay. The entry button's **12 V light** is the canonical alert
|
||||
relay, a 3-state indicator that combines a **[[hikvision-radar|radar]]** trigger input with the
|
||||
**camera "car in zone"** signal:
|
||||
|
||||
| Trigger input (radar) | Camera (lane entry busy) | Alert lamp |
|
||||
| --- | --- | --- |
|
||||
| active | **free** — no car confirmed | **BLINK** (~1 Hz) |
|
||||
| active | **busy** — camera confirms a car | **SOLID on** |
|
||||
| inactive | — | **OFF** |
|
||||
|
||||
It is a **disagreement indicator**: the radar sees *something* but the camera hasn't confirmed a
|
||||
real vehicle → blink (attention / "pull forward"); both agree → solid; nothing there → off.
|
||||
|
||||
Because it's just another relay row, a controller can carry **several** alert relays (e.g. R3 and a
|
||||
future R4), each with its own trigger input — no new config shape, no code change.
|
||||
|
||||
## Signals
|
||||
|
||||
- **Trigger** = the alert relay's own `triggerInput` edge (the [[hikvision-radar|radar]]). When
|
||||
unset, it falls back to the controller's entry-relay `presenceInput` — the same edge the
|
||||
[[entry-double-press|one-car-one-ticket]] gate observes, so the lamp and the gate agree on "a car
|
||||
is here".
|
||||
- **Lock (camera "car in zone")** = the existing **[[lpr-camera|lane status]]** (`LaneStatusEvent`,
|
||||
from camera vehicle detection). Already advisory; already drives the booth's barrier lights. Each
|
||||
lamp picks **which lane's camera** locks it via `relays[].lockLane: "entry"|"exit"` (default
|
||||
entry) — so an **exit radar's lamp locks on the EXIT camera**, not the entry one. (Lane-busy is the
|
||||
only lock *kind* wired today; the model leaves room for others later.)
|
||||
|
||||
## Config
|
||||
|
||||
An alert lamp is a `config.relays[]` row with `direction: "radarAlert"`, carrying
|
||||
`{ relay, triggerInput?, blinkOnMs?, blinkOffMs? }`. No separate `buttonLight` block (that was the
|
||||
pre-2026-06-28 shape — barriers and the lamp were two different configs; now they're one list).
|
||||
Blink defaults to 500 ms / 500 ms. The operator picks a **spare** relay (an alert relay never opens
|
||||
a barrier; every barrier resolver skips `radarAlert` rows).
|
||||
|
||||
## Implementation
|
||||
|
||||
`apps/server/src/button-light.ts` — `ButtonLightController` reads the `radarAlert` rows
|
||||
(`alertRelaysOf()` in `device-resolve.ts`), subscribes to `deviceEvents.onInput` (radar) +
|
||||
`onLaneStatus` (camera), computes the target state **per lamp** (keyed `controllerId:relay`, so
|
||||
several alert relays on one controller are independent), and drives each lamp via a **device-agnostic
|
||||
aux-output** capability.
|
||||
|
||||
- **Aux-output capability.** `AuxOutputDevice { setAux(channel, on) }` on the device interface (the
|
||||
Dingtian driver implements it as a latch). Business logic drives the lamp through this — **never**
|
||||
the driver's barrier methods.
|
||||
- **Barrier-not-a-door is preserved.** The lamp is **not a barrier**, so holding / blinking it on a
|
||||
timer is fine — the [[barrier-not-a-door]] rule forbids timing a *barrier* closed, and barriers
|
||||
still only ever `pulseOpen`. The lamp uses the separate `setAux` latch.
|
||||
- **Fails OFF.** On host loss, shutdown, or a `setAux` error the lamp defaults OFF — a dead lamp is
|
||||
"no hint", never a misleading solid "go". SOLID is only ever held while busy + present is actively
|
||||
true (never latched on through a crash path).
|
||||
- **Serialized sends (must — UDP is unordered).** The first cut fired fire-and-forget `setAux` every
|
||||
500 ms; over **unordered UDP** the on/off packets reordered/overlapped and the relay **latched on
|
||||
whichever packet the device processed last** — the lamp got stuck on/off at random (observed on
|
||||
hardware). Fix: a **desired-state + serialized worker** (`#pump`). The blink timer only flips a
|
||||
`desiredOn` flag; the worker guarantees **one in-flight send per lamp** and, on completion,
|
||||
re-converges to the latest desired state. So the **final state is always authoritative** and a
|
||||
lost/stale packet self-corrects. This also de-dupes (it skips a send when `confirmedOn === desiredOn`),
|
||||
so the input stream never spams the controller.
|
||||
- **Hot-reloads the config (no restart).** The lamp map is reconciled against the live device config
|
||||
at start AND before each event (mirroring [[device-status-monitoring|DeviceMonitor]], which re-reads
|
||||
the device set each tick) — adding/updating/dropping lamps. So a button light added or re-pointed in
|
||||
the setup UI takes effect on the **next radar edge**, not after a server restart. (The first cut
|
||||
loaded the map once at boot, so a just-saved lamp silently did nothing until restart.)
|
||||
|
||||
## On-screen twin — the booth barrier lights
|
||||
|
||||
The booth's **Hyrje / Dalje (Entry / Exit) indicators** mirror the physical lamp with the SAME
|
||||
3-state rule, per lane: radar-present + camera-free → **blink green↔red** (~1 Hz); camera-busy →
|
||||
**solid red**; else **solid green**. So the operator sees the same "detected, not yet confirmed →
|
||||
confirmed → clear" story on screen as the lamp shows on the post.
|
||||
|
||||
The radar half is sourced by a small server tracker, **`LanePresence` (`lane-presence.ts`)**, that
|
||||
subscribes to `deviceEvents.onInput` and resolves each presence edge to its lane via
|
||||
**`presenceLaneOf`** (`device-resolve.ts`) — direction-agnostic (entry **and** exit), unlike the
|
||||
entry-gated `relayForPresence` the one-car-one-ticket gate uses. It emits a `lane-presence`
|
||||
`{entry,exit}` bus event on change; the WS forwards it (hello snapshot + push) into the booth's
|
||||
`live-store.radar`, and `BarrierLight` (`BoothScreen.tsx`) blinks via the `.lane-blink` keyframe
|
||||
(`index.css`, holds solid-red under `prefers-reduced-motion`). The camera half is the existing
|
||||
[[lpr-camera|lane status]]. Same inputs, same rule as the lamp, so screen and post never disagree.
|
||||
|
||||
## Status
|
||||
|
||||
Built 2026-06-24 for the first booth (button I1, radar I2, lamp on a spare relay); the serialized-send
|
||||
+ hot-reload fixes landed the same day after the lamp stuck on/off on hardware. **Reframed
|
||||
2026-06-28**: the dedicated `config.buttonLight` block was folded into the unified `relays[]` list as
|
||||
a `radarAlert` event-relay (carrying its own `triggerInput`), so the operator can add arbitrary
|
||||
event-driven blinkers (e.g. R4) without code changes; the 3-state machine itself is unchanged.
|
||||
Covered by `apps/server/src/button-light.test.ts` (the truth table, blink toggling asserted on the
|
||||
device's *confirmed* state, fail-OFF, de-dupe, lamp-added-after-start reconcile, and two independent
|
||||
alert relays on one controller).
|
||||
Related: [[hikvision-radar]], [[entry-double-press]], [[lpr-camera]], [[dingtian-relay]],
|
||||
[[entry-exit-points]], [[barrier-not-a-door]].
|
||||
@@ -2,7 +2,7 @@
|
||||
type: concept
|
||||
tags: [parking, device, monitoring, reliability, ui]
|
||||
sources: []
|
||||
updated: 2026-06-18
|
||||
updated: 2026-06-26
|
||||
status: open
|
||||
---
|
||||
|
||||
@@ -25,6 +25,15 @@ talks only to the adapter interfaces ([[device-adapter-pattern]]), never a drive
|
||||
- **Relays / readers / cameras** → the generic `Device.healthCheck()` **reachability** probe every
|
||||
adapter implements (`ready | degraded | offline`). This is presence/up-ness, not a deep fault
|
||||
model — a relay either answers or it doesn't.
|
||||
> **Reader health was a LIE until 2026-06-26.** The QR-reader adapter (a PUSH device: it GETs our
|
||||
> backend on each scan and exposes **no TCP port**) had a hardcoded `healthCheck → { ready, "stub" }`,
|
||||
> so two genuinely-offline readers still showed **green**. A push device that's silent is
|
||||
> indistinguishable from a dead one — so claiming `ready` unconditionally is the worst failure
|
||||
> (false-healthy). Fix: an **optional reader IP** (monitor-only; scans still resolve by serial) +
|
||||
> an **unprivileged ICMP ping** (`drivers/icmp.ts`, shells `/bin/ping` in SOCK_DGRAM mode — no
|
||||
> CAP_NET_RAW, no native dep; the booth compose sets `net.ipv4.ping_group_range`). Reply → `ready`,
|
||||
> no reply → `offline`; **no IP set → `degraded` ("set IP to monitor")**, never a false green.
|
||||
> Verified on hardware: pinged the real readers on the device VLAN. See [[gee-qr-er80]].
|
||||
|
||||
Both collapse to one **traffic-light**: `ready | degraded | offline`, plus a `detail` string. Fail
|
||||
**toward "there's a problem"**, never false-healthy: a probe that throws or times out reads
|
||||
|
||||
@@ -26,10 +26,17 @@ press → print → press again issued a second ticket immediately. That is not
|
||||
The guard lives on the entry relay's spec (`config.relays[]` — see [[entry-exit-points]]), because
|
||||
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)
|
||||
`relays[].presenceInput` = the 1-based input terminal of an **induction loop / barrier presence
|
||||
signal** on the same [[dingtian-relay|controller]] (the Dingtian's inputs are decoupled from its
|
||||
relays, and loops are already in the [[bom]]). The rule makes one-car-one-ticket **physical**:
|
||||
### PRESENCE mode (preferred — when a vehicle-presence sensor is wired)
|
||||
Inputs are a first-class `config.inputs[]` list (the twin of `relays[]`): each row is a terminal +
|
||||
a **role** (`button` / `presence` / `alertTrigger`) + the `relay` it serves. A **presence** row =
|
||||
the 1-based input terminal of a **vehicle-presence sensor** serving an entry/both relay on the same
|
||||
[[dingtian-relay|controller]] (the Dingtian's inputs are decoupled from its relays). The sensor may
|
||||
be an **induction loop** OR a **[[hikvision-radar|radar]]** (`inputs[].kind: "loop"|"radar"` — a
|
||||
label; the gate behaviour is identical). A radar wired to idle opposite the button needs
|
||||
`inputs[].activeLow: true` so its edge reads correctly. **Multiple radars (entry + exit) are just
|
||||
multiple presence rows** — adding an exit radar is adding a row. (Pre-2026-06-28 configs wired this
|
||||
on `relays[].presenceInput/presenceKind/presenceActiveLow`; the resolvers still read those as
|
||||
back-compat, synthesizing inputs[] from them.) The rule makes one-car-one-ticket **physical**:
|
||||
|
||||
- A press prints **only while a car is present** on the loop.
|
||||
- After a ticket prints, the relay is **disarmed** — no second ticket — **until the loop CLEARS**
|
||||
@@ -66,11 +73,14 @@ in telemetry if ever needed.
|
||||
host (single-writer); it is derived from live input edges, never the source of truth. A restart
|
||||
starts armed (the first press after a restart works), which is the safe default.
|
||||
|
||||
## As-built (2026-06-19)
|
||||
## As-built (2026-06-19; inputs[] 2026-06-28)
|
||||
|
||||
- `RelaySpec` gains `presenceInput?` + `entryCooldownSec?` (`device-resolve.ts`); `relayForButton`
|
||||
carries them onto the `ResolvedRelay`, and a new `relayForPresence()` resolves a loop-input edge to
|
||||
the entry relay it gates.
|
||||
- Inputs live in `config.inputs[] = [{ input, role, relay?, kind?, activeLow?, cooldownSec? }]`
|
||||
(`device-resolve.ts`). `inputsOf(row)` returns them, **or synthesizes** the list from the legacy
|
||||
`relays[].button/presenceInput/...` fields when a controller predates inputs[] (one back-compat
|
||||
shim; the UI no longer writes the legacy fields). `relayForButton`/`relayForPresence` resolve
|
||||
through `inputsOf`, carry `presenceInput`/`entryCooldownSec` onto the `ResolvedRelay`, and only ever
|
||||
gate entry/both relays. An exit radar = a `presence` row on the exit relay.
|
||||
- `EntryFlow` (`entry-flow.ts`) keeps a `#guard` map keyed `controllerId:relay`: `#onPresenceEdge`
|
||||
tracks the loop, `#suppressReason` decides presence/cooldown, `#recordSuppressedPress` writes the
|
||||
telemetry. The guard disarms + stamps the cooldown on **print success** (not on open).
|
||||
|
||||
@@ -62,7 +62,49 @@ then does the gated entry/exit + barrier open. Constructed in `server.ts` (the f
|
||||
above the hik-alarm registration so the bridge can take `subscriptionFlow`). Fail-soft throughout —
|
||||
any snapshot/vision error degrades to the subscriber's card/QR, never throws into the push handler.
|
||||
Two new env knobs: `VISION_ENTRY_MIN_CONFIDENCE` (0.85), `ANPR_DEBOUNCE_MS` (12_000). Covered by
|
||||
`anpr-entry.test.ts` (7) + `hikvision-alarm.test.ts` wiring (3).
|
||||
`anpr-entry.test.ts` + `hikvision-alarm.test.ts` wiring.
|
||||
|
||||
**Toggles — two levels (the auto-open is optional).** ANPR auto entry/exit can be turned off without
|
||||
losing plate recognition:
|
||||
- **Site-wide:** `site_config.anprEntryEnabled` (a Site Settings switch) gates the WHOLE bridge
|
||||
(both directions); off ⇒ no camera auto-opens, recognition/lane-status unaffected.
|
||||
- **Per-camera** (2026-06-27): `config.anprAutoTrigger` (absent ⇒ on when `anpr` is on). This
|
||||
separates **recognition** (`config.anpr` — snapshots run through the recognizer, plate recorded,
|
||||
BOTH directions) from **auto-open** (`anprAutoTrigger` — may this camera fire the barrier). The
|
||||
case it solves: a **shared entry/exit lane** where ONE physical lane has both an entry and an exit
|
||||
camera. A subscriber driving IN is admitted by the entry cam — but the exit cam sees the SAME car
|
||||
leaving its frame and would **phantom-exit** the occurrence just opened (its back plate). Set the
|
||||
exit cam's `anprAutoTrigger = false`: it still recognises plates for the record, but never
|
||||
auto-opens. (The Setup checkbox "Auto open/close on subscriber plate" appears under ANPR.)
|
||||
|
||||
> **POLL-until-confident (2026-06-27).** The single-shot capture above was upgraded to a **poll
|
||||
> loop**. The camera fires its vehicle alarm the INSTANT motion starts — the car is still
|
||||
> APPROACHING, so the first frame's plate is small/blurry/half-in-frame and ANPR returns a
|
||||
> low-confidence misread (observed live on the exit lane: `'111'`@0.20, `'AE18671'`@0.19 …, while
|
||||
> the manual `test-anpr` on the SAME stopped car read `AA890XX`@1.00). The car then STOPS at the
|
||||
> barrier waiting for it to open — the stationary, well-framed moment the test reads at ~100%. So
|
||||
> the bridge now **pulls a FRESH frame every `ANPR_POLL_MS` (1000) and re-runs ANPR until one clears
|
||||
> the floor, or `ANPR_POLL_WINDOW_MS` (8000) elapses** (car drove off / non-subscriber → give up
|
||||
> cleanly). One loop per camera (`#polling` set) so the ~1Hz alarm re-fires JOIN it, not spawn N;
|
||||
> each tick is a fresh `camera.captureSnapshot` (NOT `captureSnapshotShared`, whose TTL would
|
||||
> re-serve the same bad frame). VERIFIED on hardware: 7 garbage approach frames → `AA890XX`@0.999
|
||||
> at the barrier → signed `vehicle_exit`. This is what made subscriber **auto-exit** actually work
|
||||
> on the DS-2CD1047G3H-LIU (whose alarm fires on approach, not at the readable moment — see
|
||||
> [[lpr-camera]] "auto-enter but don't auto-exit"). Still advisory + fail-soft; a barrier never
|
||||
> opens on a low-confidence read.
|
||||
>
|
||||
> **Two concurrency guards on the loop** (the edges a single push-triggered loop creates):
|
||||
> 1. **Credential-mid-poll abort.** If the subscriber scans their card/QR at the reader DURING the
|
||||
> loop, they've already transacted — the bridge watches their `openOccurrenceCount` (baselined
|
||||
> once a frame reads the bound plate) and **aborts without emitting** if it moves, so it never
|
||||
> double-acts (which would exit the NEXT open occurrence — bad for a fleet sub).
|
||||
> 2. **SLIDING window for a different car arriving mid-poll.** A loop started by a far/early car
|
||||
> must not (a) give up before the REAL car settles, nor (b) swallow the real car's pushes. So a
|
||||
> push that joins a running loop **extends the deadline** (`lastPush + ANPR_POLL_WINDOW_MS`),
|
||||
> capped at `start + ANPR_POLL_MAX_MS` (30s) so a continuously-busy lane can't slide forever.
|
||||
> Because each tick pulls a FRESH frame, the loop naturally tracks whoever is at the barrier
|
||||
> *now*, not the car that started it. (Knobs: `ANPR_POLL_MS`, `ANPR_POLL_WINDOW_MS`,
|
||||
> `ANPR_POLL_MAX_MS`.)
|
||||
|
||||
The goal (narrowed deliberately — see Rejected below): **a subscriber's plate, read by the lane
|
||||
camera, admits them through the same gated flow a QR/card scan uses.** Scope was cut to subscribers
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, device, printer, transport, usb, escpos, provisioning]
|
||||
sources: []
|
||||
updated: 2026-06-24
|
||||
status: settled
|
||||
---
|
||||
|
||||
# Printer USB transport (kernel usblp, behind the ESC/POS layer)
|
||||
|
||||
The ESC/POS printer drivers ([[rongta-printer|rongta]], `cashino`) can deliver their byte stream
|
||||
over **either a raw TCP socket (port 9100)** or a **local USB character device** (`/dev/usb/lp0`),
|
||||
selected per device by `config.transport` (`"tcp-ip" | "usb"`). The original architecture always
|
||||
intended one ESC/POS adapter to cover "USB **or** network" (parking-system-architecture §BOM); the
|
||||
first implementation shipped TCP-only, and this closes that gap.
|
||||
|
||||
## The seam — render once, dispatch the transport
|
||||
|
||||
Every `render*()` function in `packages/devices/src/drivers/printer-escpos.ts` produces a
|
||||
**transport-independent ESC/POS `Buffer`**. Only delivery differs. The transport is resolved **once**
|
||||
per driver from config and every print/probe call site stays transport-blind:
|
||||
|
||||
- `transportFromConfig(config)` → a discriminated `Transport` (`{ kind: "tcp", host, port }` or
|
||||
`{ kind: "usb", devicePath }`). Anything other than `transport: "usb"` is TCP, so **existing
|
||||
host-only configs keep working unchanged** (no migration).
|
||||
- `sendTo(t, payload, timeoutMs)` / `probeTo(t, timeoutMs)` dispatch to the TCP pair
|
||||
(`sendRaw`/`probe`) or the USB pair (`sendRawUsb`/`probeUsb`).
|
||||
|
||||
Adding a transport = one more arm in the dispatcher; **not a single rendered byte changes**. This is
|
||||
why the CP852 map, the Code128/QR builders, roles/failover, and the receipt/ticket/voucher layouts
|
||||
are all untouched by USB support.
|
||||
|
||||
## USB transport = the in-box `usblp` char device
|
||||
|
||||
A USB ESC/POS printer plugged into the appliance enumerates as a **character device** (e.g.
|
||||
`/dev/usb/lp0`) via the kernel's in-box **`usblp`** driver. We just **open it `O_WRONLY` and write
|
||||
the same bytes**:
|
||||
|
||||
- **No native dependency.** A plain `fs` write — no libusb, no CUPS, no native addon. This keeps the
|
||||
**MIT/Apache/BSD-only** dependency constraint and the **offline-first, minimal-deps appliance**
|
||||
posture (see [[technology-stack]], [[offline-first]]).
|
||||
- **`usblp` is raw.** Unlike the TCP path there is **no FIN/half-close dance** (the graceful-close
|
||||
fix was a *TCP* concern — an early `destroy()` could RST-truncate the stream; see
|
||||
[[rongta-printer]]). A single open + write delivers the job; we always close the handle.
|
||||
- **Bounded by a timeout.** A wedged USB printer can block the write (or the open) indefinitely; a
|
||||
stuck print must surface as a failure, not hang the entry flow. `withTimeout` rejects after
|
||||
`timeoutMs`.
|
||||
|
||||
## Status over USB — reachability only (honesty rule)
|
||||
|
||||
`probeUsb` is "does the char device exist and open writable" — the **USB analogue of the TCP connect
|
||||
probe**. A present, openable `/dev/usb/lp0` means `usblp` bound a powered, enumerated printer.
|
||||
|
||||
- The `cashino` driver is reachability-only on **both** transports (it never had a status page).
|
||||
- The `rongta` driver's rich `readStatus()` scrapes the board's **HTTP** `/prn_stat.htm` — a
|
||||
**network feature**. Over USB there is no such page, so `readStatus()` **degrades to the
|
||||
reachability floor** (ready/offline only, never a guessed paper/cover state). A USB Rongta is
|
||||
effectively a Cashino for monitoring. This preserves the standing honesty rule from
|
||||
[[printer-status-monitoring]]: never report a paper/cover verdict the transport can't actually sense.
|
||||
|
||||
## Threat model
|
||||
|
||||
The USB path is a **local character device** the booth operator (the threat model's adversary)
|
||||
cannot reach over the network — narrower attack surface than the unauthenticated TCP print socket on
|
||||
the VLAN. Printers are advisory output; nothing about the signed [[append-only-event-chain|ledger]]
|
||||
or barrier control is touched.
|
||||
|
||||
## Provisioning dependency (NOT app code) — see open-questions #14
|
||||
|
||||
Driving a USB printer depends on the appliance image:
|
||||
1. the **`usblp`** kernel module is loaded (it is in-box on Ubuntu 26.04; CUPS can claim the
|
||||
interface first — may need `usblp` to win, or CUPS masked for that device), and
|
||||
2. a **udev rule** grants the server process write access to the node (e.g. a group on
|
||||
`/dev/usb/lp*`), since the appliance server does not run as root.
|
||||
|
||||
This is a [[appliance-provisioning]] concern, recorded as **open-questions #14** until the on-site
|
||||
printer is confirmed USB and the rule is baked into the image and verified on hardware.
|
||||
|
||||
## Status
|
||||
|
||||
Built 2026-06-24 behind the existing render layer. `sendRawUsb`/`probeUsb`/`transportFromConfig`/
|
||||
`sendTo`/`probeTo` in `printer-escpos.ts`; `cashino` + `rongta` resolve a `Transport` and dispatch.
|
||||
The setup UI offers a **Connection** select (Network / USB) + a **USB device** path field (default
|
||||
`/dev/usb/lp0`); host/port are not-required so a USB printer needs neither. Covered by
|
||||
`printer-escpos.test.ts` (USB writes the exact rendered bytes; probe present/absent;
|
||||
`transportFromConfig` TCP back-compat) and `printer-cashino.test.ts` (a USB-configured driver prints
|
||||
to the node and reports ready/offline). The on-hardware confirmation + the udev/usblp provisioning
|
||||
are pending (open-questions #14).
|
||||
|
||||
Related: [[rongta-printer]], [[printer-status-monitoring]], [[printer-roles-failover]],
|
||||
[[appliance-provisioning]], [[network-isolation]], [[technology-stack]].
|
||||
@@ -2,17 +2,18 @@
|
||||
type: reference
|
||||
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
|
||||
sources: []
|
||||
updated: 2026-06-23
|
||||
updated: 2026-06-27
|
||||
status: settled
|
||||
---
|
||||
|
||||
# Appliance provisioning runbook (booth PC)
|
||||
|
||||
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
|
||||
below — every command here was run and verified on that machine, including the firmware-specific
|
||||
workaround. Companion to [[disk-os-hardening]] (the *why*), [[tpm]] (TPM analysis), and
|
||||
[[container-deployment]] (the images this runs).
|
||||
parking appliance. Written from the **first real provisioning, 2026-06-23** (hardening) +
|
||||
**first Komodo deploy, 2026-06-27** (the runtime) — every command here was run and verified on the
|
||||
actual hardware, including the firmware-specific workaround. Companion to [[disk-os-hardening]] (the
|
||||
*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
|
||||
> 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)
|
||||
|
||||
- **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
|
||||
([[desktop-shell-tauri]]).
|
||||
- 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.
|
||||
|
||||
## 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
|
||||
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).
|
||||
|
||||
Deploy from a standalone dir (hand-copied; no repo on the appliance), e.g. `/opt/parking_solution`:
|
||||
`docker-compose.yml` + `docker-compose.prod.yml` (the Caddy/prod override) + `Caddyfile` + a `.env`
|
||||
(chmod 600). The `.env` (driven into the containers by the base compose):
|
||||
This gives the appliance the engine. **How the stack gets ONTO it is step 7** — and as of
|
||||
2026-06-27 the primary path is **Komodo (remote, no-SSH)**, not a hand-copied dir. The manual
|
||||
`docker compose` flow survives as a **break-glass fallback** (§7c).
|
||||
|
||||
```
|
||||
JWT_SECRET=<openssl rand -hex 32> # server REFUSES to boot without (>=32, no insecure default)
|
||||
EVENT_SIGNING_KEY=<a DIFFERENT openssl rand -hex 32>
|
||||
COOKIE_SECURE=0 # CRITICAL on plain-http or the auth cookie never sends → no login
|
||||
WS_ALLOWED_ORIGINS=http://<name-or-ip> # any REMOTE origin admins use (same-origin always passes)
|
||||
VISION_ENABLED=1
|
||||
# REGISTRY/TAG default to git.infra.msai.al/mca/parking_solution + dev; set TAG=main to pin.
|
||||
```
|
||||
## 7. Deploy the stack — Komodo Periphery (PRIMARY, 2026-06-27)
|
||||
|
||||
The booth is driven by a central **Komodo Core** over the **NetBird** mesh. The appliance runs a
|
||||
small **Periphery** agent that *dials out* to Core; Core then deploys the same compose files. No
|
||||
inbound port on the booth, no SSH for routine ops. Full rationale + threat model:
|
||||
[[fleet-deployment-komodo]]. Verified end-to-end on the first booth (`park-buzi`) 2026-06-27.
|
||||
|
||||
### 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
|
||||
docker login git.infra.msai.al # a read-only package token, not the account password
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml config # dry-run: verify the merged env
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
# Seed the FIRST admin (DB starts empty → nobody can log in until this runs; idempotent):
|
||||
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
|
||||
curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/setup-periphery.py | python3 - --user \
|
||||
--core-address="https://komodo.infra.msai.al" \
|
||||
--connect-as="park-buzi" \
|
||||
--onboarding-key="O-…"
|
||||
sudo loginctl enable-linger admin # so the user service starts at boot without a login
|
||||
```
|
||||
|
||||
Healthy startup logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked
|
||||
weights), server `[migrate] done` → `SPA static serving enabled` → `Server listening`. The transient
|
||||
- `--connect-as` is the **Server name in Core** — unique, stable, site-meaningful (the fleet's
|
||||
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
|
||||
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"):**
|
||||
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
|
||||
`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
|
||||
|
||||
@@ -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).
|
||||
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.
|
||||
|
||||
### 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
|
||||
tags: [parking, deployment, docker, ci, offline-first]
|
||||
sources: []
|
||||
updated: 2026-06-22
|
||||
updated: 2026-06-24
|
||||
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
|
||||
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)
|
||||
|
||||
- **`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
|
||||
on a branch pulls that branch's image — the branch→environment mapping IS the override file.
|
||||
|
||||
## Booth operator wrapper — `scripts/booth.sh`
|
||||
|
||||
So the on-site operator runs one command instead of the long `docker compose -f … -f … --env-file …`
|
||||
line, **`scripts/booth.sh`** wraps the base + override + env-file. **Prod by default** (the booth is
|
||||
prod); `ENV=dev` switches to the dev override.
|
||||
|
||||
- `./scripts/booth.sh up` — start (detached). `down` / `restart` / `status` / `logs [service]` /
|
||||
`pull` / `config` / `exec <svc> …` as expected.
|
||||
- **`./scripts/booth.sh update`** — the "**I know there are new images**" path: `compose pull` the
|
||||
moving branch tag, then `up -d --remove-orphans` (recreates only services whose image digest moved;
|
||||
**named volumes — the SQLite ledger — are preserved**), then `docker image prune -f` to reclaim the
|
||||
old layers. This is the routine update after a `dev`/`main` push republishes the branch tag.
|
||||
- **Env handling.** Reads **`.env`** (copy from `.env.example`: `REGISTRY`, `TAG`, `JWT_SECRET`,
|
||||
`EVENT_SIGNING_KEY`, `COOKIE_SECURE=0`, `WS_ALLOWED_ORIGINS`). Prod **refuses to run without
|
||||
`.env`** (no safe `JWT_SECRET` default — `auth.ts` rejects weak ones). Dev with no `.env` injects
|
||||
the documented benign local secret so `up` works out of the box. The base file makes `JWT_SECRET`
|
||||
shell-required (`${JWT_SECRET:?}`), so the env-file is mandatory for both — the script surfaces that
|
||||
early with a clear message rather than a raw compose interpolation error.
|
||||
- **Safety:** `down` never passes `-v` (deleting `parking-data` would wipe the signed
|
||||
[[append-only-event-chain|ledger]]); `help`/unknown-command short-circuit before any Docker/.env
|
||||
requirement. The operator never types `JWT_SECRET` on the CLI — it lives in `.env` (the user
|
||||
generates it with `openssl rand -hex 32`).
|
||||
|
||||
## Registry + CI
|
||||
|
||||
- Published to the house **Gitea registry** `git.infra.msai.al/mca/parking_solution/{parking-server,
|
||||
|
||||
@@ -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;
|
||||
refusing to boot could strand a lane). Software-only, independent of the TPM/[[atecc608]] hardware.
|
||||
See [[append-only-event-chain]].
|
||||
14. **Printer USB transport — confirm the on-site printer + bake the provisioning.** _(Recorded
|
||||
2026-06-24; the transport code is built — see [[printer-usb-transport]].)_ The ESC/POS drivers
|
||||
now drive **TCP (port 9100) OR local USB (`/dev/usb/lp0`)** behind one render layer, selectable
|
||||
per device. **Open:** is the actual booth printer USB or network? (The site's verified units are
|
||||
*networked* — Cashino `10.0.10.9`, Rongta `10.0.10.10` — so USB may be unused here; the original
|
||||
BOM listed "Epson TM / Citizen (USB **or** network)", so a future site may need it.) If USB is
|
||||
used, the **appliance image** must (a) load/keep the **`usblp`** kernel module bound to the
|
||||
printer (CUPS can claim the interface first), and (b) ship a **udev rule** giving the non-root
|
||||
server process write access to `/dev/usb/lp*`. Both are [[appliance-provisioning]] steps, **not
|
||||
app code**, and are **unverified on hardware**. Close this once the printer transport per site is
|
||||
fixed and (if USB) the udev/usblp rule is in the image and a real USB print is verified. Relates
|
||||
to #1 (lane topology / image standardization). See [[printer-usb-transport]], [[rongta-printer]].
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: decision
|
||||
tags: [parking, decisions, vision, anpr, monorepo, packaging]
|
||||
sources: []
|
||||
updated: 2026-06-19
|
||||
updated: 2026-06-25
|
||||
status: settled
|
||||
---
|
||||
|
||||
@@ -114,3 +114,29 @@ The skeleton is **built and wired** (no recognizer models yet):
|
||||
> **Resolved 2026-06-22 → [[container-deployment]]:** the vision service now ships as the
|
||||
> `parking-vision` Docker image (uv base, `--extra alpr`), model weights **pre-warmed into the image
|
||||
> layer** at build (offline-first), and runs under **docker-compose** (base + per-env override).
|
||||
|
||||
## Two runtimes, one fragile (the `uv run` strips-the-extra trap) — 2026-06-25
|
||||
|
||||
Real ANPR runs **completely differently on the two machines**, and only the dev path was fragile:
|
||||
|
||||
- **Booth (deployment) = the Docker image.** The `Dockerfile` runs `uv sync --frozen --extra alpr`
|
||||
at build, so fast-alpr/onnxruntime are **baked into an immutable image layer** and the weights are
|
||||
pre-warmed in. `docker-compose.prod.yml` forces `VISION_RECOGNIZER=fast_alpr`. Nothing at runtime
|
||||
re-resolves the venv → **the booth's real ANPR cannot silently degrade.** (A booth
|
||||
`ModuleNotFoundError: fast_alpr` is a STALE image, not this bug — fix with `booth.sh update` to pull
|
||||
the current image.)
|
||||
- **Dev machine = bare `uv run uvicorn …`** against `apps/vision/.venv`. **This is the trap:** a plain
|
||||
`uv run` (or `uv sync` with no `--extra alpr`) re-resolves the venv to the lockfile **defaults** and
|
||||
**REMOVES** the alpr stack — leaving the model weights orphaned in `~/.cache/open-image-models` but
|
||||
no recognizer in the venv. So a dev box that ran real ANPR (weights downloaded, plate reads
|
||||
recorded) silently degrades to "**snapshot captured but no plate**" after the next `pnpm dev`. This
|
||||
exactly explains a gap observed 2026-06-25: real reads on 06-22, then nothing — the venv (frozen
|
||||
since 06-19, lean) had been stripped, while the Docker/compose work (06-23) was an innocent
|
||||
coincidence, not the cause.
|
||||
|
||||
**Fix (2026-06-25):** the vision `package.json` `dev`/`start`/`recognize` scripts now run
|
||||
`uv sync --extra alpr &&` FIRST, so `pnpm dev` is **self-healing** — the recognizer survives every
|
||||
run. A `dev:stub` script is the lean, model-free escape hatch. The booth (Docker) is untouched.
|
||||
**Implication:** local real-ANPR and booth real-ANPR are now both reliable; CI/light contributors who
|
||||
don't want the heavy stack use `dev:stub` or run the suite (tests are stub-mode, offline). See
|
||||
[[opencv-anpr-service]].
|
||||
|
||||
@@ -46,11 +46,41 @@ see [[dingtian-vs-mqtt]].
|
||||
|
||||
## Driver & config API
|
||||
|
||||
The `dingtian` driver ([[device-registry]]) implements three capabilities:
|
||||
`AccessControlDevice` (relay pulse/latch over UDP), `InputDevice` (read inputs + poll-based
|
||||
press/release events ~50 ms), and `PreconditionDevice` (below). Config fields include a separate
|
||||
**`httpPort`** — the device's web/config API is on a configurable HTTP port (default **80**),
|
||||
distinct from the UDP control port 60001.
|
||||
The `dingtian` driver ([[device-registry]]) implements:
|
||||
`AccessControlDevice` (relay pulse/latch over UDP), `AuxOutputDevice` (latch a NON-barrier output —
|
||||
see below), `InputDevice` (read inputs + poll-based press/release events ~50 ms), and
|
||||
`PreconditionDevice` (below). Config fields include a separate **`httpPort`** — the device's
|
||||
web/config API is on a configurable HTTP port (default **80**), distinct from the UDP control port
|
||||
60001.
|
||||
|
||||
### Spare relays + aux outputs (`setAux`)
|
||||
|
||||
A 4-input board typically has spare relays once the entry/exit barriers are wired. These drive
|
||||
**non-barrier indicators** — e.g. the entry button's 12 V lamp. Every relay is a `config.relays[]`
|
||||
row carrying the **event** it reacts to (`direction`): the barrier events (`entry`/`exit`/`both`)
|
||||
pulse, while a **`radarAlert`** row is an [[button-light-indicator|alert relay]] (blink + camera-lock).
|
||||
Business logic drives alert relays through the device-agnostic `AuxOutputDevice.setAux(channel, on)`
|
||||
(a latch), **never** the barrier `pulseOpen`; every barrier resolver skips `radarAlert` rows. The
|
||||
[[barrier-not-a-door]] rule doesn't apply to an aux output (it never gates a vehicle), so
|
||||
holding/blinking it is fine.
|
||||
|
||||
### Inputs are a first-class list (`config.inputs[]`)
|
||||
|
||||
Input wiring lives in `config.inputs[] = [{ input, role, relay?, kind?, activeLow?, cooldownSec? }]`
|
||||
— the twin of `relays[]`. `role` ∈ `button` | `presence` | `alertTrigger`; a button/presence row
|
||||
names the `relay` it serves; presence rows carry `kind` (loop/radar) + `activeLow`. Adding an exit
|
||||
radar is adding a `presence` row. (Pre-2026-06-28 configs wired this on the relay itself —
|
||||
`relays[].button/presenceInput/...`; `inputsOf()` synthesizes inputs[] from those for back-compat,
|
||||
so old configs keep working until re-saved.)
|
||||
|
||||
### Per-input active level (`inputs[].activeLow` / `inputActiveLow`)
|
||||
|
||||
Inputs are normalised against ONE board-wide resting level (`inputRestingHigh`). When a sensor (e.g.
|
||||
a [[hikvision-radar|radar]]) idles **opposite** the button, mark its terminal active-LOW — sourced
|
||||
from `inputs[].activeLow` (and the legacy `relays[].presenceActiveLow`, plus an explicit top-level
|
||||
`inputActiveLow[]` escape hatch), all merged by `activeLowFrom()` into the driver's `inputActiveLow`
|
||||
set — so that one input is read inverted while the button keeps the board default. (`inputActive()`
|
||||
is the pure helper; push-mode uses the device's own `ilu.active_level` instead.)
|
||||
|
||||
### Precondition: input_link_relay must be OFF
|
||||
|
||||
@@ -140,6 +170,28 @@ On assign the driver runs `harden()` (the [[device-registry|HardenableDevice]] c
|
||||
> drop connections (ECONNRESET), locking out the API the driver depends on — recoverable only by
|
||||
> factory reset. `harden()` deliberately never touches it.
|
||||
|
||||
## `relayPassword` field + the "offline despite ping" gotcha (2026-06-24)
|
||||
|
||||
`relay_pw` is in **every** binary frame — control AND the status read `healthCheck()` uses. With a
|
||||
wrong/missing value the device **silently drops the packet** (no NAK), so the probe **times out →
|
||||
the controller shows "offline" even though it pings** (ping is ICMP and never touches the binary
|
||||
protocol). This bit a real bring-up: the driver read `config.relayPassword` but there was **no form
|
||||
field** for it, so Test connection sent `0` → timeout → "offline", while `relay_pw` was actually a
|
||||
non-zero value the harden flow had set. Diagnostic: a raw UDP status frame
|
||||
(`FF AA <s> 00 <pwLo> <pwHi>`) replies *only* with the right password — `pw=N` → `ffaa…`, `pw=0` →
|
||||
timeout — and binding the WSL socket to the device-facing NIC (`localAddress`) also broke the reply
|
||||
(leave it unbound on WSL). Fix: a **"Relay control password"** config field (a **secret**; blank =
|
||||
keep the stored value).
|
||||
|
||||
> 🔒 **Secret re-merge is identity-gated (don't let a redirected probe exfiltrate it).** Because
|
||||
> `relayPassword`/`pushPassword` are redacted from the client ([[first-run-setup]]), the edit form
|
||||
> can't resend them, so `/api/setup/test` re-merges the stored secret by device **id** — but ONLY
|
||||
> when the submitted config addresses the **same device**: matching `driverId` and every
|
||||
> connection-identity field it sets (`host`/`port`/`binaryPort`/`httpPort`/`serial`). A redirected
|
||||
> host/port or mismatched driver returns NO secret, so an authenticated admin can't point a test at
|
||||
> an attacker host and have the password sent there (the booth operator is the [[threat-model]]
|
||||
> adversary). Save already merged from the stored row; this closes the same gap on test.
|
||||
|
||||
## Status — VERIFIED on hardware (DT-R004, sw V3.1.5461A, 10.0.10.172)
|
||||
|
||||
- ✅ status read (`0000:1111:4`), relay pulse, input press/release events (active-LOW, idle HIGH).
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
type: entity
|
||||
tags: [parking, device, sensor, radar, entry, presence]
|
||||
sources: []
|
||||
updated: 2026-06-24
|
||||
status: settled
|
||||
---
|
||||
|
||||
# Hikvision Radar (vehicle-presence sensor)
|
||||
|
||||
A radar mounted at an entry barrier that **closes a dry-contact relay when it detects something in
|
||||
its vicinity** (a vehicle approaching the barrier). Wired to a **[[dingtian-relay|Dingtian]] input
|
||||
terminal**, it acts as the vehicle-**presence** signal for the entry flow — functionally the same
|
||||
role as an induction loop, just a different sensor.
|
||||
|
||||
## Where it sits in the model
|
||||
|
||||
The radar is a **child of the access controller config**, not a standalone device. On the entry
|
||||
relay's spec (`config.relays[]`):
|
||||
- `presenceInput` = the 1-based input terminal the radar's contact is wired to (e.g. **I2**).
|
||||
- `presenceKind: "radar"` = a label (vs. `"loop"`) for the UI + telemetry; the **gate behaviour is
|
||||
identical** either way.
|
||||
- `presenceActiveLow` = set when the radar idles HIGH and pulls LOW on detection (see below).
|
||||
|
||||
The booth's wiring (first install): **button on I1, radar on I2**, both on the same 4-input Dingtian.
|
||||
|
||||
## Its job: the one-car-one-ticket gate (advisory, never opens a barrier)
|
||||
|
||||
The radar feeds the **[[entry-double-press|one car = one ticket]]** gate exactly as a loop does: the
|
||||
entry button prints a ticket **only while the radar shows a vehicle present**, and **no second
|
||||
ticket** issues until the radar **clears** (the car drove in) and a new car re-occupies the zone.
|
||||
|
||||
> The radar is **advisory**. A detection NEVER opens a barrier on its own — it only *gates* the
|
||||
> button press. Entry still requires the physical press (and the capacity gate). This is the
|
||||
> [[threat-model]] rule: a sensor reading is never the sole reason a barrier opens. (Distinct from
|
||||
> the [[lane-presence-and-anpr-entry|ANPR bridge]], which admits *subscribers* through the gated
|
||||
> subscription flow — also never a transient open.)
|
||||
|
||||
## The active-level gotcha (why `presenceActiveLow` exists)
|
||||
|
||||
The Dingtian normalises **all** inputs against one board-wide resting level (`inputRestingHigh`).
|
||||
The booth's **button** (NO contact to GND) idles HIGH and pulls LOW on press. A **radar's dry
|
||||
contact may idle the opposite way** — and if it does, the controller would read "vehicle present"
|
||||
exactly when the zone is *clear*, inverting the gate (and the [[button-light-indicator|button
|
||||
lamp]]).
|
||||
|
||||
Fix: mark the radar's terminal **active-LOW** (`presenceActiveLow: true` on the relay spec). The
|
||||
driver then reads just that input inverted (active when LOW), leaving the button on the board
|
||||
default. Implemented as a per-input override in `access-dingtian.ts` (`inputActive()` +
|
||||
`inputActiveLow` set, derived from each relay's `presenceActiveLow`). Push-mode (the
|
||||
`/input/:n/:edge` HTTP path) relies instead on the device's own `ilu.active_level`; the override is
|
||||
the **poll-mode** equivalent.
|
||||
|
||||
## Also drives the button light
|
||||
|
||||
The same radar present/clear signal, combined with the camera's lane status, drives the entry
|
||||
button's 12 V lamp on a spare relay — see [[button-light-indicator]].
|
||||
|
||||
## Status
|
||||
|
||||
Modelled 2026-06-24 (button I1 + radar I2 on the first booth's Dingtian). Gate behaviour reuses the
|
||||
existing presence path; only the label + active-level override were added. Related:
|
||||
[[dingtian-relay]], [[entry-double-press]], [[lpr-camera]], [[entry-exit-points]].
|
||||
+104
-1
@@ -2,7 +2,7 @@
|
||||
type: entity
|
||||
tags: [parking, hardware, readers, offline-first]
|
||||
sources: [parking-system-architecture]
|
||||
updated: 2026-06-15
|
||||
updated: 2026-06-27
|
||||
---
|
||||
|
||||
# LPR Camera
|
||||
@@ -59,6 +59,57 @@ A **Hikvision** unit ("Camera 20", MAC `94:e1:ac:…`, Hikvision OUI) at `10.0.1
|
||||
- Reaching it from the WSL dev box required forcing the source address (`config.localAddress`,
|
||||
threaded into the driver) — see [[wsl-dev-networking]] (multi-subnet source-selection trap).
|
||||
|
||||
### HTTP 503 "Device Busy" — can be PERSISTENT; the real fix is stream selection (2026-06-26)
|
||||
|
||||
The snapshot endpoint returns **HTTP 503** with the ISAPI body `statusCode 2` / `"Device Busy"` /
|
||||
`subStatusCode deviceBusy` (occasionally **500**). It comes in two flavours, and they need different
|
||||
fixes — **don't assume it's a momentary blip**:
|
||||
|
||||
- **Transient** — the encoder is briefly occupied (another snapshot in flight, a stream starting).
|
||||
Clears on retry within a frame or two.
|
||||
- **Persistent** — the **MAIN-stream encoder is saturated** and 503s on EVERY main-stream snapshot.
|
||||
Confirmed on hardware (**DS-2CD1047G3H-LIU**, 2026-06-26): `channels/101/picture` → 503 on five
|
||||
consecutive probes 800 ms apart, while **`channels/102/picture` (the SUB stream) → 200 every time**,
|
||||
a clean ~15 KB JPEG. So the path/API was correct (the camera answered with a structured Hikvision
|
||||
status); the main encoder was simply never free. A retry loop **cannot** fix this — it just delays
|
||||
the failure.
|
||||
|
||||
> **Sharper finding (2026-06-27, same DS-2CD1047G3H-LIU).** Re-probed `10.0.10.13` directly after a
|
||||
> camera reboot with every web/live-view connection closed. **Sub (102): 10/10 rapid back-to-back
|
||||
> pulls → 200** (~50 ms, ~14.7 KB) — flawless even with NO delay, harder than the live ANPR cadence.
|
||||
> **Main (101): 3/3 → 503 in ~20 ms** — an *instant* reject, not a timeout. So main isn't merely
|
||||
> "saturated/busy" on this model — its snapshot endpoint is **structurally unavailable**; **sub (102)
|
||||
> is mandatory**, not just preferable. SEPARATELY, the camera 503s on **any** stream when its
|
||||
> **connection slots are exhausted** — a human holding the web UI / live-view, or parallel
|
||||
> main-stream experiments, consume slots; a **reboot clears stuck slots**. This was the actual cause
|
||||
> of the **2026-06-27 "subscribers auto-enter but don't auto-exit"** scare: manual main-stream
|
||||
> testing held the exit camera's slots → the system's sub-stream snapshot pulls got "Device Busy" →
|
||||
> the ANPR exit read never got a frame → no auto-exit. **NOT a code/flow bug** — the subscription
|
||||
> exit logic, camera→relay binding, and `stream: "2"` config were all correct (auto-exit/entry pairs
|
||||
> were clean before the test storm and after the reboot). (Also recorded as the LLM memory
|
||||
> `g3h-main-stream-snapshot-503`.)
|
||||
|
||||
**The fix that actually works: snapshot from the SUB stream.** The Hikvision ISAPI channel id is
|
||||
`<channel><stream>` (e.g. ch1 main = `101`, ch1 **sub = `102`**). The driver now has a **`stream`
|
||||
config field** (`1` = main, default for back-compat; `2` = sub). Set the G3H camera to **Sub (02)** in
|
||||
the setup form → its status flips `degraded → ready` (verified live: pulled a 14.7 KB JPEG in ~87 ms).
|
||||
The sub-stream is also the better fit for snapshot/ANPR anyway (smaller/faster; doesn't contend with
|
||||
live-view/recording for the main encoder).
|
||||
|
||||
Two more complementary mitigations (both BUILT, for the *transient* case):
|
||||
1. **Don't cause concurrent busy.** On a vehicle entry two server paths used to snapshot the same
|
||||
camera at once (the ANPR bridge + the advisory `snapshotAsync`); the 2nd concurrent GET drew a 503.
|
||||
They now share ONE pull via `captureSnapshotShared` (deviceId-keyed, `apps/server/src/snapshot.ts`)
|
||||
— the main cause of the slow 2026-06-25 subscriber entry. See [[lane-presence-and-anpr-entry]].
|
||||
2. **Retry a transient one.** `HttpCamera.captureSnapshot` retries 503/500 with a short linear backoff
|
||||
(250/500/750 ms, ≤4 attempts), then fails naming it `(device busy)`; it does NOT retry 401/404
|
||||
(config errors won't self-heal). This recovers a momentary blip but, by design, still fails a
|
||||
PERSISTENTLY-busy main stream — the cue to switch that camera to the sub-stream.
|
||||
|
||||
Covered by `packages/devices/src/drivers/camera.test.ts` (retry behaviour + the main/sub path
|
||||
selection). `healthCheck()` deliberately reports a live 503 as `degraded` (it surfaces a genuinely
|
||||
saturated main stream rather than hiding it behind a retry).
|
||||
|
||||
## Camera PUSH — "Alarm Server" event notifications (2026-06-22)
|
||||
|
||||
Separate from the **pull** snapshot path above: newer Hikvision firmware can **push** an event to
|
||||
@@ -90,6 +141,58 @@ Center**, then **Alarm Settings → Alarm Server**, makes the camera **HTTP-POST
|
||||
use it directly; this `DS-2CD1043G2`
|
||||
does not, so the server pulls the frame and hands it to the [[opencv-anpr-service|vision service]].
|
||||
|
||||
### "Subscribers auto-enter but don't auto-exit" — a 4-layer CAMERA fault, NOT our code (2026-06-27)
|
||||
|
||||
A long debugging session on the **DS-2CD1047G3H-LIU** exit camera (`10.0.10.13`, exit-lane). The
|
||||
symptom: subscribers (e.g. Caca) auto-entered via ANPR fine but **never auto-exited**. **Every
|
||||
assumption about *our* code was wrong; all four real causes were camera-side.** Method that finally
|
||||
cracked it: a **dumb HTTP sink** (`scratch-camera-sink.py`) the camera's Alarm Server was pointed
|
||||
at, to see — verbatim — what the camera actually sends, independent of our app's parsing/acceptance.
|
||||
|
||||
The wrong turns, and what was actually true:
|
||||
|
||||
1. **Wrong assumption: "the exit camera 503s, so harden the snapshot retry / reduce load."** The 503
|
||||
storm in the data was mostly **manual main-stream testing**: on this G3H, `channels/101/picture`
|
||||
(MAIN) **503s instantly every time** — structurally unavailable, not "busy" — while `102` (SUB)
|
||||
serves 10/10 rapid pulls cleanly. AND the camera **503s on *any* stream when its connection slots
|
||||
are exhausted** (a held web UI / live-view, parallel experiments); a **reboot clears stuck slots**.
|
||||
So the snapshot retry/flow code was fine. See [[#HTTP 503 "Device Busy"]] + the
|
||||
`g3h-main-stream-snapshot-503` memory. (The exit/subscription FLOW logic, camera→relay binding,
|
||||
and `stream:"2"` config were all correct the whole time — verified: clean entry/exit pairs before
|
||||
the test storm, and after the reboot.)
|
||||
|
||||
2. **The actual blocker #1 — the exit camera never POSTed at all.** `alarmPushEnabled=true` in our
|
||||
config, but `10.0.10.13` had sent **ZERO** alarms ever (entry cam `.12`: 1126). The sink received
|
||||
nothing from `.13`; our `/event` endpoint logged no rejections either → the camera wasn't sending.
|
||||
Cause found in the camera's own **Diagnose Information** dump: **`Main Db is broken` /
|
||||
`db_restore failed` / `Going to reset cfg`** — the camera's internal config DB (`ipc_db`) was
|
||||
**CORRUPT**, plus repeated reboots. A broken config DB means the event→linkage→push pipeline can't
|
||||
reliably read its own config, so it silently never POSTs. **Fix: factory-reset the camera** (rebuilds
|
||||
`ipc_db`), then reconfigure. (If corruption returns after a clean reset → failing flash → RMA.)
|
||||
|
||||
3. **Wrong assumption: "missing gateway/DNS blocks the push."** A documented Hikvision note says a
|
||||
gateway is needed even same-subnet — but here it was **inverted**: the WORKING cam `.12` has NO
|
||||
gateway/DNS; the broken `.13` HAD both. Red herring. Gateway/DNS was not the cause.
|
||||
|
||||
4. **The actual blocker #2 — after reset, the camera pushed PLAIN MOTION, not vehicle.** Post-reset
|
||||
`.13` POSTed `<eventType>VMD</eventType>` with **no target tag**. The backend gates on
|
||||
`target == "vehicle"` (`hikvision-alarm.ts` `isVehicleActive`, matches `<targetType>` /
|
||||
`<detectionTarget>` / `<objectType>`), so a plain-motion push is **ignored** → bridge never fires.
|
||||
The working `.12` sends `eventType=VMD` **with `target=vehicle`**. The difference is the AcuSense
|
||||
**Detection-Target = Vehicle** filter ON the Motion event — defaulted OFF after factory reset.
|
||||
**Fix: enable Vehicle target classification** on `.13`'s Motion Detection. Confirmed live: a real
|
||||
drive-through then POSTed `<eventType>VMD</eventType> … <targetType>vehicle</targetType>` — exactly
|
||||
what the backend needs. (So "VMD/Motion" IS the right event for this camera class; it's the *target
|
||||
filter* that matters, not switching to a different event type.)
|
||||
|
||||
**Takeaways:** (a) a camera that's silently not-pushing looks identical to "fine" in our logs — the
|
||||
sink-to-prove-it-sends method is the fastest disambiguator; flagged as an observability gap (a
|
||||
`alarmPushEnabled=true` camera with 0 pushes ever should be a surfaced condition, like the
|
||||
[[device-status-monitoring|reader-liveness]] fix). (b) For ANPR the camera must send a **vehicle
|
||||
`targetType`** — verify the push body, not just the UI toggles. (c) Hikvision config-DB corruption
|
||||
is real; factory reset is the cure. None of this was a code bug. See
|
||||
[[lane-presence-and-anpr-entry]] for the push→bridge→exit path.
|
||||
|
||||
### Gotchas learned the hard way (2026-06-22 field session)
|
||||
|
||||
Several traps surfaced trying to get a real camera to push. In order of how long each cost:
|
||||
|
||||
@@ -34,6 +34,13 @@ many ESC/POS-compatible OEM clones that share its firmware). Driver `rongta` in
|
||||
We scrape that rather than hand-decode `DLE EOT` — this clone's DLE EOT reply bytes do **not**
|
||||
match the canonical ESC/POS bit layout (verified on hardware), so trusting the device's own
|
||||
decode avoids a false-healthy. Implemented as `readStatus()`; see [[printer-status-monitoring]].
|
||||
- **USB transport (added 2026-06-24).** The same driver can instead drive a printer over a local
|
||||
USB `usblp` char device (`/dev/usb/lp0`) — `config.transport` (`tcp-ip` | `usb`) picks the wire
|
||||
behind one render layer (the ESC/POS bytes are identical). The status web page is a **network**
|
||||
feature, so a **USB Rongta degrades to reachability-only** monitoring (open-the-node probe, no
|
||||
paper/cover verdict — the same honesty floor as the Cashino). Driving USB depends on the appliance
|
||||
image (`usblp` bound + a udev write-access rule) — a provisioning step, open-questions #14. Full
|
||||
rationale in [[printer-usb-transport]].
|
||||
|
||||
## Deployment (this site)
|
||||
|
||||
|
||||
+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.
|
||||
- [[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.
|
||||
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware).
|
||||
- [[rongta-printer]] — ✅ CHOSEN 80mm thermal printer; ESC/POS over raw TCP 9100; driver written, one unit reachable at 10.0.10.6.
|
||||
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware); spare relays drive aux outputs (`setAux`).
|
||||
- [[hikvision-radar]] — vehicle-presence radar on a Dingtian input; the entry presence gate (per-input active-level caveat).
|
||||
- [[rongta-printer]] — ✅ CHOSEN 80mm thermal printer; ESC/POS over raw TCP 9100 (or local USB, see [[printer-usb-transport]]); driver written, one unit reachable at 10.0.10.6.
|
||||
- [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network).
|
||||
|
||||
## Concepts — foundational forces
|
||||
@@ -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.
|
||||
- [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth.
|
||||
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
|
||||
- [[printer-usb-transport]] — ESC/POS drivers drive TCP (9100) OR local USB (/dev/usb/lp0) behind one render layer; USB = usblp char device, reachability-only status; provisioning open (oq#14).
|
||||
- [[device-status-monitoring]] — unified live status across ALL device categories (healthCheck + printer readStatus) → the booth footer over /api/ws.
|
||||
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
||||
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
||||
@@ -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).
|
||||
- [[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-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.
|
||||
|
||||
## 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).
|
||||
- [[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.
|
||||
- [[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.
|
||||
|
||||
+290
@@ -1552,3 +1552,293 @@ 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
|
||||
(per-commit test build); the signed/versioned release stays on `release.yml` (tag `v*`). See
|
||||
[[desktop-shell-tauri]] "Desktop in CI".
|
||||
|
||||
## [2026-06-24] build | Radar presence input + button-light output on the Dingtian
|
||||
The first booth wired an **entry button on I1** and a **[[hikvision-radar|Hikvision radar]] on I2**
|
||||
(closes a dry contact on detection), plus the **button's 12 V lamp on a spare relay**. Modelled as
|
||||
children of the access controller config — no new device category. (1) The radar reuses the existing
|
||||
`relays[].presenceInput` one-car-one-ticket gate; added `presenceKind: loop|radar` (label) and
|
||||
`presenceActiveLow` (a radar may idle opposite the button — the Dingtian has ONE board-wide resting
|
||||
level, so a per-input override `inputActiveLow` inverts just that terminal; pure helper
|
||||
`inputActive()`). (2) New device-agnostic **`AuxOutputDevice.setAux(channel,on)`** capability (Dingtian
|
||||
latch) so business logic drives a NON-barrier lamp through the interface — barriers still only
|
||||
`pulseOpen` ([[barrier-not-a-door]] preserved). (3) New `ButtonLightController`
|
||||
(`apps/server/src/button-light.ts`): subscribes to the radar input edge + the camera
|
||||
[[lpr-camera|lane status]] and drives a **3-state lamp** — radar+car=SOLID, radar-only=BLINK (~1 Hz),
|
||||
else OFF; **fails OFF**; de-duped. (4) SetupWizard: presence kind + active-low + a button-light relay
|
||||
picker; i18n parity (sq+en). Tests: `button-light.test.ts` (truth table + blink + fail-OFF + de-dupe),
|
||||
`access-dingtian.test.ts` (active-level inversion). Workspace build+lint+test green (158 server tests).
|
||||
A radar detection NEVER opens a barrier on its own — it only gates the button ([[threat-model]]). See
|
||||
[[hikvision-radar]], [[button-light-indicator]], [[entry-double-press]], [[dingtian-relay]].
|
||||
|
||||
## [2026-06-24] fix | Booth bring-up fixes — relay password, form split, lamp concurrency
|
||||
Three fixes from wiring the radar/lamp on the first booth (committed 420542c, fd15988, 830993b on
|
||||
top of the 2915d14 feature). (1) **"Offline despite ping"** — the Dingtian's `relay_pw` is in every
|
||||
binary frame incl. the status read, but had NO form field, so Test connection sent 0 → device
|
||||
silently drops the packet → "offline" (ping is ICMP, unrelated). Added a **"Relay control password"**
|
||||
secret field; because the secret is redacted, the test endpoint re-merges it by device id but ONLY
|
||||
when host/port/driver match the stored row (a redirected probe can't exfiltrate it — `setup-secrets.test.ts`).
|
||||
(2) **Form split** — the controller editor now has separate **Outputs** (relays + pulse-open + lamp)
|
||||
and **Inputs** (button + presence/radar terminals, "For relay N") sections; UI-only, storage
|
||||
unchanged. `pulse open (ms)` clarified as a relay/output setting, not an input. (3) **Lamp stuck
|
||||
on/off** — the blink fired fire-and-forget `setAux` over UNORDERED UDP; concurrent on/off packets
|
||||
reordered and the relay latched on the last-processed one. Replaced with a serialized desired-state
|
||||
worker (one in-flight send/lamp, re-converges to the latest state → final state authoritative). Also
|
||||
**hot-reload**: the lamp map now reconciles against live config each event, so a button light added
|
||||
in the UI works without a server restart. Workspace build+lint+test green (163 server tests). See
|
||||
[[dingtian-relay]] ("offline despite ping" + secret re-merge), [[button-light-indicator]] (serialized
|
||||
sends + hot-reload).
|
||||
|
||||
## [2026-06-24] build | Printer USB transport behind the ESC/POS render layer
|
||||
The ESC/POS printer drivers were **TCP-only** (every path went through `sendRaw`/`probe` to a raw
|
||||
socket on port 9100); the original BOM intended one adapter to cover "USB **or** network". Added a
|
||||
**USB transport** behind the existing render layer without touching a single `render*()` function:
|
||||
a discriminated `Transport` (`transportFromConfig` → `{kind:"tcp",host,port}` | `{kind:"usb",
|
||||
devicePath}`) and `sendTo`/`probeTo` dispatchers in `printer-escpos.ts`; USB writes the same ESC/POS
|
||||
bytes to a kernel **`usblp`** char device (`/dev/usb/lp0`) via a plain `fs` write — **no libusb/CUPS/
|
||||
native dep** (keeps MIT-only + minimal-deps appliance). `cashino` + `rongta` resolve a Transport once;
|
||||
both are reachability-only over USB, and the Rongta's HTTP **status page degrades to the open-the-node
|
||||
probe** over USB (no guessed paper/cover — the standing honesty rule). Non-`usb` configs are unchanged
|
||||
(host-only = TCP), so no migration. Setup UI gains a **Connection** select + **USB device** field;
|
||||
host/port made not-required so a USB printer needs neither. Tests: `printer-escpos.test.ts` (USB writes
|
||||
the exact rendered bytes; probe present/absent; `transportFromConfig` TCP back-compat) +
|
||||
`printer-cashino.test.ts` (USB-configured driver prints to the node, ready/offline). Devices suite
|
||||
green (29). **Flagged open-questions #14**: confirm the on-site printer is USB and bake the
|
||||
**usblp + udev write-access** rule into the appliance image (provisioning, not app code; unverified on
|
||||
hardware). See [[printer-usb-transport]], [[rongta-printer]].
|
||||
|
||||
## [2026-06-24] build | Booth operator wrapper script — scripts/booth.sh
|
||||
The booth PC (Ubuntu) needs one command instead of the long
|
||||
`docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env …` line over the
|
||||
three compose files. Added **`scripts/booth.sh`** (+ root **`.env.example`**): **prod by default**
|
||||
(`ENV=dev` for the dev override); subcommands `up`/`down`/`restart`/`status`/`logs`/`pull`/`config`/
|
||||
`exec`, and the requested **`update`** = `compose pull` the moving branch tag → `up -d --remove-orphans`
|
||||
(recreates only digest-changed services, **named volumes/SQLite ledger preserved**) → `docker image
|
||||
prune -f`. Prod **refuses to run without `.env`** (no safe `JWT_SECRET` default); dev with no `.env`
|
||||
injects the documented benign local secret (the base file makes `JWT_SECRET` shell-required via
|
||||
`${JWT_SECRET:?}`, which the dev override's service-level default alone can't satisfy). `down` never
|
||||
passes `-v` (would wipe the signed [[append-only-event-chain|ledger]] volume); `help`/unknown-command
|
||||
short-circuit before any Docker/.env requirement. Verified: prod `config` renders Caddy:80 + internal
|
||||
server + pinned images + `fast_alpr`; dev `config` renders `:dev` images + `stub` + published ports.
|
||||
Documented in [[container-deployment]] ("Booth operator wrapper").
|
||||
|
||||
## [2026-06-25] fix | Local ANPR silently degraded — `uv run` strips the alpr extra
|
||||
Diagnosed via the live DB (read-only `VACUUM INTO` copy) why entry `26799912337` recorded a snapshot
|
||||
but no plate: the dev box's vision service was running **stub**, and earlier real ANPR had stopped.
|
||||
Root cause (NOT the Docker/compose work, which was an innocent coincidence): the dev machine runs vision
|
||||
as **bare `uv run uvicorn`** against `apps/vision/.venv`, and a plain `uv run`/`uv sync` re-resolves the
|
||||
venv to the lockfile **defaults**, **stripping** fast-alpr/onnxruntime — so after any `pnpm dev` the
|
||||
recognizer vanishes (weights orphaned in `~/.cache`, no module in the venv) and ANPR silently becomes
|
||||
"snapshot, no plate". Evidence: 28 real reads through 06-22 (yolo-v9 model, ~99% conf), venv frozen lean
|
||||
since 06-19, no other env with fast_alpr on the box. **The BOOTH was never affected** — it runs the
|
||||
Docker image, which bakes `uv sync --frozen --extra alpr` at build (immutable, weights pre-warmed); a
|
||||
booth `ModuleNotFoundError` is a STALE image (fix: `booth.sh update`). **Fix:** vision `package.json`
|
||||
`dev`/`start`/`recognize` now `uv sync --extra alpr &&` first (self-healing), `.env` set to `fast_alpr`,
|
||||
+ a `dev:stub` escape hatch. Restored real ANPR locally (`/health` → `fast_alpr` ready, model loaded from
|
||||
cache, no download). Documented in [[vision-service-packaging]] ("Two runtimes, one fragile").
|
||||
|
||||
## [2026-06-26] fix | Hikvision snapshot 503 "Device Busy" — stream selection + retry + Alarm URL helper
|
||||
Three camera fixes. (1) **503 Device Busy — the REAL fix is stream selection.** First framed as
|
||||
"transient, just retry" — WRONG for this camera. Hardware probe of **DS-2CD1047G3H-LIU** (10.0.10.13):
|
||||
`channels/101/picture` (MAIN) → 503 `deviceBusy` on 5 consecutive probes 800ms apart, while
|
||||
`channels/102/picture` (SUB) → 200 clean JPEG every time. The main encoder is PERSISTENTLY saturated;
|
||||
a retry loop can't fix it. Added a **`stream` config field** to the Hikvision driver (1=main default
|
||||
for back-compat, 2=sub; ISAPI id `<channel><stream>`). Verified live: setting the camera to Sub flips
|
||||
its status degraded→ready (14.7KB JPEG in ~87ms). (2) **Transient retry** (still useful for a genuine
|
||||
momentary blip + the de-dup case): `HttpCamera.captureSnapshot` retries 503/500 with linear backoff
|
||||
(250/500/750ms ×4), fails naming it `(device busy)`, does NOT retry 401/404. Plus the already-landed
|
||||
`captureSnapshotShared` removing concurrent self-collision. `healthCheck` reports a live 503 as
|
||||
`degraded` (surfaces a saturated main stream rather than hiding it). Covered by `camera.test.ts`
|
||||
(10 tests: retry + main/sub path). (3) **Alarm Server URL helper:** the camera setup form now generates the camera's Alarm
|
||||
Settings (Destination IP / URL / Protocol / Port) ready to paste, so the operator never hunts the
|
||||
deviceId or memorises the endpoint. CRUCIAL: host/port come from the **backend address on the camera's
|
||||
subnet** (`backendIpForDevice` + server port, the same probe the push-IP picker uses) — NOT
|
||||
`window.location.origin` (the SPA's dev/proxy origin, which would wrongly say `localhost:5173`).
|
||||
Verified live: matches the on-camera config field-for-field (10.0.10.203 / …/event / HTTP / 3000).
|
||||
Shows a "save first" (needs a deviceId) then "test first" (needs the resolved backend IP) hint.
|
||||
Documented in [[lpr-camera]] ("503 Device Busy"). Devices 6 new tests; server 168 green.
|
||||
|
||||
## [2026-06-26] fix | QR reader status was a LIE (hardcoded "ready") → real ICMP liveness
|
||||
Two genuinely-OFFLINE QR readers showed GREEN in the status bar. Cause: the QR-reader adapter
|
||||
(`StubReader`) had `healthCheck → { ready, "stub" }` hardcoded — it never probed anything. These are
|
||||
PUSH devices (scan → GET our backend, resolve by serial) that expose **no TCP port**, so a connect
|
||||
probe (cameras/printers) has nothing to hit; the stub "solved" that by lying. False-healthy is the
|
||||
worst failure for a status bar. Fix: an **optional reader IP** (monitor-ONLY — scans still resolve by
|
||||
serial, operation unchanged) + an **unprivileged ICMP ping** (`drivers/icmp.ts`: shells `/bin/ping`
|
||||
`-c1`, exit-0 = reply; no native dep, no CAP_NET_RAW). `healthCheck`: IP replies → `ready`, no reply →
|
||||
`offline`, **no IP → `degraded` ("set IP to monitor")** (never a false green). Booth compose
|
||||
(`docker-compose.prod.yml`) sets `net.ipv4.ping_group_range=0 2147483647` so `/bin/ping` works
|
||||
unprivileged for the non-root container user. Verified on hardware: the readers (10.0.10.7/.8) answer
|
||||
ICMP on the device VLAN (eth1) — distinct MACs — and the UI Test connection shows "● ready — ping
|
||||
10.0.10.7". (NB: an earlier "offline" reading was a WSL wrong-route artifact, not the readers.) Covered
|
||||
by `reader.test.ts` (4 tests). Documented in [[device-status-monitoring]]. Devices +4 tests, all green.
|
||||
|
||||
## [2026-06-27] fix | booth.sh failed in the flat /opt layout (couldn't find compose files)
|
||||
|
||||
The booth deploys the compose files **flat** in `/opt/parking_systems/` with `booth.sh` next to
|
||||
them, but the script assumed `<repo>/scripts/` and did `cd ..` → `REPO_DIR=/opt` (no compose
|
||||
files); `usage()` then `sed`-read a now-relative `$0` → "can't read booth.sh". That's why
|
||||
`sudo ./booth.sh` only printed help and `/bin/bash booth.sh` errored. Fix: **discover** the
|
||||
compose files (script's own dir → `../` → `$PWD`), `usage()` reads an absolute `$SELF`. Also:
|
||||
`.env.example` defaulted `TAG=main`, but the registry only has `dev`/`dev-<sha>` (no main build) →
|
||||
`compose pull` 404s; default to `TAG=dev` + documented the moving-vs-immutable tag scheme.
|
||||
Reproduced the booth's flat layout in a scratch dir; all forms (`./booth.sh`, `/bin/bash
|
||||
booth.sh`, `config`, absolute-path) verified. Commit 83298bc.
|
||||
|
||||
## [2026-06-27] decision | Fleet deployment → Komodo Periphery over NetBird
|
||||
|
||||
booth.sh hit its ceiling: fine for one SSH-able box, but no remote/no-SSH op, no fleet view, no
|
||||
deploy history, no rollback — and the fleet is **many/growing**. Decision: **Komodo Periphery**
|
||||
on each appliance, driven by an existing **Komodo Core** over the **NetBird** mesh, running the
|
||||
**same** compose files ([[container-deployment]] pipeline unchanged); `booth.sh` demoted to
|
||||
break-glass. Three settled choices: many/growing fleet · deploys **manual + pinned** to a
|
||||
`dev-<sha>` (no webhook — preserves the determinism we chose by pinning) · secrets
|
||||
**Komodo-managed, per-booth + unique**. Threat-model caveats recorded: Periphery is a root agent
|
||||
(bind to NetBird interface only, passkey+TLS, part of the TCB); `EVENT_SIGNING_KEY` in Core is a
|
||||
fraud-root blast radius → per-booth keys + [[atecc608|ATECC608]] as the real
|
||||
long-term signer; Core becomes Tier-0. GPL-3.0 OK (external ops tooling, not a shipped dep — same
|
||||
boundary logic as the AGPL vision exception). New page [[fleet-deployment-komodo]]; infra-as-code
|
||||
sketch in `komodo/` (`resources.toml` + README + `.env.komodo.example`). Catalogued in `index.md`;
|
||||
`container-deployment` cross-linked + reframed (booth.sh = fallback).
|
||||
|
||||
## [2026-06-27] deploy | First Komodo booth deploy VERIFIED end-to-end (park-buzi)
|
||||
|
||||
Took the first booth through the whole Komodo flow on real hardware (Core v2.1.2 → agent reported
|
||||
v2.2): onboarding key → Periphery installed **user-mode** (runs as `admin`, no root daemon,
|
||||
**outbound** so the booth opens no inbound port) → server `park-buzi` **OK** in Core → Stack
|
||||
(repo `mca/parking_solution`@`dev`, base+prod compose, registry account `komodo`, per-booth
|
||||
`[[…]]` secrets) → all containers green → admin seeded via Komodo's container terminal (no SSH).
|
||||
Then `komodo/resources.toml` rewritten to mirror the **working** Stack (exported from Core, v2.2
|
||||
field shape, **Stack-only — no `[[server]]`** since onboarding owns the server), committed + pushed
|
||||
(`dev` 9918f27); a ResourceSync reads it clean — **empty diff / Execute disabled = already in
|
||||
sync** (success, not error). `booth.sh` fixed for the flat `/opt` layout earlier (83298bc).
|
||||
Gotchas that bit us (now in [[appliance-provisioning]] §7 + gotchas 7–11): `core_address` is Core's
|
||||
**proxy URL** not `:9120` (exposed-not-published → Connection refused); **git-auth ≠ registry-auth**
|
||||
(blank registry account → `no basic auth credentials`); user-mode + `/etc/komodo` root_directory →
|
||||
`Permission denied`; config key is **`core_address`** singular. [[appliance-provisioning]] §6 split:
|
||||
§6 = engine, §7 = Komodo deploy (PRIMARY) with §7c manual `booth.sh` break-glass.
|
||||
|
||||
## [2026-06-27] query | "Subscribers auto-enter but don't auto-exit" → NOT a bug; G3H main-stream snapshot is structurally dead
|
||||
|
||||
Investigated via a read-only VACUUM copy of the dev DB. Caca subscriber's ledger: clean
|
||||
entry/exit pairs until ~16:38, then 6 entries + 0 exits. Traced to the **exit camera 10.0.10.13
|
||||
(DS-2CD1047G3H-LIU)** producing only 2 reads ever (vs 60 on the entry cam) — every exit-direction
|
||||
snapshot after 16:38 was **HTTP 503 "device busy"**, so the ANPR exit read never got a frame →
|
||||
no `emitRead` → no auto-exit. The subscription-flow exit logic, camera→relay binding (relay 2 =
|
||||
exit, anpr on), and `stream: "2"` config were all **correct**. Direct hardware re-probe after a
|
||||
camera reboot + closing web connections: **sub (102) 10/10 rapid → 200 (~50 ms)**, **main (101)
|
||||
3/3 → 503 in ~20 ms (instant reject)**. So main-stream snapshots are **structurally unavailable**
|
||||
on this model (sub mandatory), and the 503 storm was **connection-slot exhaustion from manual
|
||||
main-stream testing** holding the camera's slots (reboot clears). Recorded in the
|
||||
the `g3h-main-stream-snapshot-503` LLM memory + a 2026-06-27 sharper-finding note in [[lpr-camera]]
|
||||
("503 Device Busy"). No code changed — diagnosis only.
|
||||
|
||||
## [2026-06-27] query | "Auto-exit" RESOLVED — a 4-layer CAMERA fault on the G3H, never our code
|
||||
|
||||
Continuation of the above. Drove the full diagnosis to ground using a **dumb HTTP sink**
|
||||
(`scratch-camera-sink.py`) the exit camera's Alarm Server was pointed at, to capture the verbatim
|
||||
push. Every assumption about *our* code was wrong; all real causes were camera-side on the
|
||||
**DS-2CD1047G3H-LIU** (`10.0.10.13`):
|
||||
(1) 503s were mostly **manual main-stream testing** (main 101 = instant 503 structurally; sub 102 =
|
||||
perfect) + connection-slot exhaustion (reboot clears) — NOT a retry/flow bug.
|
||||
(2) **Blocker #1:** the camera never POSTed at all (0 alarms ever vs 1126 on the entry cam); its
|
||||
**Diagnose dump showed a CORRUPT config DB** (`Main Db is broken`/`db_restore failed`/`reset cfg`) —
|
||||
factory-reset fixed it.
|
||||
(3) Gateway/DNS was a **red herring** (working cam had none; broken cam had both).
|
||||
(4) **Blocker #2:** post-reset it pushed **plain `VMD` with no target** → backend gates on
|
||||
`target=="vehicle"` (`hikvision-alarm.ts` reads `<targetType>`/`<detectionTarget>`/`<objectType>`)
|
||||
→ ignored. Enabling the AcuSense **Vehicle target filter** made the push carry
|
||||
`<targetType>vehicle</targetType>` (confirmed live on a drive-through). "VMD/Motion" is the right
|
||||
event for this class — it's the *target filter* that matters.
|
||||
Flagged an **observability gap**: a camera with `alarmPushEnabled=true` and 0 pushes ever should be
|
||||
a surfaced status (cf. the reader-liveness fix). Recorded in the `g3h-anpr-push-gotchas` memory + a
|
||||
new troubleshooting section in [[lpr-camera]]. No code changed — diagnosis + camera reconfig only.
|
||||
|
||||
## [2026-06-28] refactor | Unified controller relays into one event→action list (drop config.buttonLight)
|
||||
Reframed the controller "Outputs — relays" model with the user: **Entry / Exit / Both are EVENTS**,
|
||||
not a "direction" — a relay is uniformly *"when EVENT X happens, do action Y"*. Barrier events
|
||||
(`entry`/`exit`/`both`) `pulseOpen`; a new **`radarAlert`** event drives a non-barrier alert lamp
|
||||
(blink while its trigger input is active, SOLID once the camera confirms a car). **Dropped the
|
||||
separate `config.buttonLight` block** — the lamp is now just another `config.relays[]` row
|
||||
(`direction:"radarAlert"`, carrying `triggerInput` + blink cadence). One list, one editor, one shape;
|
||||
a future "R4 alert" is just another row with its own trigger input — no new config, no code change.
|
||||
The proven `ButtonLightController` 3-state machine (serialized UDP, fail-OFF, hot-reload) is kept
|
||||
verbatim — only its source changed from `buttonLightOf()` to `alertRelaysOf()`, keyed per
|
||||
`controllerId:relay` so several alert relays on one controller run independently. Every barrier
|
||||
resolver skips `radarAlert` rows (no auto-open; barrier-not-a-door intact). Touched
|
||||
`device-resolve.ts`, `button-light.ts`, `device-monitor.ts`, web `api.ts` + `SetupWizard.tsx` (the
|
||||
dropdown gained a "Radar alert" option that reveals trigger/blink inputs), i18n sq+en. Tests:
|
||||
rewrote `button-light.test.ts` to the `radarAlert` row + added a two-independent-alert-relays case;
|
||||
full workspace `build lint test` green (173 server tests). Updated [[button-light-indicator]],
|
||||
[[dingtian-relay]], memory `access-direction-is-per-relay`.
|
||||
|
||||
## [2026-06-28] refactor | Generic controller inputs (config.inputs[]) — the twin of unified relays[]
|
||||
After unifying OUTPUTS into one event→action `relays[]`, did the same for INPUTS — the user hit the
|
||||
wall that **there was no way to add a free-standing input** (e.g. an EXIT radar): inputs were fields
|
||||
bolted onto an entry barrier relay (`relays[].button/presenceInput/...`) and the UI only rendered a
|
||||
button+presence block per entry/both relay. Now a first-class **`config.inputs[]`** list — each row
|
||||
`{ input, role: "button"|"presence"|"alertTrigger", relay?, kind?, activeLow?, cooldownSec? }` — with
|
||||
a "+ Add input" button. An exit radar = just another `presence` row serving the exit relay. **Keystone:
|
||||
`inputsOf(row)`** returns `config.inputs[]` or SYNTHESIZES it from the legacy per-relay fields, so
|
||||
`relayForButton`/`relayForPresence` resolve identically from either shape — **zero-downtime, no DB
|
||||
migration** (old configs keep working until re-saved; the UI seeds its editor from the synth).
|
||||
`entry-flow.ts` is unchanged (resolves through the same functions). Also fixed a latent bug this
|
||||
exposed: the alert lamp's camera **lock** was hardcoded to the ENTRY camera — added
|
||||
`relays[].lockLane: "entry"|"exit"` (button-light tracks both `#entryBusy`/`#exitBusy`; a lamp goes
|
||||
SOLID off its own lane's camera), so an exit radar's lamp locks on the EXIT camera. Driver: extracted
|
||||
`activeLowFrom(config)` (merges inputs[] `activeLow` + legacy `presenceActiveLow` + the `inputActiveLow`
|
||||
escape hatch). Touched `device-resolve.ts`, `button-light.ts`, `access-dingtian.ts`, web `api.ts` +
|
||||
`SetupWizard.tsx` (InputEditor rewritten to a generic list; role select folds loop/radar; OutputEditor
|
||||
radarAlert row gained a lock-lane select), i18n sq+en. Tests: new `device-resolve.test.ts` (inputs[]
|
||||
resolution + legacy-fallback identical + exit-radar resolves to the exit relay), exit-lamp lockLane
|
||||
case in `button-light.test.ts`, `activeLowFrom` cases in the dingtian suite. Full workspace
|
||||
`build lint test` green. Updated [[entry-double-press]], [[button-light-indicator]], [[dingtian-relay]],
|
||||
memory `access-direction-is-per-relay`.
|
||||
|
||||
## [2026-06-28] feat | Booth Entry/Exit lights blink on radar presence (mirror relay 3)
|
||||
The on-screen Hyrje/Dalje barrier lights were 2-state (green=free / red=busy) off the camera
|
||||
lane-status only — they couldn't show the radar-only "detected, not yet confirmed" state that makes
|
||||
the physical button lamp (relay 3) blink. Added that signal end-to-end: a small server tracker
|
||||
**`LanePresence`** (lane-presence.ts) subscribes to `deviceEvents.onInput`, resolves each presence
|
||||
edge to its lane via a new **`presenceLaneOf`** (device-resolve.ts) — direction-agnostic (entry AND
|
||||
exit), unlike the entry-gated `relayForPresence` — and emits a `lane-presence {entry,exit}` bus
|
||||
event on change. The WS forwards it (hello snapshot + push) into `live-store.radar`; `BarrierLight`
|
||||
(BoothScreen.tsx) became **3-state**, mirroring relay 3 exactly: radar+camera-free → BLINK green↔red
|
||||
~1 Hz (`.lane-blink` keyframe in index.css, holds solid-red under prefers-reduced-motion);
|
||||
camera-busy → SOLID red; else SOLID green. Same input + same rule as the lamp, so screen and post
|
||||
never disagree. A test (lane-presence.test.ts) caught a real bug: the first cut reused
|
||||
`relayForPresence`, so the EXIT lane never resolved (entry-gated) and never blinked —
|
||||
`presenceLaneOf` fixes it. Full workspace build/lint/test green (185 server tests). Updated
|
||||
[[button-light-indicator]] (new "On-screen twin" section).
|
||||
|
||||
## [2026-06-28] fix+feat | Booth feed plate backfill, plate search, + per-user font scale
|
||||
Three booth fixes + one prefs feature:
|
||||
- **Plate not showing until refresh (fixed).** Plate recognition is async/advisory
|
||||
(snapshot.ts recognizePlate → a kind:"read" device_event keyed by session identity), so it
|
||||
lands AFTER the entry/exit event already shipped over the WS without a plate. Added a
|
||||
`plate-recognized` bus event (device-events.ts) emitted when the read is written; ws.ts
|
||||
forwards it; the client `patchPlate(identity,plate)` (live-store) backfills the already-
|
||||
rendered feed row in place and invalidates the Query-owned active-sessions list. No refresh.
|
||||
- **Plate search didn't filter (fixed).** Both the live-feed (BoothScreen) and active-sessions
|
||||
(ActiveSessions) search haystacks used the wrong field — the plate is the ENRICHED top-level
|
||||
`e.plate`/`s.plate` (set by enrichEvent), not `payload.plate` (plate is unsigned, never in the
|
||||
payload). Switched the haystacks to the displayed field.
|
||||
- **Per-user font scale (new).** A header A−/value/A+ control scales the root font-size app-wide
|
||||
(rem-based tokens scale proportionally), persisted on `users.font_scale` (migration
|
||||
0014_user_font_scale, percent 100=base, clamp 80–160 step 10) and restored on login — cloning
|
||||
the theme-pref pattern end to end (PUT /api/auth/font-scale, sessionView, setFontScalePref,
|
||||
applyFontScale in App). i18n sq+en. Tests: 4 font-scale auth-route cases (persist+/me, clamp/
|
||||
snap, 400, default). Full workspace build/lint/test green (189 server tests).
|
||||
|
||||
## [2026-06-28] fix | Font scale: rem-based root scaling (CSS `zoom` broke modal/footer layout)
|
||||
The first cut of the per-user font scale used CSS `zoom` on the root so it would scale the app's
|
||||
px-pinned type (text-[12px] etc.). But `zoom` scales the WHOLE box model including viewport-locked
|
||||
containers — the h-screen app frame and max-h-[90vh] modals — so at 130% they overflowed the viewport
|
||||
and modal headers/footers were pushed out of view (user had to scroll, big-modal chrome hidden).
|
||||
Reworked to the correct fix: converted ALL `text-[Npx]` font utilities to rem across the web app
|
||||
(~230 sites in 25 .tsx files + the .label/.hint/.btn component classes + body in index.css; 16px root
|
||||
→ 12px=0.75rem etc., so 100% is visually identical), and applyFontScale now sets the ROOT font-size
|
||||
(percent) instead of zoom. Only TEXT scales; vh/h-screen layout stays viewport-locked, so modals cap
|
||||
at 90vh and scroll their own body — chrome never clips. Verified with Playwright: at 130% root, sample
|
||||
text 12px→15.6px while the h-screen frame stayed exactly viewport-height and 90vh resolved unchanged.
|
||||
Full workspace build/lint/test green.
|
||||
|
||||
Reference in New Issue
Block a user