Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39c778fbac | |||
| 2ab001054d | |||
| 381046190b | |||
| 84f00db48b | |||
| d5e41500a8 | |||
| 0c218179c4 | |||
| 9e442586af | |||
| 11567a417f | |||
| f6e35bbebf | |||
| 96acd6b662 | |||
| 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
|
||||
@@ -1,13 +1,14 @@
|
||||
name: Build & push images
|
||||
|
||||
# Build the SERVER (API + SPA) and VISION (ANPR) container images and push them to the
|
||||
# house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, main→:main).
|
||||
# Separate from ci.yml (checks-only) and release.yml (tag-only desktop bundle). Mirrors the
|
||||
# house pattern (cf. trm/processor build.yml). See wiki/decisions/container-deployment.md.
|
||||
# house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, stage→:stage,
|
||||
# main→:main). Separate from ci.yml (checks-only) and release.yml (tag-only desktop bundle).
|
||||
# Mirrors the house pattern (cf. trm/processor build.yml). See
|
||||
# wiki/decisions/container-deployment.md and fleet-deployment-komodo.md (dev→stage→main tiers).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
branches: [dev, stage, main]
|
||||
paths:
|
||||
- 'apps/server/**'
|
||||
- 'apps/web/**'
|
||||
@@ -20,6 +21,10 @@ on:
|
||||
- 'docker-compose*.yml'
|
||||
- '.dockerignore'
|
||||
- '.gitea/workflows/build-images.yml'
|
||||
# Deploy/IaC changes (compose above, plus the Komodo Stack defs) also rebuild — so a
|
||||
# promotion or a Stack tweak gets the same build+checks sanity pass before it reaches a
|
||||
# booth, and a komodo-only push to `stage` still produces a :stage image.
|
||||
- 'komodo/**'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
|
||||
@@ -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,6 +15,20 @@ JWT_SECRET=
|
||||
# them (keyId), so verifyChain still validates a chain that spans a key change.
|
||||
EVENT_SIGNING_KEY=
|
||||
|
||||
# On-site encrypted DB backup (durability for the signed ledger). A daily timer + an admin
|
||||
# "back up now" button write a consistent, AES-256-GCM-encrypted copy to the target. The
|
||||
# TARGET DIRECTORY is chosen by the admin in the UI (Setup → Backup) and stored in the DB —
|
||||
# NOT here. Only the encryption KEY is an env secret. RESTORE is an out-of-band runbook action,
|
||||
# not a console call. See wiki/concepts/backup-recovery.md.
|
||||
#
|
||||
# Dedicated backup-encryption key (>=16 chars), SEPARATE from EVENT_SIGNING_KEY so it can
|
||||
# rotate without fracturing the signed chain. Generate with: openssl rand -hex 32
|
||||
# Escrow it offsite (alongside EVENT_SIGNING_KEY) — recovery needs both, and neither is ever
|
||||
# stored inside the backup it unlocks. Backups stay a no-op until BOTH this key and an in-UI
|
||||
# target directory are set. The target directory AND retention (keep-last / keep-daily) are
|
||||
# admin-chosen in the UI (Setup → Backup), NOT env — only this key is an env secret.
|
||||
# BACKUP_KEY=
|
||||
|
||||
# Optional ----------------------------------------------------------------
|
||||
# PORT=3000
|
||||
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"@parking/shared": "workspace:*",
|
||||
"bcrypt": "6.0.0",
|
||||
"fastify": "5.8.5",
|
||||
"fastify-plugin": "6.0.0"
|
||||
"fastify-plugin": "6.0.0",
|
||||
"sharp": "^0.35.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "6.0.0",
|
||||
|
||||
@@ -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: plate '${result.plate.text}' below entry floor ` +
|
||||
`(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`,
|
||||
`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: 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,168 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access, stat } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { eq, siteConfig, type Db } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult, type BackupRetention } from "./backup.js";
|
||||
|
||||
// Thin coordinator around the backup engine (backup.ts). The TARGET DIRECTORY is admin-chosen
|
||||
// and stored in site_config.backup_target_dir (read fresh each run, so changing it in the UI
|
||||
// takes effect with no restart). The ENCRYPTION KEY stays an env/Komodo secret (BACKUP_KEY) —
|
||||
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
|
||||
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
|
||||
// wiki/concepts/backup-recovery.md.
|
||||
|
||||
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
|
||||
export function backupKeyFromEnv(): string {
|
||||
return process.env.BACKUP_KEY ?? "";
|
||||
}
|
||||
|
||||
export interface TargetCheck {
|
||||
readonly ok: boolean;
|
||||
/** Machine-readable reason when !ok: "empty" | "missing" | "not_a_dir" | "not_writable". */
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
export interface BackupStatus {
|
||||
/** True once a target dir is set AND a usable key is present (else backups are a no-op). */
|
||||
readonly configured: boolean;
|
||||
/** The admin-chosen target dir (null if unset) — surfaced so the UI can show/edit it. */
|
||||
readonly targetDir: string | null;
|
||||
/** Admin-tuned retention (resolved: DB value or code default) — surfaced for the UI form. */
|
||||
readonly keepLast: number;
|
||||
readonly keepDailyDays: number;
|
||||
/** Whether the env key is present + long enough (the UI flags a missing key distinctly). */
|
||||
readonly keyPresent: boolean;
|
||||
readonly running: boolean;
|
||||
readonly lastSuccessAt: string | null;
|
||||
readonly lastResult: { path: string; bytes: number; prunedFiles: number } | null;
|
||||
readonly lastErrorAt: string | null;
|
||||
readonly lastError: string | null;
|
||||
}
|
||||
|
||||
/** Probe a candidate target path server-side: exists, is a directory, is writable. */
|
||||
export async function checkTargetDir(dir: string): Promise<TargetCheck> {
|
||||
const trimmed = dir.trim();
|
||||
if (!trimmed) return { ok: false, reason: "empty" };
|
||||
const path = resolve(trimmed);
|
||||
let st: Awaited<ReturnType<typeof stat>>;
|
||||
try {
|
||||
st = await stat(path);
|
||||
} catch {
|
||||
return { ok: false, reason: "missing" };
|
||||
}
|
||||
if (!st.isDirectory()) return { ok: false, reason: "not_a_dir" };
|
||||
try {
|
||||
await access(path, constants.W_OK);
|
||||
} catch {
|
||||
return { ok: false, reason: "not_writable" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export class BackupService {
|
||||
readonly #db: Db;
|
||||
readonly #logger?: FastifyBaseLogger;
|
||||
|
||||
#running = false;
|
||||
#lastSuccessAt: string | null = null;
|
||||
#lastResult: BackupResult | null = null;
|
||||
#lastErrorAt: string | null = null;
|
||||
#lastError: string | null = null;
|
||||
|
||||
constructor(db: Db, logger?: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
|
||||
targetDir(): string | null {
|
||||
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const dir = row?.backupTargetDir?.trim();
|
||||
return dir ? dir : null;
|
||||
}
|
||||
|
||||
/** Resolved retention from site_config, falling back to the code default per field. Read fresh. */
|
||||
retention(): BackupRetention {
|
||||
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const keepLast = row?.backupKeepLast;
|
||||
const keepDailyDays = row?.backupKeepDailyDays;
|
||||
return {
|
||||
keepLast: keepLast != null && keepLast >= 0 ? keepLast : DEFAULT_BACKUP_RETENTION.keepLast,
|
||||
keepDailyDays:
|
||||
keepDailyDays != null && keepDailyDays >= 0 ? keepDailyDays : DEFAULT_BACKUP_RETENTION.keepDailyDays,
|
||||
};
|
||||
}
|
||||
|
||||
get keyPresent(): boolean {
|
||||
return backupKeyFromEnv().length >= 16;
|
||||
}
|
||||
|
||||
get configured(): boolean {
|
||||
return this.targetDir() !== null && this.keyPresent;
|
||||
}
|
||||
|
||||
status(): BackupStatus {
|
||||
const r = this.retention();
|
||||
return {
|
||||
configured: this.configured,
|
||||
targetDir: this.targetDir(),
|
||||
keepLast: r.keepLast,
|
||||
keepDailyDays: r.keepDailyDays,
|
||||
keyPresent: this.keyPresent,
|
||||
running: this.#running,
|
||||
lastSuccessAt: this.#lastSuccessAt,
|
||||
lastResult: this.#lastResult
|
||||
? { path: this.#lastResult.path, bytes: this.#lastResult.bytes, prunedFiles: this.#lastResult.prunedFiles }
|
||||
: null,
|
||||
lastErrorAt: this.#lastErrorAt,
|
||||
lastError: this.#lastError,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one backup. `trigger` is just for the log line. Serialized: if one is already in
|
||||
* flight, resolves to that same promise. Reads the target dir + key at run time. Records
|
||||
* last-success/last-error. Re-throws on failure so a manual caller (the route) can surface
|
||||
* it; the scheduled timer wraps + swallows.
|
||||
*/
|
||||
#inflight: Promise<BackupResult> | null = null;
|
||||
async run(trigger: "manual" | "scheduled"): Promise<BackupResult> {
|
||||
if (this.#inflight) return this.#inflight;
|
||||
const targetDir = this.targetDir();
|
||||
const key = backupKeyFromEnv();
|
||||
if (!targetDir) throw new Error("backup: no target directory configured");
|
||||
if (key.length < 16) throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)");
|
||||
|
||||
this.#running = true;
|
||||
this.#inflight = (async () => {
|
||||
try {
|
||||
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
|
||||
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
|
||||
this.#lastResult = res;
|
||||
this.#lastSuccessAt = new Date().toISOString();
|
||||
this.#lastError = null;
|
||||
return res;
|
||||
} catch (err) {
|
||||
this.#lastError = (err as Error).message;
|
||||
this.#lastErrorAt = new Date().toISOString();
|
||||
this.#logger?.error(`backup: failed (${trigger}): ${this.#lastError}`);
|
||||
throw err;
|
||||
} finally {
|
||||
this.#running = false;
|
||||
this.#inflight = null;
|
||||
}
|
||||
})();
|
||||
return this.#inflight;
|
||||
}
|
||||
|
||||
/** Scheduled-run wrapper: never throws (a timer must not crash the process). */
|
||||
async runScheduled(): Promise<void> {
|
||||
if (!this.configured) return; // silent no-op when backups aren't set up
|
||||
try {
|
||||
await this.run("scheduled");
|
||||
} catch {
|
||||
/* recorded in last-error; already logged */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createTestDb, openRawDb } from "@parking/db/testing";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_BACKUP_RETENTION,
|
||||
parseBackupStamp,
|
||||
pruneOldBackups,
|
||||
runBackup,
|
||||
} from "./backup.js";
|
||||
|
||||
// Mirror of the engine's header layout, so the test decrypts independently (a real restore
|
||||
// tool would do exactly this) rather than trusting the engine to also decrypt.
|
||||
const MAGIC = Buffer.from("PKBK", "ascii");
|
||||
const SALT_LEN = 16;
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
|
||||
function decryptBackup(enc: Buffer, key: string): Buffer {
|
||||
expect(enc.subarray(0, 4)).toEqual(MAGIC);
|
||||
expect(enc[4]).toBe(1); // format version
|
||||
let off = 5;
|
||||
const salt = enc.subarray(off, (off += SALT_LEN));
|
||||
const iv = enc.subarray(off, (off += IV_LEN));
|
||||
const tag = enc.subarray(enc.length - TAG_LEN);
|
||||
const ciphertext = enc.subarray(off, enc.length - TAG_LEN);
|
||||
const derived = scryptSync(key, salt, 32);
|
||||
const decipher = createDecipheriv("aes-256-gcm", derived, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
}
|
||||
|
||||
let workDir: string;
|
||||
const KEY = "a-test-backup-key-that-is-long-enough";
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), "pk-backup-test-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("runBackup — round-trip", () => {
|
||||
it("produces an encrypted backup that decrypts to a byte-identical, queryable DB", async () => {
|
||||
// A real on-disk DB so the engine's better-sqlite3 .backup() runs for real.
|
||||
const dbPath = join(workDir, "source.sqlite");
|
||||
const t = createTestDb(dbPath);
|
||||
// Put some recognizable data in.
|
||||
t.sqlite.exec("CREATE TABLE marker (k TEXT PRIMARY KEY, v TEXT)");
|
||||
t.sqlite.prepare("INSERT INTO marker (k, v) VALUES (?, ?)").run("hello", "world");
|
||||
|
||||
const targetDir = join(workDir, "target");
|
||||
const res = await runBackup(t.db, { targetDir, key: KEY });
|
||||
t.close();
|
||||
|
||||
expect(res.bytes).toBeGreaterThan(0);
|
||||
expect(res.path).toMatch(/parking-backup-\d{8}T\d{6}Z\.sqlite\.enc$/);
|
||||
|
||||
// Decrypt independently and open the recovered DB raw (no migrations — verify as-written).
|
||||
const plain = decryptBackup(readFileSync(res.path), KEY);
|
||||
const restoredPath = join(workDir, "restored.sqlite");
|
||||
writeFileSync(restoredPath, plain);
|
||||
const restored = openRawDb(restoredPath);
|
||||
const row = restored.prepare("SELECT v FROM marker WHERE k = ?").get("hello") as { v: string };
|
||||
expect(row.v).toBe("world");
|
||||
restored.close();
|
||||
});
|
||||
|
||||
it("rejects a missing/short key before touching the filesystem", async () => {
|
||||
const t = createTestDb();
|
||||
await expect(runBackup(t.db, { targetDir: join(workDir, "t"), key: "short" })).rejects.toThrow(
|
||||
/BACKUP_KEY/,
|
||||
);
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("removes the plaintext scratch copy after a successful run", async () => {
|
||||
const scratchDir = join(workDir, "scratch");
|
||||
const t = createTestDb();
|
||||
await runBackup(t.db, {
|
||||
targetDir: join(workDir, "target"),
|
||||
key: KEY,
|
||||
scratchDir,
|
||||
// Stub the copy so we don't need a file-backed handle here.
|
||||
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "PRAGMA;"),
|
||||
});
|
||||
t.close();
|
||||
// The only thing left in scratch must NOT be a .sqlite plaintext.
|
||||
const left = readdirSync(scratchDir).filter((n) => n.endsWith(".sqlite"));
|
||||
expect(left).toEqual([]);
|
||||
});
|
||||
|
||||
it("wipes the plaintext scratch copy even when the copy step fails", async () => {
|
||||
const scratchDir = join(workDir, "scratch");
|
||||
mkdirSync(scratchDir, { recursive: true });
|
||||
const t = createTestDb();
|
||||
// Force a failure: the copy step writes the plaintext, then throws (mid-pipeline). The
|
||||
// finally{} must still remove the plaintext it left behind.
|
||||
await expect(
|
||||
runBackup(t.db, {
|
||||
targetDir: join(workDir, "target"),
|
||||
key: KEY,
|
||||
scratchDir,
|
||||
makeConsistentCopy: async (_db, dest) => {
|
||||
writeFileSync(dest, "PRAGMA;"); // leave a plaintext intermediate…
|
||||
throw new Error("simulated copy failure"); // …then fail
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/simulated copy failure/);
|
||||
t.close();
|
||||
const left = readdirSync(scratchDir).filter((n) => n.endsWith(".sqlite"));
|
||||
expect(left).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("backup encryption — tamper evidence (AES-256-GCM)", () => {
|
||||
it("a flipped ciphertext byte fails authentication on decrypt", async () => {
|
||||
const t = createTestDb();
|
||||
const targetDir = join(workDir, "target");
|
||||
const res = await runBackup(t.db, {
|
||||
targetDir,
|
||||
key: KEY,
|
||||
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "the quick brown fox".repeat(100)),
|
||||
});
|
||||
t.close();
|
||||
|
||||
const enc = readFileSync(res.path);
|
||||
// Flip a byte in the ciphertext region (after the header, before the tag).
|
||||
enc[5 + SALT_LEN + IV_LEN + 3] ^= 0xff;
|
||||
expect(() => decryptBackup(enc, KEY)).toThrow();
|
||||
});
|
||||
|
||||
it("the wrong key fails authentication", async () => {
|
||||
const t = createTestDb();
|
||||
const res = await runBackup(t.db, {
|
||||
targetDir: join(workDir, "target"),
|
||||
key: KEY,
|
||||
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "payload".repeat(50)),
|
||||
});
|
||||
t.close();
|
||||
expect(() => decryptBackup(readFileSync(res.path), "a-different-but-also-long-key-xx")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBackupStamp", () => {
|
||||
it("round-trips a stamped name and rejects non-backups", () => {
|
||||
const d = parseBackupStamp("parking-backup-20260629T141503Z.sqlite.enc");
|
||||
expect(d?.toISOString()).toBe("2026-06-29T14:15:03.000Z");
|
||||
expect(parseBackupStamp("random.txt")).toBeNull();
|
||||
expect(parseBackupStamp("parking-backup-not-a-date.sqlite.enc")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pruneOldBackups — keep-last-N + dailies", () => {
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
const now = new Date("2026-06-29T12:00:00Z");
|
||||
|
||||
function seed(stamps: string[]) {
|
||||
const dir = join(workDir, "retain");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
for (const s of stamps) writeFileSync(join(dir, `parking-backup-${s}.sqlite.enc`), "x");
|
||||
return dir;
|
||||
}
|
||||
const stamp = (ms: number) =>
|
||||
new Date(ms).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||
|
||||
it("keeps the keepLast newest regardless of age", async () => {
|
||||
// 5 backups within the last hour; keepLast=3 → 2 pruned, even though all are recent.
|
||||
const t = now.getTime();
|
||||
const dir = seed([0, 1, 2, 3, 4].map((i) => stamp(t - i * 60 * 1000)));
|
||||
const pruned = await pruneOldBackups(dir, { keepLast: 3, keepDailyDays: 0 }, now);
|
||||
expect(pruned).toBe(2);
|
||||
expect(readdirSync(dir).length).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps one-per-day within the daily window and drops older", async () => {
|
||||
const t = now.getTime();
|
||||
// Two backups today, one 5 days ago, one 40 days ago. keepLast=1, keepDailyDays=30.
|
||||
const dir = seed([
|
||||
stamp(t), // today A (newest → kept by keepLast)
|
||||
stamp(t - 60 * 1000), // today B (same day as the kept one → pruned)
|
||||
stamp(t - 5 * day), // 5 days ago (kept: within window, unique day)
|
||||
stamp(t - 40 * day), // 40 days ago (pruned: outside the window)
|
||||
]);
|
||||
const pruned = await pruneOldBackups(dir, { keepLast: 1, keepDailyDays: 30 }, now);
|
||||
expect(pruned).toBe(2);
|
||||
const left = readdirSync(dir);
|
||||
expect(left.length).toBe(2);
|
||||
});
|
||||
|
||||
it("is a no-op on a missing target dir", async () => {
|
||||
const pruned = await pruneOldBackups(join(workDir, "does-not-exist"), DEFAULT_BACKUP_RETENTION, now);
|
||||
expect(pruned).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { createCipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||
import { createReadStream, createWriteStream } from "node:fs";
|
||||
import { mkdir, readdir, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import type { Db } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
|
||||
// On-site encrypted DB backup — the durability half of the anti-fraud design. The SQLite
|
||||
// DB *is* the signed append-only ledger, so a disk failure / stolen-or-destroyed PC means
|
||||
// total revenue-history loss. This produces a consistent, encrypted, restore-to-a-fresh-
|
||||
// appliance copy. See wiki/concepts/backup-recovery.md.
|
||||
//
|
||||
// Two load-bearing properties:
|
||||
// 1. CONSISTENT copy of a LIVE WAL-mode DB — via better-sqlite3's online .backup() (NOT a
|
||||
// raw file copy, which can capture a torn WAL). The result must still verifyChain.
|
||||
// 2. Encrypted with a DEDICATED key (BACKUP_KEY / park_buzi_backup_key), SEPARATE from
|
||||
// EVENT_SIGNING_KEY — so the backup key can rotate without fracturing the signed chain,
|
||||
// and a backup target never exposes the signing key. The key is NEVER written into the
|
||||
// backup it unlocks.
|
||||
//
|
||||
// This module is the engine (consistent copy → encrypt → retention). Targets beyond a local/
|
||||
// mounted path (SMB/NFS are just mount paths; SFTP) and the manual button/route are layered on
|
||||
// top. RESTORE is intentionally NOT here — it's an out-of-band runbook action on a fresh box.
|
||||
|
||||
/** AES-256-GCM with a scrypt-derived key. Self-describing header so a restore tool needs only
|
||||
* the key + the file. Layout: magic | version | salt(16) | iv(12) | ciphertext… | authTag(16). */
|
||||
const MAGIC = Buffer.from("PKBK", "ascii"); // ParKing BacKup
|
||||
const FORMAT_VERSION = 1;
|
||||
const SALT_LEN = 16;
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
const SCRYPT_KEYLEN = 32; // AES-256
|
||||
|
||||
export interface BackupRetention {
|
||||
/** Keep at least this many most-recent backups regardless of age. */
|
||||
readonly keepLast: number;
|
||||
/** Beyond keepLast, keep one backup per day for this many days; older ones are pruned. */
|
||||
readonly keepDailyDays: number;
|
||||
}
|
||||
|
||||
// Code defaults — the fallback when the admin hasn't set a value in site_config (the source of
|
||||
// truth). NOT env-driven: retention is operational policy tuned from the Backup screen.
|
||||
export const DEFAULT_BACKUP_RETENTION: BackupRetention = {
|
||||
keepLast: 7,
|
||||
keepDailyDays: 30,
|
||||
};
|
||||
|
||||
export interface BackupOptions {
|
||||
/** Directory the encrypted backup is written to (a mounted local/USB/SATA/SMB/NFS path). */
|
||||
readonly targetDir: string;
|
||||
/** Encryption key (BACKUP_KEY / park_buzi_backup_key). ≥16 chars enforced. */
|
||||
readonly key: string;
|
||||
readonly retention?: BackupRetention;
|
||||
/** Override the consistent-copy step (tests inject a fake to avoid a real sqlite handle). */
|
||||
readonly makeConsistentCopy?: (db: Db, destPath: string) => Promise<void>;
|
||||
/** Override "now" for deterministic filenames/retention in tests. */
|
||||
readonly now?: () => Date;
|
||||
/** Scratch dir for the intermediate plaintext copy (default os.tmpdir()). */
|
||||
readonly scratchDir?: string;
|
||||
}
|
||||
|
||||
export interface BackupResult {
|
||||
/** Absolute path of the encrypted backup written. */
|
||||
readonly path: string;
|
||||
/** Size of the encrypted file in bytes. */
|
||||
readonly bytes: number;
|
||||
/** Backups pruned by the retention policy this run. */
|
||||
readonly prunedFiles: number;
|
||||
}
|
||||
|
||||
/** Filename convention: parking-backup-YYYYMMDDTHHMMSSZ.sqlite.enc — sortable, UTC, parseable. */
|
||||
const FILE_PREFIX = "parking-backup-";
|
||||
const FILE_SUFFIX = ".sqlite.enc";
|
||||
|
||||
function stampFor(d: Date): string {
|
||||
return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
/** Parse the UTC instant back out of a backup filename, or null if it doesn't match. */
|
||||
export function parseBackupStamp(name: string): Date | null {
|
||||
const base = basename(name);
|
||||
if (!base.startsWith(FILE_PREFIX) || !base.endsWith(FILE_SUFFIX)) return null;
|
||||
const stamp = base.slice(FILE_PREFIX.length, -FILE_SUFFIX.length);
|
||||
// 20260629T141503Z → 2026-06-29T14:15:03Z
|
||||
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(stamp);
|
||||
if (!m) return null;
|
||||
const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
|
||||
const dt = new Date(iso);
|
||||
return Number.isNaN(dt.getTime()) ? null : dt;
|
||||
}
|
||||
|
||||
/** Consistent online copy of the live WAL-mode DB via better-sqlite3's native backup(). */
|
||||
async function defaultConsistentCopy(db: Db, destPath: string): Promise<void> {
|
||||
// db.$client is the raw better-sqlite3 Database; .backup() returns a promise and copies a
|
||||
// transactionally-consistent snapshot even while the source is being written.
|
||||
const client = db.$client as { backup: (dest: string) => Promise<unknown> };
|
||||
await client.backup(destPath);
|
||||
}
|
||||
|
||||
/** Encrypt `srcPath` → `destPath` streaming, with the self-describing header. */
|
||||
async function encryptFile(srcPath: string, destPath: string, key: string): Promise<void> {
|
||||
const salt = randomBytes(SALT_LEN);
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const derived = scryptSync(key, salt, SCRYPT_KEYLEN);
|
||||
const cipher = createCipheriv("aes-256-gcm", derived, iv);
|
||||
|
||||
const out = createWriteStream(destPath);
|
||||
const header = Buffer.concat([MAGIC, Buffer.from([FORMAT_VERSION]), salt, iv]);
|
||||
out.write(header);
|
||||
|
||||
await pipeline(createReadStream(srcPath), cipher, out, { end: false });
|
||||
// GCM auth tag is available only after the cipher has flushed; append it, then close.
|
||||
const tag = cipher.getAuthTag();
|
||||
await new Promise<void>((res, rej) => {
|
||||
out.end(tag, () => res());
|
||||
out.on("error", rej);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one backup: consistent copy → encrypt → prune old backups by retention.
|
||||
* Best-effort caller-facing: throws on real failure (so a manual run surfaces the error),
|
||||
* but the scheduled timer wraps it and logs.
|
||||
*/
|
||||
export async function runBackup(
|
||||
db: Db,
|
||||
opts: BackupOptions,
|
||||
logger?: FastifyBaseLogger,
|
||||
): Promise<BackupResult> {
|
||||
if (!opts.key || opts.key.length < 16) {
|
||||
throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)");
|
||||
}
|
||||
const now = opts.now ?? (() => new Date());
|
||||
const retention = opts.retention ?? DEFAULT_BACKUP_RETENTION;
|
||||
const targetDir = resolve(opts.targetDir);
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
|
||||
const stamp = stampFor(now());
|
||||
const finalPath = join(targetDir, `${FILE_PREFIX}${stamp}${FILE_SUFFIX}`);
|
||||
|
||||
// Intermediate plaintext copy in scratch (NOT the target dir — the target may be a network
|
||||
// share / removable disk; keep the plaintext local and short-lived, then wipe it).
|
||||
const scratch = opts.scratchDir ?? tmpdir();
|
||||
await mkdir(scratch, { recursive: true });
|
||||
const plainPath = join(scratch, `${FILE_PREFIX}${stamp}.sqlite`);
|
||||
|
||||
try {
|
||||
const copy = opts.makeConsistentCopy ?? defaultConsistentCopy;
|
||||
await copy(db, plainPath);
|
||||
await encryptFile(plainPath, finalPath, opts.key);
|
||||
} finally {
|
||||
// Always wipe the plaintext intermediate, success or fail — it's the unencrypted ledger.
|
||||
await rm(plainPath, { force: true }).catch((err) =>
|
||||
logger?.warn(`backup: failed to remove plaintext scratch copy: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
|
||||
const { size } = await stat(finalPath);
|
||||
const prunedFiles = await pruneOldBackups(targetDir, retention, now());
|
||||
logger?.info(
|
||||
`backup: wrote ${basename(finalPath)} (${(size / 1048576).toFixed(1)} MB)` +
|
||||
(prunedFiles > 0 ? `, pruned ${prunedFiles} old` : ""),
|
||||
);
|
||||
return { path: finalPath, bytes: size, prunedFiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retention: keep the `keepLast` most-recent backups always; beyond those, keep at most one
|
||||
* backup per UTC day for `keepDailyDays` days; delete anything older or any extra same-day
|
||||
* duplicates outside the keepLast window. Returns the count deleted.
|
||||
*/
|
||||
export async function pruneOldBackups(
|
||||
targetDir: string,
|
||||
retention: BackupRetention,
|
||||
now: Date,
|
||||
): Promise<number> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(targetDir);
|
||||
} catch {
|
||||
return 0; // target gone/unmounted — nothing to prune (the write would have failed first)
|
||||
}
|
||||
|
||||
const backups = names
|
||||
.map((n) => ({ name: n, at: parseBackupStamp(n) }))
|
||||
.filter((b): b is { name: string; at: Date } => b.at !== null)
|
||||
.sort((a, b) => b.at.getTime() - a.at.getTime()); // newest first
|
||||
|
||||
const keep = new Set<string>();
|
||||
// 1. Always keep the keepLast newest.
|
||||
for (const b of backups.slice(0, Math.max(0, retention.keepLast))) keep.add(b.name);
|
||||
|
||||
// 2. Beyond that, keep the newest per UTC day within the keepDailyDays window.
|
||||
const cutoff = now.getTime() - retention.keepDailyDays * 24 * 60 * 60 * 1000;
|
||||
const seenDays = new Set<string>();
|
||||
for (const b of backups) {
|
||||
if (keep.has(b.name)) {
|
||||
seenDays.add(b.at.toISOString().slice(0, 10));
|
||||
continue;
|
||||
}
|
||||
if (b.at.getTime() < cutoff) continue; // too old → not kept
|
||||
const day = b.at.toISOString().slice(0, 10);
|
||||
if (seenDays.has(day)) continue; // already have a backup for this day → prune the extra
|
||||
seenDays.add(day);
|
||||
keep.add(b.name);
|
||||
}
|
||||
|
||||
let pruned = 0;
|
||||
for (const b of backups) {
|
||||
if (keep.has(b.name)) continue;
|
||||
await rm(join(targetDir, b.name), { force: true });
|
||||
pruned += 1;
|
||||
}
|
||||
return pruned;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export interface DeviceReadEvent {
|
||||
/**
|
||||
* The decision a read produced. Returned by the read flows so a SYNCHRONOUS reader
|
||||
* (e.g. the QR reader, whose HTTP reply drives its beep + output) can answer the
|
||||
* device. A fire-and-forget reader simply ignores it. See wiki/entities/gee-qr-er80.md.
|
||||
* device. A fire-and-forget reader simply ignores it. See wiki/entities/dingtian-dt008-reader.md.
|
||||
*/
|
||||
export interface ReadOutcome {
|
||||
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../server.js";
|
||||
import { seedUser, login } from "../test-helpers.js";
|
||||
|
||||
// HTTP integration for the backup routes — the security seam + the unconfigured-state
|
||||
// behaviour. The booted test app has no BACKUP_TARGET_DIR/BACKUP_KEY, so the service is
|
||||
// "not configured": status reports it, and a manual run is a clean 409 (not a 500).
|
||||
// See wiki/concepts/backup-recovery.md.
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
});
|
||||
|
||||
describe("GET /api/backup/status", () => {
|
||||
it("401 without a session", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/api/backup/status" });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("403 for a user lacking backup:read", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "viewer", roleId: "viewer", permissions: ["site:read"],
|
||||
});
|
||||
const { cookie } = await login(app, username, password);
|
||||
const res = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("an admin sees the (unconfigured) status shape", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie } = await login(app, username, password);
|
||||
const res = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body).toMatchObject({
|
||||
configured: false,
|
||||
targetDir: null,
|
||||
keepLast: 7, // code defaults surfaced when unset
|
||||
keepDailyDays: 30,
|
||||
running: false,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/backup/config — admin-chosen target", () => {
|
||||
it("403 for a user lacking backup:update", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "viewer", roleId: "viewer", permissions: ["backup:read"],
|
||||
});
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/backup/config",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { targetDir: "/tmp/x" },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("persists the target dir and reflects it in status", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const put = await app.inject({
|
||||
method: "PUT", url: "/api/backup/config",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { targetDir: " /mnt/backup " }, // trimmed server-side
|
||||
});
|
||||
expect(put.statusCode).toBe(200);
|
||||
expect(put.json()).toMatchObject({ targetDir: "/mnt/backup" });
|
||||
|
||||
const status = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
|
||||
expect(status.json().targetDir).toBe("/mnt/backup");
|
||||
});
|
||||
|
||||
it("clears the target dir when given empty/null", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
await app.inject({
|
||||
method: "PUT", url: "/api/backup/config",
|
||||
headers: { cookie, "x-csrf-token": csrf }, payload: { targetDir: "/mnt/backup" },
|
||||
});
|
||||
const clear = await app.inject({
|
||||
method: "PUT", url: "/api/backup/config",
|
||||
headers: { cookie, "x-csrf-token": csrf }, payload: { targetDir: "" },
|
||||
});
|
||||
expect(clear.json().targetDir).toBeNull();
|
||||
});
|
||||
|
||||
it("persists retention and resets to defaults on null", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
|
||||
const set = await app.inject({
|
||||
method: "PUT", url: "/api/backup/config",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { keepLast: 3, keepDailyDays: 14 },
|
||||
});
|
||||
expect(set.json()).toMatchObject({ keepLast: 3, keepDailyDays: 14 });
|
||||
|
||||
// null resets to the code default.
|
||||
const reset = await app.inject({
|
||||
method: "PUT", url: "/api/backup/config",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { keepLast: null, keepDailyDays: null },
|
||||
});
|
||||
expect(reset.json()).toMatchObject({ keepLast: 7, keepDailyDays: 30 });
|
||||
});
|
||||
|
||||
it("rejects a negative retention value (400)", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/backup/config",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { keepLast: -1 },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/backup/test — path probe", () => {
|
||||
it("reports ok for a writable directory and a reason for a missing one", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
|
||||
const good = await app.inject({
|
||||
method: "POST", url: "/api/backup/test",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { targetDir: process.cwd() }, // an existing, writable dir
|
||||
});
|
||||
expect(good.json()).toMatchObject({ ok: true });
|
||||
|
||||
const bad = await app.inject({
|
||||
method: "POST", url: "/api/backup/test",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { targetDir: "/no/such/path/here-xyz" },
|
||||
});
|
||||
expect(bad.json()).toMatchObject({ ok: false, reason: "missing" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/backup/run", () => {
|
||||
it("403 for a user lacking backup:create", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "viewer", roleId: "viewer", permissions: ["backup:read"], // read but not create
|
||||
});
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "POST", url: "/api/backup/run",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("requires CSRF on the mutation", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "POST", url: "/api/backup/run",
|
||||
headers: { cookie }, // no csrf header
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 409 backup_not_configured when no target/key is set (not a 500)", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "POST", url: "/api/backup/run",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
});
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json()).toMatchObject({ error: "backup_not_configured" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, siteConfig, type Db } from "@parking/db";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { checkTargetDir, type BackupService } from "../backup-service.js";
|
||||
|
||||
// On-site encrypted DB backup — admin-driven. See wiki/concepts/backup-recovery.md.
|
||||
// - GET /api/backup/status : config + last-run success/error. (backup:read)
|
||||
// - PUT /api/backup/config : set the admin-chosen target directory. (backup:update)
|
||||
// - POST /api/backup/test : probe a candidate path (exists/dir/writable). (backup:update)
|
||||
// - POST /api/backup/run : trigger a manual "back up now". (backup:create)
|
||||
// The target dir lives in site_config (admin picks it from the UI); the encryption key stays an
|
||||
// env secret. RESTORE is intentionally absent — out-of-band runbook on a fresh appliance.
|
||||
|
||||
interface ConfigBody {
|
||||
targetDir?: string | null;
|
||||
/** Retention: keep this many newest backups. null = reset to the code default. */
|
||||
keepLast?: number | null;
|
||||
/** Retention: keep one-per-day within this many days. null = reset to the code default. */
|
||||
keepDailyDays?: number | null;
|
||||
}
|
||||
interface TestBody {
|
||||
targetDir?: string;
|
||||
}
|
||||
|
||||
export async function backupRoutes(app: FastifyInstance, db: Db, backups: BackupService): Promise<void> {
|
||||
app.get("/api/backup/status", { preHandler: requirePermission("backup:read") }, async () =>
|
||||
backups.status(),
|
||||
);
|
||||
|
||||
// Set (or clear) the target directory. Empty/null clears it (backups become a no-op).
|
||||
app.put<{ Body: ConfigBody }>(
|
||||
"/api/backup/config",
|
||||
{ preHandler: requirePermission("backup:update") },
|
||||
async (req, reply) => {
|
||||
const body = req.body ?? {};
|
||||
const patch: { backupTargetDir?: string | null; backupKeepLast?: number | null; backupKeepDailyDays?: number | null } = {};
|
||||
|
||||
if ("targetDir" in body) {
|
||||
const raw = body.targetDir;
|
||||
if (raw != null && typeof raw !== "string") {
|
||||
return reply.code(400).send({ error: "targetDir must be a string or null" });
|
||||
}
|
||||
patch.backupTargetDir = raw == null ? null : raw.trim() || null;
|
||||
}
|
||||
// Retention: a non-negative integer, or null to reset to the code default.
|
||||
for (const [field, col] of [
|
||||
["keepLast", "backupKeepLast"],
|
||||
["keepDailyDays", "backupKeepDailyDays"],
|
||||
] as const) {
|
||||
if (field in body) {
|
||||
const v = body[field];
|
||||
if (v != null && (!Number.isInteger(v) || v < 0)) {
|
||||
return reply.code(400).send({ error: `${field} must be a non-negative integer or null` });
|
||||
}
|
||||
patch[col] = v ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
// Single-row site_config (id=1): upsert, since a fresh install may not have it yet.
|
||||
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
if (existing) {
|
||||
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||
} else {
|
||||
db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
|
||||
}
|
||||
return backups.status();
|
||||
},
|
||||
);
|
||||
|
||||
// Probe a candidate path before relying on it (the UI "Test target" button).
|
||||
app.post<{ Body: TestBody }>(
|
||||
"/api/backup/test",
|
||||
{ preHandler: requirePermission("backup:update") },
|
||||
async (req) => {
|
||||
const dir = typeof req.body?.targetDir === "string" ? req.body.targetDir : "";
|
||||
return checkTargetDir(dir);
|
||||
},
|
||||
);
|
||||
|
||||
app.post("/api/backup/run", { preHandler: requirePermission("backup:create") }, async (_req, reply) => {
|
||||
if (!backups.configured) {
|
||||
return reply.code(409).send({ error: "backup_not_configured" });
|
||||
}
|
||||
try {
|
||||
const res = await backups.run("manual");
|
||||
return reply.send({ ok: true, path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles });
|
||||
} catch (err) {
|
||||
return reply.code(500).send({ error: "backup_failed", message: (err as Error).message });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,10 @@ import type { DeviceReadEvent } from "../device-events.js";
|
||||
import type { ReadDispatcher } from "../read-dispatch.js";
|
||||
import type { CredentialCapture } from "../credential-capture.js";
|
||||
|
||||
// GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our
|
||||
// host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
|
||||
// Dingtian DT-008 QR/RFID reader endpoint. The reader is configured (vendor tool) with
|
||||
// our host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
|
||||
// our JSON reply — host-in-the-loop and synchronous. Protocol from the QRCode SDK
|
||||
// v1.6.5; see wiki/sources/qrcode-sdk.md and wiki/entities/gee-qr-er80.md.
|
||||
// v1.6.5; see wiki/sources/qrcode-sdk.md and wiki/entities/dingtian-dt008-reader.md.
|
||||
//
|
||||
// reader → GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2ch>&time=<utc>
|
||||
// server → {"data":[{cardid,cjihao,mjihao,status,time,output}],"code":0,"message":""}
|
||||
@@ -35,7 +35,7 @@ export async function qrReaderRoutes(
|
||||
): Promise<void> {
|
||||
// Resolve the lane_devices row whose config.serial matches the reader's reported
|
||||
// serial (cjihao). The row id is a normal UUID; the serial is config the admin
|
||||
// enters when assigning the gee-qr-reader. Returns the row id, or null if no
|
||||
// enters when assigning the dingtian-qr-reader. Returns the row id, or null if no
|
||||
// reader is assigned for that serial. (Small device set → scan in JS.)
|
||||
const readerRowIdForSerial = (serial: string): string | null => {
|
||||
if (!serial) return null;
|
||||
@@ -52,7 +52,7 @@ export async function qrReaderRoutes(
|
||||
// drive output) once the socket CLOSES — every vendor demo replies
|
||||
// `Connection: close` and shuts the socket. Without it the reader waits out a
|
||||
// ~10 s keep-alive timeout before beeping. So force-close the connection.
|
||||
// See wiki/sources/qrcode-sdk.md, entities/gee-qr-er80.md.
|
||||
// See wiki/sources/qrcode-sdk.md, entities/dingtian-dt008-reader.md.
|
||||
reply.header("connection", "close");
|
||||
const cardid = (q.cardid ?? "").trim();
|
||||
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
|
||||
@@ -75,7 +75,7 @@ export async function qrReaderRoutes(
|
||||
accepted = true; // beep "ok" so the operator knows the card was read
|
||||
} else {
|
||||
const read: DeviceReadEvent = {
|
||||
driverId: "gee-qr-reader",
|
||||
driverId: "dingtian-qr-reader",
|
||||
deviceId,
|
||||
value: cardid,
|
||||
kind: "qr",
|
||||
|
||||
@@ -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";
|
||||
@@ -19,6 +20,9 @@ import { PrinterMonitor } from "./printer-monitor.js";
|
||||
import { DeviceMonitor } from "./device-monitor.js";
|
||||
import { buildSigner, buildVerifier } from "./signer.js";
|
||||
import { LogService, pinoDbStream } from "./log-service.js";
|
||||
import { pruneSnapshots } from "./snapshot-retention.js";
|
||||
import { BackupService } from "./backup-service.js";
|
||||
import { backupRoutes } from "./routes/backup.js";
|
||||
import { logRoutes } from "./routes/logs.js";
|
||||
import { VisionClient } from "./vision-client.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
@@ -27,6 +31,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 +129,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 +179,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 +199,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,
|
||||
@@ -218,10 +236,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
|
||||
const credentialCapture = new CredentialCapture();
|
||||
|
||||
// GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON
|
||||
// Dingtian DT-008 QR/RFID reader: it HTTP-GETs on each scan and beeps/acts on our JSON
|
||||
// verdict (host-in-the-loop, synchronous). The capture service can intercept a read
|
||||
// on an armed reader for enrollment; otherwise the read routes through the
|
||||
// dispatcher. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
|
||||
// dispatcher. See wiki/entities/dingtian-dt008-reader.md, qrcode-sdk.md.
|
||||
await qrReaderRoutes(app, db, readDispatcher, credentialCapture);
|
||||
|
||||
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
||||
@@ -258,6 +276,13 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
||||
await logRoutes(app, logService);
|
||||
|
||||
// On-site encrypted DB backup (durability for the signed ledger). Admin-driven: the target
|
||||
// directory is admin-chosen (site_config), the key is an env secret; status + a manual "back
|
||||
// up now"; the scheduled run is the daily timer below. A no-op until a target dir is set AND
|
||||
// BACKUP_KEY is present. See wiki/concepts/backup-recovery.md.
|
||||
const backupService = new BackupService(db, app.log);
|
||||
await backupRoutes(app, db, backupService);
|
||||
|
||||
// Periodic retention prune (age + row cap) so the log table stays bounded on the
|
||||
// offline appliance. Runs hourly; unref'd so it never holds the process open.
|
||||
const pruneTimer = setInterval(() => {
|
||||
@@ -268,6 +293,35 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
logService.prune(); // once at startup
|
||||
app.addHook("onClose", async () => clearInterval(pruneTimer));
|
||||
|
||||
// Snapshot retention prune — DISK-PRESSURE safety valve: only when the DB's filesystem
|
||||
// crosses the high-water mark do we delete the oldest snapshots + VACUUM. A no-op the rest
|
||||
// of the time. Daily, unref'd, plus once at startup. See snapshot-retention.ts.
|
||||
const runSnapPrune = async () => {
|
||||
const res = await pruneSnapshots(db, {}, app.log);
|
||||
if (res.deletedRows > 0) {
|
||||
app.log.info(
|
||||
`pruned ${res.deletedRows} snapshots, freed ~${(res.freedBytesEst / 1048576).toFixed(0)} MB ` +
|
||||
`(disk was ${res.usedPctBefore.toFixed(0)}% used${res.vacuumed ? ", vacuumed" : ""})`,
|
||||
);
|
||||
}
|
||||
};
|
||||
const snapPruneTimer = setInterval(() => void runSnapPrune(), 24 * 60 * 60 * 1000);
|
||||
snapPruneTimer.unref();
|
||||
void runSnapPrune(); // once at startup
|
||||
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
|
||||
|
||||
// Scheduled encrypted backup — daily, unref'd. A no-op (silent) until BACKUP_TARGET_DIR +
|
||||
// BACKUP_KEY are configured; tolerates an unreachable/unmounted target by recording the
|
||||
// error and trying again next run. NOT run once at startup (a just-booted appliance after a
|
||||
// power cut shouldn't immediately write to a possibly-not-yet-mounted disk; the daily cadence
|
||||
// and the manual button cover it). See wiki/concepts/backup-recovery.md.
|
||||
const backupTimer = setInterval(() => void backupService.runScheduled(), 24 * 60 * 60 * 1000);
|
||||
backupTimer.unref();
|
||||
app.addHook("onClose", async () => clearInterval(backupTimer));
|
||||
if (backupService.configured) {
|
||||
app.log.info("backup: scheduled daily encrypted backup enabled");
|
||||
}
|
||||
|
||||
// Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the
|
||||
// retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs
|
||||
// every 6h, unref'd, plus once at startup. See recycle-bin.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,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { snapshots, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { pruneSnapshots, type DiskUsage, type SnapshotRetention } from "./snapshot-retention.js";
|
||||
|
||||
// Snapshot retention: DISK-PRESSURE prune. No-op unless the DB's filesystem is over the
|
||||
// high-water mark; then delete the OLDEST until ~freeTargetPct of disk is freed (estimated from
|
||||
// the deleted BLOB sizes), honoring a MIN_KEEP floor, then VACUUM once. Disk usage is injected
|
||||
// so the test controls the trigger without touching the real filesystem.
|
||||
|
||||
let db: Db;
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
});
|
||||
|
||||
/** Insert `n` snapshots, oldest first (s-0 is the oldest), each `bytes` long. */
|
||||
function seed(n: number, bytes = 1000): void {
|
||||
const t0 = Date.now() - n * 1000;
|
||||
for (let i = 0; i < n; i++) {
|
||||
db.insert(snapshots)
|
||||
.values({
|
||||
id: `s-${i}`,
|
||||
direction: "entry",
|
||||
deviceId: "cam",
|
||||
identity: `s-${i}`,
|
||||
contentType: "image/jpeg",
|
||||
bytes: Buffer.alloc(bytes, 1),
|
||||
capturedAt: new Date(t0 + i * 1000).toISOString(), // s-0 oldest … s-(n-1) newest
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
function count(): number {
|
||||
return db.select().from(snapshots).all().length;
|
||||
}
|
||||
function ids(): string[] {
|
||||
return db.select().from(snapshots).all().map((r) => r.id).sort();
|
||||
}
|
||||
|
||||
/** A fake disk at a given used% on a 1 GB volume. */
|
||||
const disk = (usedPct: number, totalBytes = 1_000_000_000): (() => Promise<DiskUsage>) =>
|
||||
() => Promise.resolve({ usedPct, totalBytes });
|
||||
|
||||
const ret = (o: Partial<SnapshotRetention>): SnapshotRetention => ({
|
||||
highPct: 70,
|
||||
freeTargetPct: 10,
|
||||
minKeep: 2,
|
||||
batch: 5,
|
||||
...o,
|
||||
});
|
||||
|
||||
describe("pruneSnapshots (disk-pressure)", () => {
|
||||
it("no-op when disk is below the high-water mark", async () => {
|
||||
seed(10);
|
||||
const res = await pruneSnapshots(db, { retention: ret({}), diskUsage: disk(50) });
|
||||
expect(res.deletedRows).toBe(0);
|
||||
expect(res.vacuumed).toBe(false);
|
||||
expect(count()).toBe(10);
|
||||
});
|
||||
|
||||
it("over the mark: deletes the OLDEST until ~freeTargetPct is freed, then VACUUMs", async () => {
|
||||
// 1 GB disk, target 10% = 100 MB. Each snapshot 20 MB → ~5 deletions reach the target.
|
||||
seed(20, 20 * 1048576);
|
||||
const vacuumSpy = vi.spyOn(db.$client as { exec: (s: string) => void }, "exec");
|
||||
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2, batch: 100 }), diskUsage: disk(80) });
|
||||
expect(res.deletedRows).toBeGreaterThanOrEqual(5);
|
||||
expect(res.freedBytesEst).toBeGreaterThanOrEqual(0.1 * 1_000_000_000);
|
||||
expect(res.vacuumed).toBe(true);
|
||||
expect(vacuumSpy).toHaveBeenCalledWith("VACUUM");
|
||||
// The survivors are the NEWEST (oldest went first).
|
||||
const survivors = ids();
|
||||
expect(survivors).toContain(`s-19`); // newest kept
|
||||
expect(survivors).not.toContain(`s-0`); // oldest pruned
|
||||
vacuumSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("honors the MIN_KEEP floor even when still over target", async () => {
|
||||
// Target 10% of 1 GB = 100 MB, but only 3 tiny snapshots exist and minKeep=2 → at most 1 deleted.
|
||||
seed(3, 1000);
|
||||
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2, freeTargetPct: 90 }), diskUsage: disk(95) });
|
||||
expect(res.deletedRows).toBe(1); // 3 − minKeep(2)
|
||||
expect(count()).toBe(2);
|
||||
expect(res.floorHitWhileOver).toBe(true); // couldn't reach target without crossing the floor
|
||||
});
|
||||
|
||||
it("skips VACUUM when nothing was deleted", async () => {
|
||||
seed(2); // == minKeep, so nothing to delete even over the mark
|
||||
const vacuumSpy = vi.spyOn(db.$client as { exec: (s: string) => void }, "exec");
|
||||
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2 }), diskUsage: disk(99) });
|
||||
expect(res.deletedRows).toBe(0);
|
||||
expect(res.vacuumed).toBe(false);
|
||||
expect(vacuumSpy).not.toHaveBeenCalled();
|
||||
vacuumSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { statfs } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { asc, snapshots, sql, type Db } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
|
||||
// Snapshot retention — DISK-PRESSURE model. Camera snapshots are unsigned, advisory, prunable
|
||||
// BLOBs (see snapshot.ts); they're referenced by the signed ledger only by id, so pruning an
|
||||
// old image never affects the chain. They no longer dominate the DB day-to-day (captures are
|
||||
// re-encoded small at SNAPSHOT_MAX_EDGE/JPEG_QUALITY), so this is a SAFETY VALVE: only when the
|
||||
// filesystem holding the DB crosses a high-water mark do we delete the OLDEST snapshots and
|
||||
// VACUUM to return disk to the OS.
|
||||
//
|
||||
// Why estimated-bytes, not live disk%: a DELETE only frees SQLite *pages* — the file (and thus
|
||||
// OS disk usage) doesn't shrink until VACUUM. So the prune loop can't watch usedPct fall in real
|
||||
// time. Instead it sums LENGTH(bytes) of the rows it deletes and stops when that estimate reaches
|
||||
// the free-target, then VACUUMs ONCE at the end to realize the space. A MIN_KEEP floor always
|
||||
// wins — we never delete evidence below it, even under pressure (if the disk is full of something
|
||||
// else, that's not ours to fix).
|
||||
|
||||
export interface SnapshotRetention {
|
||||
/** Prune when the DB's filesystem is at least this % used. */
|
||||
readonly highPct: number;
|
||||
/** Try to free roughly this % of the disk per run (the delete target). */
|
||||
readonly freeTargetPct: number;
|
||||
/** Never prune below this many snapshots (the floor). */
|
||||
readonly minKeep: number;
|
||||
/** Delete oldest in batches of this size (re-checks between batches). */
|
||||
readonly batch: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SNAPSHOT_RETENTION: SnapshotRetention = {
|
||||
highPct: Number(process.env.SNAPSHOT_DISK_HIGH_PCT ?? 70),
|
||||
freeTargetPct: Number(process.env.SNAPSHOT_DISK_FREE_TARGET_PCT ?? 10),
|
||||
minKeep: Number(process.env.SNAPSHOT_MIN_KEEP ?? 500),
|
||||
batch: Number(process.env.SNAPSHOT_PRUNE_BATCH ?? 200),
|
||||
};
|
||||
|
||||
/** Disk usage of the filesystem holding the DB. Injectable so tests don't touch the real FS. */
|
||||
export interface DiskUsage {
|
||||
readonly usedPct: number;
|
||||
readonly totalBytes: number;
|
||||
}
|
||||
|
||||
export interface PruneOptions {
|
||||
readonly retention?: SnapshotRetention;
|
||||
/** Override how disk usage is read (tests inject a fake; default = statfs the DB's FS). */
|
||||
readonly diskUsage?: () => Promise<DiskUsage>;
|
||||
}
|
||||
|
||||
export interface PruneResult {
|
||||
readonly deletedRows: number;
|
||||
readonly freedBytesEst: number;
|
||||
readonly vacuumed: boolean;
|
||||
readonly usedPctBefore: number;
|
||||
/** True if we hit the MIN_KEEP floor while the disk was still over the high-water mark. */
|
||||
readonly floorHitWhileOver: boolean;
|
||||
}
|
||||
|
||||
/** Read the used% + total bytes of the filesystem holding the DB file. */
|
||||
async function diskUsageForDb(db: Db): Promise<DiskUsage> {
|
||||
const file = (db.$client as { name?: string }).name ?? process.env.DATABASE_URL ?? "./parking.sqlite";
|
||||
const st = await statfs(dirname(resolve(file)));
|
||||
const total = st.blocks * st.bsize;
|
||||
const avail = st.bavail * st.bsize;
|
||||
const usedPct = total > 0 ? (1 - avail / total) * 100 : 0;
|
||||
return { usedPct, totalBytes: total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune snapshots under DISK PRESSURE. No-op unless the DB's filesystem is ≥ highPct used. When
|
||||
* over, deletes the OLDEST snapshots until an estimated freeTargetPct of the disk is freed (or the
|
||||
* minKeep floor is hit, or no rows remain), then VACUUMs once. Best-effort; safe on a timer.
|
||||
*/
|
||||
export async function pruneSnapshots(
|
||||
db: Db,
|
||||
opts: PruneOptions = {},
|
||||
logger?: FastifyBaseLogger,
|
||||
): Promise<PruneResult> {
|
||||
const r = opts.retention ?? DEFAULT_SNAPSHOT_RETENTION;
|
||||
const readDisk = opts.diskUsage ?? (() => diskUsageForDb(db));
|
||||
|
||||
let usedPctBefore = 0;
|
||||
try {
|
||||
const disk = await readDisk();
|
||||
usedPctBefore = disk.usedPct;
|
||||
|
||||
// The overwhelmingly common case: plenty of headroom → do nothing.
|
||||
if (disk.usedPct < r.highPct) {
|
||||
return { deletedRows: 0, freedBytesEst: 0, vacuumed: false, usedPctBefore, floorHitWhileOver: false };
|
||||
}
|
||||
|
||||
// Target bytes to free this run (≈ freeTargetPct of the whole disk).
|
||||
const targetBytes = (r.freeTargetPct / 100) * disk.totalBytes;
|
||||
|
||||
let freedBytesEst = 0;
|
||||
let deletedRows = 0;
|
||||
let floorHitWhileOver = false;
|
||||
|
||||
// Delete the oldest in batches, summing their BLOB sizes, until we've freed the target — or
|
||||
// we'd cross the MIN_KEEP floor — or there are no more rows.
|
||||
for (;;) {
|
||||
const count = db.select({ c: sql<number>`count(*)` }).from(snapshots).get()?.c ?? 0;
|
||||
if (count <= r.minKeep) {
|
||||
floorHitWhileOver = true; // still over the high-water mark but can't delete below the floor
|
||||
break;
|
||||
}
|
||||
if (freedBytesEst >= targetBytes) break;
|
||||
|
||||
const room = count - r.minKeep; // how many we may still delete before the floor
|
||||
const take = Math.min(r.batch, room);
|
||||
const oldest = db
|
||||
.select({ id: snapshots.id, len: sql<number>`length(${snapshots.bytes})` })
|
||||
.from(snapshots)
|
||||
.orderBy(asc(snapshots.capturedAt))
|
||||
.limit(take)
|
||||
.all();
|
||||
if (oldest.length === 0) break;
|
||||
|
||||
const ids = oldest.map((o) => o.id);
|
||||
db.delete(snapshots).where(sql`${snapshots.id} in (${sql.join(ids, sql`, `)})`).run();
|
||||
deletedRows += oldest.length;
|
||||
freedBytesEst += oldest.reduce((s, o) => s + (o.len ?? 0), 0);
|
||||
}
|
||||
|
||||
// Realize the freed space: VACUUM returns pages to the OS (the file shrinks). Only if we
|
||||
// actually deleted something. Non-fatal on failure — pages are still freed for reuse.
|
||||
let vacuumed = false;
|
||||
if (deletedRows > 0) {
|
||||
try {
|
||||
(db.$client as { exec: (sql: string) => void }).exec("VACUUM");
|
||||
vacuumed = true;
|
||||
} catch (err) {
|
||||
logger?.warn(`snapshot prune: VACUUM failed (pages freed for reuse): ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (floorHitWhileOver) {
|
||||
logger?.warn(
|
||||
`snapshot prune: disk ${usedPctBefore.toFixed(0)}% used but hit MIN_KEEP floor (${r.minKeep}) ` +
|
||||
`after deleting ${deletedRows} — disk pressure is not from snapshots`,
|
||||
);
|
||||
}
|
||||
return { deletedRows, freedBytesEst, vacuumed, usedPctBefore, floorHitWhileOver };
|
||||
} catch (err) {
|
||||
logger?.warn(`snapshot prune failed: ${(err as Error).message}`);
|
||||
return { deletedRows: 0, freedBytesEst: 0, vacuumed: false, usedPctBefore, floorHitWhileOver: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import sharp from "sharp";
|
||||
import type { CameraDevice, Snapshot } from "@parking/devices";
|
||||
import { captureSnapshotShared, encodeForStorage } from "./snapshot.js";
|
||||
import { silentLogger } from "./test-helpers.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);
|
||||
});
|
||||
});
|
||||
|
||||
// encodeForStorage: downscale + re-compress a captured frame for STORAGE (smaller, plate
|
||||
// still readable). Recognition uses the original; this never runs on the OCR path. Fail-soft.
|
||||
describe("encodeForStorage", () => {
|
||||
/** A big synthetic JPEG (2688×1520, the Hikvision main-stream size) to downscale. */
|
||||
async function bigJpeg(): Promise<Buffer> {
|
||||
return sharp({
|
||||
create: { width: 2688, height: 1520, channels: 3, background: { r: 120, g: 130, b: 140 } },
|
||||
})
|
||||
.jpeg({ quality: 95 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
it("downscales the long edge to ≤1280 and emits clean image/jpeg", async () => {
|
||||
const bytes = await bigJpeg();
|
||||
const shot: Snapshot = { bytes, contentType: 'image/jpeg; charset="UTF-8"', capturedAt: new Date().toISOString() };
|
||||
const out = await encodeForStorage(shot, silentLogger());
|
||||
expect(out.contentType).toBe("image/jpeg"); // charset cruft stripped
|
||||
const meta = await sharp(out.bytes).metadata();
|
||||
expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(1280);
|
||||
expect(out.bytes.length).toBeLessThan(bytes.length); // smaller than the original
|
||||
});
|
||||
|
||||
it("never enlarges an already-small image", async () => {
|
||||
const small = await sharp({ create: { width: 640, height: 360, channels: 3, background: { r: 0, g: 0, b: 0 } } })
|
||||
.jpeg()
|
||||
.toBuffer();
|
||||
const out = await encodeForStorage(
|
||||
{ bytes: small, contentType: "image/jpeg", capturedAt: new Date().toISOString() },
|
||||
silentLogger(),
|
||||
);
|
||||
const meta = await sharp(out.bytes).metadata();
|
||||
expect(meta.width).toBe(640); // withoutEnlargement
|
||||
expect(meta.height).toBe(360);
|
||||
});
|
||||
|
||||
it("fails soft: a non-image body is stored unchanged with a cleaned type", async () => {
|
||||
const garbage = Buffer.from("this is not an image");
|
||||
const out = await encodeForStorage(
|
||||
{ bytes: garbage, contentType: 'text/plain; charset="UTF-8"', capturedAt: new Date().toISOString() },
|
||||
silentLogger(),
|
||||
);
|
||||
expect(out.bytes.equals(garbage)).toBe(true); // original bytes, never dropped
|
||||
expect(out.contentType).toBe("text/plain"); // charset stripped even on the fallback
|
||||
});
|
||||
});
|
||||
+115
-4
@@ -1,8 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import sharp from "sharp";
|
||||
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
|
||||
@@ -26,6 +28,43 @@ import type { VisionClient } from "./vision-client.js";
|
||||
// fire-and-forget: it never blocks the open and never changes the entry/exit decision —
|
||||
// it's a record ("session X entered on plate AA558EE"). No polling; recognition only
|
||||
// happens on a real entry/exit. See wiki/entities/opencv-anpr-service.md.
|
||||
//
|
||||
// STORAGE RE-ENCODE (2026-06-28). Cameras serve full-res JPEGs (a Hikvision main stream is
|
||||
// 2688×1520 / ~600 KB); stored raw, snapshots dominated the appliance DB (~72%). Each frame
|
||||
// is now downscaled (long edge ≤ SNAPSHOT_MAX_EDGE) + re-compressed (q SNAPSHOT_JPEG_QUALITY)
|
||||
// BEFORE storage — ~6–10× smaller, plate still clearly readable. RECOGNITION runs on the
|
||||
// ORIGINAL full-res bytes (downscaling hurts OCR); the re-encode is storage-only. Fail-soft:
|
||||
// a re-encode error stores the original, never drops the snapshot or blocks the open.
|
||||
|
||||
/** Long-edge cap (px) + JPEG quality for the STORED snapshot. Env-overridable per appliance. */
|
||||
const SNAP_MAX_EDGE = Number(process.env.SNAPSHOT_MAX_EDGE ?? 1280);
|
||||
const SNAP_QUALITY = Number(process.env.SNAPSHOT_JPEG_QUALITY ?? 80);
|
||||
|
||||
/** Strip a camera's `; charset=...` cruft from a content type (a JPEG is binary). */
|
||||
function cleanType(ct: string): string {
|
||||
const base = ct.split(";")[0]?.trim();
|
||||
return base || "image/jpeg";
|
||||
}
|
||||
|
||||
/** Downscale + re-encode a captured frame for STORAGE (evidence, not OCR). Caps the long edge
|
||||
* and re-compresses to JPEG. Fail-soft: any error (e.g. a non-image body) returns the original
|
||||
* bytes with a cleaned content type, so a snapshot is never lost. */
|
||||
export async function encodeForStorage(
|
||||
shot: Snapshot,
|
||||
logger: FastifyBaseLogger,
|
||||
): Promise<{ bytes: Buffer; contentType: string }> {
|
||||
try {
|
||||
const out = await sharp(shot.bytes, { failOn: "none" })
|
||||
.rotate() // honor EXIF orientation before we drop the metadata
|
||||
.resize({ width: SNAP_MAX_EDGE, height: SNAP_MAX_EDGE, fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: SNAP_QUALITY, mozjpeg: true })
|
||||
.toBuffer();
|
||||
return { bytes: out, contentType: "image/jpeg" };
|
||||
} catch (err) {
|
||||
logger.warn(`snapshot re-encode failed, storing original: ${(err as Error).message}`);
|
||||
return { bytes: shot.bytes, contentType: cleanType(shot.contentType) };
|
||||
}
|
||||
}
|
||||
|
||||
interface SnapshotJob {
|
||||
readonly db: Db;
|
||||
@@ -62,16 +101,21 @@ 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();
|
||||
// Re-encode for STORAGE only (downscale + recompress). Recognition below still
|
||||
// uses the original full-res `shot`.
|
||||
const stored = await encodeForStorage(shot, logger);
|
||||
db.insert(snapshots)
|
||||
.values({
|
||||
id,
|
||||
direction,
|
||||
deviceId: row.id,
|
||||
identity,
|
||||
contentType: shot.contentType,
|
||||
bytes: shot.bytes,
|
||||
contentType: stored.contentType,
|
||||
bytes: stored.bytes,
|
||||
capturedAt: shot.capturedAt,
|
||||
})
|
||||
.run();
|
||||
@@ -136,6 +180,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 +200,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,23 +117,46 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
: t("booth.noMatch")}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((s) => {
|
||||
const badge = statusBadge(s);
|
||||
// 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 (
|
||||
<div
|
||||
<tr
|
||||
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"
|
||||
className={`cursor-pointer border-t border-term-border/50 hover:bg-term-panel-2 ${
|
||||
s.overstay ? "bg-term-red/5" : ""
|
||||
}`}
|
||||
title={t("booth.openPayExit")}
|
||||
>
|
||||
<span className="text-term-text">
|
||||
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
|
||||
</span>
|
||||
<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"
|
||||
@@ -167,45 +165,39 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
{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}
|
||||
>
|
||||
{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 ? (
|
||||
</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={() => handleReopen(s)}
|
||||
className="btn btn-pay btn-sm shrink-0"
|
||||
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>
|
||||
) : (
|
||||
<span className="w-[88px] shrink-0" />
|
||||
)}
|
||||
|
||||
{msg && (
|
||||
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
||||
<span className={`ml-2 text-[0.625rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
||||
{msg.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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]);
|
||||
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ApiError,
|
||||
fetchBackupStatus,
|
||||
runBackup,
|
||||
setBackupConfig,
|
||||
testBackupTarget,
|
||||
type BackupStatus,
|
||||
type TargetCheck,
|
||||
} from "./api.js";
|
||||
import { formatRelativeDateTime } from "./lib/format.js";
|
||||
|
||||
// Admin screen for the on-site encrypted DB backup. The admin picks the TARGET DIRECTORY here
|
||||
// (stored in site_config; a mounted USB/SATA/SMB/NFS path) — the encryption key stays a server
|
||||
// secret. Shows status + last-run outcome, a "Test target" probe, and a manual "Back up now".
|
||||
// Gated by backup:read (config/test by backup:update, run by backup:create). RESTORE is absent
|
||||
// by design — out-of-band on a fresh appliance. See wiki/concepts/backup-recovery.md.
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
const mb = n / 1048576;
|
||||
if (mb < 1024) return `${mb.toFixed(1)} MB`;
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
/** Map a target-check result to a localized message. */
|
||||
function checkMessage(c: TargetCheck, t: (k: string) => string): string {
|
||||
if (c.ok) return t("backup.testOk");
|
||||
switch (c.reason) {
|
||||
case "empty":
|
||||
return t("backup.testEmpty");
|
||||
case "not_a_dir":
|
||||
return t("backup.testNotDir");
|
||||
case "not_writable":
|
||||
return t("backup.testNotWritable");
|
||||
default:
|
||||
return t("backup.testMissing");
|
||||
}
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: BackupStatus }) {
|
||||
const { t } = useTranslation();
|
||||
if (!status.configured) {
|
||||
return <span className="text-[0.75rem] font-semibold text-term-muted">{t("backup.notConfigured")}</span>;
|
||||
}
|
||||
if (status.running) {
|
||||
return <span className="text-[0.75rem] font-semibold text-term-amber">{t("backup.running")}</span>;
|
||||
}
|
||||
return <span className="text-[0.75rem] font-semibold text-term-green">{t("backup.configured")}</span>;
|
||||
}
|
||||
|
||||
export function BackupSettings() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [toast, setToast] = useState<{ kind: "ok" | "err"; msg: string } | null>(null);
|
||||
const [target, setTarget] = useState("");
|
||||
const [keepLast, setKeepLast] = useState("");
|
||||
const [keepDaily, setKeepDaily] = useState("");
|
||||
const [check, setCheck] = useState<{ kind: "ok" | "err"; msg: string } | null>(null);
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ["backup-status"],
|
||||
queryFn: fetchBackupStatus,
|
||||
refetchInterval: (query) => (query.state.data?.running ? 2000 : false),
|
||||
});
|
||||
const status = q.data;
|
||||
|
||||
// Seed the editable fields from the saved values once they load (and on server-side change).
|
||||
useEffect(() => {
|
||||
if (status) {
|
||||
setTarget(status.targetDir ?? "");
|
||||
setKeepLast(String(status.keepLast));
|
||||
setKeepDaily(String(status.keepDailyDays));
|
||||
}
|
||||
}, [status?.targetDir, status?.keepLast, status?.keepDailyDays]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
setBackupConfig({
|
||||
targetDir: target.trim() || null,
|
||||
keepLast: keepLast.trim() === "" ? null : Number(keepLast),
|
||||
keepDailyDays: keepDaily.trim() === "" ? null : Number(keepDaily),
|
||||
}),
|
||||
onSuccess: (next) => {
|
||||
setToast({ kind: "ok", msg: t("backup.saved") });
|
||||
setCheck(null);
|
||||
qc.setQueryData(["backup-status"], next);
|
||||
},
|
||||
onError: () => setToast({ kind: "err", msg: t("backup.runFailed") }),
|
||||
});
|
||||
|
||||
const test = useMutation({
|
||||
mutationFn: () => testBackupTarget(target.trim()),
|
||||
onSuccess: (res) => setCheck({ kind: res.ok ? "ok" : "err", msg: checkMessage(res, t) }),
|
||||
});
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: runBackup,
|
||||
onSuccess: () => {
|
||||
setToast({ kind: "ok", msg: t("backup.runSuccess") });
|
||||
void qc.invalidateQueries({ queryKey: ["backup-status"] });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const code = err instanceof ApiError ? err.message : "";
|
||||
setToast({
|
||||
kind: "err",
|
||||
msg: code === "backup_not_configured" ? t("backup.notConfiguredError") : t("backup.runFailed"),
|
||||
});
|
||||
void qc.invalidateQueries({ queryKey: ["backup-status"] });
|
||||
},
|
||||
});
|
||||
|
||||
const dirty =
|
||||
(status?.targetDir ?? "") !== target.trim() ||
|
||||
String(status?.keepLast ?? "") !== keepLast.trim() ||
|
||||
String(status?.keepDailyDays ?? "") !== keepDaily.trim();
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("backup.title")}</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!status?.configured || status?.running || run.isPending || dirty}
|
||||
onClick={() => {
|
||||
setToast(null);
|
||||
run.mutate();
|
||||
}}
|
||||
>
|
||||
{status?.running || run.isPending ? t("backup.running") : t("backup.runNow")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mb-3 max-w-2xl text-[0.75rem] text-term-muted">{t("backup.intro")}</p>
|
||||
|
||||
{toast && (
|
||||
<div
|
||||
className={`mb-3 rounded-term border px-3 py-2 text-[0.75rem] ${
|
||||
toast.kind === "ok"
|
||||
? "border-term-green/40 bg-term-green/5 text-term-green"
|
||||
: "border-term-red/40 bg-term-red/5 text-term-red"
|
||||
}`}
|
||||
>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config — admin-chosen destination + retention policy. */}
|
||||
<div className="card mb-3 p-4">
|
||||
{/* Target directory + its Test probe. */}
|
||||
<div className="field">
|
||||
<span className="label">{t("backup.targetLabel")}</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="input w-96 max-w-full"
|
||||
value={target}
|
||||
placeholder={t("backup.targetPlaceholder")}
|
||||
onChange={(e) => {
|
||||
setTarget(e.target.value);
|
||||
setCheck(null);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={test.isPending || !target.trim()}
|
||||
onClick={() => test.mutate()}
|
||||
>
|
||||
{t("backup.test")}
|
||||
</button>
|
||||
</div>
|
||||
<span className="mt-1 text-[0.6875rem] text-term-muted">{t("backup.targetHint")}</span>
|
||||
{check && (
|
||||
<span className={`mt-1 text-[0.75rem] ${check.kind === "ok" ? "text-term-green" : "text-term-red"}`}>
|
||||
{check.msg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Retention — admin-tuned policy (how many backups to keep at the target). */}
|
||||
<div className="mt-4 flex flex-wrap items-start gap-6">
|
||||
<div className="field">
|
||||
<span className="label">{t("backup.keepLastLabel")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="input w-28"
|
||||
value={keepLast}
|
||||
onChange={(e) => setKeepLast(e.target.value)}
|
||||
/>
|
||||
<span className="mt-1 text-[0.6875rem] text-term-muted">{t("backup.keepLastHint")}</span>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="label">{t("backup.keepDailyLabel")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="input w-28"
|
||||
value={keepDaily}
|
||||
onChange={(e) => setKeepDaily(e.target.value)}
|
||||
/>
|
||||
<span className="mt-1 text-[0.6875rem] text-term-muted">{t("backup.keepDailyHint")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={save.isPending || !dirty}
|
||||
onClick={() => {
|
||||
setToast(null);
|
||||
save.mutate();
|
||||
}}
|
||||
>
|
||||
{t("backup.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
{q.isLoading || !status ? (
|
||||
<div className="text-[0.75rem] text-term-muted">{t("common.loading")}</div>
|
||||
) : (
|
||||
<dl className="grid grid-cols-[10rem_1fr] gap-x-4 gap-y-2 text-[0.8125rem]">
|
||||
<dt className="text-term-muted">{t("backup.statusTitle")}</dt>
|
||||
<dd>
|
||||
<StatusBadge status={status} />
|
||||
</dd>
|
||||
|
||||
{!status.keyPresent && (
|
||||
<>
|
||||
<dt className="text-term-muted" />
|
||||
<dd className="text-[0.75rem] text-term-amber">{t("backup.keyMissing")}</dd>
|
||||
</>
|
||||
)}
|
||||
|
||||
<dt className="text-term-muted">{t("backup.lastSuccess")}</dt>
|
||||
<dd className="text-term-text">
|
||||
{status.lastSuccessAt ? formatRelativeDateTime(status.lastSuccessAt, t) : t("backup.never")}
|
||||
</dd>
|
||||
|
||||
{status.lastResult && (
|
||||
<>
|
||||
<dt className="text-term-muted">{t("backup.size")}</dt>
|
||||
<dd className="text-term-text tabular-nums">
|
||||
{formatBytes(status.lastResult.bytes)}
|
||||
{status.lastResult.prunedFiles > 0 && (
|
||||
<span className="ml-2 text-term-muted">
|
||||
({t("backup.pruned")}: {status.lastResult.prunedFiles})
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status.lastError && (
|
||||
<>
|
||||
<dt className="text-term-muted">{t("backup.lastError")}</dt>
|
||||
<dd className="text-term-red">
|
||||
{status.lastError}
|
||||
{status.lastErrorAt && (
|
||||
<span className="ml-2 text-term-muted">
|
||||
({formatRelativeDateTime(status.lastErrorAt, t)})
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 max-w-2xl text-[0.6875rem] text-term-muted">{t("backup.restoreNote")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+589
-82
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
assignDevice,
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
fetchState,
|
||||
testAnpr,
|
||||
testDevice,
|
||||
testPrint,
|
||||
unassignDevice,
|
||||
type AnprTestResult,
|
||||
type PrintTestResult,
|
||||
type Assignment,
|
||||
type BackendIpCandidate,
|
||||
type Catalog,
|
||||
@@ -19,6 +21,9 @@ import {
|
||||
type DeviceConfig,
|
||||
type Direction,
|
||||
type DiscoveredDevice,
|
||||
type InputRole,
|
||||
type InputSpec,
|
||||
type RelayEvent,
|
||||
type RelaySpec,
|
||||
type TestResult,
|
||||
} from "./api.js";
|
||||
@@ -46,13 +51,63 @@ const BOUND: { key: DeviceCategory; titleKey: string; nounKey: string }[] = [
|
||||
{ key: "printer", titleKey: "setup.catPrinters", nounKey: "setup.nounPrinter" },
|
||||
];
|
||||
|
||||
// Translated direction label (relay direction / inherited binding).
|
||||
const DIRECTION_KEYS: Record<Direction, string> = {
|
||||
// Translated relay-event label (barrier direction, inherited binding, or alert).
|
||||
const DIRECTION_KEYS: Record<RelayEvent, string> = {
|
||||
entry: "setup.dirEntry",
|
||||
exit: "setup.dirExit",
|
||||
both: "setup.dirBoth",
|
||||
radarAlert: "setup.eventRadarAlert",
|
||||
};
|
||||
|
||||
// The input-role dropdown folds presence `kind` into the choice: one select offers Button,
|
||||
// Presence (loop), Presence (radar), Alert trigger. Each maps to a {role, kind} pair.
|
||||
type InputChoice = "button" | "presenceLoop" | "presenceRadar" | "alertTrigger";
|
||||
const INPUT_CHOICE_KEYS: Record<InputChoice, string> = {
|
||||
button: "setup.roleButton",
|
||||
presenceLoop: "setup.rolePresenceLoop",
|
||||
presenceRadar: "setup.rolePresenceRadar",
|
||||
alertTrigger: "setup.roleAlertTrigger",
|
||||
};
|
||||
function choiceOf(i: InputSpec): InputChoice {
|
||||
if (i.role === "button") return "button";
|
||||
if (i.role === "alertTrigger") return "alertTrigger";
|
||||
return i.kind === "radar" ? "presenceRadar" : "presenceLoop";
|
||||
}
|
||||
function applyChoice(choice: InputChoice): { role: InputRole; kind?: "loop" | "radar" } {
|
||||
switch (choice) {
|
||||
case "button":
|
||||
return { role: "button" };
|
||||
case "alertTrigger":
|
||||
return { role: "alertTrigger" };
|
||||
case "presenceLoop":
|
||||
return { role: "presence", kind: "loop" };
|
||||
case "presenceRadar":
|
||||
return { role: "presence", kind: "radar" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Synthesize an inputs[] list from the LEGACY per-relay button/presence fields, so an
|
||||
* existing controller (saved before inputs[]) opens with its inputs populated. Mirrors the
|
||||
* server's `inputsOf()` back-compat fold. */
|
||||
function synthInputsFromRelays(relays: RelaySpec[]): InputSpec[] {
|
||||
const out: InputSpec[] = [];
|
||||
for (const r of relays) {
|
||||
if (typeof r.button === "number") {
|
||||
out.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec });
|
||||
}
|
||||
if (typeof r.presenceInput === "number") {
|
||||
out.push({
|
||||
input: r.presenceInput,
|
||||
role: "presence",
|
||||
relay: r.relay,
|
||||
kind: r.presenceKind ?? "loop",
|
||||
activeLow: r.presenceActiveLow,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function SetupWizard() {
|
||||
const { t } = useTranslation();
|
||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||
@@ -79,7 +134,7 @@ export function SetupWizard() {
|
||||
return (
|
||||
<section className="px-4 py-6">
|
||||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2>
|
||||
<p className="hint mb-4 max-w-prose">{t("setup.intro")}</p>
|
||||
<p className="hint mb-4 max-w">{t("setup.intro")}</p>
|
||||
|
||||
<CategorySection
|
||||
category={CONTROLLER.key}
|
||||
@@ -151,8 +206,8 @@ function CategorySection({
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div className="mb-3 rounded-term border border-term-amber/60 bg-term-amber/10 px-3 py-2">
|
||||
<strong className="text-[12px] text-term-amber">{t("setup.warnTitle")}</strong>
|
||||
<ul className="mt-1 list-disc pl-5 text-[12px] text-term-amber">
|
||||
<strong className="text-[0.75rem] text-term-amber">{t("setup.warnTitle")}</strong>
|
||||
<ul className="mt-1 list-disc pl-5 text-[0.75rem] text-term-amber">
|
||||
{warnings.map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
@@ -178,7 +233,7 @@ function CategorySection({
|
||||
)}
|
||||
|
||||
{blockedNoController ? (
|
||||
<p className="m-0 text-[12px] text-term-amber">{t("setup.needControllerFirst", { noun })}</p>
|
||||
<p className="m-0 text-[0.75rem] text-term-amber">{t("setup.needControllerFirst", { noun })}</p>
|
||||
) : (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setFormFor("new")}>
|
||||
{assignments.length === 0 ? t("setup.add", { noun }) : t("setup.addAnother", { noun })}
|
||||
@@ -246,7 +301,7 @@ function AssignmentRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-2 border-b border-term-border/60 px-1 py-2 text-[12px]">
|
||||
<li className="flex items-center gap-2 border-b border-term-border/60 px-1 py-2 text-[0.75rem]">
|
||||
<strong className="text-term-text">{assignment.driverId}</strong>
|
||||
{host && <span className="tabular-nums text-term-muted">{host}</span>}
|
||||
<DeviceSummary assignment={assignment} controllers={controllers} />
|
||||
@@ -270,11 +325,25 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
|
||||
if (assignment.category === "access") {
|
||||
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
|
||||
if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
|
||||
// Effective inputs: config.inputs[] if present, else synthesized from legacy relay fields.
|
||||
const inputs = Array.isArray(cfg.inputs) ? (cfg.inputs as InputSpec[]) : synthInputsFromRelays(relays);
|
||||
return (
|
||||
<span className="flex gap-1.5">
|
||||
{relays.map((r) => (
|
||||
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} />
|
||||
))}
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
{relays.map((r) => {
|
||||
// Alert relay: trigger input + lock lane. Barrier: its button + presence inputs.
|
||||
let wiring = "";
|
||||
if (r.direction === "radarAlert") {
|
||||
if (r.triggerInput) wiring += `·trig${r.triggerInput}`;
|
||||
if (r.lockLane === "exit") wiring += "·lockExit";
|
||||
} else {
|
||||
const served = inputs.filter((x) => x.relay === r.relay);
|
||||
const btn = served.find((x) => x.role === "button");
|
||||
const pres = served.find((x) => x.role === "presence");
|
||||
if (btn) wiring += `·btn${btn.input}`;
|
||||
if (pres) wiring += `·${pres.kind === "radar" ? "radar" : "loop"}${pres.input}`;
|
||||
}
|
||||
return <DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${wiring}`} />;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -325,9 +394,17 @@ function DeviceForm({
|
||||
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
|
||||
const isController = category === "access";
|
||||
const isCamera = category === "camera";
|
||||
// ANPR opt-in for a camera: when true, the VisionReader polls this camera for plates
|
||||
// (config.anpr). Off by default. See wiki/entities/opencv-anpr-service.md.
|
||||
const isPrinter = category === "printer";
|
||||
// ANPR opt-in for a camera: when true, this camera's snapshots are run through the
|
||||
// recognizer (plate recorded as evidence, both directions). (config.anpr). Off by default.
|
||||
// See wiki/entities/opencv-anpr-service.md.
|
||||
const [anpr, setAnpr] = useState<boolean>(editCfg?.anpr === true);
|
||||
// Auto-trigger: when true, THIS camera's vehicle detection may auto-open the barrier
|
||||
// (subscriber entry/exit). Separate from `anpr` so a shared entry/exit lane can keep
|
||||
// RECOGNITION on both cameras but disable auto-open on, e.g., the exit camera (whose
|
||||
// back-plate read would otherwise phantom-exit the car that just entered). Defaults ON
|
||||
// when anpr is on (back-compat). (config.anprAutoTrigger).
|
||||
const [anprAuto, setAnprAuto] = useState<boolean>(editCfg?.anprAutoTrigger !== false);
|
||||
|
||||
// Pre-fill scalar config fields from the existing assignment when editing.
|
||||
// (relays/controllerId/relay are model fields handled by their own state below.)
|
||||
@@ -341,10 +418,20 @@ function DeviceForm({
|
||||
}
|
||||
return out;
|
||||
});
|
||||
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
|
||||
// Controllers: the unified relay map. Each relay reacts to an EVENT — entry/exit/both
|
||||
// (pulse a barrier) or radarAlert (drive an alert lamp). Alert relays carry a trigger
|
||||
// input + blink cadence; barriers carry no input wiring (that lives in `inputs` below).
|
||||
const [relays, setRelays] = useState<RelaySpec[]>(() =>
|
||||
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
|
||||
);
|
||||
// Controller INPUTS — a first-class list (button / presence / alertTrigger), each naming
|
||||
// the relay it serves. Seed from config.inputs[] if present, else SYNTHESIZE from the
|
||||
// legacy per-relay button/presence fields so an existing controller opens populated.
|
||||
const [inputs, setInputs] = useState<InputSpec[]>(() => {
|
||||
const stored = editCfg?.inputs;
|
||||
if (Array.isArray(stored) && stored.length > 0) return stored as InputSpec[];
|
||||
return synthInputsFromRelays(Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : []);
|
||||
});
|
||||
// Bound devices: which controller + relay this device sits at.
|
||||
const [controllerId, setControllerId] = useState<string>(
|
||||
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
|
||||
@@ -357,9 +444,21 @@ function DeviceForm({
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
// ANPR probe (camera + anpr on): snapshot → vision analyze, reported below.
|
||||
const [alarmUrlCopied, setAlarmUrlCopied] = useState(false);
|
||||
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
|
||||
const [anprTesting, setAnprTesting] = useState(false);
|
||||
const [anprError, setAnprError] = useState<string | null>(null);
|
||||
|
||||
const [printResult, setPrintResult] = useState<PrintTestResult | null>(null);
|
||||
const [printTesting, setPrintTesting] = useState(false);
|
||||
const [printError, setPrintError] = useState<string | null>(null);
|
||||
|
||||
// Which `secret` fields are currently unmasked. The device web password is an
|
||||
// operational credential the admin legitimately needs (to reach the device's web
|
||||
// UI) — it's stored + sent to this admin-only view; a per-field reveal toggle just
|
||||
// makes the already-present value readable. (Machine secrets — relay/push pw — are
|
||||
// redacted server-side and never reach here, so there's nothing to reveal.)
|
||||
const [revealed, setRevealed] = useState<Record<string, boolean>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
|
||||
@@ -368,22 +467,31 @@ function DeviceForm({
|
||||
|
||||
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
|
||||
const [backendIp, setBackendIp] = useState<string>("");
|
||||
// The server's listen port (e.g. 3000) the device must POST to — NOT the page's
|
||||
// port (the SPA may be served by Vite on :5173 in dev, or behind a proxy on :80).
|
||||
// Comes from the same /api/setup/backend-ips probe as the IPs.
|
||||
const [backendPort, setBackendPort] = useState<number | null>(null);
|
||||
|
||||
const testedHost = tested ? String(mergedScalarConfig().host ?? "") : "";
|
||||
useEffect(() => {
|
||||
if (!testedHost || !pushesToBackend) {
|
||||
setBackendIps(null);
|
||||
setBackendPort(null);
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
fetchBackendIps(testedHost)
|
||||
.then(({ candidates }) => {
|
||||
.then(({ candidates, port }) => {
|
||||
if (!live) return;
|
||||
setBackendIps(candidates);
|
||||
setBackendPort(port);
|
||||
setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || "");
|
||||
})
|
||||
.catch(() => {
|
||||
if (live) setBackendIps(null);
|
||||
if (live) {
|
||||
setBackendIps(null);
|
||||
setBackendPort(null);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
@@ -437,12 +545,30 @@ function DeviceForm({
|
||||
function mergedConfig(): DeviceConfig {
|
||||
const out: DeviceConfig = { ...mergedScalarConfig() };
|
||||
if (isController) {
|
||||
out.relays = relays.map((r) => ({
|
||||
// Relays carry ONLY the event (+ alert fields). Input wiring lives in out.inputs.
|
||||
out.relays = relays.map((r) =>
|
||||
r.direction === "radarAlert"
|
||||
? {
|
||||
// Alert lamp: trigger input + lock lane + blink cadence.
|
||||
relay: r.relay,
|
||||
direction: r.direction,
|
||||
...(r.button ? { button: r.button } : {}),
|
||||
...(r.presenceInput ? { presenceInput: r.presenceInput } : {}),
|
||||
...(r.entryCooldownSec ? { entryCooldownSec: r.entryCooldownSec } : {}),
|
||||
...(r.triggerInput ? { triggerInput: r.triggerInput } : {}),
|
||||
...(r.lockLane && r.lockLane !== "entry" ? { lockLane: r.lockLane } : {}),
|
||||
...(r.blinkOnMs ? { blinkOnMs: r.blinkOnMs } : {}),
|
||||
...(r.blinkOffMs ? { blinkOffMs: r.blinkOffMs } : {}),
|
||||
}
|
||||
: { relay: r.relay, direction: r.direction },
|
||||
);
|
||||
// Inputs: a button/presence row needs its relay; alertTrigger may be standalone.
|
||||
out.inputs = inputs
|
||||
.filter((i) => typeof i.input === "number" && i.input > 0)
|
||||
.map((i) => ({
|
||||
input: i.input,
|
||||
role: i.role,
|
||||
...(typeof i.relay === "number" ? { relay: i.relay } : {}),
|
||||
...(i.role === "presence" && i.kind ? { kind: i.kind } : {}),
|
||||
...(i.role === "presence" && i.activeLow ? { activeLow: true } : {}),
|
||||
...(i.role === "button" && i.cooldownSec ? { cooldownSec: i.cooldownSec } : {}),
|
||||
}));
|
||||
} else if (controllerId && boundRelay !== "") {
|
||||
out.controllerId = controllerId;
|
||||
@@ -450,6 +576,10 @@ function DeviceForm({
|
||||
}
|
||||
// Camera ANPR opt-in (only persisted when on, to keep configs minimal).
|
||||
if (isCamera && anpr) out.anpr = true;
|
||||
// Auto-trigger flag — only meaningful when anpr is on. Persist it (true OR false) so a
|
||||
// park can explicitly DISABLE auto-open on a camera (e.g. the exit cam of a shared lane)
|
||||
// while keeping recognition. Absent ⇒ defaults ON (back-compat for existing cameras).
|
||||
if (isCamera && anpr) out.anprAutoTrigger = anprAuto;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -467,7 +597,7 @@ function DeviceForm({
|
||||
setTestError(null);
|
||||
setTested(null);
|
||||
try {
|
||||
setTested(await testDevice(selected.id, mergedScalarConfig()));
|
||||
setTested(await testDevice(selected.id, mergedScalarConfig(), editing?.id));
|
||||
} catch (e) {
|
||||
setTestError((e as Error).message);
|
||||
} finally {
|
||||
@@ -492,6 +622,23 @@ function DeviceForm({
|
||||
}
|
||||
}
|
||||
|
||||
// Push a real test slip to the printer — proves it physically prints (healthCheck
|
||||
// only opens the transport). Passes editing?.id so an edited network printer's
|
||||
// stored secrets re-merge. Never blocks save.
|
||||
async function testPrintNow() {
|
||||
if (!selected) return;
|
||||
setPrintTesting(true);
|
||||
setPrintError(null);
|
||||
setPrintResult(null);
|
||||
try {
|
||||
setPrintResult(await testPrint(selected.id, mergedScalarConfig(), editing?.id));
|
||||
} catch (e) {
|
||||
setPrintError((e as Error).message);
|
||||
} finally {
|
||||
setPrintTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!selected) return;
|
||||
// Bound devices must point at a controller relay (binding is optional in the
|
||||
@@ -543,19 +690,19 @@ function DeviceForm({
|
||||
|
||||
{selected && (
|
||||
<div className="mt-3">
|
||||
<p className="mb-2 text-[12px] text-term-muted">{selected.description}</p>
|
||||
<p className="mb-2 text-[0.75rem] text-term-muted">{selected.description}</p>
|
||||
|
||||
{canDiscover && (
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<button type="button" className="btn btn-sm" onClick={scan} disabled={scanning}>
|
||||
{scanning ? t("setup.scanning") : t("setup.scan")}
|
||||
</button>
|
||||
{scanError && <span className="ml-2 text-[12px] text-term-red">{scanError}</span>}
|
||||
{found && found.length === 0 && <p className="mt-2 text-[12px] text-term-muted">{t("setup.noControllersFound")}</p>}
|
||||
{scanError && <span className="ml-2 text-[0.75rem] text-term-red">{scanError}</span>}
|
||||
{found && found.length === 0 && <p className="mt-2 text-[0.75rem] text-term-muted">{t("setup.noControllersFound")}</p>}
|
||||
{found && found.length > 0 && (
|
||||
<ul className="mt-2 list-none p-0">
|
||||
{found.map((d) => (
|
||||
<li key={d.id} className="my-1 flex items-center gap-2 text-[12px]">
|
||||
<li key={d.id} className="my-1 flex items-center gap-2 text-[0.75rem]">
|
||||
<button type="button" className="btn btn-sm" onClick={() => applyDiscovered(d)}>
|
||||
{t("setup.use")}
|
||||
</button>
|
||||
@@ -569,11 +716,25 @@ function DeviceForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.configFields.map((f) =>
|
||||
{selected.configFields
|
||||
// pulseMs + inputRestingHigh are surfaced in the Outputs / Inputs model
|
||||
// sections below (a relay setting and an input setting, respectively), so
|
||||
// skip them here to avoid rendering them twice. See OutputEditor/InputEditor.
|
||||
.filter((f) => !(isController && (f.key === "pulseMs" || f.key === "inputRestingHigh")))
|
||||
// Printer transport is exclusive: when Connection = USB the network fields
|
||||
// (host/port/status-page) don't apply, and vice-versa the USB device path
|
||||
// doesn't. Hide the irrelevant side so the form can't mislead (e.g. a USB
|
||||
// path lingering under a Network printer). Driven by config.transport.
|
||||
.filter((f) => {
|
||||
const transport = String(config.transport ?? "tcp-ip");
|
||||
if (transport === "usb") return !["host", "port", "httpPort"].includes(f.key);
|
||||
return f.key !== "devicePath";
|
||||
})
|
||||
.map((f) =>
|
||||
f.type === "boolean" ? (
|
||||
// Boolean config field → a real checkbox (stores a true/false boolean, not
|
||||
// the string "true"). The label sits beside the box, with the help below.
|
||||
<label key={f.key} className="my-2 flex max-w-sm items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
|
||||
<label key={f.key} className="my-2 flex max-w-sm items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
@@ -611,10 +772,36 @@ function DeviceForm({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : f.type === "secret" ? (
|
||||
// Secret field with a reveal toggle: the device web password is shown
|
||||
// here (admin-only view) so an admin can read/copy it to reach the
|
||||
// device's own web UI. Masked by default; click the eye to reveal.
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
className="input flex-1"
|
||||
type={revealed[f.key] ? "text" : "password"}
|
||||
value={(config[f.key] ?? (f.default as string | number | undefined) ?? "") as string | number}
|
||||
placeholder={f.help}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setConfig((c) => ({ ...c, [f.key]: v }));
|
||||
resetStatus();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
aria-label={revealed[f.key] ? t("setup.hideSecret") : t("setup.revealSecret")}
|
||||
title={revealed[f.key] ? t("setup.hideSecret") : t("setup.revealSecret")}
|
||||
onClick={() => setRevealed((r) => ({ ...r, [f.key]: !r[f.key] }))}
|
||||
>
|
||||
{revealed[f.key] ? "🙈" : "👁"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
||||
type={f.type === "number" || f.type === "port" ? "number" : "text"}
|
||||
value={(config[f.key] ?? (f.default as string | number | undefined) ?? "") as string | number}
|
||||
placeholder={f.help}
|
||||
onChange={(e) => {
|
||||
@@ -628,8 +815,33 @@ function DeviceForm({
|
||||
),
|
||||
)}
|
||||
|
||||
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
|
||||
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
|
||||
{/* CONTROLLER — OUTPUTS: the unified relays (barriers pulse, alert relays blink). */}
|
||||
{isController && (
|
||||
<OutputEditor
|
||||
relays={relays}
|
||||
onChange={setRelays}
|
||||
pulseMs={config.pulseMs as number | undefined}
|
||||
onPulseMsChange={(v) => {
|
||||
setConfig((c) => ({ ...c, pulseMs: v }));
|
||||
resetStatus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* CONTROLLER — INPUTS: a generic terminal list (button / presence / alert trigger),
|
||||
each naming the relay it serves. Separated from the outputs above. */}
|
||||
{isController && (
|
||||
<InputEditor
|
||||
inputs={inputs}
|
||||
onChange={setInputs}
|
||||
relays={relays}
|
||||
inputsIdleHigh={config.inputRestingHigh as boolean | undefined}
|
||||
onInputsIdleHighChange={(v) => {
|
||||
setConfig((c) => ({ ...c, inputRestingHigh: v }));
|
||||
resetStatus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* BOUND device: which controller + relay it sits at. */}
|
||||
{!isController && (
|
||||
@@ -647,7 +859,7 @@ function DeviceForm({
|
||||
|
||||
{/* CAMERA: opt this camera into ANPR (the VisionReader polls it for plates). */}
|
||||
{isCamera && (
|
||||
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
|
||||
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
@@ -661,6 +873,82 @@ function DeviceForm({
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Auto-trigger is only meaningful with ANPR on. Off = this camera RECOGNISES plates
|
||||
(evidence) but does NOT auto-open the barrier — for a shared entry/exit lane where
|
||||
the exit cam's back-plate read would phantom-exit a car that just entered. */}
|
||||
{isCamera && anpr && (
|
||||
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={anprAuto}
|
||||
onChange={(e) => setAnprAuto(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-semibold text-term-text">{t("setup.anprAuto")}</span>
|
||||
<span className="hint mt-0.5 block">{t("setup.anprAutoHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* CAMERA + Alarm Server push ON: show the camera's Alarm Server settings,
|
||||
ready to copy, so the operator never has to find the deviceId or memorise the
|
||||
endpoint. The CAMERA reaches us over the device VLAN, NOT via the browser's
|
||||
origin — so host/port are the BACKEND address (backendIp on the camera's
|
||||
subnet + the server's listen port), resolved by the same probe the push-IP
|
||||
picker uses, NOT window.location (which is the SPA's dev/proxy origin). The
|
||||
URL embeds the deviceId, so it needs a SAVED camera; and the backend IP needs
|
||||
a Test connection first. We surface each field separately, matching the
|
||||
camera's Alarm Settings form (Destination IP / URL / Protocol / Port). */}
|
||||
{isCamera && Boolean(config.alarmPushEnabled) && (
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
|
||||
<div className="font-semibold text-term-text">{t("setup.alarmUrlTitle")}</div>
|
||||
{!editing?.id ? (
|
||||
<p className="hint mt-1">{t("setup.alarmUrlSaveFirst")}</p>
|
||||
) : !backendIp || backendPort == null ? (
|
||||
<p className="hint mt-1">{t("setup.alarmUrlTestFirst")}</p>
|
||||
) : (
|
||||
(() => {
|
||||
const path = `/api/devices/hikvision/${editing.id}/event`;
|
||||
// What the operator pastes into the camera's Alarm Settings form.
|
||||
const fields: [string, string][] = [
|
||||
[t("setup.alarmFieldHost"), backendIp],
|
||||
[t("setup.alarmFieldUrl"), path],
|
||||
[t("setup.alarmFieldProtocol"), "HTTP"],
|
||||
[t("setup.alarmFieldPort"), String(backendPort)],
|
||||
];
|
||||
const copyText = fields.map(([k, v]) => `${k}: ${v}`).join("\n");
|
||||
return (
|
||||
<>
|
||||
<div className="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||
{fields.map(([k, v]) => (
|
||||
<Fragment key={k}>
|
||||
<span className="text-term-muted">{k}</span>
|
||||
<code className="break-all rounded bg-term-panel px-2 py-0.5 text-term-green">{v}</code>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(copyText);
|
||||
setAlarmUrlCopied(true);
|
||||
setTimeout(() => setAlarmUrlCopied(false), 2000);
|
||||
}}
|
||||
>
|
||||
{alarmUrlCopied ? t("setup.alarmUrlCopied") : t("setup.alarmUrlCopy")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="hint mt-1">{t("setup.alarmUrlHint")}</p>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test (no save/no device change) then Save (configures + persists). */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={test} disabled={testing}>
|
||||
@@ -676,9 +964,9 @@ function DeviceForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{testError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: testError })}</p>}
|
||||
{testError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: testError })}</p>}
|
||||
{tested && (
|
||||
<div className="mt-2 text-[12px]">
|
||||
<div className="mt-2 text-[0.75rem]">
|
||||
<div className="text-term-text">
|
||||
{t("setup.deviceLabel")} <HealthBadge status={tested.health.status} />
|
||||
{tested.health.detail && <span className="text-term-muted"> — {tested.health.detail}</span>}
|
||||
@@ -705,10 +993,10 @@ function DeviceForm({
|
||||
</button>
|
||||
<p className="hint mt-1">{t("setup.testAnprHint")}</p>
|
||||
|
||||
{anprError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: anprError })}</p>}
|
||||
{anprError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: anprError })}</p>}
|
||||
{anprResult &&
|
||||
(anprResult.ok ? (
|
||||
<div className="mt-2 text-[12px] text-term-green">
|
||||
<div className="mt-2 text-[0.75rem] text-term-green">
|
||||
{t("setup.anprOk", {
|
||||
plate: anprResult.plate,
|
||||
confidence: Math.round(anprResult.confidence * 100),
|
||||
@@ -719,7 +1007,7 @@ function DeviceForm({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-[12px] text-term-amber">
|
||||
<div className="mt-2 text-[0.75rem] text-term-amber">
|
||||
⚠ {t(`setup.anprFail.${anprResult.reason}`, { defaultValue: anprResult.reason })}
|
||||
{anprResult.detail && <span className="text-term-muted"> — {anprResult.detail}</span>}
|
||||
{anprResult.tookMs != null && (
|
||||
@@ -730,6 +1018,30 @@ function DeviceForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PRINTER: push a real test slip so the admin can confirm it physically
|
||||
prints (healthCheck only opens the transport / USB node). */}
|
||||
{isPrinter && (
|
||||
<div className="mt-3 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<button type="button" className="btn btn-sm" onClick={testPrintNow} disabled={printTesting}>
|
||||
{printTesting ? t("setup.printTesting") : t("setup.testPrint")}
|
||||
</button>
|
||||
<p className="hint mt-1">{t("setup.testPrintHint")}</p>
|
||||
|
||||
{printError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: printError })}</p>}
|
||||
{printResult &&
|
||||
(printResult.ok ? (
|
||||
<div className="mt-2 text-[0.75rem] text-term-green">
|
||||
{t("setup.printOk", { ms: printResult.tookMs })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-[0.75rem] text-term-amber">
|
||||
⚠ {t(`setup.printFail.${printResult.reason}`, { defaultValue: printResult.reason })}
|
||||
{printResult.detail && <span className="text-term-muted"> — {printResult.detail}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backendIps && backendIps.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="field max-w-md">
|
||||
@@ -748,21 +1060,37 @@ function DeviceForm({
|
||||
</select>
|
||||
</div>
|
||||
{!backendIps.some((c) => c.onDeviceSubnet) && (
|
||||
<span className="text-[12px] text-term-amber">{t("setup.noNicOnSubnet")}</span>
|
||||
<span className="text-[0.75rem] text-term-amber">{t("setup.noNicOnSubnet")}</span>
|
||||
)}
|
||||
<p className="hint mt-1">{t("setup.backendIpHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
{saveError && <p className="mt-2 text-[12px] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>}
|
||||
{saveError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Controller relay map editor: each row = a relay + its direction + (optional)
|
||||
* the input terminal its entry button is wired to. */
|
||||
function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) {
|
||||
// ── Controller OUTPUTS (relays) ────────────────────────────────────────────
|
||||
// A relay is an OUTPUT reacting to an EVENT: entry/exit/both PULSE a barrier; radarAlert
|
||||
// BLINKS an indicator lamp (and a camera-confirmed car locks it solid). This section owns
|
||||
// the relay number + event, the pulse-open hold time (barriers), and — for alert relays —
|
||||
// the trigger input + blink cadence. The barrier INPUT terminals (entry button, presence)
|
||||
// live in InputEditor below; the two are deliberately separated.
|
||||
|
||||
/** Relays = the unified event→action outputs + the pulse-open hold time. */
|
||||
function OutputEditor({
|
||||
relays,
|
||||
onChange,
|
||||
pulseMs,
|
||||
onPulseMsChange,
|
||||
}: {
|
||||
relays: RelaySpec[];
|
||||
onChange: (r: RelaySpec[]) => void;
|
||||
pulseMs: number | undefined;
|
||||
onPulseMsChange: (v: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
function update(i: number, patch: Partial<RelaySpec>) {
|
||||
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||
@@ -777,11 +1105,27 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
||||
|
||||
return (
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.relaysTitle")}</strong>
|
||||
<p className="hint mt-0.5 mb-2">{t("setup.relaysHint")}</p>
|
||||
<strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.outputsTitle")}</strong>
|
||||
<p className="hint mt-0.5 mb-2">{t("setup.outputsHint")}</p>
|
||||
|
||||
{/* Pulse-open time applies to every barrier relay (how long it's held open). */}
|
||||
<label className="my-1 inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.pulseOpenHint")}>
|
||||
{t("setup.pulseOpenMs")}
|
||||
<input
|
||||
type="number"
|
||||
min={100}
|
||||
value={pulseMs ?? ""}
|
||||
placeholder="500"
|
||||
className="input input-sm w-20"
|
||||
onChange={(e) => onPulseMsChange(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Each relay: number + event. radarAlert reveals its trigger input + blink cadence;
|
||||
barriers pulse (their button/presence terminals are in the Inputs section). */}
|
||||
{relays.map((r, i) => (
|
||||
<div key={i} className="my-1 flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.relay")}
|
||||
<input
|
||||
type="number"
|
||||
@@ -791,56 +1135,68 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
||||
onChange={(e) => update(i, { relay: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<select className="select input-sm w-auto" value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}>
|
||||
{(["entry", "exit", "both"] as Direction[]).map((d) => (
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
value={r.direction}
|
||||
onChange={(e) => update(i, { direction: e.target.value as RelayEvent })}
|
||||
>
|
||||
{(["entry", "exit", "both", "radarAlert"] as RelayEvent[]).map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{t(DIRECTION_KEYS[d])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(r.direction === "entry" || r.direction === "both") && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.entryButtonTerminal")}
|
||||
|
||||
{/* Alert relay: which input fires the blink + the blink cadence. */}
|
||||
{r.direction === "radarAlert" && (
|
||||
<>
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.triggerInputHint")}>
|
||||
{t("setup.triggerInput")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={r.button ?? ""}
|
||||
value={r.triggerInput ?? ""}
|
||||
placeholder="—"
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
onChange={(e) => update(i, { triggerInput: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{(r.direction === "entry" || r.direction === "both") && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceInputHint")}>
|
||||
{t("setup.presenceInput")}
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.lockLaneHint")}>
|
||||
{t("setup.lockLane")}
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
value={r.lockLane ?? "entry"}
|
||||
onChange={(e) => update(i, { lockLane: e.target.value as "entry" | "exit" })}
|
||||
>
|
||||
<option value="entry">{t("setup.lockLaneEntry")}</option>
|
||||
<option value="exit">{t("setup.lockLaneExit")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.blinkOnMs")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={r.presenceInput ?? ""}
|
||||
placeholder="—"
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) =>
|
||||
update(i, { presenceInput: e.target.value === "" ? undefined : Number(e.target.value) })
|
||||
}
|
||||
min={50}
|
||||
value={r.blinkOnMs ?? ""}
|
||||
placeholder="500"
|
||||
className="input input-sm w-20"
|
||||
onChange={(e) => update(i, { blinkOnMs: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{(r.direction === "entry" || r.direction === "both") && !r.presenceInput && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
|
||||
{t("setup.entryCooldown")}
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.blinkOffMs")}
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={r.entryCooldownSec ?? ""}
|
||||
placeholder="—"
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) =>
|
||||
update(i, { entryCooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })
|
||||
}
|
||||
min={50}
|
||||
value={r.blinkOffMs ?? ""}
|
||||
placeholder="500"
|
||||
className="input input-sm w-20"
|
||||
onChange={(e) => update(i, { blinkOffMs: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{relays.length > 1 && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
|
||||
✕
|
||||
@@ -855,6 +1211,152 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
||||
);
|
||||
}
|
||||
|
||||
// ── Controller INPUTS (terminals) ──────────────────────────────────────────
|
||||
// An input is a TERMINAL the host READS. It's a first-class list (the twin of the relays
|
||||
// list above): each row is a terminal + a ROLE (entry button / presence loop / presence
|
||||
// radar / alert trigger) + the relay it serves. Adding an exit radar = adding a row. The
|
||||
// button never SETS a pulse — its electrical pulse is the device's to report — so no timing
|
||||
// field lives here (pulse-open is an OUTPUT setting, in OutputEditor).
|
||||
|
||||
/** Generic controller-input list: terminal + role + the relay it serves. */
|
||||
function InputEditor({
|
||||
inputs,
|
||||
onChange,
|
||||
relays,
|
||||
inputsIdleHigh,
|
||||
onInputsIdleHighChange,
|
||||
}: {
|
||||
inputs: InputSpec[];
|
||||
onChange: (v: InputSpec[]) => void;
|
||||
relays: RelaySpec[];
|
||||
inputsIdleHigh: boolean | undefined;
|
||||
onInputsIdleHighChange: (v: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
function update(i: number, patch: Partial<InputSpec>) {
|
||||
onChange(inputs.map((row, idx) => (idx === i ? { ...row, ...patch } : row)));
|
||||
}
|
||||
function add() {
|
||||
const firstEntry = relays.find((r) => r.direction === "entry" || r.direction === "both");
|
||||
onChange([...inputs, { input: 1, role: "button", relay: firstEntry?.relay }]);
|
||||
}
|
||||
function remove(i: number) {
|
||||
onChange(inputs.filter((_, idx) => idx !== i));
|
||||
}
|
||||
// Barrier relays an input can serve (button/presence gate a barrier; alert triggers don't).
|
||||
const barrierRelays = relays.filter((r) => r.direction !== "radarAlert");
|
||||
// A button row shows its cooldown fallback only if no presence row serves the same relay.
|
||||
const hasPresenceFor = (relay?: number) =>
|
||||
relay != null && inputs.some((x) => x.role === "presence" && x.relay === relay);
|
||||
|
||||
return (
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.inputsTitle")}</strong>
|
||||
<p className="hint mt-0.5 mb-2">{t("setup.inputsHint")}</p>
|
||||
|
||||
{/* Board-wide resting level (idle HIGH vs LOW) — an input property. */}
|
||||
<label className="my-1 inline-flex items-start gap-2 text-[0.75rem] text-term-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={inputsIdleHigh ?? true}
|
||||
onChange={(e) => onInputsIdleHighChange(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-semibold text-term-text">{t("setup.inputsIdleHigh")}</span>
|
||||
<span className="hint mt-0.5 block">{t("setup.inputsIdleHighHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{inputs.map((row, i) => {
|
||||
const choice = choiceOf(row);
|
||||
const isPresence = row.role === "presence";
|
||||
const isButton = row.role === "button";
|
||||
return (
|
||||
<div key={i} className="my-1 flex flex-wrap items-center gap-2 border-t border-term-border pt-2">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.inputTerminal")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={row.input}
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) => update(i, { input: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
value={choice}
|
||||
onChange={(e) => update(i, applyChoice(e.target.value as InputChoice))}
|
||||
>
|
||||
{(["button", "presenceLoop", "presenceRadar", "alertTrigger"] as InputChoice[]).map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(INPUT_CHOICE_KEYS[c])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Which barrier this input serves — button/presence only (alert triggers a lamp). */}
|
||||
{row.role !== "alertTrigger" && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.inputServesRelay")}
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
value={row.relay ?? ""}
|
||||
onChange={(e) => update(i, { relay: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("setup.choose")}
|
||||
</option>
|
||||
{barrierRelays.map((r) => (
|
||||
<option key={r.relay} value={r.relay}>
|
||||
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Presence: active-low (a radar wired opposite the button). */}
|
||||
{isPresence && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.activeLowHint")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!row.activeLow}
|
||||
onChange={(e) => update(i, { activeLow: e.target.checked || undefined })}
|
||||
/>
|
||||
{t("setup.activeLow")}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Button cooldown fallback — only when no presence sensor serves this relay. */}
|
||||
{isButton && !hasPresenceFor(row.relay) && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.entryCooldownHint")}>
|
||||
{t("setup.entryCooldown")}
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={row.cooldownSec ?? ""}
|
||||
placeholder="—"
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) => update(i, { cooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button type="button" className="btn btn-sm mt-1" onClick={add}>
|
||||
{t("setup.addInput")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Binding picker for readers/cameras/printers: choose the controller + relay this
|
||||
* device sits at. Direction is inherited from the chosen relay (shown). */
|
||||
function BindingPicker({
|
||||
@@ -879,9 +1381,9 @@ function BindingPicker({
|
||||
|
||||
return (
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.whichBarrier")}</strong>
|
||||
<strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.whichBarrier")}</strong>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.controller")}
|
||||
<select className="select input-sm w-auto" value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
|
||||
<option value="" disabled>
|
||||
@@ -898,7 +1400,7 @@ function BindingPicker({
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.relay")}
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
@@ -909,7 +1411,10 @@ function BindingPicker({
|
||||
<option value="" disabled>
|
||||
{t("setup.choose")}
|
||||
</option>
|
||||
{relays.map((r) => (
|
||||
{/* Only barrier relays are bindable — an alert lamp opens nothing. */}
|
||||
{relays
|
||||
.filter((r) => r.direction !== "radarAlert")
|
||||
.map((r) => (
|
||||
<option key={r.relay} value={r.relay}>
|
||||
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
|
||||
</option>
|
||||
@@ -919,22 +1424,24 @@ function BindingPicker({
|
||||
{chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
|
||||
</div>
|
||||
{controller && relays.length === 0 && (
|
||||
<p className="mt-1.5 text-[12px] text-term-amber">{t("setup.noRelaysConfigured")}</p>
|
||||
<p className="mt-1.5 text-[0.75rem] text-term-amber">{t("setup.noRelaysConfigured")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) {
|
||||
// entry=green, exit=amber, both=muted — aligned to the terminal accent palette.
|
||||
function DirectionBadge({ direction, label }: { direction: RelayEvent; label?: string }) {
|
||||
// entry=green, exit=amber, radarAlert=red (an alert), both=muted — terminal accents.
|
||||
const cls =
|
||||
direction === "entry"
|
||||
? "border-term-green text-term-green"
|
||||
: direction === "exit"
|
||||
? "border-term-amber text-term-amber"
|
||||
: direction === "radarAlert"
|
||||
? "border-term-red text-term-red"
|
||||
: "border-term-muted text-term-muted";
|
||||
return (
|
||||
<span className={`rounded-term border px-1.5 text-[10px] font-semibold uppercase tracking-wider ${cls}`}>
|
||||
<span className={`rounded-term border px-1.5 text-[0.625rem] font-semibold uppercase tracking-wider ${cls}`}>
|
||||
{label ?? direction}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
+134
-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: {
|
||||
@@ -215,6 +227,64 @@ export function fetchLogs(params: {
|
||||
return apiFetch(`/api/logs${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
// --- Backup ---------------------------------------------------------------
|
||||
// On-site encrypted DB backup. See wiki/concepts/backup-recovery.md.
|
||||
|
||||
export interface BackupStatus {
|
||||
configured: boolean;
|
||||
/** Admin-chosen target directory (null = not set). */
|
||||
targetDir: string | null;
|
||||
/** Admin-tuned retention (resolved value: DB or code default). */
|
||||
keepLast: number;
|
||||
keepDailyDays: number;
|
||||
/** Whether the env encryption key is present (a missing key is flagged distinctly). */
|
||||
keyPresent: boolean;
|
||||
running: boolean;
|
||||
lastSuccessAt: string | null;
|
||||
lastResult: { path: string; bytes: number; prunedFiles: number } | null;
|
||||
lastErrorAt: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export async function fetchBackupStatus(): Promise<BackupStatus> {
|
||||
return apiFetch("/api/backup/status");
|
||||
}
|
||||
|
||||
export interface BackupConfigPatch {
|
||||
/** "" clears the target. Omit a field to leave it unchanged; null resets retention to default. */
|
||||
targetDir?: string | null;
|
||||
keepLast?: number | null;
|
||||
keepDailyDays?: number | null;
|
||||
}
|
||||
|
||||
/** Update backup config (target dir and/or retention). Returns the new status. */
|
||||
export async function setBackupConfig(patch: BackupConfigPatch): Promise<BackupStatus> {
|
||||
return apiFetch("/api/backup/config", { method: "PUT", body: JSON.stringify(patch) });
|
||||
}
|
||||
|
||||
export interface TargetCheck {
|
||||
ok: boolean;
|
||||
/** "empty" | "missing" | "not_a_dir" | "not_writable" when !ok. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Probe a candidate target path server-side (exists / is a dir / is writable). */
|
||||
export async function testBackupTarget(targetDir: string): Promise<TargetCheck> {
|
||||
return apiFetch("/api/backup/test", { method: "POST", body: JSON.stringify({ targetDir }) });
|
||||
}
|
||||
|
||||
export interface BackupRunResult {
|
||||
ok: true;
|
||||
path: string;
|
||||
bytes: number;
|
||||
prunedFiles: number;
|
||||
}
|
||||
|
||||
/** Trigger a manual "back up now". Throws on 409 (not configured) / 500 (run failed). */
|
||||
export async function runBackup(): Promise<BackupRunResult> {
|
||||
return apiFetch("/api/backup/run", { method: "POST" });
|
||||
}
|
||||
|
||||
// --- Device setup ---------------------------------------------------------
|
||||
|
||||
export interface ConfigField {
|
||||
@@ -275,18 +345,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 +401,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 +439,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);
|
||||
}
|
||||
}
|
||||
|
||||
+113
-19
@@ -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: [
|
||||
@@ -58,6 +61,7 @@ export const en: Catalog = {
|
||||
reports: "Reports",
|
||||
recycleBin: "Recycle bin",
|
||||
logs: "Logs",
|
||||
backup: "Backup",
|
||||
profile: "Profile",
|
||||
},
|
||||
profile: {
|
||||
@@ -118,7 +122,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 +137,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 +150,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",
|
||||
@@ -305,7 +308,7 @@ export const en: Catalog = {
|
||||
setup: {
|
||||
title: "Setup",
|
||||
intro:
|
||||
"Add your barrier controllers first — set which relay is entry/exit and which terminal the entry button is wired to. Then add readers, cameras and printers and point each at the barrier it serves.",
|
||||
"Add your barrier controllers first. Then add readers (QR/RF), cameras, printers",
|
||||
catControllers: "Controllers (barriers + entry button)",
|
||||
catReaders: "Readers (QR / RFID)",
|
||||
catCameras: "Cameras (snapshot + plate)",
|
||||
@@ -359,18 +362,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 +415,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 +618,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 +702,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 +738,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 +755,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",
|
||||
@@ -776,6 +831,45 @@ export const en: Catalog = {
|
||||
path: "Path",
|
||||
empty: "No logs.",
|
||||
},
|
||||
backup: {
|
||||
title: "Backup",
|
||||
intro:
|
||||
"An encrypted copy of the database (the signed ledger) to an external disk. Runs automatically every day and from the button below.",
|
||||
statusTitle: "Status",
|
||||
configured: "Enabled",
|
||||
notConfigured: "Not configured",
|
||||
notConfiguredHint: "Set BACKUP_TARGET_DIR and BACKUP_KEY on the server to enable backups.",
|
||||
running: "Running…",
|
||||
idle: "Idle",
|
||||
lastSuccess: "Last successful backup",
|
||||
lastError: "Last error",
|
||||
never: "Never",
|
||||
lastFile: "File",
|
||||
size: "Size",
|
||||
pruned: "Pruned",
|
||||
runNow: "Back up now",
|
||||
runSuccess: "Backup complete.",
|
||||
runFailed: "Backup failed.",
|
||||
notConfiguredError: "Backup is not configured.",
|
||||
restoreNote:
|
||||
"Restore is not done here — it's an out-of-band step when provisioning a fresh appliance (needs the backup file + the keys you escrowed offsite).",
|
||||
targetLabel: "Backup location",
|
||||
targetPlaceholder: "e.g. /mnt/backup or /media/usb",
|
||||
targetHint: "An absolute path to a mounted disk (USB/SATA) or a network share (SMB/NFS).",
|
||||
save: "Save",
|
||||
saved: "Saved.",
|
||||
test: "Test target",
|
||||
testOk: "The location is writable.",
|
||||
testEmpty: "Enter a path.",
|
||||
testMissing: "The location does not exist.",
|
||||
testNotDir: "The path is not a directory.",
|
||||
testNotWritable: "The directory is not writable.",
|
||||
keyMissing: "The encryption key (BACKUP_KEY) is missing on the server — set it to enable backups.",
|
||||
keepLastLabel: "Keep last",
|
||||
keepLastHint: "How many of the newest backups to always keep.",
|
||||
keepDailyLabel: "Keep daily (days)",
|
||||
keepDailyHint: "Beyond those, keep one backup per day for this many days.",
|
||||
},
|
||||
pay: {
|
||||
ticket: "Ticket",
|
||||
entry: "Entry",
|
||||
|
||||
+124
-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
|
||||
@@ -60,6 +63,7 @@ export const sq = {
|
||||
reports: "Raportet",
|
||||
recycleBin: "Koshi",
|
||||
logs: "Loget",
|
||||
backup: "Kopje rezervë",
|
||||
profile: "Profili",
|
||||
},
|
||||
profile: {
|
||||
@@ -120,7 +124,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 +139,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 +152,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 +311,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 — Pastaj shto lexues (QR/RF), kamera, printera.",
|
||||
// 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 +347,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 +368,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 +425,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 +630,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 +687,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 +715,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 +751,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 +770,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",
|
||||
@@ -790,6 +846,46 @@ export const sq = {
|
||||
path: "Rruga",
|
||||
empty: "Asnjë regjistër.",
|
||||
},
|
||||
backup: {
|
||||
title: "Kopje rezervë",
|
||||
intro:
|
||||
"Kopje e enkriptuar e bazës së të dhënave (regjistri i nënshkruar) në një disk të jashtëm. Bëhet automatikisht çdo ditë dhe me butonin më poshtë.",
|
||||
statusTitle: "Gjendja",
|
||||
configured: "Aktive",
|
||||
notConfigured: "E pakonfiguruar",
|
||||
notConfiguredHint:
|
||||
"Cakto BACKUP_TARGET_DIR dhe BACKUP_KEY në server që të aktivizohet kopja rezervë.",
|
||||
running: "Duke u kryer…",
|
||||
idle: "Në pritje",
|
||||
lastSuccess: "Kopja e fundit e suksesshme",
|
||||
lastError: "Gabimi i fundit",
|
||||
never: "Asnjëherë",
|
||||
lastFile: "Skedari",
|
||||
size: "Madhësia",
|
||||
pruned: "Të hequra",
|
||||
runNow: "Bëj kopje tani",
|
||||
runSuccess: "Kopja rezervë u krye.",
|
||||
runFailed: "Kopja rezervë dështoi.",
|
||||
notConfiguredError: "Kopja rezervë nuk është e konfiguruar.",
|
||||
restoreNote:
|
||||
"Rikthimi nuk bëhet nga këtu — është veprim i jashtëm gjatë instalimit të një aparati të ri (kërkon skedarin e kopjes + çelësat e ruajtur jashtë).",
|
||||
targetLabel: "Vendndodhja e kopjes",
|
||||
targetPlaceholder: "p.sh. /mnt/backup ose /media/usb",
|
||||
targetHint: "Rrugë absolute drejt një disku të lidhur (USB/SATA) ose një ndarjeje rrjeti (SMB/NFS).",
|
||||
save: "Ruaj",
|
||||
saved: "U ruajt.",
|
||||
test: "Testo vendndodhjen",
|
||||
testOk: "Vendndodhja është e shkruajtshme.",
|
||||
testEmpty: "Shkruaj një rrugë.",
|
||||
testMissing: "Vendndodhja nuk ekziston.",
|
||||
testNotDir: "Rruga nuk është një dosje.",
|
||||
testNotWritable: "Dosja nuk është e shkruajtshme.",
|
||||
keyMissing: "Çelësi i enkriptimit (BACKUP_KEY) mungon në server — caktoje që kopja të aktivizohet.",
|
||||
keepLastLabel: "Mbaj kopjet e fundit",
|
||||
keepLastHint: "Numri i kopjeve më të reja që mbahen gjithmonë.",
|
||||
keepDailyLabel: "Mbaj ditore (ditë)",
|
||||
keepDailyHint: "Përtej atyre, mbaj një kopje për ditë për kaq ditë.",
|
||||
},
|
||||
pay: {
|
||||
ticket: "Bileta",
|
||||
entry: "Hyrja",
|
||||
|
||||
@@ -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);
|
||||
|
||||
+78
-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";
|
||||
@@ -30,6 +42,7 @@ import { UsersManager } from "./UsersManager.js";
|
||||
import { RolesManager } from "./RolesManager.js";
|
||||
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||||
import { LogsViewer } from "./LogsViewer.js";
|
||||
import { BackupSettings } from "./BackupSettings.js";
|
||||
import { RecycleBin } from "./RecycleBin.js";
|
||||
import { Profile } from "./Profile.js";
|
||||
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
||||
@@ -56,7 +69,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 +83,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>
|
||||
@@ -94,6 +107,7 @@ function SetupLayout() {
|
||||
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
||||
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
||||
</nav>
|
||||
<Outlet />
|
||||
</div>
|
||||
@@ -147,7 +161,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 +207,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 +224,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 +318,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 +360,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 +372,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 +403,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>
|
||||
);
|
||||
}
|
||||
@@ -393,18 +446,20 @@ function RootLayout() {
|
||||
show("user:read") ||
|
||||
show("role:read") ||
|
||||
show("recyclebin:read") ||
|
||||
show("backup:read") ||
|
||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{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>
|
||||
@@ -642,6 +697,14 @@ const logsRoute = createRoute({
|
||||
component: LogsViewer,
|
||||
});
|
||||
|
||||
// Encrypted DB backup — status + manual run. Gated by backup:read (run by backup:create).
|
||||
const backupRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "backup",
|
||||
beforeLoad: ({ context }) => requirePerm("backup:read")(context),
|
||||
component: BackupSettings,
|
||||
});
|
||||
|
||||
// My profile — self-service for ANY signed-in user (no permission gate). Edits only
|
||||
// the caller's own name/email/password. See Profile.tsx and routes/auth.ts.
|
||||
const profileRoute = createRoute({
|
||||
@@ -674,6 +737,7 @@ const routeTree = rootRoute.addChildren([
|
||||
rolesRoute,
|
||||
recycleBinRoute,
|
||||
logsRoute,
|
||||
backupRoute,
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</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"
|
||||
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>
|
||||
))}
|
||||
{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={`${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
||||
)}
|
||||
</span>
|
||||
<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,120 @@
|
||||
# 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
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# BACKUP (encrypted on-site DB backup — see wiki/concepts/backup-recovery.md)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# Dedicated backup-ENCRYPTION key. SEPARATE from EVENT_SIGNING_KEY (independent rotation;
|
||||
# backups travel to the target, the signing key must not). Per booth + unique. openssl rand
|
||||
# -hex 32. ESCROW it offsite alongside EVENT_SIGNING_KEY — disaster recovery needs BOTH, and
|
||||
# neither is ever stored inside the backup it unlocks. Backups stay OFF until this key is set
|
||||
# AND the admin picks a target directory in the UI. The key is the ONLY backup env var — the
|
||||
# target directory and retention (keep-last / keep-daily) are admin-chosen in the UI (Setup →
|
||||
# Backup) and stored in the DB, so changing them needs no redeploy.
|
||||
BACKUP_KEY=[[booth_<name>_backup_key]]
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 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)
|
||||
# --- snapshots (camera evidence; stored re-encoded, then pruned under disk pressure) ---
|
||||
# SNAPSHOT_MAX_EDGE=1280 # downscale long edge (px) before storing
|
||||
# SNAPSHOT_JPEG_QUALITY=80 # stored JPEG quality (recognition uses full-res original)
|
||||
# Disk-pressure prune (daily safety valve; deletes oldest + VACUUM only when the disk is tight):
|
||||
# SNAPSHOT_DISK_HIGH_PCT=70 # prune only when the DB's filesystem is ≥ this % used
|
||||
# SNAPSHOT_DISK_FREE_TARGET_PCT=10 # try to free ~this % of the disk per run
|
||||
# SNAPSHOT_MIN_KEEP=500 # never prune below this many snapshots (floor)
|
||||
# SNAPSHOT_PRUNE_BATCH=200 # delete oldest in batches of this many
|
||||
# --- 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)
|
||||
# BACKUP_KEY -> [[booth_<name>_backup_key]] (backup encryption — escrow offsite)
|
||||
# periphery passkey -> [[periphery_passkey_booth_<name>]] (agent onboarding)
|
||||
# registry account -> [[gitea_registry_account]] (image pull)
|
||||
@@ -0,0 +1,77 @@
|
||||
# `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` / `BACKUP_KEY`) vs. plain Stack env.
|
||||
|
||||
## Promotion: dev → stage → main
|
||||
|
||||
Three tiers (see `wiki/decisions/fleet-deployment-komodo.md`):
|
||||
|
||||
- **`dev`** — the working branch. CI builds `:dev` / `:dev-<sha>`. No booth deploys off it.
|
||||
- **`stage`** — what the **staging booth (`park-buzi`)** runs, to test the app in real-world
|
||||
conditions. When `dev` is confident-ready, **merge `dev → stage`**; CI builds `:stage` /
|
||||
`:stage-<sha>`; then **bump `TAG=stage-<sha>` in `resources.toml`** to that build and deploy
|
||||
from Core (manual, pinned — no webhook even on staging).
|
||||
- **`main`** — vetted **production** booths: `:main-<sha>`, manual + pinned. `main` only gets what
|
||||
survived staging.
|
||||
|
||||
> The `TAG=stage-<sha>` in `resources.toml` is a **pinned pointer**: the moving `:stage` tag exists
|
||||
> but we deploy the immutable sha so a booth runs a known image. Re-pin on each promotion.
|
||||
|
||||
## 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]]`,
|
||||
`[[park_<site>_backup_key]]`). Create those secrets in Core's store first. Set `branch` +
|
||||
`TAG` for the tier the booth runs (staging → `stage` / `stage-<sha>`; production → `main` /
|
||||
`main-<sha>`).
|
||||
|
||||
## 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 an immutable
|
||||
`TAG=<branch>-<sha>` (staging → `stage-<sha>`, production → `main-<sha>`). A moving tag on a
|
||||
booth is the non-determinism we rejected — and we hold that line even on the staging booth.
|
||||
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,59 @@
|
||||
# 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; BACKUP_KEY encrypts on-site DB
|
||||
# backups (separate from the signing key; escrow it offsite — recovery needs both).
|
||||
#
|
||||
# Deploys are MANUAL + PINNED: park-buzi is the STAGING booth (real-world test of the app),
|
||||
# so it tracks the `stage` branch + the `:stage` image, but is still deployed by hand with a
|
||||
# PINNED immutable TAG=stage-<sha> (no webhook). Promotion: merge dev → stage when confident,
|
||||
# CI builds :stage / :stage-<sha>, then bump TAG below to that sha and deploy from Komodo Core.
|
||||
# A PRODUCTION booth tracks `main` + manual+pinned `:main-<sha>`. See
|
||||
# wiki/decisions/fleet-deployment-komodo.md (dev → stage → main tiers).
|
||||
|
||||
##############################################################################
|
||||
# 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 = "stage"
|
||||
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
|
||||
# Staging booth: pin an immutable stage-<sha> per deploy. PLACEHOLDER below — after merging
|
||||
# dev → stage and CI builds :stage-<sha>, set this to that exact sha (or override TAG in Core at
|
||||
# deploy). The moving `:stage` tag exists as the pointer; we deploy the sha, not the mover.
|
||||
TAG=stage-REPLACE_WITH_BUILT_SHA
|
||||
COOKIE_SECURE=0
|
||||
VISION_ENABLED=1
|
||||
WS_ALLOWED_ORIGINS=
|
||||
JWT_SECRET=[[park_buzi_jwt_secret]]
|
||||
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
||||
BACKUP_KEY=[[park_buzi_backup_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;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- The QR/RFID access reader was misnamed "GEE" on a wrong early assumption; it's actually a
|
||||
-- Dingtian DT-008 (same vendor as the relay board). The driver was renamed
|
||||
-- `gee-qr-reader` → `dingtian-qr-reader`. `devices.driver_id` is the persisted registry
|
||||
-- lookup key, so rewrite existing reader rows or they'd fail to resolve a driver. Idempotent.
|
||||
UPDATE `devices` SET `driver_id` = 'dingtian-qr-reader' WHERE `driver_id` = 'gee-qr-reader';
|
||||
@@ -0,0 +1,5 @@
|
||||
-- The on-site admin chooses where encrypted DB backups are written (a mounted USB/SATA/
|
||||
-- SMB/NFS path), from the Backup screen — not a server env var. Additive, nullable ALTER:
|
||||
-- null = not configured (backups stay a no-op). The BACKUP_KEY stays an env/Komodo secret
|
||||
-- (a key must NEVER live in the DB it backs up). See wiki/concepts/backup-recovery.md.
|
||||
ALTER TABLE `site_config` ADD `backup_target_dir` text;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Backup retention is OPERATIONAL POLICY the on-site admin tunes from the Backup screen, not a
|
||||
-- server env var requiring a redeploy. Two additive, nullable columns on the single-row config;
|
||||
-- null = fall back to the code default (keepLast 7, keepDailyDays 30). The backup ENGINE stays
|
||||
-- parameterized; this just moves the source of truth env → DB. See wiki/concepts/backup-recovery.md.
|
||||
ALTER TABLE `site_config` ADD `backup_keep_last` integer;--> statement-breakpoint
|
||||
ALTER TABLE `site_config` ADD `backup_keep_daily_days` integer;
|
||||
@@ -99,6 +99,34 @@
|
||||
"when": 1781885600000,
|
||||
"tag": "0013_anpr_entry_toggle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1781885700000,
|
||||
"tag": "0014_user_font_scale",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "6",
|
||||
"when": 1781885800000,
|
||||
"tag": "0015_dingtian_qr_reader_driverid",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "6",
|
||||
"when": 1781885900000,
|
||||
"tag": "0016_backup_target_dir",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "6",
|
||||
"when": 1781886000000,
|
||||
"tag": "0017_backup_retention",
|
||||
"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.
|
||||
@@ -259,6 +264,16 @@ export const siteConfig = sqliteTable("site_config", {
|
||||
* payload so V2 category pricing reprices identically at exit. null ⇒ the shared
|
||||
* DEFAULT_VEHICLE_CATEGORY fallback. See wiki/concepts/tariff-time-tiers.md. */
|
||||
defaultVehicleCategory: text("default_vehicle_category"),
|
||||
/** Admin-chosen directory encrypted DB backups are written to — a mounted local/USB/
|
||||
* SATA/SMB/NFS path. null = not configured (backups are a no-op). Set from the Backup
|
||||
* screen; the encryption key (BACKUP_KEY) stays an env/Komodo secret and is NEVER stored
|
||||
* here (a key must not live in the DB it backs up). See wiki/concepts/backup-recovery.md. */
|
||||
backupTargetDir: text("backup_target_dir"),
|
||||
/** Backup retention (admin-tunable policy, not env). Keep this many newest backups always.
|
||||
* null ⇒ code default (7). See wiki/concepts/backup-recovery.md. */
|
||||
backupKeepLast: integer("backup_keep_last"),
|
||||
/** Beyond keepLast, keep one backup per day for this many days. null ⇒ code default (30). */
|
||||
backupKeepDailyDays: integer("backup_keep_daily_days"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
|
||||
@@ -30,3 +30,13 @@ export function createTestDb(url = ":memory:"): { db: Db; sqlite: Database.Datab
|
||||
migrate(db, { migrationsFolder: MIGRATIONS_DIR });
|
||||
return { db, sqlite, close: () => sqlite.close() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an EXISTING SQLite file raw, WITHOUT running migrations — for tests that need to
|
||||
* inspect a file produced elsewhere (e.g. a restored backup) exactly as written, without
|
||||
* mutating it. Returns the raw better-sqlite3 handle so the caller depends only on
|
||||
* `@parking/db/testing`, never on `better-sqlite3` directly.
|
||||
*/
|
||||
export function openRawDb(url: string): Database.Database {
|
||||
return new Database(url);
|
||||
}
|
||||
|
||||
@@ -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 DT-008 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));
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { stubAccessDriver } from "./access-stub.js";
|
||||
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
||||
import { cashinoDriver } from "./printer-cashino.js";
|
||||
import { rongtaDriver } from "./printer-rongta.js";
|
||||
import { geeQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
||||
import { dingtianQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
||||
|
||||
let registered = false;
|
||||
|
||||
@@ -19,7 +19,7 @@ export function registerBuiltinDrivers(): void {
|
||||
registry.register(stubAccessDriver);
|
||||
registry.register(wiegandReaderDriver);
|
||||
registry.register(tcpipReaderDriver);
|
||||
registry.register(geeQrReaderDriver);
|
||||
registry.register(dingtianQrReaderDriver);
|
||||
registry.register(hikvisionDriver);
|
||||
registry.register(dahuaDriver);
|
||||
registry.register(rongtaDriver);
|
||||
@@ -31,7 +31,7 @@ export {
|
||||
stubAccessDriver,
|
||||
wiegandReaderDriver,
|
||||
tcpipReaderDriver,
|
||||
geeQrReaderDriver,
|
||||
dingtianQrReaderDriver,
|
||||
hikvisionDriver,
|
||||
dahuaDriver,
|
||||
rongtaDriver,
|
||||
|
||||
@@ -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 { dingtianQrReaderDriver } = 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 = dingtianQrReaderDriver.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 = dingtianQrReaderDriver.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 = dingtianQrReaderDriver.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 = dingtianQrReaderDriver.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;
|
||||
@@ -59,18 +75,19 @@ export const tcpipReaderDriver: ReaderDriver = {
|
||||
create: (c) => new StubReader("tcpip-reader", c),
|
||||
};
|
||||
|
||||
// GEE/Fondvision QR access reader (e.g. GEE-QR-ER80). A PUSH device: on each scan
|
||||
// it HTTP-GETs our backend (/qa/mcardsea.<ext>) carrying its serial (cjihao); the
|
||||
// backend resolves the lane by matching that serial to this device's `serial`
|
||||
// config, decides, and replies the verdict (drives the beep). No host-side
|
||||
// connection — the adapter is a stub; the real integration is the HTTP endpoint
|
||||
// (apps/server routes/qr-reader.ts). See wiki/entities/gee-qr-er80.md.
|
||||
export const geeQrReaderDriver: ReaderDriver = {
|
||||
id: "gee-qr-reader",
|
||||
// Dingtian DT-008 QR/RFID access reader (same vendor as the relay board). Scans QR/barcode
|
||||
// + ID/IC/NFC cards. A PUSH device: on each scan it HTTP-GETs our backend (/qa/mcardsea.<ext>)
|
||||
// carrying its serial (cjihao); the backend resolves the lane by matching that serial to this
|
||||
// device's `serial` config, decides, and replies the verdict (drives the beep). No host-side
|
||||
// connection — the adapter is a stub; the real integration is the HTTP endpoint (apps/server
|
||||
// routes/qr-reader.ts). Interfaces: Wiegand 26/34, TCP/IP, USB, RS485. See
|
||||
// wiki/entities/dingtian-dt008-reader.md.
|
||||
export const dingtianQrReaderDriver: ReaderDriver = {
|
||||
id: "dingtian-qr-reader",
|
||||
category: "reader",
|
||||
label: "GEE/Fondvision QR reader (HTTP push)",
|
||||
label: "Dingtian DT-008 QR/RFID reader (HTTP push)",
|
||||
description:
|
||||
"QR/barcode access reader that HTTP-pushes each scan to the backend. Set its server IP/port to this host in the vendor tool; enter its serial here so scans resolve to this lane.",
|
||||
"Dingtian DT-008 QR/barcode/RFID access reader that HTTP-pushes each scan to the backend. Set its server IP/port to this host in the vendor tool; enter its serial here so scans resolve to this lane.",
|
||||
transports: ["tcp-ip"],
|
||||
configFields: [
|
||||
{
|
||||
@@ -80,6 +97,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),
|
||||
create: (c) => new StubReader("dingtian-qr-reader", c),
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ export {
|
||||
stubAccessDriver,
|
||||
wiegandReaderDriver,
|
||||
tcpipReaderDriver,
|
||||
geeQrReaderDriver,
|
||||
dingtianQrReaderDriver,
|
||||
hikvisionDriver,
|
||||
dahuaDriver,
|
||||
rongtaDriver,
|
||||
|
||||
@@ -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-
|
||||
|
||||
@@ -28,6 +28,7 @@ export const RESOURCES = [
|
||||
"report", // events feed, occupancy, future reports
|
||||
"log", // application/diagnostic logs (app_logs) — view + retention
|
||||
"recyclebin", // soft-deleted master data: view / restore / purge
|
||||
"backup", // encrypted DB backups: configure target + trigger a manual run
|
||||
] as const;
|
||||
export type Resource = (typeof RESOURCES)[number];
|
||||
|
||||
@@ -60,6 +61,12 @@ export const PERMISSIONS: readonly Permission[] = [
|
||||
// Recycle bin: read (list soft-deleted items), update (restore), delete (purge). These
|
||||
// are admin-grade — a restore can revive a privileged user/role, a purge is permanent.
|
||||
"recyclebin:read", "recyclebin:update", "recyclebin:delete",
|
||||
// Backup: read (view config + last-run status), update (set target/schedule), create
|
||||
// (trigger a manual "back up now"). Admin-grade — a backup exposes the whole signed
|
||||
// ledger off-box. RESTORE is deliberately NOT a permission: it's an out-of-band runbook
|
||||
// action on a fresh appliance, never reachable from the running console. See
|
||||
// wiki/concepts/backup-recovery.md.
|
||||
"backup:read", "backup:update", "backup:create",
|
||||
] as const;
|
||||
|
||||
/** The protected built-in role: non-deletable, non-editable, always = ALL
|
||||
|
||||
Generated
+299
@@ -66,6 +66,9 @@ importers:
|
||||
fastify-plugin:
|
||||
specifier: 6.0.0
|
||||
version: 6.0.0
|
||||
sharp:
|
||||
specifier: ^0.35.2
|
||||
version: 0.35.2
|
||||
devDependencies:
|
||||
'@types/bcrypt':
|
||||
specifier: 6.0.0
|
||||
@@ -265,6 +268,9 @@ packages:
|
||||
'@emnapi/runtime@1.10.0':
|
||||
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
|
||||
|
||||
'@emnapi/runtime@1.11.1':
|
||||
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
|
||||
|
||||
'@emnapi/wasi-threads@1.2.1':
|
||||
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
|
||||
|
||||
@@ -774,6 +780,152 @@ packages:
|
||||
'@floating-ui/utils@0.2.11':
|
||||
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.35.2':
|
||||
resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-darwin-x64@0.35.2':
|
||||
resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-freebsd-wasm32@0.35.2':
|
||||
resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
os: [freebsd]
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.1':
|
||||
resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.3.1':
|
||||
resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.3.1':
|
||||
resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.3.1':
|
||||
resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.1':
|
||||
resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.1':
|
||||
resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.3.1':
|
||||
resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.3.1':
|
||||
resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.1':
|
||||
resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.1':
|
||||
resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-arm64@0.35.2':
|
||||
resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-arm@0.35.2':
|
||||
resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.35.2':
|
||||
resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.35.2':
|
||||
resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-s390x@0.35.2':
|
||||
resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-x64@0.35.2':
|
||||
resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.35.2':
|
||||
resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.35.2':
|
||||
resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-wasm32@0.35.2':
|
||||
resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
|
||||
'@img/sharp-webcontainers-wasm32@0.35.2':
|
||||
resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@img/sharp-win32-arm64@0.35.2':
|
||||
resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-ia32@0.35.2':
|
||||
resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==}
|
||||
engines: {node: ^20.9.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-x64@0.35.2':
|
||||
resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
@@ -2576,6 +2728,10 @@ packages:
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
sharp@0.35.2:
|
||||
resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
@@ -2953,6 +3109,11 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/runtime@1.11.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/wasi-threads@1.2.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -3276,6 +3437,112 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.11': {}
|
||||
|
||||
'@img/colour@1.1.0': {}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-x64@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-freebsd-wasm32@0.35.2':
|
||||
dependencies:
|
||||
'@img/sharp-wasm32': 0.35.2
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm64@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-ppc64@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-riscv64@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-s390x@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-x64@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-x64': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-wasm32@0.35.2':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.11.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-webcontainers-wasm32@0.35.2':
|
||||
dependencies:
|
||||
'@img/sharp-wasm32': 0.35.2
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.35.2':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-ia32@0.35.2':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-x64@0.35.2':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -4910,6 +5177,38 @@ snapshots:
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
sharp@0.35.2:
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
detect-libc: 2.1.2
|
||||
semver: 7.8.4
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.35.2
|
||||
'@img/sharp-darwin-x64': 0.35.2
|
||||
'@img/sharp-freebsd-wasm32': 0.35.2
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.1
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.1
|
||||
'@img/sharp-libvips-linux-arm': 1.3.1
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.1
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.1
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.1
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.1
|
||||
'@img/sharp-libvips-linux-x64': 1.3.1
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.1
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.1
|
||||
'@img/sharp-linux-arm': 0.35.2
|
||||
'@img/sharp-linux-arm64': 0.35.2
|
||||
'@img/sharp-linux-ppc64': 0.35.2
|
||||
'@img/sharp-linux-riscv64': 0.35.2
|
||||
'@img/sharp-linux-s390x': 0.35.2
|
||||
'@img/sharp-linux-x64': 0.35.2
|
||||
'@img/sharp-linuxmusl-arm64': 0.35.2
|
||||
'@img/sharp-linuxmusl-x64': 0.35.2
|
||||
'@img/sharp-webcontainers-wasm32': 0.35.2
|
||||
'@img/sharp-win32-arm64': 0.35.2
|
||||
'@img/sharp-win32-ia32': 0.35.2
|
||||
'@img/sharp-win32-x64': 0.35.2
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
simple-concat@1.0.1: {}
|
||||
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user