Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||||
@@ -9,5 +9,7 @@
|
|||||||
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
|
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
|
||||||
:80 {
|
:80 {
|
||||||
encode gzip
|
encode gzip
|
||||||
reverse_proxy server:3000
|
# Host network (prod): the server runs on the host's net namespace (to reach the booth LAN /
|
||||||
|
# device VLAN), so reach it over loopback, not the compose service name `server`.
|
||||||
|
reverse_proxy 127.0.0.1:3000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
"fastify": "5.8.5",
|
"fastify": "5.8.5",
|
||||||
"fastify-plugin": "6.0.0"
|
"fastify-plugin": "6.0.0",
|
||||||
|
"sharp": "^0.35.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "6.0.0",
|
"@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
|
// Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub
|
||||||
// (no registry, no network). The factory returns a fresh shot each call.
|
// (no registry, no network). The factory returns a fresh shot each call.
|
||||||
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
|
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
|
||||||
|
// The bridge now goes through captureSnapshotShared (the dedup wrapper, exercised in
|
||||||
|
// snapshot.test.ts); here it just delegates to the fake camera's captureSnapshot so this
|
||||||
|
// suite stays focused on the bridge's own match/debounce/emit logic.
|
||||||
vi.mock("./snapshot.js", () => ({
|
vi.mock("./snapshot.js", () => ({
|
||||||
buildCamera: () => ({ captureSnapshot }),
|
buildCamera: () => ({ captureSnapshot }),
|
||||||
|
captureSnapshotShared: (_id: string, camera: { captureSnapshot: typeof captureSnapshot }, ctx: unknown) =>
|
||||||
|
camera.captureSnapshot(ctx as never),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Import AFTER the mock is registered.
|
// Import AFTER the mock is registered.
|
||||||
@@ -28,13 +33,22 @@ beforeEach(() => {
|
|||||||
captureSnapshot.mockClear();
|
captureSnapshot.mockClear();
|
||||||
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
|
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
|
||||||
delete process.env.ANPR_DEBOUNCE_MS;
|
delete process.env.ANPR_DEBOUNCE_MS;
|
||||||
|
// Poll-until-confident loop: keep the window + interval tiny so a below-floor / no-plate
|
||||||
|
// case gives up in ~one tick instead of the 8s production window (tests stay fast). Each
|
||||||
|
// bridge reads these in its constructor, so set them before `new AnprBridge`.
|
||||||
|
process.env.ANPR_POLL_MS = "1";
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "5";
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
delete process.env.ANPR_POLL_MS;
|
||||||
|
delete process.env.ANPR_POLL_WINDOW_MS;
|
||||||
|
delete process.env.ANPR_POLL_MAX_MS;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */
|
/** A camera bound to an entry relay; `anpr` toggles recognition, `anprAutoTrigger` the
|
||||||
function seedCamera(opts: { anpr?: boolean } = {}): string {
|
* per-camera auto-open gate (absent ⇒ defaults on). */
|
||||||
|
function seedCamera(opts: { anpr?: boolean; anprAutoTrigger?: boolean } = {}): string {
|
||||||
const controllerId = randomUUID();
|
const controllerId = randomUUID();
|
||||||
db.insert(devices).values({
|
db.insert(devices).values({
|
||||||
id: controllerId,
|
id: controllerId,
|
||||||
@@ -48,7 +62,13 @@ function seedCamera(opts: { anpr?: boolean } = {}): string {
|
|||||||
id: camId,
|
id: camId,
|
||||||
category: "camera",
|
category: "camera",
|
||||||
driverId: "hikvision",
|
driverId: "hikvision",
|
||||||
config: { host: "10.0.0.9", controllerId, relay: 1, ...(opts.anpr ? { anpr: true } : {}) },
|
config: {
|
||||||
|
host: "10.0.0.9",
|
||||||
|
controllerId,
|
||||||
|
relay: 1,
|
||||||
|
...(opts.anpr ? { anpr: true } : {}),
|
||||||
|
...(opts.anprAutoTrigger === false ? { anprAutoTrigger: false } : {}),
|
||||||
|
},
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}).run();
|
}).run();
|
||||||
return camId;
|
return camId;
|
||||||
@@ -74,8 +94,17 @@ function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: numb
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
|
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
|
||||||
function fakeSubFlow(match: SubscriptionMatch | null): SubscriptionFlow {
|
function fakeSubFlow(
|
||||||
return { match: vi.fn(() => match) } as unknown as SubscriptionFlow;
|
match: SubscriptionMatch | null,
|
||||||
|
// openOccurrenceCount: a constant, or a sequence consumed per call (to simulate a
|
||||||
|
// credential closing an occurrence mid-poll → count changes).
|
||||||
|
openCounts: number | number[] = 1,
|
||||||
|
): SubscriptionFlow {
|
||||||
|
const seq = Array.isArray(openCounts) ? [...openCounts] : null;
|
||||||
|
return {
|
||||||
|
match: vi.fn(() => match),
|
||||||
|
openOccurrenceCount: vi.fn(() => (seq ? (seq.length > 1 ? seq.shift()! : seq[0]) : (openCounts as number))),
|
||||||
|
} as unknown as SubscriptionFlow;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
|
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
|
||||||
@@ -104,6 +133,18 @@ describe("AnprBridge", () => {
|
|||||||
expect(captureSnapshot).not.toHaveBeenCalled();
|
expect(captureSnapshot).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does NOT auto-trigger when anprAutoTrigger=false (recognition on, auto-open off)", async () => {
|
||||||
|
// Shared entry/exit lane: the exit cam keeps anpr (recognition) but auto-trigger off, so a
|
||||||
|
// car driving IN isn't phantom-EXITed by its back plate. The bridge bails before snapshot.
|
||||||
|
const cam = seedCamera({ anpr: true, anprAutoTrigger: false });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.99 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
expect(captureSnapshot).not.toHaveBeenCalled(); // gated before the poll loop
|
||||||
|
});
|
||||||
|
|
||||||
it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
|
it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
|
||||||
const cam = seedCamera({ anpr: true });
|
const cam = seedCamera({ anpr: true });
|
||||||
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
|
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
|
||||||
@@ -123,6 +164,85 @@ describe("AnprBridge", () => {
|
|||||||
expect(reads).toEqual([]);
|
expect(reads).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("POLLS until confident: low-confidence approach frames, then a clean stop-at-barrier frame", async () => {
|
||||||
|
// The car APPROACHES (garbage reads) then STOPS at the barrier (clean read) — the bridge
|
||||||
|
// must re-pull until one frame clears the floor, not give up on the first bad frame.
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
// analyze escalates: 0.20, 0.20, then 0.97 on the 3rd pull → that one emits.
|
||||||
|
const confs = [0.2, 0.2, 0.97];
|
||||||
|
let i = 0;
|
||||||
|
const vision = {
|
||||||
|
enabled: true,
|
||||||
|
analyze: vi.fn(async () => ({
|
||||||
|
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||||
|
plates: [],
|
||||||
|
lowConfidence: false,
|
||||||
|
modelVersion: "test",
|
||||||
|
tookMs: 1,
|
||||||
|
})),
|
||||||
|
} as unknown as VisionClient;
|
||||||
|
// Generous window so all 3 escalation attempts run deterministically under suite load
|
||||||
|
// (the global beforeEach sets a tiny 5ms window for the give-up cases).
|
||||||
|
process.env.ANPR_POLL_MS = "1";
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "2000";
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
expect(reads[0]).toMatchObject({ value: "AA111BB", kind: "plate" });
|
||||||
|
expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SLIDES the window: a push mid-poll keeps the loop alive past the initial deadline", async () => {
|
||||||
|
// A loop started by an early/far car would expire — but a NEW push (another car arriving)
|
||||||
|
// extends the deadline, so the loop keeps polling and reads the car that settles at the
|
||||||
|
// barrier. Here: a SHORT base window, vision stays low until attempt 5; a second push at
|
||||||
|
// the start bumps the deadline so attempt 5's confident read still lands.
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const confs = [0.2, 0.2, 0.2, 0.2, 0.97];
|
||||||
|
let i = 0;
|
||||||
|
const vision = {
|
||||||
|
enabled: true,
|
||||||
|
analyze: vi.fn(async () => ({
|
||||||
|
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||||
|
plates: [],
|
||||||
|
lowConfidence: false,
|
||||||
|
modelVersion: "test",
|
||||||
|
tookMs: 1,
|
||||||
|
})),
|
||||||
|
} as unknown as VisionClient;
|
||||||
|
process.env.ANPR_POLL_MS = "5";
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "12"; // tiny — would expire ~attempt 2 WITHOUT a slide
|
||||||
|
process.env.ANPR_POLL_MAX_MS = "5000"; // ceiling far above, so the slide is what matters
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(async () => {
|
||||||
|
const loop = bridge.onVehicleDetected(cam); // starts the loop
|
||||||
|
// Joining pushes keep sliding the deadline forward so the slow-to-confident read lands.
|
||||||
|
for (let k = 0; k < 5; k++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
void bridge.onVehicleDetected(cam); // each bumps the deadline (loop already running)
|
||||||
|
}
|
||||||
|
await loop;
|
||||||
|
});
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
expect(reads[0]).toMatchObject({ value: "AA111BB" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ABORTS if the subscriber transacts by another credential mid-poll (no double-act)", async () => {
|
||||||
|
// The car's plate is read (identity known) but stays below the floor; meanwhile the
|
||||||
|
// subscriber scans their card → openOccurrenceCount drops. The bridge must abort and NOT
|
||||||
|
// emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. fleet).
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.5 }); // never clears the floor
|
||||||
|
// openOccurrenceCount: 1 at baseline, then 0 (the card exit closed it) on the next check.
|
||||||
|
const sub = fakeSubFlow(SUB_MATCH, [1, 0]);
|
||||||
|
const bridge = new AnprBridge(db, vision, sub, silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]); // aborted — the credential already handled it
|
||||||
|
});
|
||||||
|
|
||||||
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
|
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
|
||||||
const cam = seedCamera({ anpr: true });
|
const cam = seedCamera({ anpr: true });
|
||||||
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
||||||
|
|||||||
+151
-12
@@ -30,6 +30,10 @@ import type { VisionClient } from "./vision-client.js";
|
|||||||
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
|
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
|
||||||
interface CameraConfig {
|
interface CameraConfig {
|
||||||
readonly anpr?: boolean;
|
readonly anpr?: boolean;
|
||||||
|
/** Whether this camera may AUTO-OPEN the barrier (entry/exit). Absent ⇒ true (when anpr is
|
||||||
|
* on). Set false to keep recognition but suppress auto-trigger — e.g. the exit camera on a
|
||||||
|
* shared entry/exit lane. */
|
||||||
|
readonly anprAutoTrigger?: boolean;
|
||||||
readonly [k: string]: unknown;
|
readonly [k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +53,40 @@ function debounceMs(): number {
|
|||||||
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
|
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A single alarm fires the INSTANT motion starts — the car is still approaching, so the
|
||||||
|
* first frame often has a small/blurry/absent plate (a low-confidence misread). But the car
|
||||||
|
* then STOPS at the barrier (waiting for it to open) — the same stationary, well-framed
|
||||||
|
* moment the manual test reads at ~100%. So instead of one shot, we POLL fresh frames and
|
||||||
|
* re-run ANPR until one clears the confidence floor, or the window elapses. Poll interval: */
|
||||||
|
function pollMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_POLL_MS ?? 1000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long to keep polling AFTER THE LAST vehicle push before giving up. SLIDING: each new
|
||||||
|
* push for the camera extends the deadline by this much from now — so a loop started by a
|
||||||
|
* far/early car keeps pulling fresh frames as the REAL car arrives and settles at the
|
||||||
|
* barrier (the loop tracks "whoever is here now", not the car that started it). */
|
||||||
|
function pollWindowMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_POLL_WINDOW_MS ?? 8000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hard ceiling on a single loop from its START, so a continuously-busy lane (pushes never
|
||||||
|
* stop) can't slide the window forever. The loop ends at min(lastPush + window, start + max). */
|
||||||
|
function pollMaxMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_POLL_MAX_MS ?? 30_000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** A plate DeviceReadEvent skeleton (value filled by the caller) — for matching the
|
||||||
|
* subscriber by plate during the poll loop without re-building the whole event. */
|
||||||
|
function baseRead(row: { driverId: string }, deviceId: string): Omit<DeviceReadEvent, "value"> {
|
||||||
|
return { driverId: row.driverId, deviceId, kind: "plate", at: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
export class AnprBridge {
|
export class AnprBridge {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #vision: VisionClient | null;
|
readonly #vision: VisionClient | null;
|
||||||
@@ -56,9 +94,19 @@ export class AnprBridge {
|
|||||||
readonly #logger: FastifyBaseLogger;
|
readonly #logger: FastifyBaseLogger;
|
||||||
readonly #entryMinConfidence: number;
|
readonly #entryMinConfidence: number;
|
||||||
readonly #debounceMs: number;
|
readonly #debounceMs: number;
|
||||||
|
readonly #pollMs: number;
|
||||||
|
readonly #pollWindowMs: number;
|
||||||
|
readonly #pollMaxMs: number;
|
||||||
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
|
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
|
||||||
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
|
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
|
||||||
readonly #lastFire = new Map<string, number>();
|
readonly #lastFire = new Map<string, number>();
|
||||||
|
/** Cameras with a poll loop already in flight — a re-fired alarm (the camera pushes ~1Hz
|
||||||
|
* while the car sits) must NOT start a second concurrent loop on the same camera. */
|
||||||
|
readonly #polling = new Set<string>();
|
||||||
|
/** Per-camera SLIDING deadline for the running poll loop. A push that joins a running loop
|
||||||
|
* bumps this forward (lastPush + window, capped at start + max), so the loop keeps pulling
|
||||||
|
* fresh frames while cars keep arriving — tracking whoever settles at the barrier. */
|
||||||
|
readonly #pollDeadline = new Map<string, number>();
|
||||||
|
|
||||||
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
@@ -67,6 +115,9 @@ export class AnprBridge {
|
|||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
this.#entryMinConfidence = entryMinConfidence();
|
this.#entryMinConfidence = entryMinConfidence();
|
||||||
this.#debounceMs = debounceMs();
|
this.#debounceMs = debounceMs();
|
||||||
|
this.#pollMs = pollMs();
|
||||||
|
this.#pollWindowMs = pollWindowMs();
|
||||||
|
this.#pollMaxMs = pollMaxMs();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,15 +135,35 @@ export class AnprBridge {
|
|||||||
if (site && site.anprEntryEnabled === false) return;
|
if (site && site.anprEntryEnabled === false) return;
|
||||||
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||||
if (!row || !row.enabled || row.category !== "camera") return;
|
if (!row || !row.enabled || row.category !== "camera") return;
|
||||||
if ((row.config as CameraConfig)?.anpr !== true) return; // opt-in only
|
const cfg = row.config as CameraConfig;
|
||||||
|
if (cfg?.anpr !== true) return; // recognition opt-in (also gates the evidence/advisory path)
|
||||||
|
// Per-camera AUTO-TRIGGER gate. `anpr` keeps recognition (snapshots + plate record) on;
|
||||||
|
// this controls whether THIS camera may auto-open the barrier. A shared entry/exit lane
|
||||||
|
// sets it false on (e.g.) the exit camera so its back-plate read doesn't phantom-exit the
|
||||||
|
// car that just entered. Absent ⇒ true (back-compat: existing anpr cameras still trigger).
|
||||||
|
if (cfg.anprAutoTrigger === false) return;
|
||||||
|
|
||||||
// Camera-level debounce (pre-snapshot): a car re-firing ~1Hz must not pull a
|
// Post-success debounce: once we've emitted a read for this camera, ignore the
|
||||||
// snapshot + analyze every second.
|
// ~1Hz re-fires for #debounceMs (set on success below). A fresh alarm AFTER the
|
||||||
|
// window is a new presentation and may start a new poll loop.
|
||||||
if (this.#debounced(deviceId)) return;
|
if (this.#debounced(deviceId)) return;
|
||||||
this.#stamp(deviceId);
|
// One poll loop per camera. A push that arrives while a loop runs JOINs it — and
|
||||||
|
// SLIDES the deadline forward (a different car arriving mid-loop keeps the loop alive
|
||||||
|
// so it tracks whoever's at the barrier now, instead of giving up on the early car).
|
||||||
|
const now = Date.now();
|
||||||
|
if (this.#polling.has(deviceId)) {
|
||||||
|
const cur = this.#pollDeadline.get(deviceId) ?? now;
|
||||||
|
// Slide to lastPush + window, but never past the per-loop hard ceiling (set at start).
|
||||||
|
this.#pollDeadline.set(deviceId, Math.max(cur, now + this.#pollWindowMs));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#polling.add(deviceId);
|
||||||
|
// Initial deadline; the hard ceiling (start + max) is enforced in the loop below.
|
||||||
|
this.#pollDeadline.set(deviceId, now + this.#pollWindowMs);
|
||||||
|
|
||||||
const camera = buildCamera(row);
|
const camera = buildCamera(row);
|
||||||
if (!camera) {
|
if (!camera) {
|
||||||
|
this.#polling.delete(deviceId);
|
||||||
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
|
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -100,16 +171,74 @@ export class AnprBridge {
|
|||||||
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
|
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
|
||||||
// the gated flow infers the verb from the camera's bound relay direction).
|
// the gated flow infers the verb from the camera's bound relay direction).
|
||||||
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
|
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
|
||||||
const shot = await camera.captureSnapshot({ direction });
|
|
||||||
const result = await this.#vision.analyze(shot.bytes, shot.contentType);
|
|
||||||
if (!result || !result.plate) return; // nothing read
|
|
||||||
|
|
||||||
// Entry floor — stricter than the advisory floor (analyze() still returns the plate
|
// POLL-UNTIL-CONFIDENT. The alarm fires as the car APPROACHES (small/blurry/absent
|
||||||
// object with its confidence even when its own lowConfidence flag is set).
|
// plate → low-confidence misread, e.g. '111'@0.20). But the car then STOPS at the
|
||||||
if (result.plate.confidence < this.#entryMinConfidence) {
|
// barrier — the stationary, well-framed moment the manual test reads at ~100%. So we
|
||||||
|
// pull a FRESH frame every #pollMs and re-run ANPR until one clears the floor, or the
|
||||||
|
// #pollWindowMs window elapses (car drove off / non-subscriber). NB: a fresh pull each
|
||||||
|
// tick — NOT captureSnapshotShared, whose TTL would re-serve the same bad frame.
|
||||||
|
// While polling, watch whether THIS subscriber transacts by another credential
|
||||||
|
// (card/QR at the reader). If their open-occurrence count drops mid-poll, the
|
||||||
|
// subscriber already exited/entered — the bridge must NOT also emit (it would act on
|
||||||
|
// the NEXT open occurrence: a phantom double-exit, worst for a fleet sub). We learn the
|
||||||
|
// subscription as soon as a frame reads the bound plate (identity needs no confidence),
|
||||||
|
// snapshot the count, then keep polling for a CONFIDENT read; abort if the count moved.
|
||||||
|
let result: Awaited<ReturnType<VisionClient["analyze"]>> = null;
|
||||||
|
let watchedSubId: string | null = null;
|
||||||
|
let baselineOpen = 0;
|
||||||
|
// Hard ceiling for THIS loop (start + max); the sliding deadline (bumped by joining
|
||||||
|
// pushes) is read from #pollDeadline each tick but never allowed past this cap.
|
||||||
|
const hardCap = Date.now() + this.#pollMaxMs;
|
||||||
|
let attempts = 0;
|
||||||
|
try {
|
||||||
|
while (Date.now() < Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap)) {
|
||||||
|
attempts++;
|
||||||
|
const shot = await camera.captureSnapshot({ direction });
|
||||||
|
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
|
||||||
|
|
||||||
|
// Identify the subscriber from ANY readable plate (even below the barrier floor),
|
||||||
|
// and baseline their open count once — so we can detect a credential beating us.
|
||||||
|
if (r?.plate?.text) {
|
||||||
|
const m0 = this.#subscription.match({ ...baseRead(row, deviceId), value: r.plate.text.trim().toUpperCase() });
|
||||||
|
if (m0 && watchedSubId == null) {
|
||||||
|
watchedSubId = m0.subscriptionId;
|
||||||
|
baselineOpen = this.#subscription.openOccurrenceCount(watchedSubId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A credential (card/QR) closed/opened an occurrence for this subscriber mid-poll →
|
||||||
|
// they already transacted; stop polling and do NOT emit.
|
||||||
|
if (watchedSubId && this.#subscription.openOccurrenceCount(watchedSubId) !== baselineOpen) {
|
||||||
|
this.#logger.info(
|
||||||
|
`anpr-bridge: subscriber ${watchedSubId} transacted by another credential mid-poll — aborting ANPR`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r?.plate && r.plate.confidence >= this.#entryMinConfidence) {
|
||||||
|
result = r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (r?.plate) {
|
||||||
|
this.#logger.info(
|
||||||
|
`anpr-bridge: '${r.plate.text}' (${r.plate.confidence.toFixed(3)}) below floor ` +
|
||||||
|
`${this.#entryMinConfidence} — re-pulling (attempt ${attempts})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Stop if the next tick would land past the (possibly slid) deadline or the cap.
|
||||||
|
const effDeadline = Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap);
|
||||||
|
if (Date.now() + this.#pollMs >= effDeadline) break;
|
||||||
|
await sleep(this.#pollMs);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.#polling.delete(deviceId);
|
||||||
|
this.#pollDeadline.delete(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result || !result.plate) {
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`anpr-bridge: plate '${result.plate.text}' below entry floor ` +
|
`anpr-bridge: no confident plate from ${deviceId} after ${attempts} attempt(s) ` +
|
||||||
`(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`,
|
`in ${this.#pollWindowMs}ms — gave up`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -133,11 +262,21 @@ export class AnprBridge {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final guard against the credential-mid-poll race: if the subscriber transacted between
|
||||||
|
// our baseline and now (e.g. a card scan in the last tick), don't double-act.
|
||||||
|
if (watchedSubId === match.subscriptionId && this.#subscription.openOccurrenceCount(match.subscriptionId) !== baselineOpen) {
|
||||||
|
this.#logger.info(`anpr-bridge: ${match.subscriptionId} already transacted — skipping ANPR emit`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Plate-level debounce — belt-and-suspenders against a gap that slips the
|
// Plate-level debounce — belt-and-suspenders against a gap that slips the
|
||||||
// camera-level gate re-emitting the SAME plate.
|
// camera-level gate re-emitting the SAME plate.
|
||||||
const plateKey = `${deviceId}:${plate}`;
|
const plateKey = `${deviceId}:${plate}`;
|
||||||
if (this.#debounced(plateKey)) return;
|
if (this.#debounced(plateKey)) return;
|
||||||
this.#stamp(plateKey);
|
this.#stamp(plateKey);
|
||||||
|
// Camera-level debounce stamp — now that we've emitted, suppress the camera's ~1Hz
|
||||||
|
// re-fires (and any new poll loop) for #debounceMs.
|
||||||
|
this.#stamp(deviceId);
|
||||||
|
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
|
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import type { AuxOutputDevice } from "@parking/devices";
|
||||||
|
import { ButtonLightController } from "./button-light.js";
|
||||||
|
import { deviceEvents } from "./device-events.js";
|
||||||
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// ButtonLightController: alert (radarAlert) relays — the entry-button lamp on a spare
|
||||||
|
// relay, driven by the lamp's trigger input vs. the camera lane status. Truth table:
|
||||||
|
// trigger active + lane busy -> SOLID on
|
||||||
|
// trigger active + lane free -> BLINK (~1 Hz)
|
||||||
|
// otherwise -> OFF
|
||||||
|
// Lamp is a non-barrier aux output; fails OFF; de-dupes redundant writes. A controller may
|
||||||
|
// carry several alert relays (each its own row + trigger input), keyed independently.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const CONTROLLER = "ctl-1";
|
||||||
|
const RADAR_INPUT = 2; // I2
|
||||||
|
const LAMP_RELAY = 3; // spare relay R3
|
||||||
|
|
||||||
|
/** A fake aux device recording setAux calls (channel,on). Optionally throws. */
|
||||||
|
function fakeAux(record: Array<{ ch: number; on: boolean }>, throwOnce = { v: false }): AuxOutputDevice {
|
||||||
|
return {
|
||||||
|
async setAux(channel: number, on: boolean): Promise<void> {
|
||||||
|
if (throwOnce.v) {
|
||||||
|
throwOnce.v = false;
|
||||||
|
throw new Error("UDP down");
|
||||||
|
}
|
||||||
|
record.push({ ch: channel, on });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
vi.useFakeTimers();
|
||||||
|
// One controller: entry relay 1 with radar on I2; lamp on spare relay 3.
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CONTROLLER,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Emit a radar (presence input) edge for the controller. */
|
||||||
|
function radar(present: boolean): void {
|
||||||
|
deviceEvents.emitInput({
|
||||||
|
driverId: "dingtian",
|
||||||
|
deviceId: CONTROLLER,
|
||||||
|
input: RADAR_INPUT,
|
||||||
|
edge: present ? "on" : "off",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source: "poll",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit a lane status (entry busy/free). */
|
||||||
|
function lane(entryBusy: boolean): void {
|
||||||
|
deviceEvents.emitLaneStatus({ entry: entryBusy, exit: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flush the microtask queue so serialized setAux promises (and their re-pump on
|
||||||
|
* completion) settle. The lamp worker sends ONE UDP at a time and re-pumps on resolve;
|
||||||
|
* a few turns drain a burst. Needed because sends are now async (was synchronous). */
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
for (let i = 0; i < 6; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ButtonLightController truth table", () => {
|
||||||
|
it("OFF at start (no radar, no car)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||||
|
// confirmedOn starts null; OFF de-dupes (null !== false → one off write), so the
|
||||||
|
// device is confirmed OFF and at most one call was made.
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar present + lane busy -> SOLID on", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // device latched ON
|
||||||
|
// Solid = no blinking: advancing time produces no further sends.
|
||||||
|
const n = calls.length;
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
await flush();
|
||||||
|
expect(calls.length).toBe(n);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar present + lane free -> BLINK (toggles the device over time)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
radar(true); // lane still free
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // on now
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // toggled off
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // toggled on
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blink -> solid when the camera confirms a car (lane busy)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
radar(true); // blink
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
lane(true); // camera confirms
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
// No more toggles (blink torn down) — the device stays ON over time.
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar clears -> OFF", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true); // solid
|
||||||
|
await flush();
|
||||||
|
radar(false); // car gone
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // device latched OFF
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("de-dupes redundant writes (no spam on repeat events)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true); // solid, on
|
||||||
|
await flush();
|
||||||
|
const n = calls.length;
|
||||||
|
radar(true); // same state — no new edge (present unchanged)
|
||||||
|
lane(true); // same lane — no change
|
||||||
|
await flush();
|
||||||
|
expect(calls.length).toBe(n);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails OFF: a setAux error does not throw or escalate", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const throwOnce = { v: true };
|
||||||
|
const aux = fakeAux(calls, throwOnce);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
// First write (initial off) throws — must be swallowed.
|
||||||
|
expect(() => ctl.start()).not.toThrow();
|
||||||
|
await flush();
|
||||||
|
// Subsequent writes work; driving to solid still converges to ON.
|
||||||
|
lane(true);
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores controllers without an alert relay", () => {
|
||||||
|
// A second controller, no alert relay.
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: "ctl-2",
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: { host: "10.0.0.6", relays: [{ relay: 1, direction: "entry", presenceInput: 2 }] },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||||
|
ctl.start();
|
||||||
|
expect(ctl.stateOf("ctl-2")).toBeNull();
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks up an alert relay ADDED after start() (no restart needed)", async () => {
|
||||||
|
// Fresh controller with a radar input but NO alert relay yet.
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
// Replace the seeded controller with one that has the radar but no lamp.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
// No lamp configured → an input does nothing.
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBeNull();
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
radar(false);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Admin saves an alert relay (relay 3, trigger I2) — without restarting the server.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" },
|
||||||
|
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// The very next radar edge reconciles + blinks (lane still free).
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drives two alert relays on one controller independently", async () => {
|
||||||
|
const R3 = 3;
|
||||||
|
const R4 = 4;
|
||||||
|
const I2 = 2;
|
||||||
|
const I3 = 3;
|
||||||
|
// Controller with two alert lamps, each on its own trigger input.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", presenceInput: I2, presenceKind: "radar" },
|
||||||
|
{ relay: R3, direction: "radarAlert", triggerInput: I2, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
{ relay: R4, direction: "radarAlert", triggerInput: I3, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
|
||||||
|
|
||||||
|
// I2 active → only R3 blinks; R4 stays off (different trigger).
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
|
||||||
|
|
||||||
|
// I3 active → R4 blinks too, independently.
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I3, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||||
|
|
||||||
|
// Camera confirms a car → BOTH lock solid (lane-busy is site-wide).
|
||||||
|
lane(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("solid");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||||
|
|
||||||
|
// I2 clears → R3 off, R4 still solid (its trigger still active).
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "off", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an EXIT alert lamp locks on the EXIT camera, not entry", async () => {
|
||||||
|
const R4 = 4;
|
||||||
|
const I5 = 5; // exit radar
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
// Exit alert lamp: triggers on the exit radar, locks on the EXIT camera.
|
||||||
|
{ relay: R4, direction: "radarAlert", triggerInput: I5, lockLane: "exit", blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
const aux = fakeAux([]);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Exit radar active → blink.
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I5, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||||
|
|
||||||
|
// ENTRY camera busy must NOT lock this exit lamp — it still blinks.
|
||||||
|
deviceEvents.emitLaneStatus({ entry: true, exit: false });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||||
|
|
||||||
|
// EXIT camera busy → SOLID.
|
||||||
|
deviceEvents.emitLaneStatus({ entry: true, exit: true });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { eq, devices, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { hasAuxOutput, registry, type AuxOutputDevice } from "@parking/devices";
|
||||||
|
import { deviceEvents, type DeviceInputEvent, type LaneStatusEvent } from "./device-events.js";
|
||||||
|
import { alertRelaysOf, relayForPresence, type RelaySpec } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// Alert (radarAlert) relays — non-barrier indicator lamps, e.g. the entry button's 12 V
|
||||||
|
// light. Each lamp is a `relays[]` row with event `radarAlert`, driven by ITS trigger
|
||||||
|
// input vs. the camera "car in zone" signal (the advisory lane-status). A disagreement
|
||||||
|
// indicator:
|
||||||
|
// trigger active + lane busy (camera confirms a car) → SOLID on
|
||||||
|
// trigger active + lane free (radar sees something, no car) → BLINK (~1 Hz)
|
||||||
|
// otherwise → OFF
|
||||||
|
// The lamp is a NON-barrier aux output (setAux latch), so holding/blinking it is fine
|
||||||
|
// — barrier-not-a-door applies only to barriers, which still only pulseOpen. The lamp
|
||||||
|
// FAILS OFF: any error / shutdown leaves it off, so a dead lamp is "no hint", never a
|
||||||
|
// misleading solid "go". A controller may have several alert relays (each its own row +
|
||||||
|
// trigger input), keyed independently. See wiki/concepts/button-light-indicator.md.
|
||||||
|
|
||||||
|
type LightState = "off" | "solid" | "blink";
|
||||||
|
|
||||||
|
const DEFAULT_BLINK_MS = 500;
|
||||||
|
|
||||||
|
/** Per-lamp live state for the alert rule (one per radarAlert relay). */
|
||||||
|
interface LampState {
|
||||||
|
/** The controller this lamp lives on (its deviceId) — for resolving the aux adapter. */
|
||||||
|
readonly controllerId: string;
|
||||||
|
/** Alert relay row (relay #, triggerInput, blink ms). Mutable: #reconcile updates it in
|
||||||
|
* place when the admin changes the alert config without a restart. */
|
||||||
|
spec: RelaySpec;
|
||||||
|
/** Is the lamp's trigger input (the radar) currently active? */
|
||||||
|
present: boolean;
|
||||||
|
/** The high-level state we're rendering (to avoid restarting a running blink). */
|
||||||
|
rendered: LightState | null;
|
||||||
|
/** Active blink timer, if blinking. */
|
||||||
|
blink: ReturnType<typeof setInterval> | null;
|
||||||
|
/** Blink phase (true = currently on). */
|
||||||
|
blinkOn: boolean;
|
||||||
|
/** The output we WANT the relay to be in. The serialized worker drives the device
|
||||||
|
* toward this. The blink timer only flips this flag — it never sends directly. */
|
||||||
|
desiredOn: boolean;
|
||||||
|
/** The output we last CONFIRMED on the device (after a successful send). null = unknown. */
|
||||||
|
confirmedOn: boolean | null;
|
||||||
|
/** True while a send is in flight for this lamp — serializes UDP so on/off can't
|
||||||
|
* overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */
|
||||||
|
sending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a controller's live aux-output adapter. The default goes through the
|
||||||
|
* driver registry; tests inject a spy. Returns null when the controller has no
|
||||||
|
* aux-output capability (or won't build). */
|
||||||
|
export type AuxResolver = (controllerId: string) => AuxOutputDevice | null;
|
||||||
|
|
||||||
|
export class ButtonLightController {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #resolveAux: AuxResolver;
|
||||||
|
/** Per-lamp state, keyed by `${controllerId}:${relay}` (a controller may have several). */
|
||||||
|
readonly #lamps = new Map<string, LampState>();
|
||||||
|
/** Latest lane status — a camera-confirmed car in the entry / exit zone. A lamp locks
|
||||||
|
* SOLID off its OWN lane's camera (`spec.lockLane`), so an exit radar's lamp tracks the
|
||||||
|
* exit camera, not the entry one. */
|
||||||
|
#entryBusy = false;
|
||||||
|
#exitBusy = false;
|
||||||
|
/** Controllers we've already warned lack the aux-output capability (warn once). */
|
||||||
|
readonly #warned = new Set<string>();
|
||||||
|
#unsubInput: (() => void) | null = null;
|
||||||
|
#unsubLane: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(db: Db, logger: FastifyBaseLogger, resolveAux?: AuxResolver) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
this.#resolveAux = resolveAux ?? ((id) => this.#auxFromRegistry(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to radar input edges + lane status, and initialise every lamp OFF. */
|
||||||
|
start(): void {
|
||||||
|
this.#reconcile();
|
||||||
|
// All lamps start OFF (known-safe baseline) regardless of prior device state.
|
||||||
|
for (const lamp of this.#lamps.values()) this.#apply(lamp);
|
||||||
|
|
||||||
|
this.#unsubInput = deviceEvents.onInput((e) => this.#onInput(e));
|
||||||
|
this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconcile the lamp map with the CURRENT device config (the booth can add/change a
|
||||||
|
* button light without a server restart). Mirrors DeviceMonitor, which re-reads the
|
||||||
|
* device set each tick. Adds lamps for newly-configured controllers, updates the spec
|
||||||
|
* (relay #, blink ms) in place — preserving live `present`/blink state — and drops
|
||||||
|
* lamps whose controller lost its buttonLight or was disabled. Called at start() and
|
||||||
|
* before handling each event, so a just-saved lamp takes effect immediately. */
|
||||||
|
#reconcile(): void {
|
||||||
|
const rows = this.#db.select().from(devices).where(eq(devices.category, "access")).all();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.enabled) continue;
|
||||||
|
for (const spec of alertRelaysOf(row)) {
|
||||||
|
const key = lampKey(row.id, spec.relay);
|
||||||
|
seen.add(key);
|
||||||
|
const existing = this.#lamps.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.spec = spec; // pick up a changed trigger input / blink cadence
|
||||||
|
} else {
|
||||||
|
this.#lamps.set(key, {
|
||||||
|
controllerId: row.id,
|
||||||
|
spec,
|
||||||
|
present: false,
|
||||||
|
rendered: null,
|
||||||
|
blink: null,
|
||||||
|
blinkOn: false,
|
||||||
|
desiredOn: false,
|
||||||
|
confirmedOn: null,
|
||||||
|
sending: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop lamps whose controller no longer declares one (or was disabled/removed).
|
||||||
|
for (const [key, lamp] of this.#lamps) {
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
if (lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = null;
|
||||||
|
}
|
||||||
|
this.#finalOff(lamp); // best-effort fail-OFF before forgetting it
|
||||||
|
this.#lamps.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A radar (presence) edge updates that controller's `present` flag. We resolve the
|
||||||
|
* edge the SAME way the entry flow does (relayForPresence on an entry/both relay),
|
||||||
|
* so the lamp and the one-car-one-ticket gate always agree on "a car is here". */
|
||||||
|
#onInput(e: DeviceInputEvent): void {
|
||||||
|
// Reconcile first so a lamp added/changed since boot (no restart) is picked up.
|
||||||
|
this.#reconcile();
|
||||||
|
const present = e.edge === "on";
|
||||||
|
for (const lamp of this.#lamps.values()) {
|
||||||
|
if (lamp.controllerId !== e.deviceId) continue;
|
||||||
|
// A lamp's trigger is its own `triggerInput`; if unset, fall back to the controller's
|
||||||
|
// entry-relay presence terminal (resolved the SAME way the entry flow does) so the
|
||||||
|
// lamp and the one-car-one-ticket gate always agree on "a car is here".
|
||||||
|
const trigger =
|
||||||
|
lamp.spec.triggerInput ?? relayForPresence(this.#db, e.deviceId, e.input)?.presenceInput;
|
||||||
|
if (trigger !== e.input) continue; // not this lamp's trigger terminal
|
||||||
|
if (present === lamp.present) continue;
|
||||||
|
lamp.present = present;
|
||||||
|
this.#apply(lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lane status changed: a camera-confirmed car in the entry and/or exit zone. */
|
||||||
|
#onLane(s: LaneStatusEvent): void {
|
||||||
|
if (s.entry === this.#entryBusy && s.exit === this.#exitBusy) return;
|
||||||
|
this.#entryBusy = s.entry;
|
||||||
|
this.#exitBusy = s.exit;
|
||||||
|
// Re-render every lamp (each picks its own lane's camera in #apply).
|
||||||
|
for (const lamp of this.#lamps.values()) this.#apply(lamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute + render the target state for one lamp. Drives are fire-and-forget (the
|
||||||
|
* timer/state machine is synchronous; the UDP write resolves on its own). */
|
||||||
|
#apply(lamp: LampState): void {
|
||||||
|
// SOLID only once THIS lamp's lane camera confirms a car (default entry).
|
||||||
|
const laneBusy = lamp.spec.lockLane === "exit" ? this.#exitBusy : this.#entryBusy;
|
||||||
|
const target: LightState = !lamp.present ? "off" : laneBusy ? "solid" : "blink";
|
||||||
|
if (target === lamp.rendered) return; // already rendering this state
|
||||||
|
|
||||||
|
// Tear down any running blink before switching states.
|
||||||
|
if (lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = null;
|
||||||
|
}
|
||||||
|
lamp.rendered = target;
|
||||||
|
|
||||||
|
if (target === "off") {
|
||||||
|
lamp.desiredOn = false;
|
||||||
|
this.#pump(lamp);
|
||||||
|
} else if (target === "solid") {
|
||||||
|
lamp.desiredOn = true;
|
||||||
|
this.#pump(lamp);
|
||||||
|
} else {
|
||||||
|
// BLINK: a wall-clock timer flips ONLY the desired flag; #pump does the actual
|
||||||
|
// (serialized) UDP send. A symmetric cadence uses one interval; an asymmetric one
|
||||||
|
// re-arms each phase with its own duration. Sends never overlap or reorder, so the
|
||||||
|
// relay can't get stuck on a stale packet.
|
||||||
|
const onMs = lamp.spec.blinkOnMs && lamp.spec.blinkOnMs > 0 ? lamp.spec.blinkOnMs : DEFAULT_BLINK_MS;
|
||||||
|
const offMs = lamp.spec.blinkOffMs && lamp.spec.blinkOffMs > 0 ? lamp.spec.blinkOffMs : DEFAULT_BLINK_MS;
|
||||||
|
lamp.blinkOn = true;
|
||||||
|
lamp.desiredOn = true;
|
||||||
|
const tick = () => {
|
||||||
|
lamp.blinkOn = !lamp.blinkOn;
|
||||||
|
lamp.desiredOn = lamp.blinkOn;
|
||||||
|
this.#pump(lamp);
|
||||||
|
if (onMs !== offMs && lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs);
|
||||||
|
lamp.blink.unref?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
lamp.blink = setInterval(tick, onMs);
|
||||||
|
lamp.blink.unref?.();
|
||||||
|
this.#pump(lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialized per-lamp worker: drive the relay toward `desiredOn`, one UDP send at a
|
||||||
|
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
|
||||||
|
* the relay stuck on a stale packet. Here a single in-flight send is guaranteed
|
||||||
|
* (`sending` guard); when it resolves, if the desired state moved on we send again —
|
||||||
|
* so the LAST desired state is always the one finally asserted on the device. */
|
||||||
|
#pump(lamp: LampState): void {
|
||||||
|
if (lamp.sending) return; // a send is already in flight; it'll re-check on completion
|
||||||
|
if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP
|
||||||
|
const aux = this.#resolveAux(lamp.controllerId);
|
||||||
|
if (!aux) return;
|
||||||
|
const target = lamp.desiredOn;
|
||||||
|
lamp.sending = true;
|
||||||
|
void aux
|
||||||
|
.setAux(lamp.spec.relay, target)
|
||||||
|
.then(() => {
|
||||||
|
lamp.confirmedOn = target;
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
// Leave confirmedOn unchanged so the next pump retries this state. Never escalates.
|
||||||
|
this.#logger.error(`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
lamp.sending = false;
|
||||||
|
// Desired state may have changed (or the send failed) while we were busy —
|
||||||
|
// re-pump to converge. This is what makes the final state authoritative.
|
||||||
|
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(lamp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the live aux-output adapter for a controller, or null (logged once). */
|
||||||
|
#auxFromRegistry(controllerId: string): AuxOutputDevice | null {
|
||||||
|
const row = this.#db.select().from(devices).where(eq(devices.id, controllerId)).get();
|
||||||
|
if (!row) return null;
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
let device: unknown;
|
||||||
|
try {
|
||||||
|
device = driver.create(row.config as never);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!hasAuxOutput(device)) {
|
||||||
|
if (!this.#warned.has(controllerId)) {
|
||||||
|
this.#warned.add(controllerId);
|
||||||
|
this.#logger.warn(`button-light: controller ${controllerId} (${row.driverId}) has no aux-output — lamp ignored`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsubscribe, stop all blink timers, and best-effort drive every lamp OFF. */
|
||||||
|
stop(): void {
|
||||||
|
this.#unsubInput?.();
|
||||||
|
this.#unsubLane?.();
|
||||||
|
this.#unsubInput = null;
|
||||||
|
this.#unsubLane = null;
|
||||||
|
for (const lamp of this.#lamps.values()) {
|
||||||
|
if (lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = null;
|
||||||
|
}
|
||||||
|
// Best-effort fail-OFF on shutdown.
|
||||||
|
this.#finalOff(lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired
|
||||||
|
* OFF and pump. The serialized worker still applies, so this can't collide with an
|
||||||
|
* in-flight send — it converges to OFF. */
|
||||||
|
#finalOff(lamp: LampState): void {
|
||||||
|
lamp.desiredOn = false;
|
||||||
|
this.#pump(lamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: current high-level state being rendered for a lamp (controller + relay).
|
||||||
|
* `relay` defaults to the controller's only/first alert relay for single-lamp tests. */
|
||||||
|
stateOf(controllerId: string, relay?: number): LightState | null {
|
||||||
|
return this.#lamp(controllerId, relay)?.rendered ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: the state last CONFIRMED on the device for a lamp (after a successful
|
||||||
|
* send). null = unknown / nothing sent yet. `relay` defaults to the only alert relay. */
|
||||||
|
confirmedOf(controllerId: string, relay?: number): boolean | null {
|
||||||
|
return this.#lamp(controllerId, relay)?.confirmedOn ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a lamp by controller + relay. When `relay` is omitted, returns the
|
||||||
|
* controller's single lamp (the common single-alert case); ambiguous if several. */
|
||||||
|
#lamp(controllerId: string, relay?: number): LampState | undefined {
|
||||||
|
if (relay != null) return this.#lamps.get(lampKey(controllerId, relay));
|
||||||
|
for (const lamp of this.#lamps.values()) if (lamp.controllerId === controllerId) return lamp;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Composite key for the lamp map (a controller may carry several alert relays). */
|
||||||
|
function lampKey(controllerId: string, relay: number): string {
|
||||||
|
return `${controllerId}:${relay}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a controller row's live aux device (exported for reuse/tests). */
|
||||||
|
export function buildAux(db: Db, row: DeviceRow): AuxOutputDevice | null {
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
try {
|
||||||
|
const device = driver.create(row.config as never);
|
||||||
|
return hasAuxOutput(device) ? device : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ export interface DeviceReadEvent {
|
|||||||
/**
|
/**
|
||||||
* The decision a read produced. Returned by the read flows so a SYNCHRONOUS reader
|
* 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
|
* (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 {
|
export interface ReadOutcome {
|
||||||
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
|
/** 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)
|
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 {
|
class DeviceEventBus extends EventEmitter {
|
||||||
emitInput(event: DeviceInputEvent): void {
|
emitInput(event: DeviceInputEvent): void {
|
||||||
this.emit("input", event);
|
this.emit("input", event);
|
||||||
@@ -148,6 +170,26 @@ class DeviceEventBus extends EventEmitter {
|
|||||||
this.on("lane-status", cb);
|
this.on("lane-status", cb);
|
||||||
return () => this.off("lane-status", cb);
|
return () => this.off("lane-status", cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Emitted whenever a lane's RADAR presence CHANGES (a presence input shorted/cleared
|
||||||
|
* at an entry/exit barrier). Drives the booth barrier light's blink. Advisory only. */
|
||||||
|
emitLanePresence(event: LanePresenceEvent): void {
|
||||||
|
this.emit("lane-presence", event);
|
||||||
|
}
|
||||||
|
onLanePresence(cb: (event: LanePresenceEvent) => void): () => void {
|
||||||
|
this.on("lane-presence", cb);
|
||||||
|
return () => this.off("lane-presence", cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
/** Process-wide device event bus. */
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
|
|||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
case "access": {
|
case "access": {
|
||||||
const dirs = new Set(relaysOf(row).map((r) => r.direction));
|
// Only barrier relays carry a role direction; alert (radarAlert) relays don't.
|
||||||
|
const dirs = new Set(
|
||||||
|
relaysOf(row)
|
||||||
|
.map((r) => r.direction)
|
||||||
|
.filter((d): d is "entry" | "exit" | "both" => d !== "radarAlert"),
|
||||||
|
);
|
||||||
if (dirs.size === 0) return null;
|
if (dirs.size === 0) return null;
|
||||||
if (dirs.size > 1) return "mixed";
|
if (dirs.size > 1) return "mixed";
|
||||||
const only = [...dirs][0]; // entry | exit | both
|
const only = [...dirs][0]; // entry | exit | both
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { inputsOf, relayForButton, relayForPresence } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// device-resolve: the input resolution layer. Inputs live in config.inputs[] (the first-class
|
||||||
|
// model); a pre-inputs[] controller is back-compat-synthesized from the legacy per-relay
|
||||||
|
// button/presenceInput fields. relayForButton/relayForPresence must resolve IDENTICALLY from
|
||||||
|
// either shape, so an exit radar = just another presence row.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const CTL = "ctl-1";
|
||||||
|
|
||||||
|
function seed(config: Record<string, unknown>): void {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
db.insert(devices).values({ id: CTL, category: "access", driverId: "dingtian", config, enabled: true }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("inputsOf back-compat synth", () => {
|
||||||
|
it("synthesizes inputs[] from legacy relay button/presence fields", () => {
|
||||||
|
seed({
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "radar", presenceActiveLow: true },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const row = db.select().from(devices).get()!;
|
||||||
|
const inputs = inputsOf(row);
|
||||||
|
expect(inputs).toEqual([
|
||||||
|
{ input: 1, role: "button", relay: 1, cooldownSec: undefined },
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar", activeLow: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers an explicit inputs[] over the legacy fields", () => {
|
||||||
|
seed({
|
||||||
|
relays: [{ relay: 1, direction: "entry", button: 9 /* legacy ignored */ }],
|
||||||
|
inputs: [{ input: 1, role: "button", relay: 1 }],
|
||||||
|
});
|
||||||
|
const row = db.select().from(devices).get()!;
|
||||||
|
expect(inputsOf(row)).toEqual([{ input: 1, role: "button", relay: 1 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("relayForButton / relayForPresence", () => {
|
||||||
|
it("resolves a button + presence from inputs[]", () => {
|
||||||
|
seed({
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [
|
||||||
|
{ input: 1, role: "button", relay: 1 },
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const byBtn = relayForButton(db, CTL, 1);
|
||||||
|
expect(byBtn).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2, presenceKind: "radar" });
|
||||||
|
const byPres = relayForPresence(db, CTL, 2);
|
||||||
|
expect(byPres).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves IDENTICALLY from the legacy shape (no inputs[])", () => {
|
||||||
|
seed({ relays: [{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "loop" }] });
|
||||||
|
expect(relayForButton(db, CTL, 1)).toMatchObject({ relay: 1, presenceInput: 2, presenceKind: "loop" });
|
||||||
|
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1, presenceInput: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves an EXIT presence row to the exit relay (the exit radar)", () => {
|
||||||
|
seed({
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
],
|
||||||
|
inputs: [
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar" }, // entry radar
|
||||||
|
{ input: 5, role: "presence", relay: 2, kind: "radar" }, // exit radar
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// NOTE: relayForPresence only gates entry/both relays (transient entry). The exit radar
|
||||||
|
// resolves to null HERE (the exit barrier has no entry gate) — but it's still a valid
|
||||||
|
// inputs[] row the lamp can trigger on. The entry radar resolves to relay 1.
|
||||||
|
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1 });
|
||||||
|
expect(relayForPresence(db, CTL, 5)).toBeNull(); // exit relay isn't a transient-entry gate
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a button on an exit-only relay is not a transient-entry trigger", () => {
|
||||||
|
seed({
|
||||||
|
relays: [{ relay: 2, direction: "exit" }],
|
||||||
|
inputs: [{ input: 1, role: "button", relay: 2 }],
|
||||||
|
});
|
||||||
|
expect(relayForButton(db, CTL, 1)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,35 +10,74 @@ export type Direction = "entry" | "exit" | "both";
|
|||||||
/** A concrete flow a credential/button drives (never "both"). */
|
/** A concrete flow a credential/button drives (never "both"). */
|
||||||
export type FlowDirection = "entry" | "exit";
|
export type FlowDirection = "entry" | "exit";
|
||||||
|
|
||||||
/** One relay on an access controller: which barrier it opens, in which direction,
|
/** The EVENT a relay reacts to. The barrier events (entry/exit/both) `pulseOpen`; the
|
||||||
* and (optionally) the input terminals its entry button + presence loop are wired to. */
|
* `radarAlert` event drives a non-barrier alert lamp (blink while the trigger input is
|
||||||
|
* active, locked SOLID by the camera). A relay is "when EVENT X happens, do its action" —
|
||||||
|
* the action is implied by the event. See wiki/concepts/button-light-indicator.md. */
|
||||||
|
export type RelayEvent = Direction | "radarAlert";
|
||||||
|
|
||||||
|
/** What a controller input terminal MEANS. `button` = a transient-entry button; `presence`
|
||||||
|
* = a one-car-one-ticket sensor (induction loop or radar); `alertTrigger` = the edge that
|
||||||
|
* starts a `radarAlert` lamp blinking. See wiki/concepts/entry-double-press.md. */
|
||||||
|
export type InputRole = "button" | "presence" | "alertTrigger";
|
||||||
|
|
||||||
|
/** One INPUT terminal the host reads, as a first-class citizen (the twin of RelaySpec).
|
||||||
|
* An exit radar is just another `presence` row serving the exit relay. */
|
||||||
|
export interface InputSpec {
|
||||||
|
/** 1-based input terminal the host reads. */
|
||||||
|
readonly input: number;
|
||||||
|
readonly role: InputRole;
|
||||||
|
/** The barrier relay this input serves. Required for `button`/`presence` (the gate is
|
||||||
|
* keyed per relay); optional for `alertTrigger` (a standalone lamp trigger). */
|
||||||
|
readonly relay?: number;
|
||||||
|
/** `presence` only — induction LOOP or RADAR. Label only (gate is identical). Default loop. */
|
||||||
|
readonly kind?: "loop" | "radar";
|
||||||
|
/** This terminal is ACTIVE-LOW (idles HIGH) — e.g. a radar wired opposite the button.
|
||||||
|
* Maps to the driver's per-input `inputActiveLow`. See wiki/entities/hikvision-radar.md. */
|
||||||
|
readonly activeLow?: boolean;
|
||||||
|
/** `button` only — presence-less fallback: suppress repeat presses for N seconds after a
|
||||||
|
* ticket. A timer (mitigation, not a guarantee); used when no `presence` row serves this relay. */
|
||||||
|
readonly cooldownSec?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One relay on an access controller: the event it reacts to. Input wiring (button,
|
||||||
|
* presence) lives in `config.inputs[]`; the LEGACY per-relay fields below are still read
|
||||||
|
* (back-compat) but no longer written by the UI. */
|
||||||
export interface RelaySpec {
|
export interface RelaySpec {
|
||||||
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
|
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
|
||||||
readonly relay: number;
|
readonly relay: number;
|
||||||
readonly direction: Direction;
|
/** The event this relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` →
|
||||||
/** 1-based input terminal of the entry button that fires this relay (transient
|
* drive an alert lamp (blink + camera-lock) via `setAux`, NEVER pulseOpen. */
|
||||||
* entry). Absent = no button at this barrier (subscriber/reader-driven only). */
|
readonly direction: RelayEvent;
|
||||||
|
|
||||||
|
// ── LEGACY input fields (read-only back-compat; superseded by config.inputs[]) ──
|
||||||
|
// Pre-inputs[] configs wired the entry button + presence sensor here. `inputsOf()`
|
||||||
|
// synthesizes InputSpec rows from these when a controller has no `inputs[]` yet.
|
||||||
readonly button?: number;
|
readonly button?: number;
|
||||||
/**
|
|
||||||
* Anti-double-press for the transient entry button (one car must yield ONE ticket).
|
|
||||||
* Two modes, chosen by what barrier feedback exists at this lane:
|
|
||||||
* - PRESENCE (preferred, when a vehicle loop is wired): `presenceInput` = the
|
|
||||||
* 1-based input terminal of an induction loop / barrier presence signal on THIS
|
|
||||||
* controller. A press prints only while a car is present, and no second ticket
|
|
||||||
* issues until the loop CLEARS (car drove in) and a new car re-occupies it. This
|
|
||||||
* makes one-car-one-ticket physical.
|
|
||||||
* - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses
|
|
||||||
* on this relay for N seconds after a ticket prints. A pure timer — mitigation,
|
|
||||||
* not a guarantee. Used when `presenceInput` is unset (or as a secondary guard).
|
|
||||||
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
|
|
||||||
*/
|
|
||||||
readonly presenceInput?: number;
|
readonly presenceInput?: number;
|
||||||
|
readonly presenceKind?: "loop" | "radar";
|
||||||
|
readonly presenceActiveLow?: boolean;
|
||||||
readonly entryCooldownSec?: number;
|
readonly entryCooldownSec?: number;
|
||||||
|
|
||||||
|
// ── radarAlert-only (direction === "radarAlert") ──
|
||||||
|
// A non-barrier indicator lamp wired to this (spare) relay — e.g. the entry button's
|
||||||
|
// 12 V light. Driven by the server ButtonLightController off its trigger input vs. the
|
||||||
|
// camera lane status: blink while the trigger is active + lane free, SOLID once the
|
||||||
|
// camera confirms a car, OFF otherwise. NOT a barrier (uses setAux, never pulseOpen).
|
||||||
|
/** 1-based input terminal whose active edge starts the blink (the radar). */
|
||||||
|
readonly triggerInput?: number;
|
||||||
|
/** Which lane's camera locks this lamp SOLID — the entry or the exit camera. Default
|
||||||
|
* "entry". An exit radar's lamp must lock on the EXIT camera. */
|
||||||
|
readonly lockLane?: FlowDirection;
|
||||||
|
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
|
||||||
|
readonly blinkOnMs?: number;
|
||||||
|
readonly blinkOffMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Access controller config (the `relays[]` map + connection fields). */
|
/** Access controller config (the `relays[]` + `inputs[]` maps + connection fields). */
|
||||||
interface AccessConfig {
|
interface AccessConfig {
|
||||||
readonly relays?: RelaySpec[];
|
readonly relays?: RelaySpec[];
|
||||||
|
readonly inputs?: InputSpec[];
|
||||||
readonly [k: string]: unknown;
|
readonly [k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +99,11 @@ export interface ResolvedRelay {
|
|||||||
readonly controller: DeviceRow;
|
readonly controller: DeviceRow;
|
||||||
readonly relay: number;
|
readonly relay: number;
|
||||||
readonly direction: Direction;
|
readonly direction: Direction;
|
||||||
/** 1-based presence-loop input gating this relay's entry (when wired). */
|
/** 1-based presence input gating this relay's entry (loop or radar, when wired). */
|
||||||
readonly presenceInput?: number;
|
readonly presenceInput?: number;
|
||||||
/** Cooldown seconds suppressing repeat presses (fallback when no presence loop). */
|
/** Sensor kind on the presence input (loop|radar) — telemetry/label only. */
|
||||||
|
readonly presenceKind?: "loop" | "radar";
|
||||||
|
/** Cooldown seconds suppressing repeat presses (fallback when no presence input). */
|
||||||
readonly entryCooldownSec?: number;
|
readonly entryCooldownSec?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +124,49 @@ export function relaysOf(row: DeviceRow): RelaySpec[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a button press to the relay it fires: the access controller with this
|
* The INPUT terminals declared on an access controller — the back-compat keystone. Returns
|
||||||
* deviceId, and the relay whose `button` terminal matches the pressed input. Only
|
* `config.inputs[]` when present; otherwise SYNTHESIZES InputSpec rows from the LEGACY
|
||||||
* an ENTRY (or both) relay is a transient-entry trigger. Returns null otherwise.
|
* per-relay fields (`relays[].button` → a `button` row; `relays[].presenceInput` → a
|
||||||
|
* `presence` row) so a pre-inputs[] controller resolves identically. Everything that reads
|
||||||
|
* inputs goes through here, so the legacy fold lives in exactly one place.
|
||||||
|
*/
|
||||||
|
export function inputsOf(row: DeviceRow): InputSpec[] {
|
||||||
|
const cfg = row.config as AccessConfig;
|
||||||
|
if (Array.isArray(cfg.inputs) && cfg.inputs.length > 0) return cfg.inputs;
|
||||||
|
const synth: InputSpec[] = [];
|
||||||
|
for (const r of relaysOf(row)) {
|
||||||
|
if (typeof r.button === "number") {
|
||||||
|
synth.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec });
|
||||||
|
}
|
||||||
|
if (typeof r.presenceInput === "number") {
|
||||||
|
synth.push({
|
||||||
|
input: r.presenceInput,
|
||||||
|
role: "presence",
|
||||||
|
relay: r.relay,
|
||||||
|
kind: r.presenceKind ?? "loop",
|
||||||
|
activeLow: r.presenceActiveLow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return synth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The barrier RelaySpec a `button`/`presence` input row serves (its `relay`), or null —
|
||||||
|
* only entry/both relays gate transient entry. Narrows `direction` to a barrier Direction. */
|
||||||
|
function barrierForInput(row: DeviceRow, spec: InputSpec): (RelaySpec & { direction: Direction }) | null {
|
||||||
|
if (typeof spec.relay !== "number") return null;
|
||||||
|
const relay = relaysOf(row).find((r) => r.relay === spec.relay);
|
||||||
|
if (!relay) return null;
|
||||||
|
if (relay.direction !== "entry" && relay.direction !== "both") return null;
|
||||||
|
return { ...relay, direction: relay.direction };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a button press to the relay it fires: the access controller with this deviceId,
|
||||||
|
* and the relay served by the `button` input on this terminal (via inputsOf). Only an
|
||||||
|
* ENTRY (or both) relay is a transient-entry trigger. Carries the one-car-one-ticket
|
||||||
|
* config (presence input + cooldown) for that relay so the entry flow can enforce it.
|
||||||
|
* Returns null otherwise.
|
||||||
*/
|
*/
|
||||||
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
||||||
const row = db
|
const row = db
|
||||||
@@ -94,23 +175,28 @@ export function relayForButton(db: Db, controllerId: string, terminal: number):
|
|||||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||||
.get();
|
.get();
|
||||||
if (!row || !row.enabled) return null;
|
if (!row || !row.enabled) return null;
|
||||||
const spec = relaysOf(row).find((r) => r.button === terminal);
|
const inputs = inputsOf(row);
|
||||||
if (!spec) return null;
|
const btn = inputs.find((i) => i.role === "button" && i.input === terminal);
|
||||||
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
if (!btn) return null;
|
||||||
|
const relay = barrierForInput(row, btn);
|
||||||
|
if (!relay) return null;
|
||||||
|
// The presence sensor (if any) serving the SAME relay supplies the gate.
|
||||||
|
const presence = inputs.find((i) => i.role === "presence" && i.relay === relay.relay);
|
||||||
return {
|
return {
|
||||||
controller: row,
|
controller: row,
|
||||||
relay: spec.relay,
|
relay: relay.relay,
|
||||||
direction: spec.direction,
|
direction: relay.direction,
|
||||||
presenceInput: spec.presenceInput,
|
presenceInput: presence?.input,
|
||||||
entryCooldownSec: spec.entryCooldownSec,
|
presenceKind: presence?.kind ?? "loop",
|
||||||
|
entryCooldownSec: btn.cooldownSec,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a PRESENCE-LOOP input edge to the entry relay it gates: the controller with
|
* Resolve a PRESENCE input edge to the entry relay it gates: the controller with this
|
||||||
* this deviceId, and the relay whose `presenceInput` terminal matches the fired input.
|
* deviceId, and the relay served by the `presence` input on this terminal. Lets the entry
|
||||||
* Lets the entry flow track "a car is physically at this entry barrier" so it issues
|
* flow track "a car is physically at this entry barrier" so it issues exactly one ticket
|
||||||
* exactly one ticket per car. Only entry/both relays gate transient entry. Null otherwise.
|
* per car. Only entry/both relays gate transient entry. Null otherwise.
|
||||||
*/
|
*/
|
||||||
export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
||||||
const row = db
|
const row = db
|
||||||
@@ -119,10 +205,43 @@ export function relayForPresence(db: Db, controllerId: string, terminal: number)
|
|||||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||||
.get();
|
.get();
|
||||||
if (!row || !row.enabled) return null;
|
if (!row || !row.enabled) return null;
|
||||||
const spec = relaysOf(row).find((r) => r.presenceInput === terminal);
|
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
|
||||||
if (!spec) return null;
|
if (!presence) return null;
|
||||||
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
const relay = barrierForInput(row, presence);
|
||||||
return { controller: row, relay: spec.relay, direction: spec.direction };
|
if (!relay) return null;
|
||||||
|
return {
|
||||||
|
controller: row,
|
||||||
|
relay: relay.relay,
|
||||||
|
direction: relay.direction,
|
||||||
|
presenceInput: presence.input,
|
||||||
|
presenceKind: presence.kind ?? "loop",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The alert (radarAlert) relay rows declared on an access controller — the lamps the
|
||||||
|
* ButtonLightController drives. Each is a `relays[]` row whose event is `radarAlert`. */
|
||||||
|
export function alertRelaysOf(row: DeviceRow): RelaySpec[] {
|
||||||
|
return relaysOf(row).filter((r) => r.direction === "radarAlert" && typeof r.relay === "number");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which LANE a presence input belongs to — for the booth's barrier-light blink (advisory).
|
||||||
|
* Unlike `relayForPresence` (entry-gated, for the one-car-one-ticket gate), this resolves a
|
||||||
|
* presence input on ANY barrier: entry/both → "entry", exit → "exit". Returns null if the
|
||||||
|
* terminal isn't a presence input on a barrier relay. See lane-presence.ts.
|
||||||
|
*/
|
||||||
|
export function presenceLaneOf(db: Db, controllerId: string, terminal: number): FlowDirection | null {
|
||||||
|
const row = db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||||
|
.get();
|
||||||
|
if (!row || !row.enabled) return null;
|
||||||
|
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
|
||||||
|
if (!presence || typeof presence.relay !== "number") return null;
|
||||||
|
const relay = relaysOf(row).find((r) => r.relay === presence.relay);
|
||||||
|
if (!relay) return null;
|
||||||
|
return relay.direction === "exit" ? "exit" : relay.direction === "radarAlert" ? null : "entry";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,7 +263,10 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
|
|||||||
.get();
|
.get();
|
||||||
if (controller && controller.enabled) {
|
if (controller && controller.enabled) {
|
||||||
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
|
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
|
||||||
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
// Only a barrier relay opens; an alert (radarAlert) relay is never a barrier.
|
||||||
|
if (spec && spec.direction !== "radarAlert") {
|
||||||
|
return { controller, relay: spec.relay, direction: spec.direction };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -164,7 +286,8 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
|
|||||||
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
|
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
|
||||||
for (const controller of accessRows(db)) {
|
for (const controller of accessRows(db)) {
|
||||||
const spec = relaysOf(controller).find(
|
const spec = relaysOf(controller).find(
|
||||||
(r) => r.direction === direction || r.direction === "both",
|
(r): r is RelaySpec & { direction: Direction } =>
|
||||||
|
r.direction === direction || r.direction === "both",
|
||||||
);
|
);
|
||||||
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { LanePresence } from "./lane-presence.js";
|
||||||
|
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
|
||||||
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// LanePresence: a vehicle-presence INPUT edge (loop/radar) on an entry/exit barrier marks
|
||||||
|
// that lane "present" — the same signal that blinks the physical button lamp (relay 3). It
|
||||||
|
// resolves the edge via relayForPresence (the SAME path relay 3 + the entry gate use), and
|
||||||
|
// emits a lane-presence change only when a lane's present/clear state actually flips.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const CTL = "ctl-1";
|
||||||
|
const ENTRY_RADAR = 2;
|
||||||
|
const EXIT_RADAR = 5;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
// Entry relay 1 with a radar on I2; exit relay 2 with a radar on I5.
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CTL,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
],
|
||||||
|
inputs: [
|
||||||
|
{ input: ENTRY_RADAR, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
{ input: EXIT_RADAR, role: "presence", relay: 2, kind: "radar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
function edge(input: number, on: boolean): void {
|
||||||
|
const e: DeviceInputEvent = {
|
||||||
|
driverId: "dingtian",
|
||||||
|
deviceId: CTL,
|
||||||
|
input,
|
||||||
|
edge: on ? "on" : "off",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source: "poll",
|
||||||
|
};
|
||||||
|
deviceEvents.emitInput(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect lane-presence emissions while running `fn`. */
|
||||||
|
function capture(fn: () => void): LanePresenceEvent[] {
|
||||||
|
const seen: LanePresenceEvent[] = [];
|
||||||
|
const off = deviceEvents.onLanePresence((p) => seen.push(p));
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} finally {
|
||||||
|
off();
|
||||||
|
}
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LanePresence", () => {
|
||||||
|
it("starts clear and snapshots clear", () => {
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
expect(lp.snapshot()).toEqual({ entry: false, exit: false });
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an ENTRY radar edge marks the entry lane present, then clears", () => {
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => {
|
||||||
|
edge(ENTRY_RADAR, true);
|
||||||
|
edge(ENTRY_RADAR, false);
|
||||||
|
});
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ entry: true, exit: false },
|
||||||
|
{ entry: false, exit: false },
|
||||||
|
]);
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an EXIT radar edge marks the exit lane independently", () => {
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => {
|
||||||
|
edge(EXIT_RADAR, true);
|
||||||
|
});
|
||||||
|
expect(events).toEqual([{ entry: false, exit: true }]);
|
||||||
|
expect(lp.snapshot()).toEqual({ entry: false, exit: true });
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("de-dupes: a second 'on' from another presence input on the same lane emits once", () => {
|
||||||
|
// Two radars both serving the entry lane.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
{ input: 3, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CTL))
|
||||||
|
.run();
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => {
|
||||||
|
edge(2, true); // entry → present (emit)
|
||||||
|
edge(3, true); // still present (no emit — same lane)
|
||||||
|
edge(2, false); // still present via I3 (no emit)
|
||||||
|
edge(3, false); // now clear (emit)
|
||||||
|
});
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ entry: true, exit: false },
|
||||||
|
{ entry: false, exit: false },
|
||||||
|
]);
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a non-presence input (e.g. a button terminal)", () => {
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [{ input: 1, role: "button", relay: 1 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CTL))
|
||||||
|
.run();
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => edge(1, true));
|
||||||
|
expect(events).toEqual([]);
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
|
||||||
|
import { presenceLaneOf } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// Per-lane RADAR presence for the booth's barrier lights. A vehicle-presence INPUT
|
||||||
|
// (loop/radar) shorted at an entry/exit barrier means "something is in the lane vicinity"
|
||||||
|
// BEFORE the camera confirms a vehicle. This is the SAME signal that makes the physical
|
||||||
|
// button lamp (relay 3) blink — see button-light.ts (#onInput) — so the on-screen light
|
||||||
|
// and the lamp stay in lockstep: both react to a presence edge resolved the SAME way
|
||||||
|
// (relayForPresence, on an entry/both relay). ADVISORY ONLY: it gates nothing.
|
||||||
|
//
|
||||||
|
// A radar serving an entry (or "both") barrier marks the ENTRY lane present; an exit radar
|
||||||
|
// marks EXIT. The lane is resolved via `presenceLaneOf` (direction-agnostic — unlike the
|
||||||
|
// entry-gated `relayForPresence` the one-car-one-ticket gate uses), so both lanes blink.
|
||||||
|
|
||||||
|
export class LanePresence {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
/** Active presence terminals per lane, keyed `${deviceId}:${input}` (several radars may
|
||||||
|
* serve one lane). A lane is "present" while its set is non-empty. */
|
||||||
|
readonly #entry = new Set<string>();
|
||||||
|
readonly #exit = new Set<string>();
|
||||||
|
#unsub: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(db: Db, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to presence input edges. */
|
||||||
|
start(): void {
|
||||||
|
this.#unsub = deviceEvents.onInput((e) => this.#onInput(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current snapshot (for the WS hello). */
|
||||||
|
snapshot(): LanePresenceEvent {
|
||||||
|
return { entry: this.#entry.size > 0, exit: this.#exit.size > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
#onInput(e: DeviceInputEvent): void {
|
||||||
|
const lane = presenceLaneOf(this.#db, e.deviceId, e.input);
|
||||||
|
if (!lane) return; // not a presence terminal on a barrier relay
|
||||||
|
const key = `${e.deviceId}:${e.input}`;
|
||||||
|
const set = lane === "entry" ? this.#entry : this.#exit;
|
||||||
|
const before = set.size > 0;
|
||||||
|
if (e.edge === "on") set.add(key);
|
||||||
|
else set.delete(key);
|
||||||
|
const after = set.size > 0;
|
||||||
|
if (before !== after) {
|
||||||
|
this.#logger.info(`lane-presence: ${lane} -> ${after ? "present" : "clear"}`);
|
||||||
|
deviceEvents.emitLanePresence(this.snapshot());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsubscribe on shutdown. */
|
||||||
|
stop(): void {
|
||||||
|
this.#unsub?.();
|
||||||
|
this.#unsub = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@ interface ThemeBody {
|
|||||||
theme: Theme;
|
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
|
// 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
|
// 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
|
// (req.user.sub), needs no `user:*` permission, and can't change username, role, or any
|
||||||
@@ -67,6 +74,7 @@ function sessionView(
|
|||||||
roleId: string;
|
roleId: string;
|
||||||
language: string;
|
language: string;
|
||||||
theme: string;
|
theme: string;
|
||||||
|
fontScale: number;
|
||||||
fullName?: string | null;
|
fullName?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
},
|
},
|
||||||
@@ -81,6 +89,7 @@ function sessionView(
|
|||||||
permissions,
|
permissions,
|
||||||
language: user.language,
|
language: user.language,
|
||||||
theme: user.theme,
|
theme: user.theme,
|
||||||
|
fontScale: user.fontScale,
|
||||||
fullName: user.fullName ?? null,
|
fullName: user.fullName ?? null,
|
||||||
email: user.email ?? 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
|
// 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).
|
// touches the caller). Cannot change username or role — those stay admin-only (users.ts).
|
||||||
app.put<{ Body: ProfileBody }>(
|
app.put<{ Body: ProfileBody }>(
|
||||||
|
|||||||
@@ -128,3 +128,57 @@ describe("PUT /api/auth/password (self-service)", () => {
|
|||||||
expect(res.statusCode).toBe(400);
|
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 { ReadDispatcher } from "../read-dispatch.js";
|
||||||
import type { CredentialCapture } from "../credential-capture.js";
|
import type { CredentialCapture } from "../credential-capture.js";
|
||||||
|
|
||||||
// GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our
|
// Dingtian DT-008 QR/RFID reader endpoint. The reader is configured (vendor tool) with
|
||||||
// host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
|
// 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
|
// 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>
|
// 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":""}
|
// server → {"data":[{cardid,cjihao,mjihao,status,time,output}],"code":0,"message":""}
|
||||||
@@ -35,7 +35,7 @@ export async function qrReaderRoutes(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Resolve the lane_devices row whose config.serial matches the reader's reported
|
// 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
|
// 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.)
|
// reader is assigned for that serial. (Small device set → scan in JS.)
|
||||||
const readerRowIdForSerial = (serial: string): string | null => {
|
const readerRowIdForSerial = (serial: string): string | null => {
|
||||||
if (!serial) return null;
|
if (!serial) return null;
|
||||||
@@ -52,7 +52,7 @@ export async function qrReaderRoutes(
|
|||||||
// drive output) once the socket CLOSES — every vendor demo replies
|
// drive output) once the socket CLOSES — every vendor demo replies
|
||||||
// `Connection: close` and shuts the socket. Without it the reader waits out a
|
// `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.
|
// ~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");
|
reply.header("connection", "close");
|
||||||
const cardid = (q.cardid ?? "").trim();
|
const cardid = (q.cardid ?? "").trim();
|
||||||
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
|
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
|
accepted = true; // beep "ok" so the operator knows the card was read
|
||||||
} else {
|
} else {
|
||||||
const read: DeviceReadEvent = {
|
const read: DeviceReadEvent = {
|
||||||
driverId: "gee-qr-reader",
|
driverId: "dingtian-qr-reader",
|
||||||
deviceId,
|
deviceId,
|
||||||
value: cardid,
|
value: cardid,
|
||||||
kind: "qr",
|
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,
|
isCamera,
|
||||||
isDiscoverable,
|
isDiscoverable,
|
||||||
isHardenable,
|
isHardenable,
|
||||||
|
isPrinter,
|
||||||
registerBuiltinDrivers,
|
registerBuiltinDrivers,
|
||||||
registry,
|
registry,
|
||||||
setDeviceLogSink,
|
setDeviceLogSink,
|
||||||
@@ -36,6 +37,11 @@ interface AssignBody {
|
|||||||
interface TestBody {
|
interface TestBody {
|
||||||
driverId: string;
|
driverId: string;
|
||||||
config: Record<string, string | number | boolean>;
|
config: Record<string, string | number | boolean>;
|
||||||
|
/** When editing an EXISTING device, its id — so the test re-merges the stored
|
||||||
|
* machine secrets (relayPassword/pushPassword) the client never received. Without
|
||||||
|
* this, testing an edited device would send no relay password → the device ignores
|
||||||
|
* the probe → a false "offline". Omitted when testing a brand-new device. */
|
||||||
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config keys that hold MACHINE-ONLY secrets — never sent back to the client.
|
// Config keys that hold MACHINE-ONLY secrets — never sent back to the client.
|
||||||
@@ -54,6 +60,41 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connection-identity keys: the fields that decide WHERE a probe is sent. A stored
|
||||||
|
// secret may only be re-merged when these match the stored row — otherwise an admin
|
||||||
|
// could point a test at an attacker host while keeping a real device id and have the
|
||||||
|
// secret sent there (exfiltration). host/port/binaryPort/httpPort cover the Dingtian's
|
||||||
|
// UDP + CGI targets; serial covers serial-bound readers.
|
||||||
|
const IDENTITY_KEYS = ["host", "port", "binaryPort", "httpPort", "serial"] as const;
|
||||||
|
|
||||||
|
/** Stored machine-only secrets (relayPassword/pushPassword) for a device `id`, but ONLY
|
||||||
|
* when the submitted config addresses the SAME device — same driver, and every
|
||||||
|
* connection-identity field (host/port/…) that the submitted config sets equals the
|
||||||
|
* stored value. If the admin redirected the probe (different host/port) or the driver
|
||||||
|
* doesn't match, NO secret is returned: they must re-enter it explicitly. This stops a
|
||||||
|
* redirected test from exfiltrating the secret to an attacker host. */
|
||||||
|
export function storedSecrets(
|
||||||
|
db: Db,
|
||||||
|
id: string,
|
||||||
|
driverId: string,
|
||||||
|
submitted: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const row = db.select().from(devices).where(eq(devices.id, id)).get();
|
||||||
|
if (!row || row.driverId !== driverId) return {};
|
||||||
|
const cfg = row.config as Record<string, unknown>;
|
||||||
|
// Any identity field the client SENT must equal the stored value. (A field the client
|
||||||
|
// omits falls back to the stored device, so it can't be used to redirect.)
|
||||||
|
for (const k of IDENTITY_KEYS) {
|
||||||
|
const sent = submitted[k];
|
||||||
|
if (sent !== undefined && sent !== "" && String(sent) !== String(cfg[k] ?? "")) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const k of SECRET_CONFIG_KEYS) if (cfg[k] !== undefined) out[k] = cfg[k];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Result of the device configure pipeline: a ready-to-persist config, or an
|
/** Result of the device configure pipeline: a ready-to-persist config, or an
|
||||||
* HTTP error to send back. Shared by assign (create) and patch (edit). */
|
* HTTP error to send back. Shared by assign (create) and patch (edit). */
|
||||||
type ConfigureOutcome =
|
type ConfigureOutcome =
|
||||||
@@ -249,13 +290,30 @@ export async function setupRoutes(
|
|||||||
"/api/setup/test",
|
"/api/setup/test",
|
||||||
{ preHandler: adminGuard },
|
{ preHandler: adminGuard },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { driverId, config } = req.body;
|
const { driverId, config, id } = req.body;
|
||||||
const driver = registry.get(driverId);
|
const driver = registry.get(driverId);
|
||||||
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
||||||
|
|
||||||
|
// When editing an existing device, re-merge its stored machine secrets (e.g.
|
||||||
|
// relayPassword) — redacted from the client, so the submitted config omits them.
|
||||||
|
// Submitted values win (an admin can override), but a blank/0 field falls back to
|
||||||
|
// the stored secret so the probe authenticates. Without this, an edited Dingtian
|
||||||
|
// tests with no relay password → false "offline". The submitted-value-wins rule:
|
||||||
|
// only fill a secret from the store when the form didn't send a real one.
|
||||||
|
// Re-merge stored secrets ONLY when this addresses the same device at the same
|
||||||
|
// host/port (storedSecrets enforces identity) — so a redirected probe can't leak
|
||||||
|
// the secret to an attacker host. Submitted values still win.
|
||||||
|
const merged: Record<string, string | number | boolean | undefined> = { ...config };
|
||||||
|
if (id) {
|
||||||
|
for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) {
|
||||||
|
const sent = merged[k];
|
||||||
|
if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let device;
|
let device;
|
||||||
try {
|
try {
|
||||||
device = registry.create(driverId, config);
|
device = registry.create(driverId, merged as Record<string, string | number | boolean>);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reply.code(400).send({ error: (err as Error).message });
|
return reply.code(400).send({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
@@ -334,6 +392,70 @@ export async function setupRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Print a TEST SLIP on a printer config WITHOUT saving. healthCheck only opens the
|
||||||
|
// transport (TCP connect / USB open) — it proves reachability, NOT that paper feeds
|
||||||
|
// and the head fires. This pushes a real short slip through the device-agnostic
|
||||||
|
// printReport(), so the admin can physically confirm the printer is live (the USB
|
||||||
|
// /dev/usb/lpN path or the network printer). Fail-soft like test-anpr: a print error
|
||||||
|
// is reported, never a 500. Mirrors /test's stored-secret re-merge so an edited
|
||||||
|
// network printer still authenticates.
|
||||||
|
app.post<{ Body: TestBody }>(
|
||||||
|
"/api/setup/test-print",
|
||||||
|
{ preHandler: adminGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { driverId, config, id } = req.body;
|
||||||
|
const driver = registry.get(driverId);
|
||||||
|
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
||||||
|
if (driver.category !== "printer") {
|
||||||
|
return reply.code(400).send({ error: `driver ${driverId} is not a printer` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged: Record<string, string | number | boolean | undefined> = { ...config };
|
||||||
|
if (id) {
|
||||||
|
for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) {
|
||||||
|
const sent = merged[k];
|
||||||
|
if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let device;
|
||||||
|
try {
|
||||||
|
device = registry.create(driverId, merged as Record<string, string | number | boolean>);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(400).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
if (!isPrinter(device)) {
|
||||||
|
return reply.code(400).send({ error: `driver ${driverId} cannot print` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
await device.printReport({
|
||||||
|
title: "TEST PRINT",
|
||||||
|
lines: [
|
||||||
|
"Parking System",
|
||||||
|
"Printer test slip",
|
||||||
|
new Date().toLocaleString("sv"), // YYYY-MM-DD HH:MM:SS, locale-stable
|
||||||
|
"",
|
||||||
|
"If you can read this, the",
|
||||||
|
"printer is connected and",
|
||||||
|
"printing correctly.",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// The failure we're testing for (paper out, head fault, transport drop) —
|
||||||
|
// report it, don't 500.
|
||||||
|
return reply.send({
|
||||||
|
ok: false,
|
||||||
|
reason: "print-failed",
|
||||||
|
detail: (err as Error).message,
|
||||||
|
tookMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reply.send({ ok: true, tookMs: Date.now() - startedAt });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Candidate backend IPs the device can push to, for a given device host. The
|
// Candidate backend IPs the device can push to, for a given device host. The
|
||||||
// wizard pre-fills with the on-subnet one and lets the admin override (matters
|
// wizard pre-fills with the on-subnet one and lets the admin override (matters
|
||||||
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import type { Db } from "@parking/db";
|
import type { Db } from "@parking/db";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
import { roleHasPermissions } from "../auth.js";
|
import { roleHasPermissions } from "../auth.js";
|
||||||
import { deviceEvents, type LaneStatusEvent } from "../device-events.js";
|
import {
|
||||||
|
deviceEvents,
|
||||||
|
type LaneStatusEvent,
|
||||||
|
type LanePresenceEvent,
|
||||||
|
type PlateRecognizedEvent,
|
||||||
|
} from "../device-events.js";
|
||||||
import { enrichEvent } from "../event-enrich.js";
|
import { enrichEvent } from "../event-enrich.js";
|
||||||
import type { DeviceMonitor } from "../device-monitor.js";
|
import type { DeviceMonitor } from "../device-monitor.js";
|
||||||
import type { LaneStatus } from "../lane-status.js";
|
import type { LaneStatus } from "../lane-status.js";
|
||||||
|
import type { LanePresence } from "../lane-presence.js";
|
||||||
import { getOccupancy } from "../occupancy.js";
|
import { getOccupancy } from "../occupancy.js";
|
||||||
|
|
||||||
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
|
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
|
||||||
@@ -53,17 +59,26 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OutMsg =
|
type OutMsg =
|
||||||
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown; lanes: LaneStatusEvent }
|
| {
|
||||||
|
kind: "hello";
|
||||||
|
occupancy: ReturnType<typeof getOccupancy>;
|
||||||
|
devices: unknown;
|
||||||
|
lanes: LaneStatusEvent;
|
||||||
|
radar: LanePresenceEvent;
|
||||||
|
}
|
||||||
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
||||||
| { kind: "printer-status"; event: unknown }
|
| { kind: "printer-status"; event: unknown }
|
||||||
| { kind: "device-status"; event: unknown }
|
| { kind: "device-status"; event: unknown }
|
||||||
| { kind: "lane-status"; lanes: LaneStatusEvent };
|
| { kind: "lane-status"; lanes: LaneStatusEvent }
|
||||||
|
| { kind: "lane-presence"; radar: LanePresenceEvent }
|
||||||
|
| { kind: "plate-recognized"; plate: PlateRecognizedEvent };
|
||||||
|
|
||||||
export async function wsRoutes(
|
export async function wsRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
deviceMonitor: DeviceMonitor,
|
deviceMonitor: DeviceMonitor,
|
||||||
laneStatus: LaneStatus,
|
laneStatus: LaneStatus,
|
||||||
|
lanePresence: LanePresence,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
app.get(
|
app.get(
|
||||||
"/api/ws",
|
"/api/ws",
|
||||||
@@ -96,7 +111,13 @@ export async function wsRoutes(
|
|||||||
|
|
||||||
// Initial snapshot so the client renders immediately, before any event:
|
// Initial snapshot so the client renders immediately, before any event:
|
||||||
// occupancy AND the current device-status set (for the footer).
|
// occupancy AND the current device-status set (for the footer).
|
||||||
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot(), lanes: laneStatus.snapshot() });
|
send({
|
||||||
|
kind: "hello",
|
||||||
|
occupancy: getOccupancy(db),
|
||||||
|
devices: deviceMonitor.snapshot(),
|
||||||
|
lanes: laneStatus.snapshot(),
|
||||||
|
radar: lanePresence.snapshot(),
|
||||||
|
});
|
||||||
|
|
||||||
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
||||||
// ledger (cheap fold) so the pushed count is always authoritative.
|
// ledger (cheap fold) so the pushed count is always authoritative.
|
||||||
@@ -117,12 +138,22 @@ export async function wsRoutes(
|
|||||||
const offLane = deviceEvents.onLaneStatus((lanes) => {
|
const offLane = deviceEvents.onLaneStatus((lanes) => {
|
||||||
send({ kind: "lane-status", lanes });
|
send({ kind: "lane-status", lanes });
|
||||||
});
|
});
|
||||||
|
// Lane RADAR presence (presence-input edge → barrier-light blink). Advisory.
|
||||||
|
const offPresence = deviceEvents.onLanePresence((radar) => {
|
||||||
|
send({ kind: "lane-presence", radar });
|
||||||
|
});
|
||||||
|
// 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", () => {
|
socket.on("close", () => {
|
||||||
offLedger();
|
offLedger();
|
||||||
offPrinter();
|
offPrinter();
|
||||||
offDevice();
|
offDevice();
|
||||||
offLane();
|
offLane();
|
||||||
|
offPresence();
|
||||||
|
offPlate();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||||
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
|
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
|
||||||
import { deviceEvents } from "./device-events.js";
|
import { deviceEvents } from "./device-events.js";
|
||||||
|
import { ButtonLightController } from "./button-light.js";
|
||||||
import { EntryFlow } from "./entry-flow.js";
|
import { EntryFlow } from "./entry-flow.js";
|
||||||
import { EventLog } from "./event-log.js";
|
import { EventLog } from "./event-log.js";
|
||||||
import { ExitFlow } from "./exit-flow.js";
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
@@ -19,6 +20,7 @@ import { PrinterMonitor } from "./printer-monitor.js";
|
|||||||
import { DeviceMonitor } from "./device-monitor.js";
|
import { DeviceMonitor } from "./device-monitor.js";
|
||||||
import { buildSigner, buildVerifier } from "./signer.js";
|
import { buildSigner, buildVerifier } from "./signer.js";
|
||||||
import { LogService, pinoDbStream } from "./log-service.js";
|
import { LogService, pinoDbStream } from "./log-service.js";
|
||||||
|
import { pruneSnapshots } from "./snapshot-retention.js";
|
||||||
import { logRoutes } from "./routes/logs.js";
|
import { logRoutes } from "./routes/logs.js";
|
||||||
import { VisionClient } from "./vision-client.js";
|
import { VisionClient } from "./vision-client.js";
|
||||||
import { authRoutes } from "./routes/auth.js";
|
import { authRoutes } from "./routes/auth.js";
|
||||||
@@ -27,6 +29,7 @@ import { roleRoutes } from "./routes/roles.js";
|
|||||||
import { deviceRoutes } from "./routes/devices.js";
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
|
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
|
||||||
import { LaneStatus } from "./lane-status.js";
|
import { LaneStatus } from "./lane-status.js";
|
||||||
|
import { LanePresence } from "./lane-presence.js";
|
||||||
import { AnprBridge } from "./anpr-entry.js";
|
import { AnprBridge } from "./anpr-entry.js";
|
||||||
import { eventRoutes } from "./routes/events.js";
|
import { eventRoutes } from "./routes/events.js";
|
||||||
import { reportRoutes } from "./routes/reports.js";
|
import { reportRoutes } from "./routes/reports.js";
|
||||||
@@ -124,6 +127,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
const laneStatus = new LaneStatus(db, app.log);
|
const laneStatus = new LaneStatus(db, app.log);
|
||||||
app.addHook("onClose", async () => laneStatus.stop());
|
app.addHook("onClose", async () => laneStatus.stop());
|
||||||
|
|
||||||
|
// Per-lane RADAR presence (presence-input edges → barrier-light blink). Mirrors the
|
||||||
|
// physical button lamp (relay 3): the SAME presence signal, surfaced to the booth UI.
|
||||||
|
const lanePresence = new LanePresence(db, app.log);
|
||||||
|
lanePresence.start();
|
||||||
|
app.addHook("onClose", async () => lanePresence.stop());
|
||||||
|
|
||||||
// NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read
|
// NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read
|
||||||
// flows are constructed — because the ANPR bridge they carry depends on the
|
// flows are constructed — because the ANPR bridge they carry depends on the
|
||||||
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
|
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
|
||||||
@@ -168,7 +177,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
|
|
||||||
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
||||||
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
||||||
await wsRoutes(app, db, deviceMonitor, laneStatus);
|
await wsRoutes(app, db, deviceMonitor, laneStatus, lanePresence);
|
||||||
|
|
||||||
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
||||||
await snapshotRoutes(app, db);
|
await snapshotRoutes(app, db);
|
||||||
@@ -188,6 +197,13 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeEntry());
|
app.addHook("onClose", async () => unsubscribeEntry());
|
||||||
|
|
||||||
|
// Button-light indicator: drives the entry button's lamp on a spare relay from the
|
||||||
|
// RADAR input vs. the camera lane status (blink = radar-only, solid = radar+camera,
|
||||||
|
// off otherwise). A non-barrier aux output; fails OFF. See button-light.ts.
|
||||||
|
const buttonLight = new ButtonLightController(db, app.log);
|
||||||
|
buttonLight.start();
|
||||||
|
app.addHook("onClose", async () => buttonLight.stop());
|
||||||
|
|
||||||
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
||||||
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
|
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
|
||||||
// transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
|
// transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
|
||||||
@@ -218,10 +234,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
|
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
|
||||||
const credentialCapture = new CredentialCapture();
|
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
|
// 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
|
// 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);
|
await qrReaderRoutes(app, db, readDispatcher, credentialCapture);
|
||||||
|
|
||||||
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
||||||
@@ -268,6 +284,23 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
logService.prune(); // once at startup
|
logService.prune(); // once at startup
|
||||||
app.addHook("onClose", async () => clearInterval(pruneTimer));
|
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));
|
||||||
|
|
||||||
// Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the
|
// 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
|
// retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs
|
||||||
// every 6h, unref'd, plus once at startup. See recycle-bin.ts.
|
// every 6h, unref'd, plus once at startup. See recycle-bin.ts.
|
||||||
|
|||||||
@@ -548,8 +548,9 @@ export class ShiftService {
|
|||||||
"",
|
"",
|
||||||
"-- Arkëtime sipas burimit --",
|
"-- Arkëtime sipas burimit --",
|
||||||
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
|
`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}`,
|
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
||||||
` shitje: ${money(r.subscriptionSalesMinor)} ${cur}`,
|
|
||||||
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
||||||
"",
|
"",
|
||||||
"-- Arka --",
|
"-- 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 { randomUUID } from "node:crypto";
|
||||||
|
import sharp from "sharp";
|
||||||
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
||||||
import { registry, type CameraDevice } from "@parking/devices";
|
import { registry, type CameraDevice, type Snapshot } from "@parking/devices";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
||||||
|
import { deviceEvents } from "./device-events.js";
|
||||||
import type { VisionClient } from "./vision-client.js";
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
|
||||||
// Camera snapshot capture, fired AFTER the barrier opens and never awaited on the
|
// 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 —
|
// 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
|
// 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.
|
// 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 {
|
interface SnapshotJob {
|
||||||
readonly db: Db;
|
readonly db: Db;
|
||||||
@@ -62,16 +101,21 @@ export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const shot = await camera.captureSnapshot({ direction });
|
// Shared capture: if the ANPR bridge just pulled this camera's frame for the
|
||||||
|
// same vehicle, reuse it instead of a 2nd concurrent GET (which 503s).
|
||||||
|
const shot = await captureSnapshotShared(row.id, camera, { direction });
|
||||||
const id: string = randomUUID();
|
const id: string = randomUUID();
|
||||||
|
// 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)
|
db.insert(snapshots)
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
direction,
|
direction,
|
||||||
deviceId: row.id,
|
deviceId: row.id,
|
||||||
identity,
|
identity,
|
||||||
contentType: shot.contentType,
|
contentType: stored.contentType,
|
||||||
bytes: shot.bytes,
|
bytes: stored.bytes,
|
||||||
capturedAt: shot.capturedAt,
|
capturedAt: shot.capturedAt,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
@@ -136,6 +180,9 @@ async function recognizePlate(
|
|||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
logger.info(`anpr plate '${plate}' (${result.plate.confidence.toFixed(3)}) for ${identity}`);
|
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) {
|
} catch (err) {
|
||||||
logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`);
|
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(
|
function recordFailure(
|
||||||
db: Db,
|
db: Db,
|
||||||
direction: FlowDirection,
|
direction: FlowDirection,
|
||||||
|
|||||||
@@ -310,6 +310,15 @@ export class SubscriptionFlow {
|
|||||||
* subscription, (b) pick which occurrence a read closes, and (c) enforce
|
* subscription, (b) pick which occurrence a read closes, and (c) enforce
|
||||||
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
|
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
|
||||||
*/
|
*/
|
||||||
|
/** How many occurrences this subscription currently has OPEN (entries not yet exited).
|
||||||
|
* Public so the ANPR bridge can detect a credential (card/QR) exit landing mid-poll — if
|
||||||
|
* the count drops while it's polling, the subscriber already transacted and the bridge must
|
||||||
|
* NOT also emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. for
|
||||||
|
* a fleet sub). See anpr-entry.ts. */
|
||||||
|
openOccurrenceCount(subscriptionId: string): number {
|
||||||
|
return this.#openOccurrences(subscriptionId).length;
|
||||||
|
}
|
||||||
|
|
||||||
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
|
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
|
||||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
// Net entries−exits per occurrence identity, keeping the entry order (oldest first).
|
// Net entries−exits per occurrence identity, keeping the entry order (oldest first).
|
||||||
|
|||||||
@@ -3,14 +3,16 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"//": "Thin shim so this Python service is a first-class node in the Turbo task graph (it is NOT a JS package — deps are managed by uv/pyproject.toml). Each script shells to Python tooling. See wiki/decisions/vision-service-packaging.md.",
|
"//": "Thin shim so this Python service is a first-class node in the Turbo task graph (it is NOT a JS package — deps are managed by uv/pyproject.toml). Each script shells to Python tooling. See wiki/decisions/vision-service-packaging.md.",
|
||||||
|
"//alpr": "DEV self-heals real ANPR: `dev`/`start` run `uv sync --extra alpr` FIRST, because a plain `uv run` re-resolves the venv to the lockfile DEFAULTS and STRIPS fast-alpr (the cause of silent 'snapshot but no plate' after a prior pnpm dev). Syncing the extra here guarantees the recognizer survives every run. Use `dev:stub` for a lean, model-free local run. The BOOTH is unaffected — it runs the Docker image, which bakes `--extra alpr` at build (see Dockerfile + docker-compose.prod.yml).",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
|
"dev": "uv sync --extra alpr && uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
|
||||||
"start": "uv run uvicorn vision_service.app:app --host 0.0.0.0 --port 8089",
|
"dev:stub": "uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
|
||||||
|
"start": "uv sync --extra alpr && uv run uvicorn vision_service.app:app --host 0.0.0.0 --port 8089",
|
||||||
"lint": "uv run ruff check .",
|
"lint": "uv run ruff check .",
|
||||||
"format": "uv run ruff format .",
|
"format": "uv run ruff format .",
|
||||||
"typecheck": "uv run mypy vision_service",
|
"typecheck": "uv run mypy vision_service",
|
||||||
"test": "uv run pytest -q",
|
"test": "uv run pytest -q",
|
||||||
"recognize": "uv run python -m vision_service.cli",
|
"recognize": "uv sync --extra alpr && uv run python -m vision_service.cli",
|
||||||
"build": "echo 'no build step (Python service; models fetched at deploy)'"
|
"build": "echo 'no build step (Python service; models fetched at deploy)'"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
# Production build env for the SPA (auto-loaded by `vite build`, which the Tauri
|
# Production build env for the SPA (auto-loaded by `vite build`). NOT loaded by `vite` dev.
|
||||||
# desktop bundle runs via beforeBuildCommand). NOT loaded by `vite` dev.
|
|
||||||
#
|
#
|
||||||
# The desktop shell serves the bundled SPA from tauri://localhost (no proxy, not
|
# RELATIVE /api base (empty value). The booth serves the SPA same-origin (Fastify serves
|
||||||
# same-origin), so the SPA must reach Fastify by absolute origin. This is the
|
# dist/, reached via Caddy on :80), so requests must stay relative — baking an absolute
|
||||||
# appliance's local Fastify address. Not a secret — committed for reproducible
|
# origin here would point the browser at the wrong host. This matches the deploy
|
||||||
# desktop builds. Override per-deployment if Fastify binds elsewhere.
|
# (wiki/decisions/container-deployment.md "Web access"; the 77b2acb fix).
|
||||||
#
|
#
|
||||||
# NOTE: a plain browser prod build (Fastify serving dist/ same-origin) does NOT
|
# DESKTOP (Tauri) NOTE: the desktop shell serves the SPA from tauri://localhost (no proxy,
|
||||||
# want this set. If you build the SPA for that, override VITE_API_BASE="" .
|
# not same-origin) and DOES need an absolute Fastify origin — but the desktop app is a
|
||||||
VITE_API_BASE=http://127.0.0.1:3000
|
# DEFERRED, separate task (it's currently hardcoded to localhost:3000; see apps/desktop +
|
||||||
|
# the desktop-app-hardcoded-localhost note). When that work resumes, set VITE_API_BASE to
|
||||||
|
# the appliance's Fastify origin for the desktop build only (e.g. via apps/desktop or an
|
||||||
|
# exported override), NOT here.
|
||||||
|
VITE_API_BASE=
|
||||||
|
|||||||
@@ -26,26 +26,8 @@ import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
|||||||
// reconciles via the pay/exit modal — never a free barrier open.
|
// reconciles via the pay/exit modal — never a free barrier open.
|
||||||
// See wiki/concepts/booth-exit-flow.md.
|
// See wiki/concepts/booth-exit-flow.md.
|
||||||
|
|
||||||
type StatusFilter = "unpaid" | "paid" | "exiting" | "overstay";
|
|
||||||
type KindFilter = "transient" | "subscription";
|
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 }) {
|
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
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);
|
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 [search, setSearch] = useState("");
|
||||||
const [status, setStatus] = useState<StatusFilter | "">("");
|
|
||||||
const [kind, setKind] = useState<KindFilter | "">("");
|
const [kind, setKind] = useState<KindFilter | "">("");
|
||||||
|
|
||||||
const sessions = useMemo(() => data?.sessions ?? [], [data]);
|
const sessions = useMemo(() => data?.sessions ?? [], [data]);
|
||||||
@@ -81,21 +63,15 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
return sessions.filter((s) => {
|
return sessions.filter((s) => {
|
||||||
if (kind === "transient" && s.subscription) return false;
|
if (kind === "transient" && s.subscription) return false;
|
||||||
if (kind === "subscription" && !s.subscription) return false;
|
if (kind === "subscription" && !s.subscription) return false;
|
||||||
if (status && statusOf(s) !== status) return false;
|
|
||||||
if (q) {
|
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;
|
if (!hay.includes(q)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
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>[] = [
|
const kindOpts: SegOption<KindFilter>[] = [
|
||||||
{ value: "transient", label: t("booth.fKindTransient") },
|
{ value: "transient", label: t("booth.fKindTransient") },
|
||||||
{ value: "subscription", label: t("booth.fKindSubscription") },
|
{ value: "subscription", label: t("booth.fKindSubscription") },
|
||||||
@@ -119,7 +95,7 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
<Panel
|
<Panel
|
||||||
title={t("booth.activeSessions")}
|
title={t("booth.activeSessions")}
|
||||||
right={
|
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}
|
||||||
{filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")}
|
{filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")}
|
||||||
</span>
|
</span>
|
||||||
@@ -128,7 +104,6 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
>
|
>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<FilterBar search={search} onSearch={setSearch} searchPlaceholder={t("booth.filterSearchSessions")}>
|
<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")} />
|
<SegGroup value={kind} options={kindOpts} onChange={setKind} allLabel={t("booth.filterAll")} />
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
@@ -142,70 +117,87 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
: t("booth.noMatch")}
|
: t("booth.noMatch")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((s) => {
|
// A real table — aligned columns (who · plate · entry · elapsed · action). No
|
||||||
const badge = statusBadge(s);
|
// status column: an unpaid transient is the normal case, and a subscriber is
|
||||||
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
|
// already marked with ★ + holder name. Overstay (a top-up is owed) keeps a row
|
||||||
return (
|
// tint so that fraud-relevant signal isn't lost. The whole row is clickable
|
||||||
<div
|
// (→ pay/exit modal); the trailing cell holds the audited Open-barrier action.
|
||||||
key={s.identity}
|
<table className="w-full text-[0.75rem] tabular-nums">
|
||||||
className="flex items-center gap-3 border-b border-term-border/50 py-1.5 text-[12px] tabular-nums"
|
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
>
|
<tr>
|
||||||
<button
|
<th className="px-2 py-1.5 text-left font-semibold">{t("booth.colWho")}</th>
|
||||||
type="button"
|
<th className="px-2 py-1.5 text-left font-semibold">{t("booth.colPlate")}</th>
|
||||||
onClick={() => onPick(s.identity)}
|
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colEntry")}</th>
|
||||||
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
|
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colElapsed")}</th>
|
||||||
title={t("booth.openPayExit")}
|
<th className="px-2 py-1.5" />
|
||||||
>
|
</tr>
|
||||||
<span className="text-term-text">
|
</thead>
|
||||||
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
|
<tbody>
|
||||||
</span>
|
{filtered.map((s) => {
|
||||||
{s.plate && (
|
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
|
||||||
<span
|
// Paid-and-in-grace TRANSIENT only: an audited re-pulse for a car that paid
|
||||||
className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber"
|
// but the barrier didn't confirm. NOT overstay (owes a top-up → modal) and
|
||||||
title={t("booth.plateTitle")}
|
// NOT a subscription (assist-open lives in the modal). An unpaid transient
|
||||||
>
|
// gets no button (no-unpaid-bypass). Mirrors reopenBarrier's server guard.
|
||||||
{s.plate}
|
const canReopen = s.paidAt && !s.overstay && !s.subscription;
|
||||||
</span>
|
return (
|
||||||
)}
|
<tr
|
||||||
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span>
|
key={s.identity}
|
||||||
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
|
onClick={() => onPick(s.identity)}
|
||||||
<span
|
className={`cursor-pointer border-t border-term-border/50 hover:bg-term-panel-2 ${
|
||||||
className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}
|
s.overstay ? "bg-term-red/5" : ""
|
||||||
title={badge.titleKey ? t(badge.titleKey) : undefined}
|
}`}
|
||||||
|
title={t("booth.openPayExit")}
|
||||||
>
|
>
|
||||||
{t(badge.key)}
|
<td className="px-2 py-1.5 text-term-text">
|
||||||
</span>
|
{s.subscription ? (
|
||||||
</button>
|
<span className="text-term-cyan">★ {s.subscriptionHolder ?? t("subs.unnamed")}</span>
|
||||||
|
) : (
|
||||||
{/* Open barrier — PAID-and-still-in-grace TRANSIENT only: an audited
|
s.identity
|
||||||
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)
|
</td>
|
||||||
and NOT a SUBSCRIPTION (the assist-open, and any out-of-window payment,
|
<td className="px-2 py-1.5">
|
||||||
live in the pay/exit modal — the list must not offer a one-click open,
|
{s.plate && (
|
||||||
which would bypass an unpaid window charge). An unpaid transient has no
|
<span
|
||||||
button either (no-unpaid-bypass). Mirrors reopenBarrier's server guard. */}
|
className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber"
|
||||||
{s.paidAt && !s.overstay && !s.subscription ? (
|
title={t("booth.plateTitle")}
|
||||||
<button
|
>
|
||||||
type="button"
|
{s.plate}
|
||||||
disabled={reopen.isPending || !shiftReady}
|
</span>
|
||||||
onClick={() => handleReopen(s)}
|
)}
|
||||||
className="btn btn-pay btn-sm shrink-0"
|
</td>
|
||||||
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
|
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
|
||||||
>
|
{formatRelativeDateTime(s.enteredAt, t)}
|
||||||
{t("booth.openBarrier")}
|
</td>
|
||||||
</button>
|
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
|
||||||
) : (
|
{formatDuration(s.enteredAt, new Date().toISOString())}
|
||||||
<span className="w-[88px] shrink-0" />
|
</td>
|
||||||
)}
|
<td className="px-2 py-1.5 text-right">
|
||||||
|
{canReopen && (
|
||||||
{msg && (
|
<button
|
||||||
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
type="button"
|
||||||
{msg.text}
|
disabled={reopen.isPending || !shiftReady}
|
||||||
</span>
|
onClick={(e) => {
|
||||||
)}
|
e.stopPropagation(); // don't also open the pay/exit modal
|
||||||
</div>
|
void handleReopen(s);
|
||||||
);
|
}}
|
||||||
})
|
className="btn btn-pay btn-sm"
|
||||||
|
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
|
||||||
|
>
|
||||||
|
{t("booth.openBarrier")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{msg && (
|
||||||
|
<span className={`ml-2 text-[0.625rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
||||||
|
{msg.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fetchMe, type SessionUser } from "./api.js";
|
|||||||
import { Login } from "./Login.js";
|
import { Login } from "./Login.js";
|
||||||
import { queryClient } from "./lib/query.js";
|
import { queryClient } from "./lib/query.js";
|
||||||
import { setLanguage } from "./lib/i18n/index.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";
|
import { router } from "./router.js";
|
||||||
|
|
||||||
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
||||||
@@ -24,15 +24,17 @@ export function App() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Apply the signed-in user's preferred language + theme whenever they resolve/
|
// Apply the signed-in user's preferred language + theme + font scale whenever they
|
||||||
// change (login, bootstrap, or a toggle). Albanian + dark are the defaults before
|
// resolve/change (login, bootstrap, or a toggle). Albanian + dark + 100% are the defaults
|
||||||
// auth resolves; on logout, fall back to dark so the Login screen is consistent.
|
// before auth resolves; on logout, fall back so the Login screen is consistent.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setLanguage(user.language);
|
setLanguage(user.language);
|
||||||
applyTheme(user.theme);
|
applyTheme(user.theme);
|
||||||
|
applyFontScale(user.fontScale);
|
||||||
} else {
|
} else {
|
||||||
applyTheme("dark");
|
applyTheme("dark");
|
||||||
|
applyFontScale(100);
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
aria-describedby={undefined}
|
aria-describedby={undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
|
<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
|
{isSubscription
|
||||||
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
|
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
|
||||||
: `${t("pay.ticket")} ${identity}`}
|
: `${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">
|
<div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2">
|
||||||
{blockedByOther ? (
|
{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")}
|
{t("shift.gateOtherTitle")}
|
||||||
</div>
|
</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 ?? "?" })}
|
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
|
||||||
</div>
|
</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")}
|
{t("shift.gateTitle")}
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleOpenShift}
|
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
|
out-of-window window charge; then show that amount. For an overstay the
|
||||||
amount is the TOP-UP delta, not the whole stay. */}
|
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">
|
<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")}
|
{subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-3xl font-bold text-term-cyan">
|
<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
|
gate; once paid, prompt the operator to open the barrier; a prepaid
|
||||||
subscriber sees the assist explanation only after revealing it. */}
|
subscriber sees the assist explanation only after revealing it. */}
|
||||||
{subWindowDue && !windowPaid ? (
|
{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")}
|
{t("pay.windowChargeHint")}
|
||||||
</div>
|
</div>
|
||||||
) : isSubscription && windowPaid ? (
|
) : 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")}
|
{t("pay.windowPaidHint")}
|
||||||
</div>
|
</div>
|
||||||
) : isSubscription && assistRevealed ? (
|
) : 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")}
|
{t("pay.subAssistHint")}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* For an overstay, explain why a top-up is required (no free exit). */}
|
{/* For an overstay, explain why a top-up is required (no free exit). */}
|
||||||
{isOverstay && (
|
{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")}
|
{t("pay.overstayHint")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -368,7 +368,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
subscriber window charge that's still unpaid). */}
|
subscriber window charge that's still unpaid). */}
|
||||||
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && (
|
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && (
|
||||||
<div className="flex items-center gap-2">
|
<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) => (
|
{(["cash", "card"] as const).map((tn) => (
|
||||||
<button
|
<button
|
||||||
key={tn}
|
key={tn}
|
||||||
@@ -384,7 +384,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
|
|
||||||
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */}
|
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */}
|
||||||
{phase !== "done" && !isSubscription && (
|
{phase !== "done" && !isSubscription && (
|
||||||
<label className="flex items-center gap-2 text-[12px]">
|
<label className="flex items-center gap-2 text-[0.75rem]">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="accent-term-amber"
|
className="accent-term-amber"
|
||||||
@@ -401,10 +401,10 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
signed `void` event — the entry is never edited. */}
|
signed `void` event — the entry is never edited. */}
|
||||||
{voiding && phase !== "done" && (
|
{voiding && phase !== "done" && (
|
||||||
<div className="rounded-term border border-term-amber/50 bg-term-amber/5 px-3 py-2">
|
<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")}
|
{t("pay.cancelTicketTitle")}
|
||||||
</div>
|
</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">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{(["misprint", "test", "wrongVehicle"] as const).map((k) => (
|
{(["misprint", "test", "wrongVehicle"] as const).map((k) => (
|
||||||
<button
|
<button
|
||||||
@@ -560,7 +560,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
|
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-baseline justify-between">
|
<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>
|
<span className={`text-sm ${valueClass}`}>{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
|||||||
<div className="flex items-end gap-4">
|
<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="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
|
||||||
<div className="pb-1 text-term-muted">
|
<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">
|
<div className="text-sm tabular-nums">
|
||||||
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
|
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto text-right">
|
<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"}`}>
|
<div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
|
||||||
{occ.free == null ? "∞" : occ.free}
|
{occ.free == null ? "∞" : occ.free}
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +67,7 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{occ.full && (
|
{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")}
|
{t("booth.lotFull")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -106,46 +106,51 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
|||||||
className="input h-11 flex-1 px-3 text-lg tabular-nums"
|
className="input h-11 flex-1 px-3 text-lg tabular-nums"
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn btn-primary btn-lg">
|
<button type="submit" className="btn btn-primary btn-lg">
|
||||||
{t("booth.open")}
|
{t("booth.openTicket")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One barrier light — green = free, red = busy (a vehicle is at the lane vicinity,
|
/** One barrier light — a 3-state indicator mirroring the physical button lamp (relay 3):
|
||||||
* from camera detection). Advisory only; it gates nothing. */
|
* - radar present + camera NOT busy → BLINK green↔red (~1 Hz): "detected, not yet confirmed"
|
||||||
function BarrierLight({ label, busy }: { label: string; busy: boolean }) {
|
* - camera busy → SOLID red: a vehicle is confirmed at the lane vicinity
|
||||||
|
* - otherwise → SOLID green: free
|
||||||
|
* Advisory only; it gates nothing. The blink uses the `.lane-blink` keyframe (index.css),
|
||||||
|
* whose children inherit the alternating colour via `currentColor`. */
|
||||||
|
function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; radar: boolean }) {
|
||||||
|
// Blink only when the radar sees something the camera hasn't confirmed.
|
||||||
|
const blinking = radar && !busy;
|
||||||
|
const solid = busy ? "border-term-red bg-term-red/10 text-term-red" : "border-term-green bg-term-green/10 text-term-green";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${
|
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${blinking ? "lane-blink" : solid}`}
|
||||||
busy ? "border-term-red bg-term-red/10" : "border-term-green bg-term-green/10"
|
|
||||||
}`}
|
|
||||||
title={label}
|
title={label}
|
||||||
>
|
>
|
||||||
{/* Barrier glyph: a post + an arm. Colour carries the state. */}
|
{/* Barrier glyph: a post + an arm. `currentColor` follows the (possibly blinking) state. */}
|
||||||
<svg viewBox="0 0 24 24" className={`h-5 w-5 ${busy ? "text-term-red" : "text-term-green"}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
<line x1="5" y1="21" x2="5" y2="9" />
|
<line x1="5" y1="21" x2="5" y2="9" />
|
||||||
<line x1="5" y1="10" x2="21" y2="6" />
|
<line x1="5" y1="10" x2="21" y2="6" />
|
||||||
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
|
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
|
||||||
</svg>
|
</svg>
|
||||||
<div className="leading-tight">
|
<div className="leading-tight">
|
||||||
<div className="text-[10px] uppercase tracking-wider text-term-muted">{label}</div>
|
<div className="text-[0.625rem] uppercase tracking-wider text-term-muted">{label}</div>
|
||||||
<div className={`text-xs font-bold ${busy ? "text-term-red" : "text-term-green"}`}>
|
<div className="text-xs font-bold">{busy ? "●" : blinking ? "◐" : "○"}</div>
|
||||||
{busy ? "●" : "○"}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The two lane barrier lights (entry / exit) fed by the live lane-status. */
|
/** The two lane barrier lights (entry / exit) fed by the live lane-status (camera busy/free)
|
||||||
|
* and lane-presence (radar). */
|
||||||
function LaneIndicators() {
|
function LaneIndicators() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const lanes = useLiveStore((s) => s.lanes);
|
const lanes = useLiveStore((s) => s.lanes);
|
||||||
|
const radar = useLiveStore((s) => s.radar);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} />
|
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} radar={radar?.entry ?? false} />
|
||||||
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} />
|
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} radar={radar?.exit ?? false} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -176,10 +181,10 @@ export function BoothScreen() {
|
|||||||
// abandon an in-progress payment (the operator finishes/closes, then scans the next).
|
// abandon an in-progress payment (the operator finishes/closes, then scans the next).
|
||||||
useScanner({ onScan: setActiveTicket, paused: activeTicket != null || detailEvent != null });
|
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 [feedSearch, setFeedSearch] = useState("");
|
||||||
const [feedType, setFeedType] = useState<FeedCat | "">("");
|
const [feedType, setFeedType] = useState<FeedCat | "">("");
|
||||||
const [feedDir, setFeedDir] = useState<"entry" | "exit" | "">("");
|
|
||||||
const [feedSrc, setFeedSrc] = useState<"booth" | "reader" | "">("");
|
const [feedSrc, setFeedSrc] = useState<"booth" | "reader" | "">("");
|
||||||
|
|
||||||
// Live overlays from the WS store.
|
// 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`)
|
// Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
|
||||||
// vs reader (device-initiated: wiegand/lpr/qr/ticket). Search spans identity,
|
// 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 fq = feedSearch.trim().toLowerCase();
|
||||||
const events = scoped.filter((e) => {
|
const events = scoped.filter((e) => {
|
||||||
if (feedType && feedCat(e.type) !== feedType) return false;
|
if (feedType && feedCat(e.type) !== feedType) return false;
|
||||||
if (feedDir && e.direction !== feedDir) return false;
|
|
||||||
if (feedSrc) {
|
if (feedSrc) {
|
||||||
const isBooth = e.source === "manual";
|
const isBooth = e.source === "manual";
|
||||||
if (feedSrc === "booth" ? !isBooth : isBooth) return false;
|
if (feedSrc === "booth" ? !isBooth : isBooth) return false;
|
||||||
}
|
}
|
||||||
if (fq) {
|
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;
|
if (!hay.includes(fq)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -225,10 +230,6 @@ export function BoothScreen() {
|
|||||||
{ value: "void", label: t("booth.fEvtVoid") },
|
{ value: "void", label: t("booth.fEvtVoid") },
|
||||||
{ value: "anomaly", label: t("booth.fEvtAnomaly") },
|
{ 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">[] = [
|
const feedSrcOpts: SegOption<"booth" | "reader">[] = [
|
||||||
{ value: "booth", label: t("booth.fSrcBooth") },
|
{ value: "booth", label: t("booth.fSrcBooth") },
|
||||||
{ value: "reader", label: t("booth.fSrcReader") },
|
{ value: "reader", label: t("booth.fSrcReader") },
|
||||||
@@ -266,7 +267,7 @@ export function BoothScreen() {
|
|||||||
<Panel
|
<Panel
|
||||||
title={t("booth.liveFeed")}
|
title={t("booth.liveFeed")}
|
||||||
right={
|
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}
|
||||||
{events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")}
|
{events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")}
|
||||||
</span>
|
</span>
|
||||||
@@ -282,7 +283,6 @@ export function BoothScreen() {
|
|||||||
onChange={setFeedType}
|
onChange={setFeedType}
|
||||||
allLabel={t("booth.filterAll")}
|
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")} />
|
<SegGroup value={feedSrc} options={feedSrcOpts} onChange={setFeedSrc} allLabel={t("booth.filterAll")} />
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
|
|||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}>
|
<button type="submit" className="btn btn-primary btn-lg w-full" disabled={busy || !username || !password}>
|
||||||
{busy ? t("auth.signingIn") : t("auth.signIn")}
|
{busy ? t("auth.signingIn") : t("auth.signIn")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function LogRow({ log }: { log: AppLogRecord }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => hasDetail && setOpen((v) => !v)}
|
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"
|
hasDetail ? "hover:bg-term-panel-2" : "cursor-default"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -44,17 +44,17 @@ function LogRow({ log }: { log: AppLogRecord }) {
|
|||||||
{open && hasDetail && (
|
{open && hasDetail && (
|
||||||
<div className="border-t border-term-border/40 bg-term-bg px-3 py-2">
|
<div className="border-t border-term-border/40 bg-term-bg px-3 py-2">
|
||||||
{log.path && (
|
{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>
|
{t("logs.path")}: <code className="text-term-text">{log.path}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{log.context && Object.keys(log.context).length > 0 && (
|
{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)}
|
{JSON.stringify(log.context, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
{log.stack && (
|
{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}
|
{log.stack}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
@@ -136,12 +136,12 @@ export function LogsViewer() {
|
|||||||
|
|
||||||
<div className="card p-2">
|
<div className="card p-2">
|
||||||
{q.isLoading ? (
|
{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 ? (
|
) : 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.time")}</span>
|
||||||
<span>{t("logs.level")}</span>
|
<span>{t("logs.level")}</span>
|
||||||
<span>{t("logs.source")}</span>
|
<span>{t("logs.source")}</span>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function Profile({
|
|||||||
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
||||||
{t("profile.accountSection")}
|
{t("profile.accountSection")}
|
||||||
</h2>
|
</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>
|
<div>
|
||||||
<span className="block">{t("profile.username")}</span>
|
<span className="block">{t("profile.username")}</span>
|
||||||
<span className="text-sm text-term-text">{user.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>
|
<span className="text-sm text-term-text">{user.roleName}</span>
|
||||||
</div>
|
</div>
|
||||||
</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")}
|
{t("profile.fullName")}
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -101,7 +101,7 @@ export function Profile({
|
|||||||
onChange={(e) => setFullName(e.target.value)}
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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")}
|
{t("profile.email")}
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -115,7 +115,7 @@ export function Profile({
|
|||||||
<button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}>
|
<button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}>
|
||||||
{t("profile.saveProfile")}
|
{t("profile.saveProfile")}
|
||||||
</button>
|
</button>
|
||||||
{accountMsg && <span className="text-[11px] text-term-muted">{accountMsg}</span>}
|
{accountMsg && <span className="text-[0.6875rem] text-term-muted">{accountMsg}</span>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ export function Profile({
|
|||||||
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
||||||
{t("profile.passwordSection")}
|
{t("profile.passwordSection")}
|
||||||
</h2>
|
</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")}
|
{t("profile.currentPassword")}
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -134,7 +134,7 @@ export function Profile({
|
|||||||
onChange={(e) => setCurrent(e.target.value)}
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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")}
|
{t("profile.newPassword")}
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -144,7 +144,7 @@ export function Profile({
|
|||||||
onChange={(e) => setNext(e.target.value)}
|
onChange={(e) => setNext(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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")}
|
{t("profile.confirmPassword")}
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -163,7 +163,7 @@ export function Profile({
|
|||||||
>
|
>
|
||||||
{t("profile.changePassword")}
|
{t("profile.changePassword")}
|
||||||
</button>
|
</button>
|
||||||
{pwMsg && <span className="text-[11px] text-term-muted">{pwMsg}</span>}
|
{pwMsg && <span className="text-[0.6875rem] text-term-muted">{pwMsg}</span>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,13 +76,13 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
|
|||||||
{t("recycleBin.title")}
|
{t("recycleBin.title")}
|
||||||
</h1>
|
</h1>
|
||||||
{retentionDays > 0 && (
|
{retentionDays > 0 && (
|
||||||
<span className="text-[12px] text-term-muted">
|
<span className="text-[0.75rem] text-term-muted">
|
||||||
{t("recycleBin.retentionNote", { days: retentionDays })}
|
{t("recycleBin.retentionNote", { days: retentionDays })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && <p className="text-term-muted">{t("common.loading")}</p>}
|
||||||
|
|
||||||
{!binQ.isLoading && items.length === 0 ? (
|
{!binQ.isLoading && items.length === 0 ? (
|
||||||
@@ -90,9 +90,9 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
|
|||||||
{t("recycleBin.empty")}
|
{t("recycleBin.empty")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-[13px]">
|
<table className="w-full text-[0.8125rem]">
|
||||||
<thead>
|
<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.type")}</th>
|
||||||
<th className="py-1.5 pr-3">{t("recycleBin.col.item")}</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>
|
<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) => (
|
{items.map((it) => (
|
||||||
<tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50">
|
<tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50">
|
||||||
<td className="py-1.5 pr-3">
|
<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])}
|
{t(KIND_KEY[it.kind])}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -146,10 +146,10 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
|
|||||||
|
|
||||||
{purging && (
|
{purging && (
|
||||||
<Modal open onClose={() => setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}>
|
<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 })}
|
{t("recycleBin.purgeConfirmBody", { label: purging.label })}
|
||||||
</p>
|
</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">
|
<div className="mt-3 flex justify-end gap-2">
|
||||||
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}>
|
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function Reports() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
<span>{t("reports.groupBy")}</span>
|
||||||
<select
|
<select
|
||||||
className="select input-sm w-auto"
|
className="select input-sm w-auto"
|
||||||
@@ -247,7 +247,7 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
|
|
||||||
{/* Cash / card + duration + subscription breakdown (numbers). */}
|
{/* Cash / card + duration + subscription breakdown (numbers). */}
|
||||||
<Panel title={t("reports.chart.breakdown")}>
|
<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.cash")} value={money(tot.cashMinor)} />
|
||||||
<Row label={t("reports.row.card")} value={money(tot.cardMinor)} />
|
<Row label={t("reports.row.card")} value={money(tot.cardMinor)} />
|
||||||
<Row label={t("reports.mix.ticket")} value={money(tot.ticketMinor)} />
|
<Row label={t("reports.mix.ticket")} value={money(tot.ticketMinor)} />
|
||||||
@@ -262,7 +262,7 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-[11px] text-term-muted">
|
<p className="text-[0.6875rem] text-term-muted">
|
||||||
{t("reports.footnote", { tz: data.tz })}
|
{t("reports.footnote", { tz: data.tz })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -290,7 +290,7 @@ function Kpi({ label, value, accent }: { label: string; value: string; accent?:
|
|||||||
: "text-term-text";
|
: "text-term-text";
|
||||||
return (
|
return (
|
||||||
<div className="rounded-term border border-term-border bg-term-panel p-2.5">
|
<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 className={`mt-0.5 text-lg font-bold tabular-nums ${color}`}>{value}</div>
|
||||||
</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 }) {
|
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-term border border-term-border bg-term-panel p-3">
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -315,5 +315,5 @@ function Row({ label, value }: { label: string; value: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Empty({ t }: { t: TFunction }) {
|
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>
|
</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
|
<Modal
|
||||||
open={editing != null}
|
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 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 justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{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")}
|
{t("roles.builtin")}
|
||||||
</span>
|
</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 })}
|
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,11 +155,11 @@ function RoleEditor({
|
|||||||
<div className="mt-1 grid grid-cols-1 gap-1">
|
<div className="mt-1 grid grid-cols-1 gap-1">
|
||||||
{Object.entries(grouped).map(([resource, list]) => (
|
{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">
|
<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) => {
|
{list.map((p) => {
|
||||||
const action = p.split(":")[1]!;
|
const action = p.split(":")[1]!;
|
||||||
return (
|
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)} />
|
<input type="checkbox" className="accent-term-amber" checked={perms.has(p)} onChange={() => toggle(p)} />
|
||||||
{action}
|
{action}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
+612
-105
File diff suppressed because it is too large
Load Diff
@@ -175,7 +175,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{q.isError && (
|
{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
|
{/* 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="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">
|
<div className="flex min-h-0 flex-col gap-1.5 overflow-y-auto pr-1">
|
||||||
{!q.isLoading && list.length === 0 && (
|
{!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) => (
|
{list.map((s) => (
|
||||||
<ShiftCard key={s.id} s={s} showOperator={isAdmin} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
|
<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}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,7 +228,7 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<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}>
|
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
|
||||||
{busy ? t("shift.starting") : t("shift.startShift")}
|
{busy ? t("shift.starting") : t("shift.startShift")}
|
||||||
</button>
|
</button>
|
||||||
@@ -244,11 +244,11 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
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">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="flex items-center gap-2 font-semibold text-term-text">
|
<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)}
|
{showOperator ? s.operator : when(s.startedAt)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
||||||
@@ -300,9 +300,9 @@ function ShiftActivityLog({
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<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="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">
|
<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} · `}
|
{showOperator && `${shift.operator} · `}
|
||||||
{formatRelativeDateTime(shift.startedAt, t)}
|
{formatRelativeDateTime(shift.startedAt, t)}
|
||||||
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
|
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
|
||||||
@@ -316,10 +316,10 @@ function ShiftActivityLog({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
|
||||||
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, 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.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
||||||
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
||||||
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
||||||
@@ -331,8 +331,8 @@ function ShiftActivityLog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-1">
|
<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 && <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-[12px] text-term-muted">{t("shifts.noActivity")}</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) => (
|
{events.map((e) => (
|
||||||
<EventRow key={e.id} e={e} onOpen={setDetailEvent} />
|
<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">
|
<Modal open onClose={onClose} title={t("shift.endShift")} width="max-w-md">
|
||||||
{report ? (
|
{report ? (
|
||||||
// Result — the signed Z-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="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">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||||
<Figure label={t("shift.payments")} value={String(report.paymentCount)} />
|
<Figure label={t("shift.payments")} value={String(report.paymentCount)} />
|
||||||
<span />
|
<span />
|
||||||
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} />
|
<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.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 />
|
<Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// Confirm — show the live takings (split by source) + drawer before closing.
|
// 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>
|
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
||||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
<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.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
|
||||||
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, 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 />
|
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
<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.cash")} value={money(shift.cashTotalMinor, cur)} />
|
||||||
<Figure label={t("shift.card")} value={money(shift.cardTotalMinor, 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 />
|
<Figure label={t("shift.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
||||||
</div>
|
</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">
|
<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" onClick={onClose}>{t("subs.cancel")}</button>
|
||||||
<button type="button" className="btn btn-sm btn-danger" onClick={confirm} disabled={busy}>
|
<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 (
|
return (
|
||||||
<Modal open onClose={onClose} title={t("shift.drawerVoucher")} width="max-w-md">
|
<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">
|
<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 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")} />
|
<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" 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" />
|
<input className="input w-36" type="password" value={authPassword} onChange={(e) => setAuthPassword(e.target.value)} placeholder={t("shift.authPassword")} autoComplete="off" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-term-muted">{t("shift.voucherHint")}</div>
|
<div className="text-[0.6875rem] text-term-muted">{t("shift.voucherHint")}</div>
|
||||||
{msg && <div className="text-[12px] text-term-muted">{msg}</div>}
|
{msg && <div className="text-[0.75rem] text-term-muted">{msg}</div>}
|
||||||
<div className="mt-1 flex justify-end gap-2">
|
<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-sm" onClick={onClose}>{t("common.close")}</button>
|
||||||
<button type="button" className="btn btn-go btn-sm" onClick={() => submit("cash_in")}>{t("shift.mandatArketimi")}</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 (
|
return (
|
||||||
<Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md">
|
<Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md">
|
||||||
{!x ? (
|
{!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="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">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||||
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
|
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
|
||||||
<span />
|
<span />
|
||||||
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} />
|
<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.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 />
|
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
<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.cashRemoved")} value={money(x.cashRemovedMinor, x.currency)} />
|
||||||
<Figure label={t("shift.expectedDrawer")} value={money(x.expectedDrawerMinor, x.currency)} bold />
|
<Figure label={t("shift.expectedDrawer")} value={money(x.expectedDrawerMinor, x.currency)} bold />
|
||||||
</div>
|
</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">
|
<div className="mt-3 flex justify-end">
|
||||||
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
|
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -513,8 +519,9 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
|||||||
function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
<div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
||||||
<span className={sub ? "text-term-muted/70" : "text-term-muted"}>{label}</span>
|
<span className={`whitespace-nowrap ${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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card mt-6 max-w-md p-4">
|
<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>
|
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
||||||
{occ == null ? (
|
{occ == null ? (
|
||||||
<span className="text-term-muted">…</span>
|
<span className="text-term-muted">…</span>
|
||||||
@@ -93,7 +93,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
<span className="label">{t("site.capacityLabel")}</span>
|
<span className="label">{t("site.capacityLabel")}</span>
|
||||||
<input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} />
|
<input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} />
|
||||||
</div>
|
</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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="accent-term-amber"
|
className="accent-term-amber"
|
||||||
@@ -103,7 +103,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
{t("site.printExitDefault")}
|
{t("site.printExitDefault")}
|
||||||
<span className="hint">{t("site.printExitHint")}</span>
|
<span className="hint">{t("site.printExitHint")}</span>
|
||||||
</label>
|
</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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="mt-0.5 accent-term-amber"
|
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 className="hint block">{t("site.reserveSubsHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="mt-0.5 accent-term-amber"
|
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 className="hint block">{t("site.anprEntryHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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")}
|
{t("site.parkDetails")}
|
||||||
</div>
|
</div>
|
||||||
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
|
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
|
||||||
@@ -153,7 +153,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
))}
|
))}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("site.save")}</button>
|
<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>
|
||||||
</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>
|
<h2 className="mb-3 text-h4 font-semibold text-term-text">{t("subs.title")}</h2>
|
||||||
<ul className="mb-3 list-none p-0">
|
<ul className="mb-3 list-none p-0">
|
||||||
{subs.map((s) => (
|
{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>
|
<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={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>
|
<span className="tabular-nums text-term-cyan">{priceLabel(s, t)}</span>
|
||||||
@@ -464,13 +464,13 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<label className="label">{t("subs.plan")}</label>
|
<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
|
{/* 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
|
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
|
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 (cur) versions.unshift(cur);
|
||||||
}
|
}
|
||||||
if (versions.length < 2 && versions.some((v) => v.id === form.planVersionId)) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -504,7 +512,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -523,7 +531,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
onChange={(e) => setForm((f) => ({ ...f, quantity: e.target.value, maxConcurrent: e.target.value }))}
|
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>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -533,7 +541,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
<>
|
<>
|
||||||
<label className="label">{t("subs.tender")}</label>
|
<label className="label">{t("subs.tender")}</label>
|
||||||
<span className="flex items-center gap-3">
|
<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
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="tender"
|
name="tender"
|
||||||
@@ -543,7 +551,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
/>
|
/>
|
||||||
{t("subs.tenderCash")}
|
{t("subs.tenderCash")}
|
||||||
</label>
|
</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
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="tender"
|
name="tender"
|
||||||
@@ -553,13 +561,13 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
/>
|
/>
|
||||||
{t("subs.tenderCard")}
|
{t("subs.tenderCard")}
|
||||||
</label>
|
</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>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<label className="label">{t("subs.carLimit")}</label>
|
<label className="label">{t("subs.carLimit")}</label>
|
||||||
<span className="flex items-center gap-3">
|
<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")}
|
<input type="checkbox" className="accent-term-amber" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
|
||||||
</label>
|
</label>
|
||||||
{form.carBound && (
|
{form.carBound && (
|
||||||
@@ -580,7 +588,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
onChange={(e) => setForm((f) => ({ ...f, count: e.target.value }))}
|
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])}
|
× {t(PERIOD_KEY[selectedPlan.period])}
|
||||||
</span>
|
</span>
|
||||||
</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
|
{/* Live SERVER quote: ceil(periods) × per-period price. The operator can't
|
||||||
override it — this is exactly what will be charged + signed. */}
|
override it — this is exactly what will be charged + signed. */}
|
||||||
{editing === "new" && form.planId.trim() !== "" && (
|
{editing === "new" && form.planId.trim() !== "" && (
|
||||||
<span className="text-[12px] text-term-cyan">
|
<span className="text-[0.75rem] text-term-cyan">
|
||||||
{quoting
|
{quoting
|
||||||
? t("subs.quoting")
|
? t("subs.quoting")
|
||||||
: quote
|
: 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")} />
|
<input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
|
||||||
</div>
|
</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) => (
|
{form.credentials.map((c, i) => (
|
||||||
<div key={i} className="mb-1.5 flex items-center gap-2">
|
<div key={i} className="mb-1.5 flex items-center gap-2">
|
||||||
{/* Operator chooses the credential type: QR (auto-generated) or RFID
|
{/* Operator chooses the credential type: QR (auto-generated) or RFID
|
||||||
@@ -625,7 +633,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
c.value.trim() ? (
|
c.value.trim() ? (
|
||||||
<input className="input input-sm flex-1 opacity-70" value={c.value} readOnly />
|
<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"
|
// 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
|
{/* Capture panel: pick a reader, present the card; the captured value fills
|
||||||
the credential. The OTHER reader keeps serving the live flow. */}
|
the credential. The OTHER reader keeps serving the live flow. */}
|
||||||
{capture && (
|
{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" ? (
|
{capture.phase === "pick" ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-1.5 text-term-text">{t("subs.captureChooseReader")}</div>
|
<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>
|
<button type="button" className="btn btn-sm" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,19 +237,19 @@ export function SubscriptionPlansManager() {
|
|||||||
return (
|
return (
|
||||||
<section className="px-4 py-6">
|
<section className="px-4 py-6">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<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())}>
|
<button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
|
||||||
{t("plans.add")}
|
{t("plans.add")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 && (
|
{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 ? (
|
{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">
|
<div className="flex flex-col gap-2">
|
||||||
{groups.map(({ planId, head: p, active, versions }) => {
|
{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">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<span className="font-semibold text-term-text">{p.name}</span>
|
<span className="font-semibold text-term-text">{p.name}</span>
|
||||||
{active ? (
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Details: price · hours · effective */}
|
{/* 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">
|
<span className="tabular-nums text-term-text">
|
||||||
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
||||||
</span>
|
</span>
|
||||||
@@ -280,7 +280,7 @@ export function SubscriptionPlansManager() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Used by */}
|
{/* Used by */}
|
||||||
<div className="mt-1 text-[12px]">
|
<div className="mt-1 text-[0.75rem]">
|
||||||
{users.length > 0 ? (
|
{users.length > 0 ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -295,12 +295,12 @@ export function SubscriptionPlansManager() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isOpen && users.length > 0 && (
|
{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) => (
|
{users.map((s) => (
|
||||||
<li key={s.id} className={s.status === "active" ? "text-term-text" : "text-term-muted"}>
|
<li key={s.id} className={s.status === "active" ? "text-term-text" : "text-term-muted"}>
|
||||||
{s.holderName || t("subs.unnamed")}
|
{s.holderName || t("subs.unnamed")}
|
||||||
{s.quantity > 1 && <span className="text-term-muted"> ×{s.quantity}</span>}
|
{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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -349,14 +349,14 @@ export function SubscriptionPlansManager() {
|
|||||||
<span className="flex items-center gap-2">
|
<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-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 })} />
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the
|
{/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the
|
||||||
window they're charged the transient tariff for the gap. Off = 24/7. */}
|
window they're charged the transient tariff for the gap. Off = 24/7. */}
|
||||||
<div className="mt-3 border-t border-term-border pt-3">
|
<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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="accent-term-amber"
|
className="accent-term-amber"
|
||||||
@@ -370,7 +370,7 @@ export function SubscriptionPlansManager() {
|
|||||||
<label className="label">{t("plans.days")}</label>
|
<label className="label">{t("plans.days")}</label>
|
||||||
<span className="flex flex-wrap gap-2">
|
<span className="flex flex-wrap gap-2">
|
||||||
{DOW_ORDER.map((d) => (
|
{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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="accent-term-amber"
|
className="accent-term-amber"
|
||||||
@@ -386,7 +386,7 @@ export function SubscriptionPlansManager() {
|
|||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<label className="label">{t("plans.window")}</label>
|
<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")}
|
{t("plans.enterAfter")}
|
||||||
<input type="time" className="input w-28" value={form.winFrom} onChange={(e) => setForm((f) => f && { ...f, winFrom: e.target.value })} />
|
<input type="time" className="input w-28" value={form.winFrom} onChange={(e) => setForm((f) => f && { ...f, winFrom: e.target.value })} />
|
||||||
{t("plans.exitBefore")}
|
{t("plans.exitBefore")}
|
||||||
@@ -395,14 +395,14 @@ export function SubscriptionPlansManager() {
|
|||||||
<label className="label">{t("plans.grace")}</label>
|
<label className="label">{t("plans.grace")}</label>
|
||||||
<span className="flex items-center gap-2">
|
<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 })} />
|
<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>
|
</span>
|
||||||
</div>
|
</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>
|
</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">
|
<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-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button>
|
||||||
<button type="button" className="btn btn-go btn-sm" onClick={save}>{t("subs.save")}</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">
|
<section className="px-4 py-6">
|
||||||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2>
|
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2>
|
||||||
{!state?.active ? (
|
{!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")}
|
{t("tariff.noRateCard")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="mb-4 text-[12px] text-term-muted">
|
<p className="mb-4 text-[0.75rem] text-term-muted">
|
||||||
{t("tariff.activeSince", {
|
{t("tariff.activeSince", {
|
||||||
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
||||||
count: state.versions.length,
|
count: state.versions.length,
|
||||||
@@ -407,7 +407,7 @@ export function TariffComposer() {
|
|||||||
{/* A stepped ("up-to") base rate cannot be combined with time tiers — the
|
{/* 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. */}
|
engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
|
||||||
{form.base.mode === "stepped" && form.tiers.length > 0 && (
|
{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")}
|
{t("tariff.steppedTiersConflict")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -432,7 +432,7 @@ export function TariffComposer() {
|
|||||||
<label className="label">{t("tariff.tierDays")}</label>
|
<label className="label">{t("tariff.tierDays")}</label>
|
||||||
<span className="flex flex-wrap gap-2">
|
<span className="flex flex-wrap gap-2">
|
||||||
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
{[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)} />
|
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
|
||||||
{t(`tariff.dow${d}`)}
|
{t(`tariff.dow${d}`)}
|
||||||
</label>
|
</label>
|
||||||
@@ -444,7 +444,7 @@ export function TariffComposer() {
|
|||||||
<span className="text-term-muted">–</span>
|
<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" />
|
<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 && (
|
{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>
|
</span>
|
||||||
<label className="label">{t("tariff.tierDates")}</label>
|
<label className="label">{t("tariff.tierDates")}</label>
|
||||||
@@ -478,7 +478,7 @@ export function TariffComposer() {
|
|||||||
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
||||||
</button>
|
</button>
|
||||||
{msg && (
|
{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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -504,7 +504,7 @@ function PricingEditor(props: {
|
|||||||
const { t, pricing: p } = props;
|
const { t, pricing: p } = props;
|
||||||
return (
|
return (
|
||||||
<div>
|
<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">
|
<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")} />
|
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
|
||||||
{t("tariff.modeLadder")}
|
{t("tariff.modeLadder")}
|
||||||
@@ -538,7 +538,7 @@ function PricingEditor(props: {
|
|||||||
<td className="px-2 py-1">
|
<td className="px-2 py-1">
|
||||||
<span className="inline-flex items-center gap-2">
|
<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")} />
|
<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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1">
|
<td className="px-2 py-1">
|
||||||
@@ -587,7 +587,7 @@ function PricingEditor(props: {
|
|||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center gap-2">
|
<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")} />
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export function TariffLab() {
|
|||||||
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}>
|
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}>
|
||||||
{t("lab.load")}
|
{t("lab.load")}
|
||||||
</button>
|
</button>
|
||||||
{loadMsg && <span className="text-[12px] text-term-green">{loadMsg}</span>}
|
{loadMsg && <span className="text-[0.75rem] text-term-green">{loadMsg}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hypothetical session inputs */}
|
{/* Hypothetical session inputs */}
|
||||||
@@ -167,7 +167,7 @@ export function TariffLab() {
|
|||||||
|
|
||||||
<label className="label">{t("lab.payment")}</label>
|
<label className="label">{t("lab.payment")}</label>
|
||||||
<span className="flex flex-wrap items-center gap-2">
|
<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)} />
|
<input type="checkbox" className="accent-term-amber" checked={paid} onChange={(e) => setPaid(e.target.checked)} />
|
||||||
{t("lab.paid")}
|
{t("lab.paid")}
|
||||||
</label>
|
</label>
|
||||||
@@ -190,7 +190,7 @@ export function TariffLab() {
|
|||||||
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
|
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
|
||||||
{busy ? t("lab.pricing") : t("lab.price")}
|
{busy ? t("lab.pricing") : t("lab.price")}
|
||||||
</button>
|
</button>
|
||||||
{err && <span className="text-[12px] text-term-red">{err}</span>}
|
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
@@ -198,19 +198,19 @@ export function TariffLab() {
|
|||||||
{/* Outcome */}
|
{/* Outcome */}
|
||||||
<div className="card card-body">
|
<div className="card card-body">
|
||||||
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
|
<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>
|
<dt className="text-term-muted">{t("lab.amountDue")}</dt>
|
||||||
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
|
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
|
||||||
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
|
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
|
||||||
<dd className="text-term-text">
|
<dd className="text-term-text">
|
||||||
{formatDuration(result.pricing.periodStart, fromLocalInput(asOf))}
|
{formatDuration(result.pricing.periodStart, fromLocalInput(asOf))}
|
||||||
{result.pricing.overstay && (
|
{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")}
|
{t("lab.overstay")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{result.pricing.withinGrace && (
|
{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")}
|
{t("lab.settled")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -230,7 +230,7 @@ export function TariffLab() {
|
|||||||
<div className="card card-body">
|
<div className="card card-body">
|
||||||
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
|
<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>
|
<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>
|
<tbody>
|
||||||
{result.curve.map((c) => (
|
{result.curve.map((c) => (
|
||||||
<tr key={c.minutes} className="border-b border-term-border/40">
|
<tr key={c.minutes} className="border-b border-term-border/40">
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function UsersManager({ user }: { user: SessionUser | null }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<Modal open={adding} onClose={() => setAdding(false)} title={t("users.new")} width="max-w-2xl">
|
||||||
<UserForm
|
<UserForm
|
||||||
@@ -100,8 +100,8 @@ export function UsersManager({ user }: { user: SessionUser | null }) {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-term border border-term-border">
|
<div className="overflow-hidden rounded-term border border-term-border">
|
||||||
<table className="w-full text-[12px]">
|
<table className="w-full text-[0.75rem]">
|
||||||
<thead className="bg-term-panel-2 text-[11px] uppercase tracking-wider text-term-muted">
|
<thead className="bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
<tr>
|
<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.username")}</th>
|
||||||
<th className="px-3 py-1.5 text-left">{t("users.role")}</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>}
|
{!isEdit && <div className="hint mt-1">{t("users.passwordHint")}</div>}
|
||||||
|
|
||||||
{/* Optional profile metadata. */}
|
{/* 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="grid grid-cols-2 gap-3">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<span className="label">{t("users.fullName")}</span>
|
<span className="label">{t("users.fullName")}</span>
|
||||||
|
|||||||
+76
-10
@@ -73,6 +73,8 @@ export interface SessionUser {
|
|||||||
language: Lang;
|
language: Lang;
|
||||||
/** Preferred UI theme (loaded from the server on login). */
|
/** Preferred UI theme (loaded from the server on login). */
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
/** Preferred UI font scale, percent of base (100 = base; clamped 80–160). */
|
||||||
|
fontScale: number;
|
||||||
/** Optional display name (profile metadata); null if unset. */
|
/** Optional display name (profile metadata); null if unset. */
|
||||||
fullName: string | null;
|
fullName: string | null;
|
||||||
/** Optional contact email (profile metadata); null if unset. */
|
/** 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 }) });
|
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.
|
/** Edit MY own profile (display name / email). Returns the refreshed session.
|
||||||
* Self-service — touches only the signed-in user; no `user:*` permission needed. */
|
* Self-service — touches only the signed-in user; no `user:*` permission needed. */
|
||||||
export function updateMyProfile(patch: {
|
export function updateMyProfile(patch: {
|
||||||
@@ -275,18 +287,52 @@ export type DeviceConfig = Record<string, ConfigValue>;
|
|||||||
/** Direction a barrier/relay (or a device bound to it) serves. */
|
/** Direction a barrier/relay (or a device bound to it) serves. */
|
||||||
export type Direction = "entry" | "exit" | "both";
|
export type Direction = "entry" | "exit" | "both";
|
||||||
|
|
||||||
/** One relay on an access controller: which barrier it opens, in which direction,
|
/** The EVENT a relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` → drive a
|
||||||
* and (optionally) the input terminal its entry button is wired to. */
|
* non-barrier alert lamp (blink while its trigger input is active, SOLID once the camera
|
||||||
|
* confirms a car). The action is implied by the event. */
|
||||||
|
export type RelayEvent = Direction | "radarAlert";
|
||||||
|
|
||||||
|
/** What a controller input terminal means: a transient-entry `button`, a one-car-one-ticket
|
||||||
|
* `presence` sensor (loop/radar), or an `alertTrigger` for a radarAlert lamp. */
|
||||||
|
export type InputRole = "button" | "presence" | "alertTrigger";
|
||||||
|
|
||||||
|
/** One INPUT terminal the host reads (the twin of RelaySpec). An exit radar is just another
|
||||||
|
* `presence` row serving the exit relay. */
|
||||||
|
export interface InputSpec {
|
||||||
|
input: number;
|
||||||
|
role: InputRole;
|
||||||
|
/** The barrier relay this input serves (required for button/presence; optional for
|
||||||
|
* alertTrigger). */
|
||||||
|
relay?: number;
|
||||||
|
/** presence only — induction LOOP or RADAR (label only). */
|
||||||
|
kind?: "loop" | "radar";
|
||||||
|
/** This terminal idles HIGH / is active-LOW (e.g. a radar wired opposite the button). */
|
||||||
|
activeLow?: boolean;
|
||||||
|
/** button only — presence-less fallback cooldown (seconds). */
|
||||||
|
cooldownSec?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One relay on an access controller: the event it reacts to. Input wiring lives in
|
||||||
|
* `config.inputs[]`; the legacy per-relay button/presence fields are still read for
|
||||||
|
* back-compat but no longer written. */
|
||||||
export interface RelaySpec {
|
export interface RelaySpec {
|
||||||
relay: number;
|
relay: number;
|
||||||
direction: Direction;
|
/** The event this relay reacts to (UI label: "Event"). */
|
||||||
/** Input terminal of the entry button that fires this relay (transient entry). */
|
direction: RelayEvent;
|
||||||
|
// ── legacy input fields (read-only back-compat; superseded by config.inputs[]) ──
|
||||||
button?: number;
|
button?: number;
|
||||||
/** Anti-double-press (one car = one ticket). PRESENCE: input terminal of a vehicle
|
|
||||||
* loop/barrier-feedback signal; a press prints only with a car present + re-arms when
|
|
||||||
* it clears. COOLDOWN (fallback, no feedback): suppress repeat presses for N seconds. */
|
|
||||||
presenceInput?: number;
|
presenceInput?: number;
|
||||||
|
presenceKind?: "loop" | "radar";
|
||||||
|
presenceActiveLow?: boolean;
|
||||||
entryCooldownSec?: number;
|
entryCooldownSec?: number;
|
||||||
|
// ── radarAlert-only ──
|
||||||
|
/** Input terminal whose active edge starts the blink (the radar). */
|
||||||
|
triggerInput?: number;
|
||||||
|
/** Which lane's camera locks this lamp SOLID (default entry). An exit radar locks on exit. */
|
||||||
|
lockLane?: "entry" | "exit";
|
||||||
|
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
|
||||||
|
blinkOnMs?: number;
|
||||||
|
blinkOffMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TestResult {
|
export interface TestResult {
|
||||||
@@ -297,11 +343,13 @@ export interface TestResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test a device config (reachability + preconditions) without saving. */
|
/** Test a device config (reachability + preconditions) without saving. Pass the
|
||||||
export function testDevice(driverId: string, config: DeviceConfig): Promise<TestResult> {
|
* device `id` when editing an existing one so the server re-merges its stored
|
||||||
|
* machine secrets (e.g. the relay password redacted from the client). */
|
||||||
|
export function testDevice(driverId: string, config: DeviceConfig, id?: string): Promise<TestResult> {
|
||||||
return apiFetch<TestResult>("/api/setup/test", {
|
return apiFetch<TestResult>("/api/setup/test", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ driverId, config }),
|
body: JSON.stringify({ driverId, config, ...(id ? { id } : {}) }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +381,24 @@ export function testAnpr(driverId: string, config: DeviceConfig): Promise<AnprTe
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of a physical print test: a real test slip is pushed to the printer. */
|
||||||
|
export type PrintTestResult =
|
||||||
|
| { ok: true; tookMs: number }
|
||||||
|
| { ok: false; reason: string; detail?: string; tookMs?: number };
|
||||||
|
|
||||||
|
/** Print a real test slip on the printer — without saving. Confirms the printer
|
||||||
|
* actually feeds paper + fires the head (healthCheck only opens the transport). */
|
||||||
|
export function testPrint(
|
||||||
|
driverId: string,
|
||||||
|
config: DeviceConfig,
|
||||||
|
id?: string,
|
||||||
|
): Promise<PrintTestResult> {
|
||||||
|
return apiFetch<PrintTestResult>("/api/setup/test-print", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ driverId, config, id }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Admin reports -------------------------------------------------------
|
// --- Admin reports -------------------------------------------------------
|
||||||
export type ReportBucket = "hour" | "day" | "month";
|
export type ReportBucket = "hour" | "day" | "month";
|
||||||
|
|
||||||
|
|||||||
+35
-8
@@ -180,7 +180,7 @@ body {
|
|||||||
background: var(--color-term-bg);
|
background: var(--color-term-bg);
|
||||||
color: var(--color-term-text);
|
color: var(--color-term-text);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
/* Crisp text and no rubber-banding on the fixed appliance display. */
|
/* Crisp text and no rubber-banding on the fixed appliance display. */
|
||||||
@@ -266,23 +266,23 @@ body {
|
|||||||
/* Small / dense variant for inline table cells */
|
/* Small / dense variant for inline table cells */
|
||||||
.input-sm {
|
.input-sm {
|
||||||
height: var(--control-h-sm);
|
height: var(--control-h-sm);
|
||||||
@apply px-2 text-[12px];
|
@apply px-2 text-[0.75rem];
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
@apply flex flex-col gap-1;
|
@apply flex flex-col gap-1;
|
||||||
}
|
}
|
||||||
.label {
|
.label {
|
||||||
@apply text-[11px] uppercase tracking-wider text-term-muted;
|
@apply text-[0.6875rem] uppercase tracking-wider text-term-muted;
|
||||||
}
|
}
|
||||||
.hint {
|
.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 ---- */
|
/* ---- Buttons: a button must look pressable, never like a field ---- */
|
||||||
.btn {
|
.btn {
|
||||||
@apply inline-flex items-center justify-center gap-1.5 rounded-term border
|
@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;
|
transition-colors select-none;
|
||||||
height: var(--control-h-md);
|
height: var(--control-h-md);
|
||||||
/* Neutral default: a filled grey body, not a bare outline. */
|
/* Neutral default: a filled grey body, not a bare outline. */
|
||||||
@@ -302,11 +302,11 @@ body {
|
|||||||
}
|
}
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
height: var(--control-h-sm);
|
height: var(--control-h-sm);
|
||||||
@apply px-2.5 text-[11px];
|
@apply px-2.5 text-[0.6875rem];
|
||||||
}
|
}
|
||||||
.btn-lg {
|
.btn-lg {
|
||||||
height: var(--control-h-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. */
|
/* Primary: FILLED amber, dark text — the unmistakable main action. */
|
||||||
@@ -366,7 +366,7 @@ body {
|
|||||||
}
|
}
|
||||||
.card-head {
|
.card-head {
|
||||||
@apply flex items-center justify-between border-b border-term-border
|
@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 {
|
.card-body {
|
||||||
@apply p-4;
|
@apply p-4;
|
||||||
@@ -428,3 +428,30 @@ html.theme-light .btn:hover:not(:disabled) {
|
|||||||
html.theme-light .btn-primary {
|
html.theme-light .btn-primary {
|
||||||
color: #fafaf7;
|
color: #fafaf7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Barrier-light blink ────────────────────────────────────────────────────
|
||||||
|
The booth Entry/Exit indicator blinks green↔red (~1 Hz) when the radar/presence
|
||||||
|
input is active but the camera hasn't confirmed a vehicle yet — mirroring the
|
||||||
|
physical button lamp (relay 3). Toggles a CSS var the component maps onto its
|
||||||
|
border / tint / glyph, so green and red alternate every 500 ms. */
|
||||||
|
@keyframes lane-blink {
|
||||||
|
0%, 49% { --lane-c: var(--color-term-green); --lane-tint: color-mix(in srgb, var(--color-term-green) 10%, transparent); }
|
||||||
|
50%, 100% { --lane-c: var(--color-term-red); --lane-tint: color-mix(in srgb, var(--color-term-red) 10%, transparent); }
|
||||||
|
}
|
||||||
|
.lane-blink {
|
||||||
|
animation: lane-blink 1s steps(1, end) infinite;
|
||||||
|
border-color: var(--lane-c);
|
||||||
|
background: var(--lane-tint);
|
||||||
|
color: var(--lane-c);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
/* No flashing for motion-sensitive users — hold the "attention" (red) state. */
|
||||||
|
.lane-blink {
|
||||||
|
animation: none;
|
||||||
|
--lane-c: var(--color-term-red);
|
||||||
|
--lane-tint: color-mix(in srgb, var(--color-term-red) 10%, transparent);
|
||||||
|
border-color: var(--lane-c);
|
||||||
|
background: var(--lane-tint);
|
||||||
|
color: var(--lane-c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+72
-18
@@ -14,6 +14,9 @@ export const en: Catalog = {
|
|||||||
themeDark: "dark",
|
themeDark: "dark",
|
||||||
themeLight: "light",
|
themeLight: "light",
|
||||||
theme: "Theme",
|
theme: "Theme",
|
||||||
|
fontSmaller: "Smaller text",
|
||||||
|
fontLarger: "Larger text",
|
||||||
|
fontSize: "Text size",
|
||||||
today: "Today",
|
today: "Today",
|
||||||
yesterday: "Yesterday",
|
yesterday: "Yesterday",
|
||||||
months: [
|
months: [
|
||||||
@@ -118,7 +121,7 @@ export const en: Catalog = {
|
|||||||
scanPlaceholder: "Scan or type ticket number…",
|
scanPlaceholder: "Scan or type ticket number…",
|
||||||
laneEntry: "Entry",
|
laneEntry: "Entry",
|
||||||
laneExit: "Exit",
|
laneExit: "Exit",
|
||||||
open: "Open",
|
openTicket: "Read",
|
||||||
occupancy: "Occupancy",
|
occupancy: "Occupancy",
|
||||||
occUnavailable: "occupancy unavailable",
|
occUnavailable: "occupancy unavailable",
|
||||||
inside: "inside",
|
inside: "inside",
|
||||||
@@ -133,6 +136,11 @@ export const en: Catalog = {
|
|||||||
insideCount: "inside",
|
insideCount: "inside",
|
||||||
noActiveSessions: "No active sessions.",
|
noActiveSessions: "No active sessions.",
|
||||||
noMatch: "No sessions match the filter.",
|
noMatch: "No sessions match the filter.",
|
||||||
|
// Active-sessions table column headers.
|
||||||
|
colWho: "Ticket / subscriber",
|
||||||
|
colPlate: "Plate",
|
||||||
|
colEntry: "Entry",
|
||||||
|
colElapsed: "Elapsed",
|
||||||
badgeOverstay: "overstay",
|
badgeOverstay: "overstay",
|
||||||
badgeOverstayTitle:
|
badgeOverstayTitle:
|
||||||
"Paid session. The customer failed to exit during the grace period. A new period began.",
|
"Paid session. The customer failed to exit during the grace period. A new period began.",
|
||||||
@@ -141,14 +149,8 @@ export const en: Catalog = {
|
|||||||
filterSearchSessions: "Search ticket / subscriber / plate…",
|
filterSearchSessions: "Search ticket / subscriber / plate…",
|
||||||
filterSearchFeed: "Search event / identity / plate…",
|
filterSearchFeed: "Search event / identity / plate…",
|
||||||
filterAll: "All",
|
filterAll: "All",
|
||||||
fStatusUnpaid: "Unpaid",
|
|
||||||
fStatusPaid: "Paid",
|
|
||||||
fStatusExiting: "Exiting",
|
|
||||||
fStatusOverstay: "Overstay",
|
|
||||||
fKindTransient: "Transient",
|
fKindTransient: "Transient",
|
||||||
fKindSubscription: "Subscribers",
|
fKindSubscription: "Subscribers",
|
||||||
fDirEntry: "Entry",
|
|
||||||
fDirExit: "Exit",
|
|
||||||
fSrcBooth: "Booth",
|
fSrcBooth: "Booth",
|
||||||
fSrcReader: "Reader",
|
fSrcReader: "Reader",
|
||||||
fEvtEntry: "Entry",
|
fEvtEntry: "Entry",
|
||||||
@@ -359,18 +361,49 @@ export const en: Catalog = {
|
|||||||
relaysTitle: "Relays on this controller",
|
relaysTitle: "Relays on this controller",
|
||||||
relaysHint:
|
relaysHint:
|
||||||
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
|
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
|
||||||
|
outputsTitle: "Outputs — relays (barriers + lamp)",
|
||||||
|
outputsHint:
|
||||||
|
"Relays are OUTPUTS: each opens a barrier (or drives the button lamp). Set the relay number and direction. The input terminals (button, sensor) are in the Inputs section below.",
|
||||||
|
pulseOpenMs: "Pulse open (ms)",
|
||||||
|
pulseOpenHint: "How long a barrier relay is held open (jog). Applies to all barrier relays.",
|
||||||
|
inputsTitle: "Inputs — terminals (button, sensor)",
|
||||||
|
inputsHint:
|
||||||
|
"Inputs are TERMINALS the host READS: the entry button and the presence/radar sensor. Each belongs to an entry barrier — it triggers or gates that relay.",
|
||||||
|
inputsIdleHigh: "Inputs idle HIGH",
|
||||||
|
inputsIdleHighHint: "This board idles inputs HIGH (status 1111); a press pulls LOW.",
|
||||||
relay: "Relay",
|
relay: "Relay",
|
||||||
entryButtonTerminal: "Entry button on terminal",
|
// Generic input rows: terminal + role + the relay it serves.
|
||||||
presenceInput: "Presence loop (terminal)",
|
inputTerminal: "Terminal",
|
||||||
presenceInputHint:
|
inputServesRelay: "Serves relay",
|
||||||
"Input terminal the vehicle-presence loop / barrier feedback is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the loop clears (the car drove in) and a new car re-occupies it. Preferred mode.",
|
roleButton: "Entry button",
|
||||||
|
rolePresenceLoop: "Presence (loop)",
|
||||||
|
rolePresenceRadar: "Presence (radar)",
|
||||||
|
roleAlertTrigger: "Alert trigger",
|
||||||
|
addInput: "+ Add input",
|
||||||
entryCooldown: "Cooldown after ticket (s)",
|
entryCooldown: "Cooldown after ticket (s)",
|
||||||
entryCooldownHint:
|
entryCooldownHint:
|
||||||
"When there's no presence loop: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
|
"When there's no presence sensor: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
|
||||||
|
activeLow: "Active-low",
|
||||||
|
activeLowHint:
|
||||||
|
"Tick if the presence sensor (e.g. a radar) idles HIGH and goes LOW on detection — the opposite of the button. This inverts that terminal's reading so 'present' is read correctly.",
|
||||||
|
eventRadarAlert: "Radar alert (lamp)",
|
||||||
|
triggerInput: "Trigger input",
|
||||||
|
triggerInputHint:
|
||||||
|
"The input terminal (the radar) that starts this relay blinking. Blinks while the trigger is active but the camera doesn't confirm a car; solid on once the camera confirms; off otherwise.",
|
||||||
|
lockLane: "Lock from",
|
||||||
|
lockLaneHint:
|
||||||
|
"Which camera locks the lamp solid: the entry or the exit camera. An exit radar must lock on the EXIT camera.",
|
||||||
|
lockLaneEntry: "Entry camera",
|
||||||
|
lockLaneExit: "Exit camera",
|
||||||
|
blinkOnMs: "Blink on (ms)",
|
||||||
|
blinkOffMs: "Blink off (ms)",
|
||||||
addRelay: "+ Add relay",
|
addRelay: "+ Add relay",
|
||||||
anpr: "Plate recognition (ANPR)",
|
anpr: "Plate recognition (ANPR)",
|
||||||
anprHint:
|
anprHint:
|
||||||
"Enable to scan plates on this camera: the vision service reads the plate from a snapshot and feeds it as a read (advisory only — it never opens a barrier on its own). Requires the vision service running.",
|
"Read plates on this camera: the vision service reads the plate from each snapshot and records it (both entry and exit). Requires the vision service running.",
|
||||||
|
anprAuto: "Auto open/close on subscriber plate",
|
||||||
|
anprAutoHint:
|
||||||
|
"Let THIS camera auto-open the barrier when it recognises a subscriber's plate. Turn OFF on a shared entry/exit lane's exit camera, so a car driving IN isn't auto-EXITed by its back plate (recognition still runs — only the auto-trigger is off).",
|
||||||
testAnpr: "Test ANPR",
|
testAnpr: "Test ANPR",
|
||||||
anprTesting: "Testing ANPR…",
|
anprTesting: "Testing ANPR…",
|
||||||
testAnprHint:
|
testAnprHint:
|
||||||
@@ -381,6 +414,29 @@ export const en: Catalog = {
|
|||||||
"anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.",
|
"anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.",
|
||||||
"anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).",
|
"anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).",
|
||||||
"anprFail.no-plate": "No plate found in the snapshot.",
|
"anprFail.no-plate": "No plate found in the snapshot.",
|
||||||
|
// Printer test slip — pushes a real slip so the admin can confirm it physically prints.
|
||||||
|
testPrint: "Print test slip",
|
||||||
|
printTesting: "Printing…",
|
||||||
|
testPrintHint:
|
||||||
|
"Sends a test slip to the printer now. ‘Connected’ only opens the link — this confirms the printer actually feeds paper.",
|
||||||
|
printOk: "✓ Test slip sent ({{ms}} ms). Check the printer.",
|
||||||
|
"printFail.print-failed": "The printer rejected the job (out of paper, cover open, or the link dropped).",
|
||||||
|
// Reveal/hide toggle for a secret field (e.g. the device web password).
|
||||||
|
revealSecret: "Show password",
|
||||||
|
hideSecret: "Hide password",
|
||||||
|
alarmUrlTitle: "Alarm Server settings (enter these in the camera)",
|
||||||
|
alarmUrlHint:
|
||||||
|
"Enter these in the camera at Configuration → Event → … → Alarm Settings (or Notify Surveillance Center). The camera POSTs every event here — no polling.",
|
||||||
|
alarmUrlCopy: "Copy all",
|
||||||
|
alarmUrlCopied: "Copied ✓",
|
||||||
|
alarmUrlSaveFirst:
|
||||||
|
"Save the camera first — the address is generated once the device has an ID. Re-open it for editing to see it.",
|
||||||
|
alarmUrlTestFirst:
|
||||||
|
"Click “Test connection” first — that resolves this host's IP on the camera's network (so the camera can reach it).",
|
||||||
|
alarmFieldHost: "Destination IP / Host",
|
||||||
|
alarmFieldUrl: "URL",
|
||||||
|
alarmFieldProtocol: "Protocol",
|
||||||
|
alarmFieldPort: "Port",
|
||||||
whichBarrier: "Which barrier does this device serve?",
|
whichBarrier: "Which barrier does this device serve?",
|
||||||
controller: "Controller",
|
controller: "Controller",
|
||||||
choose: "Choose…",
|
choose: "Choose…",
|
||||||
@@ -561,7 +617,7 @@ export const en: Catalog = {
|
|||||||
save: "Save",
|
save: "Save",
|
||||||
saved: "Saved.",
|
saved: "Saved.",
|
||||||
fieldParkName: "Park name",
|
fieldParkName: "Park name",
|
||||||
fieldParkNamePh: "e.g. Acme Parking",
|
fieldParkNamePh: "e.g. Airport Parking",
|
||||||
fieldOperator: "Operator (legal name)",
|
fieldOperator: "Operator (legal name)",
|
||||||
fieldOperatorPh: "operating company",
|
fieldOperatorPh: "operating company",
|
||||||
fieldNius: "NIUS",
|
fieldNius: "NIUS",
|
||||||
@@ -645,10 +701,9 @@ export const en: Catalog = {
|
|||||||
card: "Card:",
|
card: "Card:",
|
||||||
srcTickets: "Tickets:",
|
srcTickets: "Tickets:",
|
||||||
srcSubscriptions: "Subscriptions:",
|
srcSubscriptions: "Subscriptions:",
|
||||||
srcSubSales: "sales",
|
|
||||||
srcSubWindow: "out-of-window",
|
srcSubWindow: "out-of-window",
|
||||||
drawerSection: "— Drawer —",
|
drawerSection: "— Drawer —",
|
||||||
openingFloat: "Opening float:",
|
openingFloat: "Opening cash:",
|
||||||
cashTaken: "Cash taken:",
|
cashTaken: "Cash taken:",
|
||||||
cashAdded: "Cash added:",
|
cashAdded: "Cash added:",
|
||||||
cashRemoved: "Cash removed:",
|
cashRemoved: "Cash removed:",
|
||||||
@@ -682,7 +737,6 @@ export const en: Catalog = {
|
|||||||
card: "Card",
|
card: "Card",
|
||||||
srcTickets: "Tickets",
|
srcTickets: "Tickets",
|
||||||
srcSubscriptions: "Subscriptions",
|
srcSubscriptions: "Subscriptions",
|
||||||
srcSubSales: "subs sales",
|
|
||||||
srcSubWindow: "out-of-window",
|
srcSubWindow: "out-of-window",
|
||||||
expectedDrawer: "Expected drawer",
|
expectedDrawer: "Expected drawer",
|
||||||
filterFrom: "From",
|
filterFrom: "From",
|
||||||
@@ -700,7 +754,7 @@ export const en: Catalog = {
|
|||||||
noActivity: "No activity in this shift.",
|
noActivity: "No activity in this shift.",
|
||||||
current: "current",
|
current: "current",
|
||||||
drawerSection: "Drawer",
|
drawerSection: "Drawer",
|
||||||
openingFloat: "Opening float",
|
openingFloat: "Opening cash",
|
||||||
cashTaken: "Cash taken",
|
cashTaken: "Cash taken",
|
||||||
cashAdded: "Cash added",
|
cashAdded: "Cash added",
|
||||||
cashRemoved: "Cash removed",
|
cashRemoved: "Cash removed",
|
||||||
|
|||||||
+83
-28
@@ -14,6 +14,9 @@ export const sq = {
|
|||||||
themeDark: "errët",
|
themeDark: "errët",
|
||||||
themeLight: "çelët",
|
themeLight: "çelët",
|
||||||
theme: "Tema",
|
theme: "Tema",
|
||||||
|
fontSmaller: "Zvogëlo tekstin",
|
||||||
|
fontLarger: "Rrit tekstin",
|
||||||
|
fontSize: "Madhësia e tekstit",
|
||||||
today: "Sot",
|
today: "Sot",
|
||||||
yesterday: "Dje",
|
yesterday: "Dje",
|
||||||
// Month names (index 0 = January) — kept in the catalog because the appliance's
|
// Month names (index 0 = January) — kept in the catalog because the appliance's
|
||||||
@@ -120,7 +123,7 @@ export const sq = {
|
|||||||
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
|
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
|
||||||
laneEntry: "Hyrje",
|
laneEntry: "Hyrje",
|
||||||
laneExit: "Dalje",
|
laneExit: "Dalje",
|
||||||
open: "Hap",
|
openTicket: "Lexo",
|
||||||
occupancy: "Prania",
|
occupancy: "Prania",
|
||||||
occUnavailable: "zënia e padisponueshme",
|
occUnavailable: "zënia e padisponueshme",
|
||||||
inside: "brenda",
|
inside: "brenda",
|
||||||
@@ -135,6 +138,11 @@ export const sq = {
|
|||||||
insideCount: "brenda",
|
insideCount: "brenda",
|
||||||
noActiveSessions: "Asnjë sesion aktiv.",
|
noActiveSessions: "Asnjë sesion aktiv.",
|
||||||
noMatch: "Asnjë rezultat për filtrin.",
|
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",
|
badgeOverstay: "tej afatit",
|
||||||
badgeOverstayTitle:
|
badgeOverstayTitle:
|
||||||
"Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.",
|
"Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.",
|
||||||
@@ -143,14 +151,8 @@ export const sq = {
|
|||||||
filterSearchSessions: "Kërko biletë / abonent / targë…",
|
filterSearchSessions: "Kërko biletë / abonent / targë…",
|
||||||
filterSearchFeed: "Kërko event / identitet / targë…",
|
filterSearchFeed: "Kërko event / identitet / targë…",
|
||||||
filterAll: "Të gjitha",
|
filterAll: "Të gjitha",
|
||||||
fStatusUnpaid: "Papaguar",
|
|
||||||
fStatusPaid: "Paguar",
|
|
||||||
fStatusExiting: "Duke dalë",
|
|
||||||
fStatusOverstay: "Tej afatit",
|
|
||||||
fKindTransient: "Kalimtarë",
|
fKindTransient: "Kalimtarë",
|
||||||
fKindSubscription: "Abonentë",
|
fKindSubscription: "Abonentë",
|
||||||
fDirEntry: "Hyrje",
|
|
||||||
fDirExit: "Dalje",
|
|
||||||
fSrcBooth: "Kabinë",
|
fSrcBooth: "Kabinë",
|
||||||
fSrcReader: "Lexues",
|
fSrcReader: "Lexues",
|
||||||
fEvtEntry: "Hyrje",
|
fEvtEntry: "Hyrje",
|
||||||
@@ -308,21 +310,21 @@ export const sq = {
|
|||||||
setup: {
|
setup: {
|
||||||
title: "Konfigurimi",
|
title: "Konfigurimi",
|
||||||
intro:
|
intro:
|
||||||
"Shto fillimisht kontrolluesit e barrierave — cakto cili rele është hyrje/dalje dhe në cilin terminal është lidhur butoni i hyrjes. Pastaj shto lexues, kamera dhe printera dhe drejto secilin te barriera që shërben.",
|
"Shto fillimisht kontrollerat e barrierave — cakto cili rele është hyrje/dalje dhe në cilin terminal është lidhur butoni i hyrjes. Pastaj shto lexues, kamera dhe printera dhe drejto secilin te barriera që shërben.",
|
||||||
// Category titles + the singular noun used in buttons/modal titles.
|
// Category titles + the singular noun used in buttons/modal titles.
|
||||||
catControllers: "Kontrolluesit (barrierat + butoni i hyrjes)",
|
catControllers: "Kontrollerat (barrierat + butoni i hyrjes)",
|
||||||
catReaders: "Lexuesit (QR / RFID)",
|
catReaders: "Lexuesit (QR / RFID)",
|
||||||
catCameras: "Kamerat (foto + targë)",
|
catCameras: "Kamerat (foto + targë)",
|
||||||
catPrinters: "Printerat (bileta / vouchera)",
|
catPrinters: "Printerat (bileta / vouchera)",
|
||||||
nounController: "kontrollues",
|
nounController: "kontroller",
|
||||||
nounReader: "lexues",
|
nounReader: "lexues",
|
||||||
nounCamera: "kamerë",
|
nounCamera: "kamerë",
|
||||||
nounPrinter: "printer",
|
nounPrinter: "printer",
|
||||||
add: "+ Shto {{noun}}",
|
add: "+ Shto {{noun}}",
|
||||||
addAnother: "+ Shto edhe një {{noun}}",
|
addAnother: "+ Shto {{noun}}",
|
||||||
addTitle: "Shto {{noun}}",
|
addTitle: "Shto {{noun}}",
|
||||||
editTitle: "Ndrysho {{noun}}",
|
editTitle: "Ndrysho {{noun}}",
|
||||||
needControllerFirst: "Shto fillimisht një kontrollues — {{noun}} drejtohet te një prej releve të tij.",
|
needControllerFirst: "Shto fillimisht një kontroller — {{noun}} drejtohet te një prej releve të tij.",
|
||||||
failedToLoad: "Ngarkimi i konfigurimit dështoi: {{error}}",
|
failedToLoad: "Ngarkimi i konfigurimit dështoi: {{error}}",
|
||||||
loadingCatalog: "Duke ngarkuar katalogun e pajisjeve…",
|
loadingCatalog: "Duke ngarkuar katalogun e pajisjeve…",
|
||||||
// Direction labels (relay direction + inherited binding).
|
// Direction labels (relay direction + inherited binding).
|
||||||
@@ -344,9 +346,9 @@ export const sq = {
|
|||||||
// Device form.
|
// Device form.
|
||||||
noDrivers: "Asnjë drejtues i regjistruar.",
|
noDrivers: "Asnjë drejtues i regjistruar.",
|
||||||
chooseDevice: "Zgjidh një pajisje…",
|
chooseDevice: "Zgjidh një pajisje…",
|
||||||
scan: "Skano për kontrollues",
|
scan: "Skano për kontroller",
|
||||||
scanning: "Duke skanuar…",
|
scanning: "Duke skanuar…",
|
||||||
noControllersFound: "Asnjë kontrollues në LAN.",
|
noControllersFound: "Asnjë kontroller në LAN.",
|
||||||
use: "Përdor",
|
use: "Përdor",
|
||||||
test: "Testo lidhjen",
|
test: "Testo lidhjen",
|
||||||
testing: "Duke testuar…",
|
testing: "Duke testuar…",
|
||||||
@@ -365,22 +367,53 @@ export const sq = {
|
|||||||
noNicOnSubnet: "⚠ asnjë NIC në subnetin e pajisjes — pajisja mund të mos arrijë backend-in",
|
noNicOnSubnet: "⚠ asnjë NIC në subnetin e pajisjes — pajisja mund të mos arrijë backend-in",
|
||||||
backendIpHint: "Adresa te e cila kjo pajisje do të dërgojë eventet e hyrjes.",
|
backendIpHint: "Adresa te e cila kjo pajisje do të dërgojë eventet e hyrjes.",
|
||||||
// Relay editor.
|
// Relay editor.
|
||||||
relaysTitle: "Relet në këtë kontrollues",
|
relaysTitle: "Relet në këtë kontroller",
|
||||||
relaysHint:
|
relaysHint:
|
||||||
"Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.",
|
"Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.",
|
||||||
|
outputsTitle: "Daljet — relet (barrierat + drita)",
|
||||||
|
outputsHint:
|
||||||
|
"Relet janë DALJE: secila hap një barrierë (ose ndez dritën e butonit). Cakto numrin e relesë dhe drejtimin. Terminalet hyrëse (butoni, sensori) janë te seksioni Hyrjet më poshtë.",
|
||||||
|
pulseOpenMs: "Kohëzgjatja e hapjes (ms)",
|
||||||
|
pulseOpenHint: "Sa kohë mbahet rele e barrierës e hapur (jog). Vlen për të gjitha relet e barrierave.",
|
||||||
|
inputsTitle: "Hyrjet — terminalet (buton, sensor)",
|
||||||
|
inputsHint:
|
||||||
|
"Hyrjet janë TERMINALE që hosti i LEXON: butoni i hyrjes dhe sensori i pranisë/radari. Secila i përket një barriere hyrëse — e gateron ose e nis atë rele.",
|
||||||
|
inputsIdleHigh: "Hyrjet në pushim HIGH",
|
||||||
|
inputsIdleHighHint: "Kjo pllakë i mban hyrjet HIGH në pushim (statusi 1111); një shtypje e ul në LOW.",
|
||||||
relay: "Rele",
|
relay: "Rele",
|
||||||
entryButtonTerminal: "Butoni i hyrjes në terminalin",
|
// Generic input rows: terminal + role + the relay it serves.
|
||||||
presenceInput: "Sensori i pranisë (terminali)",
|
inputTerminal: "Terminali",
|
||||||
presenceInputHint:
|
inputServesRelay: "I shërben reles",
|
||||||
"Terminali hyrës ku është lidhur sensori/laku i pranisë së automjetit. Kur vendoset, lëshohet vetëm NJË biletë për automjet: butoni printon vetëm kur ka makinë, dhe nuk lëshon biletë të dytë derisa laku të lirohet (makina hyri) dhe një makinë e re ta zërë. Mënyra e preferuar.",
|
roleButton: "Butoni i hyrjes",
|
||||||
|
rolePresenceLoop: "Prania (lak induktiv)",
|
||||||
|
rolePresenceRadar: "Prania (radar)",
|
||||||
|
roleAlertTrigger: "Trigger alarmi",
|
||||||
|
addInput: "+ Shto hyrje",
|
||||||
entryCooldown: "Pritje pas biletës (sek)",
|
entryCooldown: "Pritje pas biletës (sek)",
|
||||||
entryCooldownHint:
|
entryCooldownHint:
|
||||||
"Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.",
|
"Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.",
|
||||||
|
activeLow: "Aktiv-ulët",
|
||||||
|
activeLowHint:
|
||||||
|
"Shëno nëse sensori i pranisë (p.sh. radari) qëndron HIGH në pushim dhe shkon LOW kur detekton — e kundërta e butonit. Kjo përmbys leximin e atij terminali që 'prania' të lexohet saktë.",
|
||||||
|
eventRadarAlert: "Alarm radar (dritë)",
|
||||||
|
triggerInput: "Trigger input",
|
||||||
|
triggerInputHint:
|
||||||
|
"Terminali i hyrjes (radari) që nis pulsimin e kësaj rele. Pulson kur Trigger input është aktiv por kamera s'konfirmon makinë; ndizet fiks kur kamera konfirmon; përndryshe fiket.",
|
||||||
|
lockLane: "Bllokimi nga",
|
||||||
|
lockLaneHint:
|
||||||
|
"Cila kamerë e ndez dritën fiks: hyrja apo dalja. Një radar i daljes duhet të bllokohet nga kamera e DALJES.",
|
||||||
|
lockLaneEntry: "Kamera e hyrjes",
|
||||||
|
lockLaneExit: "Kamera e daljes",
|
||||||
|
blinkOnMs: "Pulsim ndezur (ms)",
|
||||||
|
blinkOffMs: "Pulsim fikur (ms)",
|
||||||
addRelay: "+ Shto rele",
|
addRelay: "+ Shto rele",
|
||||||
// Camera ANPR opt-in.
|
// Camera ANPR opt-in.
|
||||||
anpr: "Njohja e targave (ANPR)",
|
anpr: "Njohja e targave (ANPR)",
|
||||||
anprHint:
|
anprHint:
|
||||||
"Aktivizo që ky aparat të skanojë targat: shërbimi i vizionit lexon targën nga pamja dhe e dërgon si lexim (vetëm këshillues — nuk hap vetë barrierën). Kërkon shërbimin e vizionit aktiv.",
|
"Lexo targat në këtë aparat: shërbimi i vizionit lexon targën nga çdo pamje dhe e regjistron (hyrje dhe dalje). Kërkon shërbimin e vizionit aktiv.",
|
||||||
|
anprAuto: "Hapje/mbyllje automatike me targën e abonentit",
|
||||||
|
anprAutoHint:
|
||||||
|
"Lejo që KY aparat të hapë vetë barrierën kur njeh targën e një abonenti. ÇAKTIVIZOJE te aparati i daljes në një korsi të përbashkët hyrje/dalje, që një makinë që HYN të mos DALË automatikisht nga targa e pasme (njohja vazhdon — fiket vetëm hapja automatike).",
|
||||||
testAnpr: "Testo ANPR",
|
testAnpr: "Testo ANPR",
|
||||||
anprTesting: "Duke testuar ANPR…",
|
anprTesting: "Duke testuar ANPR…",
|
||||||
testAnprHint:
|
testAnprHint:
|
||||||
@@ -391,12 +424,36 @@ export const sq = {
|
|||||||
"anprFail.vision-disabled": "Shërbimi i vizionit është çaktivizuar — aktivizoje (VISION_ENABLED) për ta testuar ANPR.",
|
"anprFail.vision-disabled": "Shërbimi i vizionit është çaktivizuar — aktivizoje (VISION_ENABLED) për ta testuar ANPR.",
|
||||||
"anprFail.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).",
|
"anprFail.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).",
|
||||||
"anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.",
|
"anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.",
|
||||||
|
// Printer test slip — pushes a real slip so the admin can confirm it physically prints.
|
||||||
|
testPrint: "Printo provë",
|
||||||
|
printTesting: "Duke printuar…",
|
||||||
|
testPrintHint:
|
||||||
|
"Dërgon një fletë prove te printeri tani. ‘I lidhur’ vetëm hap lidhjen — kjo konfirmon se printeri vërtet nxjerr letër.",
|
||||||
|
printOk: "✓ Fleta e provës u dërgua ({{ms}} ms). Kontrollo printerin.",
|
||||||
|
"printFail.print-failed": "Printeri nuk pranoi punën (pa letër, kapaku hapur, ose lidhja ra).",
|
||||||
|
// Reveal/hide toggle for a secret field (e.g. the device web password).
|
||||||
|
revealSecret: "Shfaq fjalëkalimin",
|
||||||
|
hideSecret: "Fshih fjalëkalimin",
|
||||||
|
// Alarm Server push settings — generated for the camera's Event → Alarm Server form.
|
||||||
|
alarmUrlTitle: "Cilësimet e Alarm Server (vendosi te kamera)",
|
||||||
|
alarmUrlHint:
|
||||||
|
"Vendosi këto te kamera: Configuration → Event → … → Alarm Settings (ose Notify Surveillance Center). Kamera do të dërgojë çdo ngjarje këtu — pa polling.",
|
||||||
|
alarmUrlCopy: "Kopjo të gjitha",
|
||||||
|
alarmUrlCopied: "U kopjua ✓",
|
||||||
|
alarmUrlSaveFirst:
|
||||||
|
"Ruaje kamerën më parë — adresa gjenerohet pasi pajisja të marrë një ID. Hape sërish për editim që ta shohësh.",
|
||||||
|
alarmUrlTestFirst:
|
||||||
|
"Kliko “Testo lidhjen” më parë — kështu përcaktohet IP-ja e këtij hosti në rrjetin e kamerës (që kamera ta thërrasë).",
|
||||||
|
alarmFieldHost: "Destination IP / Host",
|
||||||
|
alarmFieldUrl: "URL",
|
||||||
|
alarmFieldProtocol: "Protokolli",
|
||||||
|
alarmFieldPort: "Porta",
|
||||||
// Binding picker.
|
// Binding picker.
|
||||||
whichBarrier: "Cilën barrierë shërben kjo pajisje?",
|
whichBarrier: "Cilën barrierë shërben kjo pajisje?",
|
||||||
controller: "Kontrolluesi",
|
controller: "Kontrolleri",
|
||||||
choose: "Zgjidh…",
|
choose: "Zgjidh…",
|
||||||
relayLabel: "Rele {{relay}} ({{direction}})",
|
relayLabel: "Rele {{relay}} ({{direction}})",
|
||||||
noRelaysConfigured: "Ky kontrollues nuk ka rele të konfiguruar.",
|
noRelaysConfigured: "Ky kontroller nuk ka rele të konfiguruar.",
|
||||||
},
|
},
|
||||||
lab: {
|
lab: {
|
||||||
title: "Lab Tarife",
|
title: "Lab Tarife",
|
||||||
@@ -572,7 +629,7 @@ export const sq = {
|
|||||||
save: "Ruaj",
|
save: "Ruaj",
|
||||||
saved: "U ruajt.",
|
saved: "U ruajt.",
|
||||||
fieldParkName: "Emri i parkimit",
|
fieldParkName: "Emri i parkimit",
|
||||||
fieldParkNamePh: "p.sh. Acme Parking",
|
fieldParkNamePh: "p.sh. Parking Aeroport",
|
||||||
fieldOperator: "Operatori (emri ligjor)",
|
fieldOperator: "Operatori (emri ligjor)",
|
||||||
fieldOperatorPh: "kompania operuese",
|
fieldOperatorPh: "kompania operuese",
|
||||||
fieldNius: "NIUS",
|
fieldNius: "NIUS",
|
||||||
@@ -629,7 +686,7 @@ export const sq = {
|
|||||||
starting: "Duke filluar…",
|
starting: "Duke filluar…",
|
||||||
endShift: "Mbyll turnin",
|
endShift: "Mbyll turnin",
|
||||||
ending: "Duke mbyllur…",
|
ending: "Duke mbyllur…",
|
||||||
endConfirm: "Të mbyllet ky turn? Regjistrohet dhe printohet një Raport Z i nënshkruar.",
|
endConfirm: "",
|
||||||
drawer: "Arka:",
|
drawer: "Arka:",
|
||||||
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
|
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
|
||||||
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
|
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
|
||||||
@@ -657,10 +714,9 @@ export const sq = {
|
|||||||
card: "Kartë:",
|
card: "Kartë:",
|
||||||
srcTickets: "Bileta:",
|
srcTickets: "Bileta:",
|
||||||
srcSubscriptions: "Abonime:",
|
srcSubscriptions: "Abonime:",
|
||||||
srcSubSales: "shitje",
|
|
||||||
srcSubWindow: "jashtë orarit",
|
srcSubWindow: "jashtë orarit",
|
||||||
drawerSection: "— Arka —",
|
drawerSection: "— Arka —",
|
||||||
openingFloat: "Bilanci fillestar:",
|
openingFloat: "Arka fillestare:",
|
||||||
cashTaken: "Para të marra:",
|
cashTaken: "Para të marra:",
|
||||||
cashAdded: "Para të shtuara:",
|
cashAdded: "Para të shtuara:",
|
||||||
cashRemoved: "Para të hequra:",
|
cashRemoved: "Para të hequra:",
|
||||||
@@ -694,7 +750,6 @@ export const sq = {
|
|||||||
card: "Kartë",
|
card: "Kartë",
|
||||||
srcTickets: "Bileta",
|
srcTickets: "Bileta",
|
||||||
srcSubscriptions: "Abonime",
|
srcSubscriptions: "Abonime",
|
||||||
srcSubSales: "shitje abonimesh",
|
|
||||||
srcSubWindow: "jashtë orarit",
|
srcSubWindow: "jashtë orarit",
|
||||||
expectedDrawer: "Gjëndje arke",
|
expectedDrawer: "Gjëndje arke",
|
||||||
// Filter (admin only).
|
// Filter (admin only).
|
||||||
@@ -714,7 +769,7 @@ export const sq = {
|
|||||||
current: "aktual",
|
current: "aktual",
|
||||||
// Expanded drawer detail.
|
// Expanded drawer detail.
|
||||||
drawerSection: "Arka",
|
drawerSection: "Arka",
|
||||||
openingFloat: "Bilanci fillestar",
|
openingFloat: "Arka fillestare",
|
||||||
cashTaken: "Para të marra",
|
cashTaken: "Para të marra",
|
||||||
cashAdded: "Para të shtuara",
|
cashAdded: "Para të shtuara",
|
||||||
cashRemoved: "Para të hequra",
|
cashRemoved: "Para të hequra",
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ export interface LaneStatus {
|
|||||||
exit: boolean; // true = busy
|
exit: boolean; // true = busy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-lane RADAR presence — a presence input (loop/radar) is shorted at the barrier,
|
||||||
|
* i.e. "something is in the lane" BEFORE the camera confirms a vehicle. Drives the
|
||||||
|
* barrier light's BLINK (the same signal as the physical button lamp / relay 3). */
|
||||||
|
export interface LanePresence {
|
||||||
|
entry: boolean; // true = a radar/presence input on an entry barrier is active
|
||||||
|
exit: boolean; // true = … on an exit barrier
|
||||||
|
}
|
||||||
|
|
||||||
/** Cap the in-memory live feed so a long-running booth session can't grow it
|
/** Cap the in-memory live feed so a long-running booth session can't grow it
|
||||||
* unbounded — the full history is always available via the /api/events query. */
|
* unbounded — the full history is always available via the /api/events query. */
|
||||||
const MAX_FEED = 200;
|
const MAX_FEED = 200;
|
||||||
@@ -31,6 +39,8 @@ interface LiveState {
|
|||||||
devices: Record<string, DeviceStatus>;
|
devices: Record<string, DeviceStatus>;
|
||||||
/** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */
|
/** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */
|
||||||
lanes: LaneStatus | null;
|
lanes: LaneStatus | null;
|
||||||
|
/** Per-lane radar presence (advisory blink). Null until the first WS hello. */
|
||||||
|
radar: LanePresence | null;
|
||||||
setStatus: (s: WsStatus) => void;
|
setStatus: (s: WsStatus) => void;
|
||||||
setOccupancy: (o: Occupancy) => void;
|
setOccupancy: (o: Occupancy) => void;
|
||||||
pushEvent: (e: LedgerEvent) => void;
|
pushEvent: (e: LedgerEvent) => void;
|
||||||
@@ -40,6 +50,11 @@ interface LiveState {
|
|||||||
upsertDevice: (d: DeviceStatus) => void;
|
upsertDevice: (d: DeviceStatus) => void;
|
||||||
/** Set lane busy/free (WS hello + each lane-status push). */
|
/** Set lane busy/free (WS hello + each lane-status push). */
|
||||||
setLanes: (l: LaneStatus) => void;
|
setLanes: (l: LaneStatus) => void;
|
||||||
|
/** Set lane radar presence (WS hello + each lane-presence push). */
|
||||||
|
setRadar: (r: LanePresence) => void;
|
||||||
|
/** 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;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +71,7 @@ export const useLiveStore = create<LiveState>((set) => ({
|
|||||||
feed: [],
|
feed: [],
|
||||||
devices: {},
|
devices: {},
|
||||||
lanes: null,
|
lanes: null,
|
||||||
|
radar: null,
|
||||||
setStatus: (status) => set({ status }),
|
setStatus: (status) => set({ status }),
|
||||||
setOccupancy: (occupancy) => set({ occupancy }),
|
setOccupancy: (occupancy) => set({ occupancy }),
|
||||||
pushEvent: (e) =>
|
pushEvent: (e) =>
|
||||||
@@ -66,5 +82,11 @@ export const useLiveStore = create<LiveState>((set) => ({
|
|||||||
setDevices: (list) => set({ devices: byId(list) }),
|
setDevices: (list) => set({ devices: byId(list) }),
|
||||||
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
|
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
|
||||||
setLanes: (lanes) => set({ lanes }),
|
setLanes: (lanes) => set({ lanes }),
|
||||||
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null }),
|
setRadar: (radar) => set({ radar }),
|
||||||
|
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 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;
|
// Theme + font-scale application. The whole UI reads colour through the --color-term-*
|
||||||
// the light palette lives in index.css under `html.theme-light`. Applying a theme is
|
// tokens; the light palette lives in index.css under `html.theme-light`. Applying a theme
|
||||||
// just toggling that class on <html>. The active theme is the LOGGED-IN USER's stored
|
// is just toggling that class on <html>. Both are the LOGGED-IN USER's stored preferences
|
||||||
// preference (users.theme), applied via applyTheme() after auth resolves — mirroring
|
// (users.theme / users.font_scale), applied after auth resolves — mirroring how language
|
||||||
// how language works. Dark is the default before auth resolves. Printed tickets are
|
// works. Defaults (dark, 100%) apply before auth resolves. Printed tickets are unaffected.
|
||||||
// unaffected (always Albanian, dark-agnostic).
|
|
||||||
|
|
||||||
/** Apply a theme by toggling `theme-light` on <html>. Dark is the absence of the
|
/** 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. */
|
* class (the base tokens). No-op-safe to call repeatedly. */
|
||||||
export function applyTheme(theme: Theme): void {
|
export function applyTheme(theme: Theme): void {
|
||||||
document.documentElement.classList.toggle("theme-light", theme === "light");
|
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 { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
||||||
import { qk } from "./query.js";
|
import { qk } from "./query.js";
|
||||||
import { useLiveStore, type LaneStatus } from "./live-store.js";
|
import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js";
|
||||||
import { wsUrl } from "./origin.js";
|
import { wsUrl } from "./origin.js";
|
||||||
|
|
||||||
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
||||||
@@ -14,16 +14,19 @@ import { wsUrl } from "./origin.js";
|
|||||||
|
|
||||||
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
||||||
type WsMessage =
|
type WsMessage =
|
||||||
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus }
|
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus; radar: LanePresence }
|
||||||
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
||||||
| { kind: "printer-status"; event: unknown }
|
| { kind: "printer-status"; event: unknown }
|
||||||
| { kind: "device-status"; event: DeviceStatus }
|
| { kind: "device-status"; event: DeviceStatus }
|
||||||
| { kind: "lane-status"; lanes: LaneStatus };
|
| { kind: "lane-status"; lanes: LaneStatus }
|
||||||
|
| { kind: "lane-presence"; radar: LanePresence }
|
||||||
|
| { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } };
|
||||||
|
|
||||||
|
|
||||||
export function useLiveFeed(): void {
|
export function useLiveFeed(): void {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes } = useLiveStore();
|
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } =
|
||||||
|
useLiveStore();
|
||||||
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
||||||
// double-invoke and unmount.
|
// double-invoke and unmount.
|
||||||
const sockRef = useRef<WebSocket | null>(null);
|
const sockRef = useRef<WebSocket | null>(null);
|
||||||
@@ -56,10 +59,18 @@ export function useLiveFeed(): void {
|
|||||||
// Initial device-status snapshot for the footer.
|
// Initial device-status snapshot for the footer.
|
||||||
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
||||||
if (msg.lanes) setLanes(msg.lanes);
|
if (msg.lanes) setLanes(msg.lanes);
|
||||||
|
if (msg.radar) setRadar(msg.radar);
|
||||||
} else if (msg.kind === "device-status") {
|
} else if (msg.kind === "device-status") {
|
||||||
upsertDevice(msg.event);
|
upsertDevice(msg.event);
|
||||||
} else if (msg.kind === "lane-status") {
|
} else if (msg.kind === "lane-status") {
|
||||||
setLanes(msg.lanes);
|
setLanes(msg.lanes);
|
||||||
|
} else if (msg.kind === "lane-presence") {
|
||||||
|
setRadar(msg.radar);
|
||||||
|
} else if (msg.kind === "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") {
|
} else if (msg.kind === "ledger") {
|
||||||
setOccupancy(msg.occupancy);
|
setOccupancy(msg.occupancy);
|
||||||
pushEvent(msg.event);
|
pushEvent(msg.event);
|
||||||
|
|||||||
+66
-14
@@ -10,11 +10,23 @@ import { lazy, Suspense, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
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 { qk, queryClient } from "./lib/query.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
import { setLanguage } from "./lib/i18n/index.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 { useLiveFeed } from "./lib/use-live-feed.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
||||||
@@ -56,7 +68,7 @@ function NavLink({ to, label }: { to: string; label: string }) {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={to}
|
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}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -70,7 +82,7 @@ function SetupTab({ to, label, exact = false }: { to: string; label: string; exa
|
|||||||
<Link
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
activeOptions={{ exact }}
|
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}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -147,7 +159,7 @@ function LanguageToggle({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
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) => (
|
{(["sq", "en"] as const).map((l) => (
|
||||||
<button
|
<button
|
||||||
key={l}
|
key={l}
|
||||||
@@ -193,7 +205,7 @@ function ThemeToggle({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
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) => (
|
{(["dark", "light"] as const).map((th) => (
|
||||||
<button
|
<button
|
||||||
key={th}
|
key={th}
|
||||||
@@ -210,6 +222,37 @@ function ThemeToggle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Header font-size control: A−/value/A+ scaling the whole UI (root font-size). Persisted
|
||||||
|
* to the user profile like the theme, restored on next login. Local `active` state seeded
|
||||||
|
* from the prop (the router context doesn't re-render on setUser); App's effect keeps the
|
||||||
|
* DOM in sync with the persisted user on (re)login. */
|
||||||
|
function FontScaleToggle({ user, setUser }: { user: SessionUser; setUser: (u: SessionUser | null) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [active, setActive] = useState<number>(user.fontScale);
|
||||||
|
function step(delta: number) {
|
||||||
|
const next = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, active + delta));
|
||||||
|
if (next === active) return;
|
||||||
|
setActive(next);
|
||||||
|
applyFontScale(next); // instant UI
|
||||||
|
setUser({ ...user, fontScale: next });
|
||||||
|
void setFontScalePref(next).catch(() => {
|
||||||
|
/* non-fatal — the choice still applies this session */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const btn = "rounded-term px-1.5 py-0.5 text-term-muted hover:text-term-text disabled:opacity-40";
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
|
||||||
|
<button type="button" className={btn} onClick={() => step(-FONT_SCALE_STEP)} disabled={active <= FONT_SCALE_MIN} title={t("common.fontSmaller")} aria-label={t("common.fontSmaller")}>
|
||||||
|
A−
|
||||||
|
</button>
|
||||||
|
<span className="min-w-[2.5rem] text-center text-term-muted" title={t("common.fontSize")}>{active}%</span>
|
||||||
|
<button type="button" className={btn} onClick={() => step(FONT_SCALE_STEP)} disabled={active >= FONT_SCALE_MAX} title={t("common.fontLarger")} aria-label={t("common.fontLarger")}>
|
||||||
|
A+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Header shift control — the site-wide single-open shift expressed as one button:
|
* Header shift control — the site-wide single-open shift expressed as one button:
|
||||||
* - no shift open → "Open shift" (enabled; opens this operator's shift)
|
* - no shift open → "Open shift" (enabled; opens this operator's shift)
|
||||||
@@ -273,14 +316,14 @@ function ShiftButton() {
|
|||||||
disabled={busy || blockedByOther}
|
disabled={busy || blockedByOther}
|
||||||
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
||||||
onClick={onClick}
|
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}
|
{busy ? t("shift.opening") : label}
|
||||||
</button>
|
</button>
|
||||||
{!isOpen && (
|
{!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 && (
|
{confirmingClose && (
|
||||||
<CloseShiftConfirm
|
<CloseShiftConfirm
|
||||||
busy={busy}
|
busy={busy}
|
||||||
@@ -315,7 +358,7 @@ function CloseShiftConfirm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open onClose={onCancel} title={t("shift.endShift")} width="max-w-md">
|
<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>
|
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
||||||
{!x ? (
|
{!x ? (
|
||||||
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
|
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
|
||||||
@@ -327,12 +370,17 @@ function CloseShiftConfirm({
|
|||||||
{/* Split by source — the operator's ask: subscription money apart from tickets. */}
|
{/* 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.srcTickets")} value={fmt(x.ticketTotalMinor)} />
|
||||||
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
|
<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 />
|
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
<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.cash")} value={fmt(x.cashTotalMinor)} />
|
||||||
<ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />
|
<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 />
|
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -353,10 +401,13 @@ function CloseShiftConfirm({
|
|||||||
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
<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}
|
{label}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -399,12 +450,13 @@ function RootLayout() {
|
|||||||
{user && <ShiftButton />}
|
{user && <ShiftButton />}
|
||||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||||
|
{user && <FontScaleToggle user={user} setUser={setUser} />}
|
||||||
<StatusDot />
|
<StatusDot />
|
||||||
{user && (
|
{user && (
|
||||||
<Link
|
<Link
|
||||||
to="/profile"
|
to="/profile"
|
||||||
title={t("nav.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}
|
{user.username} · {user.roleName}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function DeviceFooter() {
|
|||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
ref={rootRef}
|
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">
|
<span className="shrink-0 font-semibold uppercase tracking-wider text-term-muted">
|
||||||
{t("devices.footerTitle")}
|
{t("devices.footerTitle")}
|
||||||
@@ -156,7 +156,7 @@ export function DeviceFooter() {
|
|||||||
{open && problems.length > 0 && (
|
{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="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">
|
<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")}
|
{t("devices.issuesTitle")}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<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]}`} />
|
<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="min-w-0 flex-1">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<span className={`text-[12px] font-semibold ${TEXT[d.state]}`}>{label(d)}</span>
|
<span className={`text-[0.75rem] font-semibold ${TEXT[d.state]}`}>{label(d)}</span>
|
||||||
<span className="shrink-0 text-[10px] uppercase tracking-wider text-term-muted">
|
<span className="shrink-0 text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||||
{t(`devices.state.${d.state}`)}
|
{t(`devices.state.${d.state}`)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{d.detail && <div className="mt-0.5 break-words text-[11px] text-term-muted">{d.detail}</div>}
|
{d.detail && <div className="mt-0.5 break-words text-[0.6875rem] text-term-muted">{d.detail}</div>}
|
||||||
<div className="mt-0.5 text-[10px] tabular-nums text-term-muted/70">
|
<div className="mt-0.5 text-[0.625rem] tabular-nums text-term-muted/70">
|
||||||
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
|
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function SegGroup<V extends string>({
|
|||||||
key={v || "all"}
|
key={v || "all"}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(v)}
|
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"
|
value === v ? "bg-term-border text-term-text" : "text-term-muted hover:text-term-text"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -61,7 +61,7 @@ export function FilterBar({
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => onSearch(e.target.value)}
|
onChange={(e) => onSearch(e.target.value)}
|
||||||
placeholder={searchPlaceholder}
|
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function Modal({
|
|||||||
aria-describedby={undefined}
|
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">
|
<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}
|
{title}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close">
|
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function Panel({
|
|||||||
>
|
>
|
||||||
{title && (
|
{title && (
|
||||||
<header className="flex items-center justify-between px-3 py-1.5 bg-term-panel-2 border-b border-term-border">
|
<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}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
{right}
|
{right}
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
|||||||
const dirLabel = (dir: "entry" | "exit" | null): string =>
|
const dirLabel = (dir: "entry" | "exit" | null): string =>
|
||||||
dir === "entry" ? t("pay.snapEntry") : dir === "exit" ? t("pay.snapExit") : "—";
|
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)
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -50,15 +50,15 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
|||||||
{dedupePlates(plates).map((p, i) => (
|
{dedupePlates(plates).map((p, i) => (
|
||||||
<span
|
<span
|
||||||
key={`${p.plate}-${p.direction}-${i}`}
|
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}` : ""}${
|
title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${
|
||||||
p.at ? ` · ${new Date(p.at).toLocaleString()}` : ""
|
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>
|
<span className="font-mono font-semibold text-term-cyan">{p.plate}</span>
|
||||||
{typeof p.confidence === "number" && (
|
{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>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -81,7 +81,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<span
|
<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"
|
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()}` : ""}`}
|
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-lg leading-none text-term-amber">⚠</span>
|
||||||
<span className="text-[9px] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
|
<span className="text-[0.5625rem] 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="px-1 text-[0.5625rem] leading-tight text-term-muted">{t("pay.snapFailed")}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function StatusDot() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const status = useLiveStore((s) => s.status);
|
const status = useLiveStore((s) => s.status);
|
||||||
return (
|
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
|
<span
|
||||||
className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`}
|
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 ?? "—";
|
return e.subscriberLabel ?? e.identity ?? "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps
|
/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps the
|
||||||
* the time/label/identity/index columns aligned across rows; the detail line lives in
|
* time/label/#index columns aligned across rows; the identity, plate, badges and reason flow
|
||||||
* its own row, indented under the identity column. */
|
* 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 }) {
|
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const style = eventStyleFor(e);
|
const style = eventStyleFor(e);
|
||||||
@@ -110,52 +110,44 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
|
|||||||
const reason = renderReason(p, t);
|
const reason = renderReason(p, t);
|
||||||
const amount = paymentSummary(p);
|
const amount = paymentSummary(p);
|
||||||
const badges = eventBadges(p);
|
const badges = eventBadges(p);
|
||||||
const via = viaKey(p);
|
|
||||||
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
|
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
|
||||||
const showDetail = detail != null || badges.length > 0 || via != null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpen(e)}
|
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" : ""
|
isAnomaly ? "bg-term-red/5" : refusedWarning ? "bg-term-amber/5" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
<span className="py-px text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||||
<span className={`shrink-0 font-semibold ${style.color}`}>{label}</span>
|
<span className={`py-px shrink-0 font-semibold ${style.color}`}>{label}</span>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
{/* Identity + plate + detail all flow in ONE wrapping line — they fill the available
|
||||||
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
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 && (
|
{e.plate && (
|
||||||
<span
|
<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")}
|
title={t("booth.plateTitle")}
|
||||||
>
|
>
|
||||||
{e.plate}
|
{e.plate}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{badges.map((k) => (
|
||||||
|
<span
|
||||||
|
key={k}
|
||||||
|
className="shrink-0 rounded-sm bg-term-red/15 px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide text-term-red"
|
||||||
|
>
|
||||||
|
{t(k)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{detail && (
|
||||||
|
<span className={`${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-term-muted">#{e.index}</span>
|
<span className="py-px text-term-muted">#{e.index}</span>
|
||||||
{showDetail && (
|
|
||||||
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
||||||
{badges.map((k) => (
|
|
||||||
<span
|
|
||||||
key={k}
|
|
||||||
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
|
||||||
>
|
|
||||||
{t(k)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{via && (
|
|
||||||
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
|
|
||||||
{t(via)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{detail && (
|
|
||||||
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -163,8 +155,8 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
|
|||||||
/** One label/value line in the event-detail modal. */
|
/** One label/value line in the event-detail modal. */
|
||||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[12px]">
|
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[0.75rem]">
|
||||||
<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="min-w-0 break-words text-term-text">{children}</span>
|
<span className="min-w-0 break-words text-term-text">{children}</span>
|
||||||
</div>
|
</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={`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>
|
<div className={`text-sm font-bold uppercase tracking-widest ${style.color}`}>{label}</div>
|
||||||
{(reason || money) && (
|
{(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}
|
{reason ?? money}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!reason && !money && isAnomaly && (
|
{!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 && (
|
{badges.length > 0 && (
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{badges.map((k) => (
|
{badges.map((k) => (
|
||||||
<span
|
<span
|
||||||
key={k}
|
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)}
|
{t(k)}
|
||||||
</span>
|
</span>
|
||||||
@@ -235,7 +227,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
|||||||
(the SUBSESS-… session key) for traceability against the ledger. */}
|
(the SUBSESS-… session key) for traceability against the ledger. */}
|
||||||
{e.subscriberLabel && e.identity && (
|
{e.subscriberLabel && e.identity && (
|
||||||
<DetailRow label={t("booth.edOccurrence")}>
|
<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>
|
</DetailRow>
|
||||||
)}
|
)}
|
||||||
{money && (
|
{money && (
|
||||||
@@ -257,7 +249,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
|||||||
)}
|
)}
|
||||||
{tariffVersionId && (
|
{tariffVersionId && (
|
||||||
<DetailRow label={t("booth.edTariffVersion")}>
|
<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>
|
</DetailRow>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -265,7 +257,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
|||||||
{/* The entry/exit evidence images for this session's identity. */}
|
{/* The entry/exit evidence images for this session's identity. */}
|
||||||
{e.identity && (
|
{e.identity && (
|
||||||
<div>
|
<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} />
|
<SnapshotStrip identity={e.identity} />
|
||||||
</div>
|
</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
|
operator's; tucking them behind a disclosure keeps the common view clean
|
||||||
while preserving the tamper-evidence trail on demand. */}
|
while preserving the tamper-evidence trail on demand. */}
|
||||||
<details className="rounded-term border border-term-border bg-term-panel-2">
|
<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")}
|
{t("booth.edAuditData")}
|
||||||
</summary>
|
</summary>
|
||||||
<div className="border-t border-term-border px-3 pb-3 pt-1">
|
<div className="border-t border-term-border px-3 pb-3 pt-1">
|
||||||
<DetailRow label={t("booth.edSignature")}>
|
<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>
|
||||||
<DetailRow label={t("booth.edKeyId")}>
|
<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>
|
||||||
<DetailRow label={t("booth.edPrevHash")}>
|
<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>
|
</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")}
|
{t("booth.edRawPayload")}
|
||||||
</div>
|
</div>
|
||||||
{p && Object.keys(p).length > 0 ? (
|
{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)}
|
{JSON.stringify(p, null, 2)}
|
||||||
</pre>
|
</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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
+54
-13
@@ -7,24 +7,25 @@
|
|||||||
# See wiki/decisions/container-deployment.md.
|
# See wiki/decisions/container-deployment.md.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Reverse proxy: :80 → server:3000 (WebSocket /api/ws upgrades pass through natively).
|
# Reverse proxy: :80 → server (127.0.0.1:3000). On the HOST network (see the server note),
|
||||||
# Caddy is a single static binary with a one-line proxy config; swapping http:// for the
|
# so it reaches the host-net server over loopback and publishes :80 directly on the host.
|
||||||
# site's real hostname later enables automatic HTTPS. The booth is reached at
|
# WebSocket /api/ws upgrades pass through natively. Swapping http:// for the site's real
|
||||||
# http://<name-or-ip>/ (the name set via hosts/DNS on-site — NOT baked into any image).
|
# hostname later enables automatic HTTPS. Reached at http://<name-or-ip>/ (name via hosts/DNS
|
||||||
|
# on-site — NOT baked into any image).
|
||||||
proxy:
|
proxy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
# Host network: Caddy listens on the host's :80 and proxies the host-net server on
|
||||||
- "80:80"
|
# 127.0.0.1:3000. (No `ports:` mapping — host mode publishes directly.)
|
||||||
# - "443:443" # uncomment when moving to TLS (and set a real hostname in Caddyfile)
|
network_mode: host
|
||||||
|
# host mode is mutually exclusive with a named network; the base file doesn't attach proxy,
|
||||||
|
# so nothing to null here (server does — see below).
|
||||||
volumes:
|
volumes:
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
- caddy-data:/data
|
- caddy-data:/data
|
||||||
- caddy-config:/config
|
- caddy-config:/config
|
||||||
depends_on:
|
depends_on:
|
||||||
- server
|
- server
|
||||||
networks:
|
|
||||||
- parking
|
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
@@ -33,9 +34,45 @@ services:
|
|||||||
|
|
||||||
server:
|
server:
|
||||||
restart: always
|
restart: always
|
||||||
# No published port — only the proxy reaches the server, over the private network.
|
# HOST NETWORK — the crux of the appliance. The server is the ONLY container doing device
|
||||||
expose:
|
# I/O (camera ISAPI snapshots, relay control, receiving reader/alarm pushes), all on the
|
||||||
- "3000"
|
# booth's LAN / isolated device VLAN (10.0.10.x). On a bridge network it sees only the Docker
|
||||||
|
# subnet (172.18.0.x) — it can't reach the relay, can't be reached by push devices, and the
|
||||||
|
# backend-IP picker (net.ts networkInterfaces) only sees eth0. Host mode puts it on the real
|
||||||
|
# NICs. Vision stays bridged (it never touches a device — the server hands it JPEG bytes).
|
||||||
|
network_mode: host
|
||||||
|
# host mode is mutually exclusive with a named network — detach the base file's `parking`
|
||||||
|
# attachment (compose errors otherwise: "network_mode and networks cannot both be set").
|
||||||
|
networks: !reset []
|
||||||
|
# Listens on :3000 directly on the host (Caddy proxies it). Loopback to vision:
|
||||||
|
environment:
|
||||||
|
VISION_URL: http://127.0.0.1:8089
|
||||||
|
# NB: NO `sysctls:` here. net.ipv4.ping_group_range is a per-netns sysctl; under host net
|
||||||
|
# there is no separate namespace, and runc REFUSES it ("not allowed in host network
|
||||||
|
# namespace"). Reader liveness ping uses the HOST's setting instead — the booth host must
|
||||||
|
# set net.ipv4.ping_group_range (see appliance-provisioning §7 / disk-os-hardening).
|
||||||
|
#
|
||||||
|
# USB PRINTER PASSTHROUGH. A USB ESC/POS printer (Rongta/Cashino) is the kernel `usblp` char
|
||||||
|
# device /dev/usb/lpN on the HOST — the container has its own /dev and can't see it (probeUsb
|
||||||
|
# open() → ENOENT → printer always "offline"). Two parts, both needed:
|
||||||
|
# - bind-mount /dev/usb so the lpN NODES appear inside the container, and
|
||||||
|
# - a device-cgroup rule permitting the usblp char major (180) so the kernel allows the
|
||||||
|
# open(). `180:*` covers lp0/lp1/lp2… so a USB replug/boot-order renumber still works
|
||||||
|
# (the printer's path can move; set Connection=USB + the matching /dev/usb/lpN in setup).
|
||||||
|
# (Bind-mounting the dir, not a single `devices:` node, is what survives renumbering.)
|
||||||
|
#
|
||||||
|
# ...AND access: the lpN node is `crw-rw---- root:lp` (mode 660). The server runs as the
|
||||||
|
# non-root `app` user, which is NOT in `lp`, so open(O_WRONLY) → EACCES → still "offline".
|
||||||
|
# group_add the HOST's `lp` GID (numeric — `getent group lp`, typically 7 on Debian/Ubuntu)
|
||||||
|
# so the app process gains that supplementary group and can write the 660 node. Least-
|
||||||
|
# privilege (no world-writable device, no root, no rebuild). VERIFY the GID on the booth;
|
||||||
|
# if the host's lp GID differs, change the number here.
|
||||||
|
group_add:
|
||||||
|
- "7"
|
||||||
|
volumes:
|
||||||
|
- /dev/usb:/dev/usb
|
||||||
|
device_cgroup_rules:
|
||||||
|
- "c 180:* rmw"
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
@@ -45,9 +82,13 @@ services:
|
|||||||
vision:
|
vision:
|
||||||
restart: always
|
restart: always
|
||||||
# The real ANPR engine. The image baked the model weights at build (offline-first).
|
# The real ANPR engine. The image baked the model weights at build (offline-first).
|
||||||
|
# Stays on the bridge network (isolated — it makes NO outbound device calls), but PUBLISHES
|
||||||
|
# 8089 on the host LOOPBACK ONLY so the host-net server can reach it. 127.0.0.1 binding keeps
|
||||||
|
# it off the booth LAN — nothing on the network can hit the ANPR service.
|
||||||
environment:
|
environment:
|
||||||
VISION_RECOGNIZER: fast_alpr
|
VISION_RECOGNIZER: fast_alpr
|
||||||
# No published ports — vision is reached only by the server over the private network.
|
ports:
|
||||||
|
- "127.0.0.1:8089:8089"
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
|
|||||||
+5
-2
@@ -13,8 +13,11 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: /data/parking.sqlite
|
DATABASE_URL: /data/parking.sqlite
|
||||||
# Reach the vision service over the private compose network by service name.
|
# Reach the vision service. DEV: the private compose-network service name (`vision`).
|
||||||
VISION_URL: http://vision:8089
|
# PROD: the server runs on the HOST network (to see the booth LAN / device VLAN — it's the
|
||||||
|
# only container doing device I/O), where compose DNS doesn't resolve, so the prod override
|
||||||
|
# sets VISION_URL=http://127.0.0.1:8089 and vision publishes 8089 on the host loopback.
|
||||||
|
VISION_URL: ${VISION_URL:-http://vision:8089}
|
||||||
VISION_ENABLED: ${VISION_ENABLED:-1}
|
VISION_ENABLED: ${VISION_ENABLED:-1}
|
||||||
# JWT signing secret MUST be provided at deploy (no insecure default — see auth.ts).
|
# JWT signing secret MUST be provided at deploy (no insecure default — see auth.ts).
|
||||||
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in the env/.env}
|
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in the env/.env}
|
||||||
|
|||||||
+238
@@ -0,0 +1,238 @@
|
|||||||
|
# i18n long-sentence review — EN vs SQ
|
||||||
|
|
||||||
|
Generated 2026-06-24. Source: `apps/web/src/lib/i18n/en.ts` + `sq.ts`. Threshold: strings ≥ 80 chars in either language (35 keys).
|
||||||
|
|
||||||
|
Review each pair for MEANING (does SQ say the same as EN?). Mark your decision in the **Verdict** line: `OK` / `FIX: <new text>` / `?`.
|
||||||
|
|
||||||
|
Keys with an automated note are tagged **⚑ NOTE**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. `badgeOverstayTitle` ⚑
|
||||||
|
|
||||||
|
- **EN** (en.ts:138, 86 ch): Paid session. The customer failed to exit during the grace period. A new period began.
|
||||||
|
- **SQ** (sq.ts:140, 93 ch): Sesion i paguar. Klienti nuk doli brenda afatit kohor. Ka filluar një periudhë e re tarifimi.
|
||||||
|
- **⚑ NOTE:** Spelling: "brënda" → standard is "brenda" (no ë).
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 2. `activeSince`
|
||||||
|
|
||||||
|
- **EN** (en.ts:254, 133 ch): Active since {{date}} · {{count}} version(s) in history. Publishing creates a new version; past sessions keep their original pricing.
|
||||||
|
- **SQ** (sq.ts:257, 137 ch): Aktive që nga {{date}} · {{count}} version(e) në histori. Publikimi krijon një version të ri; sesionet e kaluara ruajnë çmimin origjinal.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 3. `rateBlocksHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:263, 256 ch): Each band lasts a number of hours and bills at its own price; bands are consumed in order (the first hours, then the next hours). The last band is \"thereafter\" (open-ended) — its price applies once the ladder is exhausted. Price is per billing increment.
|
||||||
|
- **SQ** (sq.ts:266, 251 ch): Çdo brez zgjat një numër orësh dhe faturohet me çmimin e tij; brezat konsumohen me radhë (orët e para, pastaj orët në vijim). Brezi i fundit është \"më pas\" (i hapur) — çmimi i tij zbatohet pas mbarimit të shkallës. Çmimi është për interval faturimi.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 4. `defaultCardHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:275, 98 ch): The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.
|
||||||
|
- **SQ** (sq.ts:278, 116 ch): Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 5. `steppedHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:280, 210 ch): Set the TOTAL price for a stay up to a given time (e.g. up to 3h = 500). The first row whose limit ≥ the duration wins (the limit is inclusive). The last row's total repeats as a per-day price for longer stays.
|
||||||
|
- **SQ** (sq.ts:283, 233 ch): Vendos çmimin TOTAL për një qëndrim deri në një kohë të caktuar (p.sh. deri 3 orë = 500). Fiton rreshti i parë me kufi ≥ kohëzgjatjes (kufiri përfshihet). Totali i rreshtit të fundit përsëritet si çmim ditor për qëndrime më të gjata.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 6. `steppedTiersConflict`
|
||||||
|
|
||||||
|
- **EN** (en.ts:285, 235 ch): ⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price'. Publishing is blocked until this is fixed.
|
||||||
|
- **SQ** (sq.ts:288, 244 ch): ⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks'. Publikimi bllokohet derisa kjo të rregullohet.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 7. `tiersHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:287, 174 ch): Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.
|
||||||
|
- **SQ** (sq.ts:290, 182 ch): Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 8. `intro`
|
||||||
|
|
||||||
|
- **EN** (en.ts:526, 160 ch): Admin-defined plans the operator sells from — the price is looked up, never typed. Editing a plan publishes a new version; past sales keep their recorded price.
|
||||||
|
- **SQ** (sq.ts:537, 193 ch): Planet i përcakton admini; operatori vetëm shet prej tyre — çmimi merret automatikisht, nuk shkruhet. Ndryshimi i një plani publikon një version të ri; shitjet e mëparshme ruajnë çmimin e tyre.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 9. `relaysHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:361, 124 ch): Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.
|
||||||
|
- **SQ** (sq.ts:370, 130 ch): Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 10. `outputsHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:364, 175 ch): Relays are OUTPUTS: each opens a barrier (or drives the button lamp). Set the relay number and direction. The input terminals (button, sensor) are in the Inputs section below.
|
||||||
|
- **SQ** (sq.ts:373, 178 ch): Relet janë DALJE: secila hap një barrierë (ose ndez dritën e butonit). Cakto numrin e relesë dhe drejtimin. Terminalet hyrëse (butoni, sensori) janë te seksioni Hyrjet më poshtë.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 11. `pulseOpenHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:366, 75 ch): How long a barrier relay is held open (jog). Applies to all barrier relays.
|
||||||
|
- **SQ** (sq.ts:375, 85 ch): Sa kohë mbahet rele e barrierës e hapur (jog). Vlen për të gjitha relet e barrierave.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 12. `inputsHint` ⚑
|
||||||
|
|
||||||
|
- **EN** (en.ts:369, 152 ch): Inputs are TERMINALS the host READS: the entry button and the presence/radar sensor. Each belongs to an entry barrier — it triggers or gates that relay.
|
||||||
|
- **SQ** (sq.ts:378, 153 ch): Hyrjet janë TERMINALE që hosti i LEXON: butoni i hyrjes dhe sensori i pranisë/radari. Secila i përket një barriere hyrëse — e gateron ose e nis atë rele.
|
||||||
|
- **⚑ NOTE:** "e gateron" is an anglicism ("gates it"). Native: "e kushtëzon" / "e lejon".
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 13. `inputsIdleHighHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:371, 62 ch): This board idles inputs HIGH (status 1111); a press pulls LOW.
|
||||||
|
- **SQ** (sq.ts:380, 80 ch): Kjo pllakë i mban hyrjet HIGH në pushim (statusi 1111); një shtypje e ul në LOW.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 14. `inputsNoEntryRelay`
|
||||||
|
|
||||||
|
- **EN** (en.ts:373, 87 ch): No entry relay — add an 'Entry' or 'Entry + exit' relay in Outputs to assign terminals.
|
||||||
|
- **SQ** (sq.ts:382, 97 ch): Asnjë rele hyrëse — shto një rele 'Hyrje' ose 'Hyrje + dalje' te Daljet që të caktosh terminalet.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 15. `presenceInputHint` ⚑
|
||||||
|
|
||||||
|
- **EN** (en.ts:378, 290 ch): Input terminal the vehicle-presence sensor (induction loop or radar) is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the sensor clears (the car drove in) and a new car re-occupies it. Preferred mode.
|
||||||
|
- **SQ** (sq.ts:387, 275 ch): Terminali hyrës ku është lidhur sensori/laku i pranisë së automjetit. Kur vendoset, lëshohet vetëm NJË biletë për automjet: butoni printon vetëm kur ka makinë, dhe nuk lëshon biletë të dytë derisa laku të lirohet (makina hyri) dhe një makinë e re ta zërë. Mënyra e preferuar.
|
||||||
|
- **⚑ NOTE:** EN names "induction loop OR radar"; SQ "sensori/laku i pranisë" omits radar explicitly.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 16. `entryCooldownHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:381, 175 ch): When there's no presence sensor: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.
|
||||||
|
- **SQ** (sq.ts:390, 165 ch): Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 17. `presenceActiveLowHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:387, 178 ch): Tick if the presence sensor (e.g. a radar) idles HIGH and goes LOW on detection — the opposite of the button. This inverts that terminal's reading so 'present' is read correctly.
|
||||||
|
- **SQ** (sq.ts:396, 184 ch): Shëno nëse sensori i pranisë (p.sh. radari) qëndron HIGH në pushim dhe shkon LOW kur detekton — e kundërta e butonit. Kjo përmbys leximin e atij terminali që 'prania' të lexohet saktë.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 18. `buttonLightHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:391, 152 ch): The button's 12 V light on a spare relay. Blinks when the radar detects but the camera doesn't confirm a car; solid on when both confirm; off otherwise.
|
||||||
|
- **SQ** (sq.ts:400, 160 ch): Drita 12V e butonit e lidhur në një rele rezervë. Pulson kur radari detekton por kamera s'konfirmon makinë; ndizet fiks kur të dy konfirmojnë; përndryshe fiket.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 19. `anprHint` ⚑
|
||||||
|
|
||||||
|
- **EN** (en.ts:398, 203 ch): Enable to scan plates on this camera: the vision service reads the plate from a snapshot and feeds it as a read (advisory only — it never opens a barrier on its own). Requires the vision service running.
|
||||||
|
- **SQ** (sq.ts:408, 185 ch): Aktivizo që ky aparat të skanojë targat: shërbimi i vizionit lexon targën nga pamja dhe e dërgon si lexim (vetëm këshillues — nuk hap vetë barrierën). Kërkon shërbimin e vizionit aktiv.
|
||||||
|
- **⚑ NOTE:** SQ uses "aparat" for camera; elsewhere camera = "kamerë" (see testAnprHint). Inconsistent.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 20. `testAnprHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:402, 143 ch): Takes a live snapshot from this camera and tries to read a plate, reporting the result and the time it took. Point a plate at the camera first.
|
||||||
|
- **SQ** (sq.ts:412, 169 ch): Merr një pamje të drejtpërdrejtë nga kjo kamerë dhe përpiqet të lexojë një targë, duke raportuar rezultatin dhe kohën e nevojshme. Vendos një targë para kamerës më parë.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 21. `curveHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:443, 88 ch): Fee from entry at several durations — see where the daily cap flattens or windows shift.
|
||||||
|
- **SQ** (sq.ts:454, 96 ch): Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 22. `versionHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:465, 128 ch): Move this subscriber to another version of the same plan. The price stays as billed; only the access hours change going forward.
|
||||||
|
- **SQ** (sq.ts:476, 139 ch): Zhvendos këtë abonent në një version tjetër të të njëjtit plan. Çmimi mbetet siç u faturua; ndryshon vetëm orari i lejuar nga këtu e tutje.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 23. `deleteInUse`
|
||||||
|
|
||||||
|
- **EN** (en.ts:550, 68 ch): Can't delete — subscriptions still use this plan. Retire it instead.
|
||||||
|
- **SQ** (sq.ts:561, 80 ch): S'mund të fshihet — abonime ende e përdorin këtë plan. Tërhiqe në vend të kësaj.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 24. `newVersionHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:553, 84 ch): This publishes a NEW version of the plan — existing sales keep their original price.
|
||||||
|
- **SQ** (sq.ts:564, 86 ch): Kjo publikon një version TË RI të planit — shitjet ekzistuese ruajnë çmimin origjinal.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 25. `timeframesHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:570, 168 ch): A scan outside the allowed window is charged the normal transient tariff for the out-of-window minutes (early entry is deferred to exit; late exit is gated until paid).
|
||||||
|
- **SQ** (sq.ts:581, 184 ch): Një skanim jashtë intervalit të lejuar tarifohet me tarifën normale kalimtare për minutat jashtë intervalit (hyrja e hershme shtyhet në dalje; dalja e vonuar bllokohet derisa paguhet).
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 26. `reserveSubsHint` ⚑
|
||||||
|
|
||||||
|
- **EN** (en.ts:582, 164 ch): Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).
|
||||||
|
- **SQ** (sq.ts:593, 198 ch): Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).
|
||||||
|
- **⚑ NOTE:** EN "handle overflow by valet" → SQ "parkim manual" (manual parking) — meaning shift; valet ≠ manual parking.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 27. `anprEntryHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:584, 195 ch): When on, a subscriber's plate read by a lane camera opens the barrier through the normal subscription gate. Off: subscribers must use their card/QR. Plate snapshots are still recorded either way.
|
||||||
|
- **SQ** (sq.ts:595, 207 ch): Kur është aktiv, targa e një abonenti e lexuar nga kamera e korsisë hap barrierën përmes portës normale të abonimit. Joaktiv: abonentët duhet të përdorin kartën/QR-në. Fotot e targave regjistrohen gjithsesi.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 28. `voucherHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:659, 128 ch): A receipt (Mandat Arkëtimi) adds cash; a disbursement (Mandat Pagese) removes it. The float only moves with an admin's sign-off.
|
||||||
|
- **SQ** (sq.ts:671, 97 ch): Mandat Arkëtimi shton para; Mandat Pagese heq para. Arka lëviz vetëm me autorizimin e një admini.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 29. `xReportHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:667, 83 ch): View only — nothing is recorded. These figures are signed when the shift is closed.
|
||||||
|
- **SQ** (sq.ts:679, 85 ch): Vetëm për shikim — asgjë nuk regjistrohet. Këto shifra nënshkruhen kur mbyllet turni.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 30. `gateBody`
|
||||||
|
|
||||||
|
- **EN** (en.ts:691, 80 ch): No shift is open. Open your shift so payments and exits are recorded against it.
|
||||||
|
- **SQ** (sq.ts:703, 95 ch): Asnjë turn nuk është i hapur. Hap turnin tënd që pagesat dhe daljet të regjistrohen te ky turn.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 31. `gateOtherBody`
|
||||||
|
|
||||||
|
- **EN** (en.ts:694, 120 ch): {{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.
|
||||||
|
- **SQ** (sq.ts:706, 140 ch): {{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 32. `overstayHint` ⚑
|
||||||
|
|
||||||
|
- **EN** (en.ts:813, 155 ch): Earlier session paid. The customer failed to exit during the grace period. Payment for the new period is required. The total below is the new period's fee.
|
||||||
|
- **SQ** (sq.ts:827, 151 ch): Sesion i mëparshëm i paguar. Klienti nuk doli brënda afatit kohor. Kërkohet pagesë për periudhën e re. Totali më poshtë është tarifa e periudhës së re.
|
||||||
|
- **⚑ NOTE:** Spelling: "brënda" → standard is "brenda" (no ë).
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 33. `subAssistHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:837, 101 ch): Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.
|
||||||
|
- **SQ** (sq.ts:851, 108 ch): Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 34. `windowChargeHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:842, 154 ch): This subscriber parked outside their plan's allowed hours. They owe the transient tariff for the out-of-window time — take payment, then open the barrier.
|
||||||
|
- **SQ** (sq.ts:856, 153 ch): Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën, pastaj hap barrierën.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 35. `cancelTicketHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:852, 115 ch): Cancels a wrongly-printed ticket. A signed record is kept (operator + reason); the original entry is never deleted.
|
||||||
|
- **SQ** (sq.ts:866, 129 ch): Anulon një biletë të printuar gabimisht. Ruhet një gjurmë e nënshkruar (operatori + arsyeja); hyrja origjinale nuk fshihet kurrë.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of flagged items
|
||||||
|
|
||||||
|
- `badgeOverstayTitle` — Spelling: "brënda" → standard is "brenda" (no ë).
|
||||||
|
- `inputsHint` — "e gateron" is an anglicism ("gates it"). Native: "e kushtëzon" / "e lejon".
|
||||||
|
- `presenceInputHint` — EN names "induction loop OR radar"; SQ "sensori/laku i pranisë" omits radar explicitly.
|
||||||
|
- `anprHint` — SQ uses "aparat" for camera; elsewhere camera = "kamerë" (see testAnprHint). Inconsistent.
|
||||||
|
- `reserveSubsHint` — EN "handle overflow by valet" → SQ "parkim manual" (manual parking) — meaning shift; valet ≠ manual parking.
|
||||||
|
- `overstayHint` — Spelling: "brënda" → standard is "brenda" (no ë).
|
||||||
|
|
||||||
|
_All other pairs: meaning judged faithful in automated review; confirm during your read._
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Komodo Stack environment — the COMPLETE reference of every env the booth stack reads:
|
||||||
|
# required, image-selection, set-by-compose (don't override), and the optional tunables
|
||||||
|
# with their code defaults. Under Komodo, plain env lives in the Stack definition
|
||||||
|
# (komodo/resources.toml); the two SECRETS come from Core's secret store, PER BOOTH and
|
||||||
|
# UNIQUE. This file is DOCUMENTATION — never fill in real secrets here. See
|
||||||
|
# wiki/decisions/fleet-deployment-komodo.md.
|
||||||
|
#
|
||||||
|
# A minimal working Stack only needs: REGISTRY, TAG, the two secrets, COOKIE_SECURE=0,
|
||||||
|
# VISION_ENABLED=1, WS_ALLOWED_ORIGINS. Everything under "OPTIONAL TUNABLES" has a safe
|
||||||
|
# default in code — set one only to override it.
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# IMAGE SELECTION (picks which container image to pull — not server runtime env)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||||
|
# IMMUTABLE per-commit tag. Manual + pinned. Bump per deploy. Never the moving `dev`
|
||||||
|
# on a PRODUCTION booth (a staging booth may track `dev`).
|
||||||
|
TAG=dev-830993b
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# REQUIRED (no safe default — the server refuses to boot / login breaks without)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Login signing secret (>=32 chars, no change-me/insecure/dev-only). openssl rand -hex 32.
|
||||||
|
JWT_SECRET=[[booth_<name>_jwt_secret]]
|
||||||
|
# Ledger-signing HMAC key — the anti-fraud root. DISTINCT per booth; never reuse. If unset
|
||||||
|
# it falls back to JWT_SECRET (warned). openssl rand -hex 32.
|
||||||
|
EVENT_SIGNING_KEY=[[booth_<name>_event_signing_key]]
|
||||||
|
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT, so
|
||||||
|
# without =0 the auth cookie never sends and operators CANNOT log in. Set 1 only behind TLS.
|
||||||
|
COOKIE_SECURE=0
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# COMMONLY SET (have defaults, but you usually want these explicit on a booth)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Turn the ANPR/vision call on. Default "" (off-ish). Set 1 to enable. (Prod compose also
|
||||||
|
# forces VISION_RECOGNIZER=fast_alpr on the vision container.)
|
||||||
|
VISION_ENABLED=1
|
||||||
|
# Extra origins the booth WebSocket (/api/ws) accepts beyond same-origin. Comma-separated,
|
||||||
|
# e.g. http://parksystems.msai.al. Blank = only the same-origin booth URL. Default "".
|
||||||
|
WS_ALLOWED_ORIGINS=
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# SET BY COMPOSE — do NOT put these in the Stack (the compose files own them)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# VISION_URL=http://127.0.0.1:8089 # prod override (host-net server → loopback vision)
|
||||||
|
# DATABASE_URL=/data/parking.sqlite # the mounted volume (the signed ledger)
|
||||||
|
# VISION_RECOGNIZER=fast_alpr # prod override on the vision container
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# OPTIONAL TUNABLES — all have code defaults; set only to override. (defaults shown)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# --- networking / process ---
|
||||||
|
# PORT=3000 # server listen port
|
||||||
|
# HOST=0.0.0.0 # bind interface (127.0.0.1 = loopback only)
|
||||||
|
# BACKEND_HOST_IP= # override the auto-picked IP devices push back to
|
||||||
|
# # (multi-NIC hosts; usually auto-detected fine)
|
||||||
|
# WEB_DIST_DIR= # where the built SPA lives (the image sets it)
|
||||||
|
# --- logging ---
|
||||||
|
# LOG_LEVEL=info # debug|info|warn|error
|
||||||
|
# LOG_RETENTION_DAYS=30 # app_logs auto-purge age
|
||||||
|
# LOG_RETENTION_MAX_ROWS=50000 # app_logs row cap
|
||||||
|
# RECYCLE_BIN_RETENTION_DAYS=30 # soft-deleted items auto-purge age (0 = keep forever)
|
||||||
|
# --- 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)
|
||||||
|
# periphery passkey -> [[periphery_passkey_booth_<name>]] (agent onboarding)
|
||||||
|
# registry account -> [[gitea_registry_account]] (image pull)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# `komodo/` — fleet deployment as code
|
||||||
|
|
||||||
|
Infra-as-code for the **Komodo Core** control plane that deploys the parking appliance to the
|
||||||
|
booth fleet over the **NetBird** mesh. See `wiki/decisions/fleet-deployment-komodo.md` for the
|
||||||
|
rationale, threat-model analysis, and the three settled choices (many/growing fleet · deploys
|
||||||
|
are **manual + pinned** · secrets are **Komodo-managed, per-booth**).
|
||||||
|
|
||||||
|
This directory does **not** change how images are built or how the app runs — it's only the
|
||||||
|
control plane. The booth still runs the same `docker-compose.yml` + `docker-compose.prod.yml`
|
||||||
|
([[container-deployment]]); Komodo just drives them remotely instead of someone SSH-ing in to
|
||||||
|
run `booth.sh`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- **`resources.toml`** — the Komodo resource definitions (Servers, Stacks, optional Builders/
|
||||||
|
Procedures), synced into Core via a **ResourceSync**. This is the reviewable, version-
|
||||||
|
controlled source of truth for *which booth runs what*.
|
||||||
|
- **`.env.komodo.example`** — the variables a Stack expects, documenting what comes from Core's
|
||||||
|
**secret store** (per-booth `JWT_SECRET` / `EVENT_SIGNING_KEY`) vs. plain Stack env.
|
||||||
|
|
||||||
|
## How Core consumes this (one-time)
|
||||||
|
|
||||||
|
In Komodo Core, create a **ResourceSync** pointing at this repo + path (`komodo/resources.toml`),
|
||||||
|
on the branch you manage from (e.g. `main`). Core reads the file and reconciles Servers/Stacks to
|
||||||
|
match. Thereafter, a PR to this directory + a sync is how you change the fleet — no clicking.
|
||||||
|
|
||||||
|
> Komodo's TOML schema evolves across releases. Treat `resources.toml` as a **starting sketch**:
|
||||||
|
> `resources.toml` mirrors the **working `park-buzi` Stack** (built by hand in the Core UI, then
|
||||||
|
> exported to TOML — so field names match the running Komodo version, v2.2). Import it into the
|
||||||
|
> sync **Unmanaged** first and review the diff; it should be ~empty against the live Stack.
|
||||||
|
|
||||||
|
## How servers get created — NOT here
|
||||||
|
|
||||||
|
There is **no `[[server]]` block** in `resources.toml`. Servers are created by the **Periphery
|
||||||
|
agent onboarding outbound**: in Core, create a one-time **Onboarding Key** (Settings →
|
||||||
|
Onboarding), then install Periphery on the booth passing `--onboarding-key` + `--core-address`
|
||||||
|
(Core's reverse-proxy URL, reached over the NetBird mesh) + `--connect-as=<booth-name>`. The
|
||||||
|
agent self-registers, generates its own auto-rotating key pair (private key never leaves the
|
||||||
|
booth), and connects **outbound** — the booth opens **no inbound port**. The sync owns only the
|
||||||
|
**Stack**, which references the server by the name it onboarded as (`server = "park-buzi"`). See
|
||||||
|
`wiki/decisions/fleet-deployment-komodo.md`.
|
||||||
|
|
||||||
|
## Adding booth N
|
||||||
|
|
||||||
|
Copy the `[[stack]]` block, change `name`, `server` (its onboarded name), and the per-booth
|
||||||
|
secret references (`[[park_<site>_jwt_secret]]`, `[[park_<site>_event_signing_key]]`). Create
|
||||||
|
those secrets in Core's store first.
|
||||||
|
|
||||||
|
## Hard rules encoded here (do not relax without updating the decision page)
|
||||||
|
|
||||||
|
1. **No deploy webhook on a booth Stack.** Deploys are a human action; pin `TAG=dev-<sha>` before
|
||||||
|
a production booth goes live. A moving `:dev` on a production booth is the non-determinism we
|
||||||
|
rejected. (`TAG=dev` here is fine while staging.)
|
||||||
|
2. **Onboarding, outbound, mesh-only.** Servers self-register via an onboarding key; Periphery
|
||||||
|
connects outbound to Core's mesh URL and exposes no inbound port. Never a LAN/WAN address.
|
||||||
|
3. **Secrets are per-booth and unique.** `EVENT_SIGNING_KEY` signs the anti-fraud ledger — one
|
||||||
|
leak must taint one booth, never the fleet. Reference Core secrets by name; never inline a
|
||||||
|
real value in this file (it's in git).
|
||||||
|
4. **Volumes preserved.** The Stack must never run `compose down -v` — that would wipe the
|
||||||
|
`parking-data` volume (the signed ledger). Komodo's "destroy" is gated for the same reason.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Komodo resources — parking appliance fleet (control plane as code)
|
||||||
|
#
|
||||||
|
# Synced into Komodo Core via a ResourceSync pointing at this file. Drives the SAME
|
||||||
|
# compose files the booth runs locally (docker-compose.yml + docker-compose.prod.yml);
|
||||||
|
# Komodo Periphery on each booth executes them. See:
|
||||||
|
# wiki/decisions/fleet-deployment-komodo.md (rationale + threat model)
|
||||||
|
# wiki/decisions/container-deployment.md (image build/tag/registry — unchanged)
|
||||||
|
#
|
||||||
|
# This file mirrors the WORKING park-buzi Stack (built by hand in the Core UI, then
|
||||||
|
# exported to TOML). Field names match the running Komodo version (v2.2).
|
||||||
|
#
|
||||||
|
# NO [[server]] block: servers are created by the AGENT onboarding outbound (a one-time
|
||||||
|
# onboarding key → Periphery self-registers with auto-rotating key pairs). The sync owns
|
||||||
|
# only the Stack; it references the server by the name it onboarded as (`connect_as`).
|
||||||
|
#
|
||||||
|
# Secrets ([[park_buzi_jwt_secret]] etc.) are REFERENCES to Komodo Core's secret store —
|
||||||
|
# per-booth + unique, never inlined here (this file is in git). JWT_SECRET gates login;
|
||||||
|
# EVENT_SIGNING_KEY signs the append-only anti-fraud ledger.
|
||||||
|
#
|
||||||
|
# Deploys are MANUAL + PINNED in spirit: bump TAG to an immutable dev-<sha> before a
|
||||||
|
# production booth goes live (TAG=dev here is the moving tag, fine while staging). NO
|
||||||
|
# deploy webhook is attached to a booth Stack.
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
# Stack — the deployable unit for booth "park-buzi". One Stack per booth; add a
|
||||||
|
# new [[stack]] block per site (unique name, its own per-booth secret refs).
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
[[stack]]
|
||||||
|
name = "park-buzi"
|
||||||
|
[stack.config]
|
||||||
|
server = "park-buzi"
|
||||||
|
git_provider = "git.infra.msai.al"
|
||||||
|
git_account = "komodo"
|
||||||
|
repo = "mca/parking_solution"
|
||||||
|
branch = "dev"
|
||||||
|
file_paths = [
|
||||||
|
"docker-compose.yml",
|
||||||
|
"docker-compose.prod.yml"
|
||||||
|
]
|
||||||
|
registry_provider = "git.infra.msai.al"
|
||||||
|
registry_account = "komodo"
|
||||||
|
environment = """
|
||||||
|
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||||
|
TAG=dev
|
||||||
|
COOKIE_SECURE=0
|
||||||
|
VISION_ENABLED=1
|
||||||
|
WS_ALLOWED_ORIGINS=
|
||||||
|
JWT_SECRET=[[park_buzi_jwt_secret]]
|
||||||
|
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
||||||
|
"""
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Per-user UI font scale (PERCENT of base; 100 = base). Persisted like `theme`/`language`
|
||||||
|
-- so an operator's chosen text size is restored on the next login from any booth. Additive
|
||||||
|
-- ALTER ADD COLUMN — backward-compatible; existing users default to 100 (base). The client
|
||||||
|
-- clamps to 80–160 in steps of 10. Printed tickets are unaffected (server-rendered).
|
||||||
|
ALTER TABLE `users` ADD `font_scale` integer DEFAULT 100 NOT NULL;
|
||||||
@@ -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';
|
||||||
@@ -99,6 +99,20 @@
|
|||||||
"when": 1781885600000,
|
"when": 1781885600000,
|
||||||
"tag": "0013_anpr_entry_toggle",
|
"tag": "0013_anpr_entry_toggle",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -73,6 +73,11 @@ export const users = sqliteTable("users", {
|
|||||||
theme: text("theme", { enum: ["dark", "light"] })
|
theme: text("theme", { enum: ["dark", "light"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("dark"),
|
.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
|
// Optional operator profile metadata — display name + contact details. All
|
||||||
// nullable; only username/password/role are required to create a user. fullName
|
// nullable; only username/password/role are required to create a user. fullName
|
||||||
// (when set) is the human label for audit/Z-report display.
|
// (when set) is the human label for audit/Z-report display.
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { activeLowFrom, inputActive } from "./access-dingtian.js";
|
||||||
|
|
||||||
|
// Per-input active-level normalisation. The board has ONE resting level, but a radar
|
||||||
|
// can idle opposite the button — listing its terminal in `activeLow` inverts just that
|
||||||
|
// input so "present" reads correctly. See wiki/entities/hikvision-radar.md.
|
||||||
|
|
||||||
|
describe("inputActive (per-input active-level)", () => {
|
||||||
|
const none = new Set<number>();
|
||||||
|
const radarOnI2 = new Set<number>([2]);
|
||||||
|
|
||||||
|
it("default board (resting HIGH): a pull LOW is active, HIGH is rest", () => {
|
||||||
|
// Button on I1, board idles HIGH → active when LOW.
|
||||||
|
expect(inputActive(false, 1, true, none)).toBe(true); // LOW = pressed
|
||||||
|
expect(inputActive(true, 1, true, none)).toBe(false); // HIGH = rest
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resting LOW board: a pull HIGH is active", () => {
|
||||||
|
expect(inputActive(true, 1, false, none)).toBe(true);
|
||||||
|
expect(inputActive(false, 1, false, none)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("active-low override inverts ONLY the listed input", () => {
|
||||||
|
// Board idles HIGH (button on I1), radar on I2 idles HIGH and goes LOW on detect →
|
||||||
|
// mark I2 active-low so detection (LOW) reads active.
|
||||||
|
// I1 (button) keeps the board default:
|
||||||
|
expect(inputActive(false, 1, true, radarOnI2)).toBe(true); // button LOW = active
|
||||||
|
expect(inputActive(true, 1, true, radarOnI2)).toBe(false);
|
||||||
|
// I2 (radar) overridden to active-low: active when LOW.
|
||||||
|
expect(inputActive(false, 2, true, radarOnI2)).toBe(true); // radar LOW = detecting
|
||||||
|
expect(inputActive(true, 2, true, radarOnI2)).toBe(false); // radar HIGH = clear
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("activeLowFrom (config → active-low terminal set)", () => {
|
||||||
|
it("reads a config.inputs[] presence row with activeLow", () => {
|
||||||
|
const set = activeLowFrom({
|
||||||
|
inputs: [
|
||||||
|
{ input: 1, role: "button", relay: 1 },
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar", activeLow: true },
|
||||||
|
{ input: 5, role: "presence", relay: 2, kind: "radar", activeLow: true }, // exit radar
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect([...set].sort()).toEqual([2, 5]); // both radars inverted; the button is not
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still reads the LEGACY relays[].presenceActiveLow (back-compat)", () => {
|
||||||
|
const set = activeLowFrom({
|
||||||
|
relays: [{ relay: 1, direction: "entry", presenceInput: 2, presenceActiveLow: true }],
|
||||||
|
});
|
||||||
|
expect([...set]).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours an explicit top-level inputActiveLow escape hatch + merges all sources", () => {
|
||||||
|
const set = activeLowFrom({
|
||||||
|
inputActiveLow: [3],
|
||||||
|
inputs: [{ input: 2, role: "presence", activeLow: true }],
|
||||||
|
relays: [{ relay: 1, direction: "entry", presenceInput: 4, presenceActiveLow: true }],
|
||||||
|
});
|
||||||
|
expect([...set].sort()).toEqual([2, 3, 4]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { createSocket } from "node:dgram";
|
|||||||
import { request as httpRequest } from "node:http";
|
import { request as httpRequest } from "node:http";
|
||||||
import type {
|
import type {
|
||||||
AccessControlDevice,
|
AccessControlDevice,
|
||||||
|
AuxOutputDevice,
|
||||||
DeviceHealth,
|
DeviceHealth,
|
||||||
HardenableDevice,
|
HardenableDevice,
|
||||||
HardenResult,
|
HardenResult,
|
||||||
@@ -166,6 +167,49 @@ interface DingtianStatus {
|
|||||||
channels: number;
|
channels: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise one input line to "active". `high` = the line is currently HIGH. An input
|
||||||
|
* whose 1-based channel is in `activeLow` is active when LOW (idles HIGH), overriding
|
||||||
|
* the board-wide `restingHigh`; otherwise active = differs from the resting level. This
|
||||||
|
* is the seam that lets a radar (wired opposite the button) read correctly. Exported for
|
||||||
|
* unit testing the bit logic without a UDP socket. See wiki/entities/hikvision-radar.md.
|
||||||
|
*/
|
||||||
|
export function inputActive(
|
||||||
|
high: boolean,
|
||||||
|
channel1Based: number,
|
||||||
|
restingHigh: boolean,
|
||||||
|
activeLow: ReadonlySet<number>,
|
||||||
|
): boolean {
|
||||||
|
return activeLow.has(channel1Based) ? !high : high !== restingHigh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the set of 1-based ACTIVE-LOW input terminals from a controller config. Three
|
||||||
|
* sources, all merged: (a) `config.inputs[]` presence rows with `activeLow:true` (the
|
||||||
|
* first-class model); (b) LEGACY per-relay `presenceActiveLow` (pre-inputs[] configs);
|
||||||
|
* (c) an explicit top-level `inputActiveLow` array (escape hatch). A radar terminal wired
|
||||||
|
* opposite the button idles HIGH, so it must be read inverted. */
|
||||||
|
export function activeLowFrom(config: Record<string, unknown>): Set<number> {
|
||||||
|
const set = new Set<number>();
|
||||||
|
const add = (n: unknown) => {
|
||||||
|
const v = Number(n);
|
||||||
|
if (Number.isInteger(v) && v > 0) set.add(v);
|
||||||
|
};
|
||||||
|
if (Array.isArray(config.inputActiveLow)) {
|
||||||
|
for (const n of config.inputActiveLow as unknown[]) add(n);
|
||||||
|
}
|
||||||
|
if (Array.isArray(config.inputs)) {
|
||||||
|
for (const i of config.inputs as Array<Record<string, unknown>>) {
|
||||||
|
if (i?.role === "presence" && i?.activeLow === true) add(i.input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(config.relays)) {
|
||||||
|
for (const r of config.relays as Array<Record<string, unknown>>) {
|
||||||
|
if (r?.presenceActiveLow === true) add(r.presenceInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
const INPUT_LINK_ISSUE = {
|
const INPUT_LINK_ISSUE = {
|
||||||
key: "input_link_relay",
|
key: "input_link_relay",
|
||||||
message:
|
message:
|
||||||
@@ -223,6 +267,7 @@ function configApi(
|
|||||||
class DingtianController
|
class DingtianController
|
||||||
implements
|
implements
|
||||||
AccessControlDevice,
|
AccessControlDevice,
|
||||||
|
AuxOutputDevice,
|
||||||
InputDevice,
|
InputDevice,
|
||||||
PreconditionDevice,
|
PreconditionDevice,
|
||||||
PushConfigurableDevice,
|
PushConfigurableDevice,
|
||||||
@@ -242,6 +287,13 @@ class DingtianController
|
|||||||
readonly #channels: number;
|
readonly #channels: number;
|
||||||
/** Input level at rest; an input is "active" when it differs from this. */
|
/** Input level at rest; an input is "active" when it differs from this. */
|
||||||
readonly #restingHigh: boolean;
|
readonly #restingHigh: boolean;
|
||||||
|
/** 1-based input terminals whose ACTIVE level is LOW, overriding the board-wide
|
||||||
|
* #restingHigh for just those inputs. A button and a radar can idle oppositely:
|
||||||
|
* the button (NO-to-GND) pulls LOW on press while the board idles HIGH, but a
|
||||||
|
* radar's dry contact may idle LOW and go HIGH on detection. Listing the radar's
|
||||||
|
* terminal here flips its edge so "active" still means "detecting". See
|
||||||
|
* wiki/entities/hikvision-radar.md. */
|
||||||
|
readonly #inputActiveLow: Set<number>;
|
||||||
readonly #pulseMs: number;
|
readonly #pulseMs: number;
|
||||||
/** Device web-UI login user (gates the browser UI only, not the CGI API). */
|
/** Device web-UI login user (gates the browser UI only, not the CGI API). */
|
||||||
readonly #webUser: string;
|
readonly #webUser: string;
|
||||||
@@ -269,6 +321,7 @@ class DingtianController
|
|||||||
this.#channels = config.channels ? Number(config.channels) : 4;
|
this.#channels = config.channels ? Number(config.channels) : 4;
|
||||||
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
|
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
|
||||||
this.#restingHigh = config.inputRestingHigh !== false;
|
this.#restingHigh = config.inputRestingHigh !== false;
|
||||||
|
this.#inputActiveLow = activeLowFrom(config);
|
||||||
this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
|
this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
|
||||||
this.#webUser = config.webUser ? String(config.webUser) : "admin";
|
this.#webUser = config.webUser ? String(config.webUser) : "admin";
|
||||||
// webPassword = the DESIRED login (admin's choice; blank → harden generates).
|
// webPassword = the DESIRED login (admin's choice; blank → harden generates).
|
||||||
@@ -319,6 +372,14 @@ class DingtianController
|
|||||||
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
|
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** AuxOutputDevice: latch a NON-barrier output (e.g. a button lamp) on a spare
|
||||||
|
* relay. Same wire op as setRelay — separated so business logic drives indicators
|
||||||
|
* through the aux capability, never the barrier relay methods. Holding/blinking an
|
||||||
|
* aux output is allowed (it is not a barrier). See button-light-indicator.md. */
|
||||||
|
async setAux(channel: number, on: boolean): Promise<void> {
|
||||||
|
await this.setRelay(channel, on);
|
||||||
|
}
|
||||||
|
|
||||||
async getDoorStatus(doorId: number): Promise<"open" | "closed"> {
|
async getDoorStatus(doorId: number): Promise<"open" | "closed"> {
|
||||||
this.#assertChannel(doorId);
|
this.#assertChannel(doorId);
|
||||||
const { relays } = await this.#status();
|
const { relays } = await this.#status();
|
||||||
@@ -672,8 +733,10 @@ class DingtianController
|
|||||||
for (let i = 0; i < this.#channels; i++) {
|
for (let i = 0; i < this.#channels; i++) {
|
||||||
const high = (inputVal & (1 << i)) !== 0;
|
const high = (inputVal & (1 << i)) !== 0;
|
||||||
relays.push((relayVal & (1 << i)) !== 0);
|
relays.push((relayVal & (1 << i)) !== 0);
|
||||||
// active = differs from the resting level (a press pulls the line).
|
// active = differs from the resting level (a press pulls the line); a terminal in
|
||||||
inputs.push(high !== this.#restingHigh);
|
// inputActiveLow is read inverted (active when LOW) — so a radar wired opposite the
|
||||||
|
// button reads right. See inputActive().
|
||||||
|
inputs.push(inputActive(high, i + 1, this.#restingHigh, this.#inputActiveLow));
|
||||||
}
|
}
|
||||||
return { relays, inputs, channels: this.#channels };
|
return { relays, inputs, channels: this.#channels };
|
||||||
}
|
}
|
||||||
@@ -728,6 +791,19 @@ export const dingtianDriver: AccessDriver = {
|
|||||||
{ key: "binaryPort", label: "Binary protocol port", type: "port", required: false, default: 60000, help: "Dingtian binary protocol UDP port — authenticated relay control (default 60000)." },
|
{ key: "binaryPort", label: "Binary protocol port", type: "port", required: false, default: 60000, help: "Dingtian binary protocol UDP port — authenticated relay control (default 60000)." },
|
||||||
{ key: "httpPort", label: "HTTP config port", type: "port", required: false, default: 80, help: "Device web/config-API port (default 80)." },
|
{ key: "httpPort", label: "HTTP config port", type: "port", required: false, default: 80, help: "Device web/config-API port (default 80)." },
|
||||||
{ key: "channels", label: "Channels (relays/inputs)", type: "number", required: true, default: 4 },
|
{ key: "channels", label: "Channels (relays/inputs)", type: "number", required: true, default: 4 },
|
||||||
|
{
|
||||||
|
// relay_pw — the BINARY-protocol control/status password (NOT the web-UI login
|
||||||
|
// below). Every relay command + the status read embeds it; with the wrong/no
|
||||||
|
// value the device silently ignores the packet → healthCheck times out → the
|
||||||
|
// controller shows "offline" even though it pings. Redacted from the client
|
||||||
|
// (SECRET_CONFIG_KEYS), so it renders as a secret: blank KEEPS the stored value
|
||||||
|
// (the server re-merges it on test/save); type a value to set/change it.
|
||||||
|
key: "relayPassword",
|
||||||
|
label: "Relay control password",
|
||||||
|
type: "secret",
|
||||||
|
required: false,
|
||||||
|
help: "Binary-protocol relay password (relay_pw). Leave blank to keep the current one; a wrong/missing value makes the device ignore commands (Test connection times out).",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "pulseMs",
|
key: "pulseMs",
|
||||||
label: "Pulse open (ms)",
|
label: "Pulse open (ms)",
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { DigestGetResult } from "./http-digest.js";
|
||||||
|
|
||||||
|
// The HTTP layer is mocked so the camera driver's RETRY logic is tested without a
|
||||||
|
// network. Hikvision returns 503 "Device Busy" (sometimes 500) transiently when its
|
||||||
|
// snapshot encoder is occupied — captureSnapshot must retry those and succeed, but
|
||||||
|
// fail FAST on a config error (401 auth / 404 path). See camera.ts.
|
||||||
|
|
||||||
|
const digestGet = vi.fn<(...a: unknown[]) => Promise<DigestGetResult>>();
|
||||||
|
vi.mock("./http-digest.js", () => ({ digestGet: (...a: unknown[]) => digestGet(...a) }));
|
||||||
|
|
||||||
|
// Import the driver AFTER the mock is registered.
|
||||||
|
const { hikvisionDriver } = await import("./camera.js");
|
||||||
|
|
||||||
|
function reply(status: number, body = "jpeg-bytes"): DigestGetResult {
|
||||||
|
return { status, contentType: "image/jpeg", body: Buffer.from(body) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCamera() {
|
||||||
|
return hikvisionDriver.create({ host: "10.0.10.12", port: 80, username: "admin", password: "x", channel: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
digestGet.mockReset();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hikvision captureSnapshot — 503 Device Busy retry", () => {
|
||||||
|
it("retries a transient 503 and succeeds", async () => {
|
||||||
|
digestGet
|
||||||
|
.mockResolvedValueOnce(reply(503))
|
||||||
|
.mockResolvedValueOnce(reply(503))
|
||||||
|
.mockResolvedValueOnce(reply(200, "the-frame"));
|
||||||
|
const cam = makeCamera();
|
||||||
|
const p = cam.captureSnapshot({ direction: "entry" });
|
||||||
|
await vi.runAllTimersAsync(); // let the backoff sleeps fire
|
||||||
|
const shot = await p;
|
||||||
|
expect(shot.bytes.toString()).toBe("the-frame");
|
||||||
|
expect(digestGet).toHaveBeenCalledTimes(3); // 503, 503, 200
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also retries a transient 500", async () => {
|
||||||
|
digestGet.mockResolvedValueOnce(reply(500)).mockResolvedValueOnce(reply(200));
|
||||||
|
const cam = makeCamera();
|
||||||
|
const p = cam.captureSnapshot({ direction: "entry" });
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await p;
|
||||||
|
expect(digestGet).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives up after the attempt cap, naming it 'device busy'", async () => {
|
||||||
|
digestGet.mockResolvedValue(reply(503)); // always busy
|
||||||
|
const cam = makeCamera();
|
||||||
|
// Attach the rejection assertion BEFORE flushing timers so the rejection always
|
||||||
|
// has a handler (no unhandled-rejection noise), then drive the backoff sleeps.
|
||||||
|
const assertion = expect(cam.captureSnapshot({ direction: "entry" })).rejects.toThrow(/HTTP 503 \(device busy\)/);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await assertion;
|
||||||
|
expect(digestGet).toHaveBeenCalledTimes(4); // SNAPSHOT_MAX_ATTEMPTS
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT retry a 401 (auth error self-won't-heal) — fails fast", async () => {
|
||||||
|
digestGet.mockResolvedValue(reply(401));
|
||||||
|
const cam = makeCamera();
|
||||||
|
const assertion = expect(cam.captureSnapshot({ direction: "entry" })).rejects.toThrow(/HTTP 401/);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await assertion;
|
||||||
|
expect(digestGet).toHaveBeenCalledTimes(1); // no retry
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT retry a 404 (wrong path/channel) — fails fast", async () => {
|
||||||
|
digestGet.mockResolvedValue(reply(404));
|
||||||
|
const cam = makeCamera();
|
||||||
|
const assertion = expect(cam.captureSnapshot({ direction: "entry" })).rejects.toThrow(/HTTP 404/);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await assertion;
|
||||||
|
expect(digestGet).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("succeeds first try with no retry on a clean 200", async () => {
|
||||||
|
digestGet.mockResolvedValue(reply(200));
|
||||||
|
const cam = makeCamera();
|
||||||
|
const shot = await cam.captureSnapshot({ direction: "entry" });
|
||||||
|
expect(shot.contentType).toBe("image/jpeg");
|
||||||
|
expect(digestGet).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hikvision snapshot stream selection (main vs sub)", () => {
|
||||||
|
function pathFor(config: Record<string, unknown>): string {
|
||||||
|
digestGet.mockReset();
|
||||||
|
digestGet.mockResolvedValue(reply(200));
|
||||||
|
hikvisionDriver.create(config as never).captureSnapshot({ direction: "entry" });
|
||||||
|
return String((digestGet.mock.calls[0]![0] as { path: string }).path);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("defaults to the MAIN stream (…/channels/101/picture) — back-compat", () => {
|
||||||
|
expect(pathFor({ host: "1.2.3.4", channel: 1 })).toBe("/ISAPI/Streaming/channels/101/picture");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stream=2 selects the SUB stream (…/channels/102/picture) — the G3H 503 fix", () => {
|
||||||
|
expect(pathFor({ host: "1.2.3.4", channel: 1, stream: 2 })).toBe("/ISAPI/Streaming/channels/102/picture");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours the channel number with the stream (ch2 sub = 202)", () => {
|
||||||
|
expect(pathFor({ host: "1.2.3.4", channel: 2, stream: 2 })).toBe("/ISAPI/Streaming/channels/202/picture");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an invalid stream falls back to main (1)", () => {
|
||||||
|
expect(pathFor({ host: "1.2.3.4", channel: 1, stream: 9 })).toBe("/ISAPI/Streaming/channels/101/picture");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
import type { CameraDevice, DeviceHealth, Snapshot, SnapshotContext } from "../interfaces.js";
|
import type {
|
||||||
|
CameraDevice,
|
||||||
|
DeviceHealth,
|
||||||
|
Snapshot,
|
||||||
|
SnapshotContext,
|
||||||
|
} from "../interfaces.js";
|
||||||
import type { CameraDriver, ConfigField, DeviceConfig } from "../registry.js";
|
import type { CameraDriver, ConfigField, DeviceConfig } from "../registry.js";
|
||||||
import { hostField, passwordField, portField, usernameField, stubLog } from "./common.js";
|
import {
|
||||||
|
hostField,
|
||||||
|
passwordField,
|
||||||
|
portField,
|
||||||
|
usernameField,
|
||||||
|
stubLog,
|
||||||
|
} from "./common.js";
|
||||||
import { digestGet } from "./http-digest.js";
|
import { digestGet } from "./http-digest.js";
|
||||||
|
|
||||||
// Camera drivers — entry/exit snapshot-on-event. The host pulls a still over
|
// Camera drivers — entry/exit snapshot-on-event. The host pulls a still over
|
||||||
@@ -15,12 +26,32 @@ import { digestGet } from "./http-digest.js";
|
|||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 8000;
|
const DEFAULT_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
// Hikvision returns HTTP 503 (statusCode 2 / "Device Busy" / subStatus deviceBusy) —
|
||||||
|
// and occasionally 500 — when its snapshot encoder is momentarily occupied (another
|
||||||
|
// snapshot in flight, a stream starting, on-camera VCA). It is TRANSIENT: a retry a
|
||||||
|
// few hundred ms later succeeds. The newer G3H sensors (e.g. DS-2CD1047G3H) hit it
|
||||||
|
// more readily. So a standalone capture retries a few times before giving up; we do
|
||||||
|
// NOT retry config errors (401 auth, 404 path/channel) — those won't self-heal.
|
||||||
|
// (Concurrent same-camera hits are separately de-duped by captureSnapshotShared in
|
||||||
|
// the server.) See wiki/entities/lpr-camera.md ("503 Device Busy").
|
||||||
|
const SNAPSHOT_RETRY_STATUSES = new Set([500, 503]);
|
||||||
|
const SNAPSHOT_MAX_ATTEMPTS = 4;
|
||||||
|
const SNAPSHOT_RETRY_BASE_MS = 250;
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
class HttpCamera implements CameraDevice {
|
class HttpCamera implements CameraDevice {
|
||||||
readonly #host: string;
|
readonly #host: string;
|
||||||
readonly #port: number;
|
readonly #port: number;
|
||||||
readonly #user: string;
|
readonly #user: string;
|
||||||
readonly #password: string;
|
readonly #password: string;
|
||||||
readonly #channel: number;
|
readonly #channel: number;
|
||||||
|
/** Hikvision stream within the channel: 1 = main (high-res), 2 = sub (lighter).
|
||||||
|
* Some models (e.g. the G3H) keep the MAIN encoder saturated and return a
|
||||||
|
* persistent 503 deviceBusy on the main-stream snapshot, while the sub-stream
|
||||||
|
* serves fine — so this is selectable. Ignored by drivers (Dahua) that don't
|
||||||
|
* encode a stream in the path. See wiki/entities/lpr-camera.md ("503 Device Busy"). */
|
||||||
|
readonly #stream: number;
|
||||||
readonly #timeout: number;
|
readonly #timeout: number;
|
||||||
// Source outbound from the device-facing NIC on a multi-homed host (the
|
// Source outbound from the device-facing NIC on a multi-homed host (the
|
||||||
// multi-subnet source-address trap — see wiki/concepts/wsl-dev-networking.md).
|
// multi-subnet source-address trap — see wiki/concepts/wsl-dev-networking.md).
|
||||||
@@ -29,16 +60,20 @@ class HttpCamera implements CameraDevice {
|
|||||||
constructor(
|
constructor(
|
||||||
readonly driverId: string,
|
readonly driverId: string,
|
||||||
config: DeviceConfig,
|
config: DeviceConfig,
|
||||||
/** Builds the snapshot path from the configured channel. */
|
/** Builds the snapshot path from the configured channel + stream (1=main, 2=sub). */
|
||||||
private readonly snapshotPath: (channel: number) => string,
|
private readonly snapshotPath: (channel: number, stream: number) => string,
|
||||||
) {
|
) {
|
||||||
this.#host = String(config.host);
|
this.#host = String(config.host);
|
||||||
this.#port = Number(config.port ?? 80);
|
this.#port = Number(config.port ?? 80);
|
||||||
this.#user = String(config.username ?? "");
|
this.#user = String(config.username ?? "");
|
||||||
this.#password = String(config.password ?? "");
|
this.#password = String(config.password ?? "");
|
||||||
this.#channel = Number(config.channel ?? 1);
|
this.#channel = Number(config.channel ?? 1);
|
||||||
|
// 1 = main, 2 = sub. Clamp to those two; default main for back-compat.
|
||||||
|
this.#stream = Number(config.stream) === 2 ? 2 : 1;
|
||||||
this.#timeout = Number(config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
this.#timeout = Number(config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||||
this.#localAddress = config.localAddress ? String(config.localAddress) : undefined;
|
this.#localAddress = config.localAddress
|
||||||
|
? String(config.localAddress)
|
||||||
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(): Promise<void> {}
|
async connect(): Promise<void> {}
|
||||||
@@ -49,8 +84,13 @@ class HttpCamera implements CameraDevice {
|
|||||||
// frame: it exercises reachability + auth + the path/channel in one shot.
|
// frame: it exercises reachability + auth + the path/channel in one shot.
|
||||||
try {
|
try {
|
||||||
const res = await this.#get();
|
const res = await this.#get();
|
||||||
if (res.status === 200) return { status: "ready", detail: `${res.body.length} bytes` };
|
if (res.status === 200)
|
||||||
if (res.status === 401) return { status: "degraded", detail: "auth rejected (check username/password)" };
|
return { status: "ready", detail: `${res.body.length} bytes` };
|
||||||
|
if (res.status === 401)
|
||||||
|
return {
|
||||||
|
status: "degraded",
|
||||||
|
detail: "auth rejected (check username/password)",
|
||||||
|
};
|
||||||
return { status: "degraded", detail: `HTTP ${res.status}` };
|
return { status: "degraded", detail: `HTTP ${res.status}` };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { status: "offline", detail: (err as Error).message };
|
return { status: "offline", detail: (err as Error).message };
|
||||||
@@ -58,13 +98,37 @@ class HttpCamera implements CameraDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async captureSnapshot(ctx: SnapshotContext): Promise<Snapshot> {
|
async captureSnapshot(ctx: SnapshotContext): Promise<Snapshot> {
|
||||||
const res = await this.#get();
|
// Retry transient "Device Busy" (503/500); a config error (401/404) fails fast.
|
||||||
|
let res = await this.#get();
|
||||||
|
for (
|
||||||
|
let attempt = 1;
|
||||||
|
res.status !== 200 &&
|
||||||
|
SNAPSHOT_RETRY_STATUSES.has(res.status) &&
|
||||||
|
attempt < SNAPSHOT_MAX_ATTEMPTS;
|
||||||
|
attempt++
|
||||||
|
) {
|
||||||
|
// Linear backoff (250/500/750ms) — the encoder frees within a frame or two.
|
||||||
|
await sleep(SNAPSHOT_RETRY_BASE_MS * attempt);
|
||||||
|
stubLog(
|
||||||
|
this.driverId,
|
||||||
|
`captureSnapshot ${ctx.direction} retry ${attempt} (was HTTP ${res.status})`,
|
||||||
|
);
|
||||||
|
res = await this.#get();
|
||||||
|
}
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
|
// Name the busy case so the operator/telemetry can tell "camera busy" from a
|
||||||
|
// real fault (offline / auth / wrong path).
|
||||||
|
const busy = SNAPSHOT_RETRY_STATUSES.has(res.status)
|
||||||
|
? " (device busy)"
|
||||||
|
: "";
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${this.driverId} snapshot failed (${ctx.direction}): HTTP ${res.status}`,
|
`${this.driverId} snapshot failed (${ctx.direction}): HTTP ${res.status}${busy}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
stubLog(this.driverId, `captureSnapshot ${ctx.direction} (${res.body.length} bytes)`);
|
stubLog(
|
||||||
|
this.driverId,
|
||||||
|
`captureSnapshot ${ctx.direction} (${res.body.length} bytes)`,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
bytes: res.body,
|
bytes: res.body,
|
||||||
contentType: res.contentType || "image/jpeg",
|
contentType: res.contentType || "image/jpeg",
|
||||||
@@ -76,7 +140,7 @@ class HttpCamera implements CameraDevice {
|
|||||||
return digestGet({
|
return digestGet({
|
||||||
host: this.#host,
|
host: this.#host,
|
||||||
port: this.#port,
|
port: this.#port,
|
||||||
path: this.snapshotPath(this.#channel),
|
path: this.snapshotPath(this.#channel, this.#stream),
|
||||||
user: this.#user,
|
user: this.#user,
|
||||||
password: this.#password,
|
password: this.#password,
|
||||||
timeoutMs: this.#timeout,
|
timeoutMs: this.#timeout,
|
||||||
@@ -93,7 +157,32 @@ const channelField: ConfigField = {
|
|||||||
default: 1,
|
default: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, channelField];
|
// Hikvision stream-within-channel for the snapshot: main (01) is full-res; sub (02)
|
||||||
|
// is lighter. Default MAIN (back-compat). Switch to SUB when the main encoder is
|
||||||
|
// saturated and returns a persistent 503 deviceBusy (seen on DS-2CD1047G3H-LIU) —
|
||||||
|
// the sub-stream is also the better fit for snapshot/ANPR (smaller, faster, doesn't
|
||||||
|
// contend with live-view/recording). See wiki/entities/lpr-camera.md.
|
||||||
|
const streamField: ConfigField = {
|
||||||
|
key: "stream",
|
||||||
|
label: "Snapshot stream",
|
||||||
|
type: "select",
|
||||||
|
required: false,
|
||||||
|
default: "1",
|
||||||
|
options: [
|
||||||
|
{ value: "1", label: "Main (01)" },
|
||||||
|
{ value: "2", label: "Sub (02)" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dahua has no stream selector (its CGI snapshot isn't stream-encoded in the path).
|
||||||
|
const cameraConfigFields = [
|
||||||
|
hostField,
|
||||||
|
portField(80),
|
||||||
|
usernameField,
|
||||||
|
passwordField,
|
||||||
|
channelField,
|
||||||
|
];
|
||||||
|
const hikvisionConfigFields = [...cameraConfigFields, streamField];
|
||||||
|
|
||||||
// Hikvision "Alarm Server" PUSH config. The newer firmware (Event → Smart/VCA →
|
// Hikvision "Alarm Server" PUSH config. The newer firmware (Event → Smart/VCA →
|
||||||
// "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm Settings →
|
// "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm Settings →
|
||||||
@@ -138,15 +227,21 @@ export const hikvisionDriver: CameraDriver = {
|
|||||||
id: "hikvision",
|
id: "hikvision",
|
||||||
category: "camera",
|
category: "camera",
|
||||||
label: "Hikvision camera",
|
label: "Hikvision camera",
|
||||||
description: "Hikvision snapshot via ISAPI (HTTP Digest) + optional Alarm Server event push.",
|
description:
|
||||||
|
"Hikvision snapshot via ISAPI (HTTP Digest) + optional Alarm Server event push.",
|
||||||
transports: ["tcp-ip"],
|
transports: ["tcp-ip"],
|
||||||
// The camera PULLS snapshots, but with Alarm Server on it ALSO pushes events to us —
|
// The camera PULLS snapshots, but with Alarm Server on it ALSO pushes events to us —
|
||||||
// so it may need the backend push IP at assign time (like the Dingtian).
|
// so it may need the backend push IP at assign time (like the Dingtian).
|
||||||
pushesToBackend: true,
|
pushesToBackend: true,
|
||||||
configFields: [...cameraConfigFields, ...alarmPushFields],
|
configFields: [...hikvisionConfigFields, ...alarmPushFields],
|
||||||
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch2 main = 201.
|
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch1 sub = 102, ch2 main = 201.
|
||||||
|
// stream 1 → "01" (main), 2 → "02" (sub).
|
||||||
create: (c) =>
|
create: (c) =>
|
||||||
new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`),
|
new HttpCamera(
|
||||||
|
"hikvision",
|
||||||
|
c,
|
||||||
|
(ch, stream) => `/ISAPI/Streaming/channels/${ch}0${stream}/picture`,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dahuaDriver: CameraDriver = {
|
export const dahuaDriver: CameraDriver = {
|
||||||
@@ -156,7 +251,12 @@ export const dahuaDriver: CameraDriver = {
|
|||||||
description: "Dahua snapshot via CGI (HTTP Digest).",
|
description: "Dahua snapshot via CGI (HTTP Digest).",
|
||||||
transports: ["tcp-ip"],
|
transports: ["tcp-ip"],
|
||||||
configFields: cameraConfigFields,
|
configFields: cameraConfigFields,
|
||||||
// Dahua channels are 0-based on the CGI; the admin enters 1-based.
|
// Dahua channels are 0-based on the CGI; the admin enters 1-based. No stream in the
|
||||||
|
// path (the second arg is ignored — Dahua has no main/sub snapshot distinction here).
|
||||||
create: (c) =>
|
create: (c) =>
|
||||||
new HttpCamera("dahua", c, (ch) => `/cgi-bin/snapshot.cgi?channel=${Math.max(0, ch - 1)}`),
|
new HttpCamera(
|
||||||
|
"dahua",
|
||||||
|
c,
|
||||||
|
(ch) => `/cgi-bin/snapshot.cgi?channel=${Math.max(0, ch - 1)}`,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
|
||||||
|
// Unprivileged ICMP liveness check for PUSH-only devices that expose no TCP port —
|
||||||
|
// e.g. the Dingtian 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 { dahuaDriver, hikvisionDriver } from "./camera.js";
|
||||||
import { cashinoDriver } from "./printer-cashino.js";
|
import { cashinoDriver } from "./printer-cashino.js";
|
||||||
import { rongtaDriver } from "./printer-rongta.js";
|
import { rongtaDriver } from "./printer-rongta.js";
|
||||||
import { geeQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
import { dingtianQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
||||||
|
|
||||||
let registered = false;
|
let registered = false;
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export function registerBuiltinDrivers(): void {
|
|||||||
registry.register(stubAccessDriver);
|
registry.register(stubAccessDriver);
|
||||||
registry.register(wiegandReaderDriver);
|
registry.register(wiegandReaderDriver);
|
||||||
registry.register(tcpipReaderDriver);
|
registry.register(tcpipReaderDriver);
|
||||||
registry.register(geeQrReaderDriver);
|
registry.register(dingtianQrReaderDriver);
|
||||||
registry.register(hikvisionDriver);
|
registry.register(hikvisionDriver);
|
||||||
registry.register(dahuaDriver);
|
registry.register(dahuaDriver);
|
||||||
registry.register(rongtaDriver);
|
registry.register(rongtaDriver);
|
||||||
@@ -31,7 +31,7 @@ export {
|
|||||||
stubAccessDriver,
|
stubAccessDriver,
|
||||||
wiegandReaderDriver,
|
wiegandReaderDriver,
|
||||||
tcpipReaderDriver,
|
tcpipReaderDriver,
|
||||||
geeQrReaderDriver,
|
dingtianQrReaderDriver,
|
||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
dahuaDriver,
|
dahuaDriver,
|
||||||
rongtaDriver,
|
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 type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
||||||
import { hostField, portField, stubLog } from "./common.js";
|
import { hostField, portField, stubLog } from "./common.js";
|
||||||
import {
|
import {
|
||||||
probe,
|
devicePathField,
|
||||||
|
probeTo,
|
||||||
renderReceipt,
|
renderReceipt,
|
||||||
renderReport,
|
renderReport,
|
||||||
renderSubscriptionCard,
|
renderSubscriptionCard,
|
||||||
renderTicket,
|
renderTicket,
|
||||||
renderWindowChargeNotice,
|
renderWindowChargeNotice,
|
||||||
sendRaw,
|
sendTo,
|
||||||
|
transportField,
|
||||||
|
transportFromConfig,
|
||||||
|
type Transport,
|
||||||
} from "./printer-escpos.js";
|
} from "./printer-escpos.js";
|
||||||
|
|
||||||
// Cashino 80mm network thermal printer driver. The Cashino is an ESC/POS clone:
|
// Cashino 80mm thermal printer driver (network OR USB). The Cashino is an ESC/POS
|
||||||
// it PRINTS identically to the Rongta (same byte stream — see ./printer-escpos.ts),
|
// clone: it PRINTS identically to the Rongta (same byte stream — see
|
||||||
// so tickets, reports and subscription cards render the same. What it does NOT
|
// ./printer-escpos.ts), so tickets, reports and subscription cards render the same,
|
||||||
// have is the Rongta board's decoded status web page (/prn_stat.htm). It cannot
|
// over either transport. What it does NOT have is the Rongta board's decoded status
|
||||||
// report paper-out / cover-open / cutter faults in a form we trust.
|
// web page (/prn_stat.htm). It cannot report paper-out / cover-open / cutter faults
|
||||||
|
// in a form we trust.
|
||||||
|
//
|
||||||
|
// TRANSPORT: a single `config.transport` ("tcp-ip" | "usb") picks the wire; the
|
||||||
|
// driver resolves it ONCE into a Transport and every print/probe stays transport-
|
||||||
|
// blind (see transportFromConfig/sendTo/probeTo). USB writes the same bytes to a
|
||||||
|
// local usblp char device (/dev/usb/lp0); TCP writes to the raw print socket. This
|
||||||
|
// clone is the natural USB candidate — reachability-only, no status page to lose.
|
||||||
//
|
//
|
||||||
// Therefore this driver deliberately does NOT implement MonitorableDevice
|
// Therefore this driver deliberately does NOT implement MonitorableDevice
|
||||||
// (no readStatus). The device monitor then falls back to the generic
|
// (no readStatus). The device monitor then falls back to the generic
|
||||||
@@ -40,13 +51,11 @@ import {
|
|||||||
|
|
||||||
class CashinoPrinter implements PrinterDevice {
|
class CashinoPrinter implements PrinterDevice {
|
||||||
readonly driverId = "cashino";
|
readonly driverId = "cashino";
|
||||||
readonly #host: string;
|
readonly #transport: Transport;
|
||||||
readonly #port: number;
|
|
||||||
readonly #timeout: number;
|
readonly #timeout: number;
|
||||||
|
|
||||||
constructor(config: DeviceConfig) {
|
constructor(config: DeviceConfig) {
|
||||||
this.#host = String(config.host);
|
this.#transport = transportFromConfig(config);
|
||||||
this.#port = config.port ? Number(config.port) : 9100;
|
|
||||||
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
|
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,15 +68,15 @@ class CashinoPrinter implements PrinterDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reachability only — a TCP connect probe of the raw print socket. The Cashino
|
* Reachability only — a connect probe (TCP) or char-device open probe (USB) of
|
||||||
* has no trustworthy status protocol, so this is the floor and the ceiling of
|
* the print path. The Cashino has no trustworthy status protocol, so this is the
|
||||||
* what we report: reachable → ready, unreachable → offline. Deliberately NO
|
* floor and the ceiling of what we report: reachable → ready, unreachable →
|
||||||
* readStatus(): the monitor uses this for the traffic-light, never a guessed
|
* offline. Deliberately NO readStatus(): the monitor uses this for the
|
||||||
* paper/cover state.
|
* traffic-light, never a guessed paper/cover state.
|
||||||
*/
|
*/
|
||||||
async healthCheck(): Promise<DeviceHealth> {
|
async healthCheck(): Promise<DeviceHealth> {
|
||||||
try {
|
try {
|
||||||
await probe(this.#host, this.#port, this.#timeout);
|
await probeTo(this.#transport, this.#timeout);
|
||||||
return { status: "ready" };
|
return { status: "ready" };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { status: "offline", detail: (err as Error).message };
|
return { status: "offline", detail: (err as Error).message };
|
||||||
@@ -75,12 +84,12 @@ class CashinoPrinter implements PrinterDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async printTicket(data: TicketData): Promise<void> {
|
async printTicket(data: TicketData): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
|
await sendTo(this.#transport, renderTicket(data), this.#timeout);
|
||||||
stubLog(this.driverId, `printed ticket ${data.ticketId}`);
|
stubLog(this.driverId, `printed ticket ${data.ticketId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async printReport(report: PrintReport): Promise<void> {
|
async printReport(report: PrintReport): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout);
|
await sendTo(this.#transport, renderReport(report), this.#timeout);
|
||||||
stubLog(
|
stubLog(
|
||||||
this.driverId,
|
this.driverId,
|
||||||
`printed report "${report.title}" (${report.lines.length} lines)`,
|
`printed report "${report.title}" (${report.lines.length} lines)`,
|
||||||
@@ -88,17 +97,12 @@ class CashinoPrinter implements PrinterDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
|
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
|
||||||
await sendRaw(
|
await sendTo(this.#transport, renderSubscriptionCard(data), this.#timeout);
|
||||||
this.#host,
|
|
||||||
this.#port,
|
|
||||||
renderSubscriptionCard(data),
|
|
||||||
this.#timeout,
|
|
||||||
);
|
|
||||||
stubLog(this.driverId, `printed subscription card ${data.code}`);
|
stubLog(this.driverId, `printed subscription card ${data.code}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async printReceipt(data: ReceiptData): Promise<void> {
|
async printReceipt(data: ReceiptData): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout);
|
await sendTo(this.#transport, renderReceipt(data), this.#timeout);
|
||||||
stubLog(
|
stubLog(
|
||||||
this.driverId,
|
this.driverId,
|
||||||
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
|
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
|
||||||
@@ -106,7 +110,7 @@ class CashinoPrinter implements PrinterDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
|
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderWindowChargeNotice(data), this.#timeout);
|
await sendTo(this.#transport, renderWindowChargeNotice(data), this.#timeout);
|
||||||
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
|
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,14 +145,17 @@ export const cashinoDriver: PrinterDriver = {
|
|||||||
category: "printer",
|
category: "printer",
|
||||||
label: "Cashino 80mm thermal printer",
|
label: "Cashino 80mm thermal printer",
|
||||||
description:
|
description:
|
||||||
"Cashino 80mm thermal printer (ESC/POS over raw TCP, port 9100). Prints like the Rongta but has no status page — monitored by reachability ping only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
|
"Cashino 80mm thermal printer (ESC/POS over raw TCP port 9100, OR local USB /dev/usb/lp0). Prints like the Rongta but has no status page — monitored by reachability only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
|
||||||
transports: ["tcp-ip"],
|
transports: ["tcp-ip", "usb"],
|
||||||
configFields: [
|
configFields: [
|
||||||
hostField,
|
transportField,
|
||||||
|
devicePathField,
|
||||||
|
// host/port are TCP-only; not required because a USB printer needs neither.
|
||||||
|
{ ...hostField, required: false, help: `${hostField.help} Leave blank for a USB printer.` },
|
||||||
{
|
{
|
||||||
...portField(9100),
|
...portField(9100),
|
||||||
required: false,
|
required: false,
|
||||||
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).",
|
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100). TCP only.",
|
||||||
},
|
},
|
||||||
roleField,
|
roleField,
|
||||||
rankField,
|
rankField,
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
import {
|
import {
|
||||||
renderTicket,
|
renderTicket,
|
||||||
renderReceipt,
|
renderReceipt,
|
||||||
renderWindowChargeNotice,
|
renderWindowChargeNotice,
|
||||||
renderSubscriptionCard,
|
renderSubscriptionCard,
|
||||||
|
probeUsb,
|
||||||
|
sendRawUsb,
|
||||||
|
transportFromConfig,
|
||||||
stamp,
|
stamp,
|
||||||
} from "./printer-escpos.js";
|
} from "./printer-escpos.js";
|
||||||
|
|
||||||
@@ -103,6 +109,69 @@ describe("CP852 character mapping (the misprint fixes)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("USB transport (sendRawUsb / probeUsb / transportFromConfig)", () => {
|
||||||
|
// A regular file stands in for the usblp character device: open(O_WRONLY) + write
|
||||||
|
// is the same syscall path. This proves the transport is byte-blind — the EXACT
|
||||||
|
// ESC/POS stream renderTicket produces lands at the device path, with no transport
|
||||||
|
// touching a rendered byte (the whole point of the seam).
|
||||||
|
let dir: string;
|
||||||
|
let devicePath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), "escpos-usb-"));
|
||||||
|
devicePath = join(dir, "lp0");
|
||||||
|
// A real usblp node already EXISTS (created by the kernel on enumeration); we open
|
||||||
|
// it O_WRONLY without O_CREAT, never create it. Pre-create the stand-in file so the
|
||||||
|
// test mirrors that — opening an ABSENT path means "printer not present" (offline).
|
||||||
|
writeFileSync(devicePath, "");
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the exact rendered ESC/POS bytes to the device path", async () => {
|
||||||
|
const payload = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" });
|
||||||
|
await sendRawUsb(devicePath, payload, 1000);
|
||||||
|
const written = readFileSync(devicePath);
|
||||||
|
expect(written.equals(payload)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the device path can't be opened (printer not present)", async () => {
|
||||||
|
await expect(
|
||||||
|
sendRawUsb(join(dir, "absent-lp0"), Buffer.from([0x1b, 0x40]), 1000),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("probeUsb resolves for an existing node, rejects for a missing one", async () => {
|
||||||
|
await expect(probeUsb(devicePath, 1000)).resolves.toBeUndefined();
|
||||||
|
await expect(probeUsb(join(dir, "nope"), 1000)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transportFromConfig: transport=usb selects the char device (default /dev/usb/lp0)", () => {
|
||||||
|
expect(transportFromConfig({ transport: "usb", devicePath: "/dev/usb/lp1" })).toEqual({
|
||||||
|
kind: "usb",
|
||||||
|
devicePath: "/dev/usb/lp1",
|
||||||
|
});
|
||||||
|
expect(transportFromConfig({ transport: "usb" })).toEqual({
|
||||||
|
kind: "usb",
|
||||||
|
devicePath: "/dev/usb/lp0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transportFromConfig: anything else is TCP (back-compat with host-only configs)", () => {
|
||||||
|
expect(transportFromConfig({ host: "10.0.0.9" })).toEqual({
|
||||||
|
kind: "tcp",
|
||||||
|
host: "10.0.0.9",
|
||||||
|
port: 9100,
|
||||||
|
});
|
||||||
|
expect(transportFromConfig({ host: "10.0.0.9", port: 9101 })).toEqual({
|
||||||
|
kind: "tcp",
|
||||||
|
host: "10.0.0.9",
|
||||||
|
port: 9101,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("stamp (Albanian date format)", () => {
|
describe("stamp (Albanian date format)", () => {
|
||||||
it("formats an ISO time as '<day> <Month> <year> HH:MM:SS'", () => {
|
it("formats an ISO time as '<day> <Month> <year> HH:MM:SS'", () => {
|
||||||
// Local-time dependent, so assert the structure + the Albanian month name.
|
// Local-time dependent, so assert the structure + the Albanian month name.
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Socket } from "node:net";
|
import { Socket } from "node:net";
|
||||||
|
import { open } from "node:fs/promises";
|
||||||
|
import { constants as FS } from "node:fs";
|
||||||
import type {
|
import type {
|
||||||
PrintReport,
|
PrintReport,
|
||||||
ReceiptData,
|
ReceiptData,
|
||||||
@@ -567,7 +569,150 @@ export function probe(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- USB transport (kernel usblp character device) ----------------------------
|
||||||
|
// An ESC/POS USB printer plugged into the appliance enumerates as a character
|
||||||
|
// device (e.g. /dev/usb/lp0) via the in-box `usblp` kernel driver. We deliver the
|
||||||
|
// SAME ESC/POS byte stream there as over TCP — only the transport differs, not a
|
||||||
|
// single rendered byte. No libusb / CUPS / native addon: a plain file write keeps
|
||||||
|
// the MIT-only + offline-first, minimal-deps appliance constraints, and the path is
|
||||||
|
// a LOCAL char device the booth operator (the threat model's adversary) can't reach
|
||||||
|
// over the network. Paper/cover is NOT sensed here — same honesty floor as the
|
||||||
|
// Cashino TCP probe. usblp + a udev rule granting the server write access to the
|
||||||
|
// node are a provisioning dependency. See wiki/concepts/printer-usb-transport.md.
|
||||||
|
|
||||||
|
/** Bound a promise with a timeout — a wedged USB printer can block a write (or even
|
||||||
|
* the open) indefinitely, and a stuck print must surface as a failure rather than
|
||||||
|
* hang the entry flow. The underlying handle leaks on timeout, but the process is
|
||||||
|
* the appliance server; a failed print is logged and retried/failed-over upstream. */
|
||||||
|
function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const t = setTimeout(() => reject(new Error(msg)), ms);
|
||||||
|
p.then(
|
||||||
|
(v) => {
|
||||||
|
clearTimeout(t);
|
||||||
|
resolve(v);
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
clearTimeout(t);
|
||||||
|
reject(e as Error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
|
||||||
|
* is a RAW character device: a single open + write delivers the job — there is no
|
||||||
|
* FIN/half-close dance (that was a TCP concern, where an early destroy() could
|
||||||
|
* truncate the stream). We always close the handle (even on a failed write). */
|
||||||
|
export async function sendRawUsb(
|
||||||
|
devicePath: string,
|
||||||
|
payload: Buffer,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const handle = await withTimeout(
|
||||||
|
open(devicePath, FS.O_WRONLY | FS.O_NONBLOCK),
|
||||||
|
timeoutMs,
|
||||||
|
"usb open timeout",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await withTimeout(handle.write(payload), timeoutMs, "usb write timeout");
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reachability for a USB printer: the floor is "does the char device exist and
|
||||||
|
* open writable". A present, openable /dev/usb/lp0 means usblp bound a powered,
|
||||||
|
* enumerated printer — the USB analogue of the TCP connect probe. (Like the Cashino
|
||||||
|
* TCP probe, this reports reachability only, never a guessed paper/cover state.) */
|
||||||
|
export async function probeUsb(devicePath: string, timeoutMs: number): Promise<void> {
|
||||||
|
const handle = await withTimeout(
|
||||||
|
open(devicePath, FS.O_WRONLY | FS.O_NONBLOCK),
|
||||||
|
timeoutMs,
|
||||||
|
"usb open timeout",
|
||||||
|
);
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- transport dispatch -------------------------------------------------------
|
||||||
|
// A discriminated transport so each driver resolves the wire ONCE (from config) and
|
||||||
|
// every print/probe call site stays transport-blind. Adding a transport = one more
|
||||||
|
// arm here + the render layer is untouched.
|
||||||
|
|
||||||
|
/** Where a printer's bytes go: a TCP raw-print socket, or a local USB char device. */
|
||||||
|
export type Transport =
|
||||||
|
| { kind: "tcp"; host: string; port: number }
|
||||||
|
| { kind: "usb"; devicePath: string };
|
||||||
|
|
||||||
|
/** Build a Transport from a driver's flat config. `transport: "usb"` selects the
|
||||||
|
* USB char device (`devicePath`, default /dev/usb/lp0); anything else is TCP
|
||||||
|
* (host + port, default 9100) — so existing network configs with no `transport`
|
||||||
|
* key keep working unchanged. */
|
||||||
|
export function transportFromConfig(config: {
|
||||||
|
transport?: unknown;
|
||||||
|
host?: unknown;
|
||||||
|
port?: unknown;
|
||||||
|
devicePath?: unknown;
|
||||||
|
}): Transport {
|
||||||
|
if (config.transport === "usb") {
|
||||||
|
return { kind: "usb", devicePath: String(config.devicePath ?? "/dev/usb/lp0") };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "tcp",
|
||||||
|
host: String(config.host),
|
||||||
|
port: config.port ? Number(config.port) : 9100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send an ESC/POS payload over whichever transport the printer is configured for. */
|
||||||
|
export function sendTo(t: Transport, payload: Buffer, timeoutMs: number): Promise<void> {
|
||||||
|
return t.kind === "usb"
|
||||||
|
? sendRawUsb(t.devicePath, payload, timeoutMs)
|
||||||
|
: sendRaw(t.host, t.port, payload, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reachability probe over whichever transport the printer is configured for. */
|
||||||
|
export function probeTo(t: Transport, timeoutMs: number): Promise<void> {
|
||||||
|
return t.kind === "usb"
|
||||||
|
? probeUsb(t.devicePath, timeoutMs)
|
||||||
|
: probe(t.host, t.port, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human label for a transport, for status detail / logs. */
|
||||||
|
export function transportLabel(t: Transport): string {
|
||||||
|
return t.kind === "usb" ? t.devicePath : `${t.host}:${t.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
// --- shared driver config fields ----------------------------------------------
|
// --- shared driver config fields ----------------------------------------------
|
||||||
// Role + failover are identical across ESC/POS printers; defined here so each
|
// Role + failover are identical across ESC/POS printers; defined here so each
|
||||||
// driver shares them. See wiki/concepts/printer-roles-failover.md.
|
// driver shares them. See wiki/concepts/printer-roles-failover.md.
|
||||||
export type PrinterRole = "entry-dispenser" | "booth-receipt";
|
export type PrinterRole = "entry-dispenser" | "booth-receipt";
|
||||||
|
|
||||||
|
// --- shared printer config fields (transport) ---------------------------------
|
||||||
|
// TCP-or-USB is offered identically across the ESC/POS drivers; defined here so each
|
||||||
|
// shares the exact field set. The setup wizard renders these generically.
|
||||||
|
import type { ConfigField } from "../registry.js";
|
||||||
|
|
||||||
|
/** Connection-transport select: network (raw TCP 9100) or local USB char device. */
|
||||||
|
export const transportField: ConfigField = {
|
||||||
|
key: "transport",
|
||||||
|
label: "Connection",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
default: "tcp-ip",
|
||||||
|
options: [
|
||||||
|
{ value: "tcp-ip", label: "Network (raw TCP, port 9100)" },
|
||||||
|
{ value: "usb", label: "USB (local /dev/usb/lp0)" },
|
||||||
|
],
|
||||||
|
help: "USB drives a printer plugged into the appliance (usblp); Network drives one on the isolated device VLAN.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** USB character-device path; used only when transport=usb (ignored for TCP). */
|
||||||
|
export const devicePathField: ConfigField = {
|
||||||
|
key: "devicePath",
|
||||||
|
label: "USB device",
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
default: "/dev/usb/lp0",
|
||||||
|
help: "Character device for a USB printer (usblp), e.g. /dev/usb/lp0. Only used when Connection is USB.",
|
||||||
|
};
|
||||||
|
|||||||
@@ -13,22 +13,28 @@ import type {
|
|||||||
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
||||||
import { hostField, portField, stubLog } from "./common.js";
|
import { hostField, portField, stubLog } from "./common.js";
|
||||||
import {
|
import {
|
||||||
probe,
|
devicePathField,
|
||||||
|
probeTo,
|
||||||
renderReceipt,
|
renderReceipt,
|
||||||
renderReport,
|
renderReport,
|
||||||
renderSubscriptionCard,
|
renderSubscriptionCard,
|
||||||
renderTicket,
|
renderTicket,
|
||||||
renderWindowChargeNotice,
|
renderWindowChargeNotice,
|
||||||
sendRaw,
|
sendTo,
|
||||||
|
transportField,
|
||||||
|
transportFromConfig,
|
||||||
|
type Transport,
|
||||||
} from "./printer-escpos.js";
|
} from "./printer-escpos.js";
|
||||||
|
|
||||||
// Rongta 80mm network thermal printer driver. Rongta RP-series printers (and the
|
// Rongta 80mm thermal printer driver (network OR USB). Rongta RP-series printers
|
||||||
// many OEM clones that share their firmware) speak ESC/POS over a raw TCP socket
|
// (and the many OEM clones that share their firmware) speak ESC/POS over a raw TCP
|
||||||
// on port 9100 — the JetDirect/RAW convention. The ESC/POS rendering + transport
|
// socket on port 9100 — the JetDirect/RAW convention — or over a local USB usblp
|
||||||
// are shared with the other ESC/POS clones in ./printer-escpos.ts; what is unique
|
// char device. The ESC/POS rendering + transport are shared with the other ESC/POS
|
||||||
// to Rongta — and lives here — is LIVE STATUS via the board's own status web page.
|
// clones in ./printer-escpos.ts (config.transport picks the wire); what is unique to
|
||||||
// There is no auth on the print socket; like the other field devices it lives on
|
// Rongta — and lives here — is LIVE STATUS via the board's own status web page. That
|
||||||
// the isolated device VLAN.
|
// page is a NETWORK feature: a USB Rongta degrades to reachability-only monitoring
|
||||||
|
// (see readStatus). There is no auth on the print socket; like the other field
|
||||||
|
// devices a networked unit lives on the isolated device VLAN.
|
||||||
// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md.
|
// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md.
|
||||||
//
|
//
|
||||||
// ROLES + FAILOVER: a lane has more than one printer. Each instance declares a
|
// ROLES + FAILOVER: a lane has more than one printer. Each instance declares a
|
||||||
@@ -128,14 +134,15 @@ function parseStatusPage(html: string): StatusFlags {
|
|||||||
|
|
||||||
class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||||
readonly driverId = "rongta";
|
readonly driverId = "rongta";
|
||||||
|
readonly #transport: Transport;
|
||||||
readonly #host: string;
|
readonly #host: string;
|
||||||
readonly #port: number;
|
|
||||||
readonly #httpPort: number;
|
readonly #httpPort: number;
|
||||||
readonly #timeout: number;
|
readonly #timeout: number;
|
||||||
|
|
||||||
constructor(config: DeviceConfig) {
|
constructor(config: DeviceConfig) {
|
||||||
this.#host = String(config.host);
|
this.#transport = transportFromConfig(config);
|
||||||
this.#port = config.port ? Number(config.port) : 9100;
|
// Kept for the HTTP status page (TCP only); empty on a USB printer.
|
||||||
|
this.#host = config.host ? String(config.host) : "";
|
||||||
this.#httpPort = config.httpPort ? Number(config.httpPort) : 80;
|
this.#httpPort = config.httpPort ? Number(config.httpPort) : 80;
|
||||||
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
|
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
|
||||||
}
|
}
|
||||||
@@ -150,7 +157,7 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
|||||||
|
|
||||||
async healthCheck(): Promise<DeviceHealth> {
|
async healthCheck(): Promise<DeviceHealth> {
|
||||||
try {
|
try {
|
||||||
await probe(this.#host, this.#port, this.#timeout);
|
await probeTo(this.#transport, this.#timeout);
|
||||||
return { status: "ready" };
|
return { status: "ready" };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { status: "offline", detail: (err as Error).message };
|
return { status: "offline", detail: (err as Error).message };
|
||||||
@@ -158,12 +165,12 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async printTicket(data: TicketData): Promise<void> {
|
async printTicket(data: TicketData): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
|
await sendTo(this.#transport, renderTicket(data), this.#timeout);
|
||||||
stubLog(this.driverId, `printed ticket ${data.ticketId}`);
|
stubLog(this.driverId, `printed ticket ${data.ticketId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async printReport(report: PrintReport): Promise<void> {
|
async printReport(report: PrintReport): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout);
|
await sendTo(this.#transport, renderReport(report), this.#timeout);
|
||||||
stubLog(
|
stubLog(
|
||||||
this.driverId,
|
this.driverId,
|
||||||
`printed report "${report.title}" (${report.lines.length} lines)`,
|
`printed report "${report.title}" (${report.lines.length} lines)`,
|
||||||
@@ -171,17 +178,12 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
|
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
|
||||||
await sendRaw(
|
await sendTo(this.#transport, renderSubscriptionCard(data), this.#timeout);
|
||||||
this.#host,
|
|
||||||
this.#port,
|
|
||||||
renderSubscriptionCard(data),
|
|
||||||
this.#timeout,
|
|
||||||
);
|
|
||||||
stubLog(this.driverId, `printed subscription card ${data.code}`);
|
stubLog(this.driverId, `printed subscription card ${data.code}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async printReceipt(data: import("../interfaces.js").ReceiptData): Promise<void> {
|
async printReceipt(data: import("../interfaces.js").ReceiptData): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout);
|
await sendTo(this.#transport, renderReceipt(data), this.#timeout);
|
||||||
stubLog(
|
stubLog(
|
||||||
this.driverId,
|
this.driverId,
|
||||||
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
|
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
|
||||||
@@ -189,7 +191,7 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
|
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderWindowChargeNotice(data), this.#timeout);
|
await sendTo(this.#transport, renderWindowChargeNotice(data), this.#timeout);
|
||||||
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
|
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +208,18 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
|||||||
*/
|
*/
|
||||||
async readStatus(): Promise<PrinterStatus> {
|
async readStatus(): Promise<PrinterStatus> {
|
||||||
const checkedAt = new Date().toISOString();
|
const checkedAt = new Date().toISOString();
|
||||||
|
// The status page is an HTTP feature of the network board; a USB printer has no
|
||||||
|
// such page. Degrade to the reachability floor (open the char device) and report
|
||||||
|
// ready/offline only — never a guessed paper/cover state, same honesty rule as
|
||||||
|
// the Cashino. (A USB Rongta is effectively a Cashino for monitoring purposes.)
|
||||||
|
if (this.#transport.kind === "usb") {
|
||||||
|
try {
|
||||||
|
await probeTo(this.#transport, this.#timeout);
|
||||||
|
return { status: "ready", checkedAt };
|
||||||
|
} catch (err) {
|
||||||
|
return { status: "offline", detail: (err as Error).message, checkedAt };
|
||||||
|
}
|
||||||
|
}
|
||||||
let html: string;
|
let html: string;
|
||||||
try {
|
try {
|
||||||
html = await fetchStatusPage(this.#host, this.#httpPort, this.#timeout);
|
html = await fetchStatusPage(this.#host, this.#httpPort, this.#timeout);
|
||||||
@@ -281,14 +295,17 @@ export const rongtaDriver: PrinterDriver = {
|
|||||||
category: "printer",
|
category: "printer",
|
||||||
label: "Rongta 80mm thermal printer",
|
label: "Rongta 80mm thermal printer",
|
||||||
description:
|
description:
|
||||||
"Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones that serve the /prn_stat.htm status page) over raw TCP (port 9100). No auth on the print socket — isolate the VLAN.",
|
"Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones that serve the /prn_stat.htm status page) over raw TCP (port 9100), OR local USB /dev/usb/lp0. The decoded status page is a network feature — a USB Rongta is monitored by reachability only. No auth on the print socket — isolate the VLAN.",
|
||||||
transports: ["tcp-ip"],
|
transports: ["tcp-ip", "usb"],
|
||||||
configFields: [
|
configFields: [
|
||||||
hostField,
|
transportField,
|
||||||
|
devicePathField,
|
||||||
|
// host/port/status-page are TCP-only; not required for a USB printer.
|
||||||
|
{ ...hostField, required: false, help: `${hostField.help} Leave blank for a USB printer.` },
|
||||||
{
|
{
|
||||||
...portField(9100),
|
...portField(9100),
|
||||||
required: false,
|
required: false,
|
||||||
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).",
|
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100). TCP only.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "httpPort",
|
key: "httpPort",
|
||||||
@@ -296,7 +313,7 @@ export const rongtaDriver: PrinterDriver = {
|
|||||||
type: "port",
|
type: "port",
|
||||||
required: false,
|
required: false,
|
||||||
default: 80,
|
default: 80,
|
||||||
help: "Device status page (/prn_stat.htm) port for live monitoring (default 80).",
|
help: "Device status page (/prn_stat.htm) port for live monitoring (default 80). TCP only.",
|
||||||
},
|
},
|
||||||
roleField,
|
roleField,
|
||||||
rankField,
|
rankField,
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Reader health: push-only QR readers expose no TCP port, so liveness is an ICMP
|
||||||
|
// ping of the (optional) configured IP. With no IP we must NOT claim "ready" (the old
|
||||||
|
// stub did, hiding offline readers behind a green dot) — we report degraded instead.
|
||||||
|
// icmpPing is mocked so the test is deterministic + offline.
|
||||||
|
|
||||||
|
const icmpPing = vi.fn<(host: string, timeoutMs?: number) => Promise<boolean>>();
|
||||||
|
vi.mock("./icmp.js", () => ({ icmpPing: (...a: [string, number?]) => icmpPing(...a) }));
|
||||||
|
|
||||||
|
const { 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 { DeviceHealth, ReaderDevice, ReaderEvent } from "../interfaces.js";
|
||||||
import type { DeviceConfig, ReaderDriver } from "../registry.js";
|
import type { DeviceConfig, ReaderDriver } from "../registry.js";
|
||||||
import { hostField, portField, stubLog } from "./common.js";
|
import { hostField, portField, stubLog } from "./common.js";
|
||||||
|
import { icmpPing } from "./icmp.js";
|
||||||
|
|
||||||
// Reader drivers (RF / optical). Two integration paths: Wiegand reads reach the
|
// Reader drivers (RF / optical). Two integration paths: Wiegand reads reach the
|
||||||
// access controller directly (autonomous); TCP-IP readers are seen host-side.
|
// access controller directly (autonomous); TCP-IP readers are seen host-side.
|
||||||
@@ -18,8 +19,23 @@ class StubReader implements ReaderDevice {
|
|||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
stubLog(this.driverId, "disconnect");
|
stubLog(this.driverId, "disconnect");
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Liveness. These readers PUSH (scan → GET our backend) and expose no TCP port, so
|
||||||
|
* there's nothing to connect-probe. If the admin gave the reader's IP we ICMP-ping
|
||||||
|
* it (powered + on-network); a reply → ready, no reply → offline. With NO IP we
|
||||||
|
* report `degraded` ("set IP to monitor") rather than a false `ready` — a push
|
||||||
|
* device that's silent is indistinguishable from a dead one, so claiming `ready`
|
||||||
|
* unconditionally (the old behaviour) hid offline readers behind a green dot.
|
||||||
|
*/
|
||||||
async healthCheck(): Promise<DeviceHealth> {
|
async healthCheck(): Promise<DeviceHealth> {
|
||||||
return { status: "ready", detail: "stub" };
|
const host = this.config.host ? String(this.config.host) : "";
|
||||||
|
if (!host) {
|
||||||
|
return { status: "degraded", detail: "push device — set IP to monitor" };
|
||||||
|
}
|
||||||
|
const alive = await icmpPing(host);
|
||||||
|
return alive
|
||||||
|
? { status: "ready", detail: `ping ${host}` }
|
||||||
|
: { status: "offline", detail: `no ping reply from ${host}` };
|
||||||
}
|
}
|
||||||
onRead(cb: (r: ReaderEvent) => void): void {
|
onRead(cb: (r: ReaderEvent) => void): void {
|
||||||
this.#cb = cb;
|
this.#cb = cb;
|
||||||
@@ -59,18 +75,19 @@ export const tcpipReaderDriver: ReaderDriver = {
|
|||||||
create: (c) => new StubReader("tcpip-reader", c),
|
create: (c) => new StubReader("tcpip-reader", c),
|
||||||
};
|
};
|
||||||
|
|
||||||
// GEE/Fondvision QR access reader (e.g. GEE-QR-ER80). A PUSH device: on each scan
|
// Dingtian DT-008 QR/RFID access reader (same vendor as the relay board). Scans QR/barcode
|
||||||
// it HTTP-GETs our backend (/qa/mcardsea.<ext>) carrying its serial (cjihao); the
|
// + ID/IC/NFC cards. A PUSH device: on each scan it HTTP-GETs our backend (/qa/mcardsea.<ext>)
|
||||||
// backend resolves the lane by matching that serial to this device's `serial`
|
// carrying its serial (cjihao); the backend resolves the lane by matching that serial to this
|
||||||
// config, decides, and replies the verdict (drives the beep). No host-side
|
// 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
|
// connection — the adapter is a stub; the real integration is the HTTP endpoint (apps/server
|
||||||
// (apps/server routes/qr-reader.ts). See wiki/entities/gee-qr-er80.md.
|
// routes/qr-reader.ts). Interfaces: Wiegand 26/34, TCP/IP, USB, RS485. See
|
||||||
export const geeQrReaderDriver: ReaderDriver = {
|
// wiki/entities/dingtian-dt008-reader.md.
|
||||||
id: "gee-qr-reader",
|
export const dingtianQrReaderDriver: ReaderDriver = {
|
||||||
|
id: "dingtian-qr-reader",
|
||||||
category: "reader",
|
category: "reader",
|
||||||
label: "GEE/Fondvision QR reader (HTTP push)",
|
label: "Dingtian DT-008 QR/RFID reader (HTTP push)",
|
||||||
description:
|
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"],
|
transports: ["tcp-ip"],
|
||||||
configFields: [
|
configFields: [
|
||||||
{
|
{
|
||||||
@@ -80,6 +97,14 @@ export const geeQrReaderDriver: ReaderDriver = {
|
|||||||
required: true,
|
required: true,
|
||||||
help: "The reader's serial as it reports in each scan (the `cjihao` field). Used to map scans to this lane.",
|
help: "The reader's serial as it reports in each scan (the `cjihao` field). Used to map scans to this lane.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// OPTIONAL: the reader pushes by serial (operation needs no IP), but giving its
|
||||||
|
// IP lets the status monitor ICMP-ping it for a real online/offline dot instead
|
||||||
|
// of an always-green stub. Leave blank to skip monitoring (shows "set IP").
|
||||||
|
...hostField,
|
||||||
|
required: false,
|
||||||
|
help: "Optional: the reader's IP, used ONLY to monitor it (ping). Scans still resolve by serial. Leave blank to skip liveness monitoring.",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
create: (c) => new StubReader("gee-qr-reader", c),
|
create: (c) => new StubReader("dingtian-qr-reader", c),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export {
|
|||||||
stubAccessDriver,
|
stubAccessDriver,
|
||||||
wiegandReaderDriver,
|
wiegandReaderDriver,
|
||||||
tcpipReaderDriver,
|
tcpipReaderDriver,
|
||||||
geeQrReaderDriver,
|
dingtianQrReaderDriver,
|
||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
dahuaDriver,
|
dahuaDriver,
|
||||||
rongtaDriver,
|
rongtaDriver,
|
||||||
|
|||||||
@@ -35,6 +35,23 @@ export interface AccessControlDevice extends Device {
|
|||||||
getDoorStatus(doorId: number): Promise<"open" | "closed">;
|
getDoorStatus(doorId: number): Promise<"open" | "closed">;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Auxiliary outputs (non-barrier latched signals) ---------------------
|
||||||
|
// Optional capability for controllers with SPARE relays wired to something that
|
||||||
|
// is NOT a barrier — a button lamp, a "wait"/"go" sign. setAux LATCHES the output
|
||||||
|
// on or off and holds it (unlike pulseOpen, which is momentary). The
|
||||||
|
// barrier-not-a-door rule does NOT apply here: this output never gates a vehicle,
|
||||||
|
// so holding/blinking it is fine. Business logic drives indicators through THIS,
|
||||||
|
// never the driver's own relay methods. See wiki/concepts/button-light-indicator.md.
|
||||||
|
export interface AuxOutputDevice {
|
||||||
|
/** Latch an auxiliary output on/off. 1-based channel (a spare relay). */
|
||||||
|
setAux(channel: number, on: boolean): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feature-detect the aux-output capability on a built device adapter. */
|
||||||
|
export function hasAuxOutput(d: unknown): d is AuxOutputDevice {
|
||||||
|
return typeof (d as Partial<AuxOutputDevice>)?.setAux === "function";
|
||||||
|
}
|
||||||
|
|
||||||
// --- Inputs (buttons / dry contacts) -------------------------------------
|
// --- Inputs (buttons / dry contacts) -------------------------------------
|
||||||
// Optional capability for controllers that expose host-readable inputs SEPARATE
|
// Optional capability for controllers that expose host-readable inputs SEPARATE
|
||||||
// from their relays — e.g. the Dingtian board. This is what enables host-in-the-
|
// from their relays — e.g. the Dingtian board. This is what enables host-in-the-
|
||||||
|
|||||||
Generated
+299
@@ -66,6 +66,9 @@ importers:
|
|||||||
fastify-plugin:
|
fastify-plugin:
|
||||||
specifier: 6.0.0
|
specifier: 6.0.0
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
|
sharp:
|
||||||
|
specifier: ^0.35.2
|
||||||
|
version: 0.35.2
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/bcrypt':
|
'@types/bcrypt':
|
||||||
specifier: 6.0.0
|
specifier: 6.0.0
|
||||||
@@ -265,6 +268,9 @@ packages:
|
|||||||
'@emnapi/runtime@1.10.0':
|
'@emnapi/runtime@1.10.0':
|
||||||
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
|
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.11.1':
|
||||||
|
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
|
||||||
|
|
||||||
'@emnapi/wasi-threads@1.2.1':
|
'@emnapi/wasi-threads@1.2.1':
|
||||||
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
|
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
|
||||||
|
|
||||||
@@ -774,6 +780,152 @@ packages:
|
|||||||
'@floating-ui/utils@0.2.11':
|
'@floating-ui/utils@0.2.11':
|
||||||
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
|
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':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
|
|
||||||
@@ -2576,6 +2728,10 @@ packages:
|
|||||||
setprototypeof@1.2.0:
|
setprototypeof@1.2.0:
|
||||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
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:
|
siginfo@2.0.0:
|
||||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
@@ -2953,6 +3109,11 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.11.1':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@emnapi/wasi-threads@1.2.1':
|
'@emnapi/wasi-threads@1.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -3276,6 +3437,112 @@ snapshots:
|
|||||||
|
|
||||||
'@floating-ui/utils@0.2.11': {}
|
'@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':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@@ -4910,6 +5177,38 @@ snapshots:
|
|||||||
|
|
||||||
setprototypeof@1.2.0: {}
|
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: {}
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
simple-concat@1.0.1: {}
|
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
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8099
|
||||||
|
|
||||||
|
|
||||||
|
class Sink(BaseHTTPRequestHandler):
|
||||||
|
def _log(self, method: str) -> None:
|
||||||
|
ts = datetime.now().strftime("%H:%M:%S")
|
||||||
|
src = self.client_address[0]
|
||||||
|
clen = int(self.headers.get("Content-Length", 0) or 0)
|
||||||
|
body = self.rfile.read(clen) if clen else b""
|
||||||
|
ctype = self.headers.get("Content-Type", "-")
|
||||||
|
print(f"\n=== {ts} {method} {self.path} from {src} ===", flush=True)
|
||||||
|
print(f" Content-Type: {ctype} ({clen} bytes)", flush=True)
|
||||||
|
text = body.decode("utf-8", "replace")
|
||||||
|
cut = text.find('Content-Type: image/jpeg')
|
||||||
|
if cut != -1:
|
||||||
|
text = text[:cut] + "\n [...JPEG image part omitted...]"
|
||||||
|
print(" body:\n" + text, flush=True)
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Length", "2")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"OK")
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
self._log("POST")
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
self._log("GET")
|
||||||
|
|
||||||
|
def do_PUT(self):
|
||||||
|
self._log("PUT")
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(f"camera-event post test listening on 0.0.0.0:{PORT}", flush=True)
|
||||||
|
print("Ctrl-C to stop.\n", flush=True)
|
||||||
|
ThreadingHTTPServer(("0.0.0.0", PORT), Sink).serve_forever()
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, device, indicator, radar, camera, aux-output, barrier-not-a-door, event-relay]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-28
|
||||||
|
status: settled
|
||||||
|
---
|
||||||
|
|
||||||
|
# Alert relays (radar × camera disagreement lamp)
|
||||||
|
|
||||||
|
A relay on the [[dingtian-relay|Dingtian]] controller is uniformly **"when EVENT X happens, do
|
||||||
|
action Y"** — see [[entry-exit-points|relays carry an event]]. The barrier events (`entry`/`exit`/
|
||||||
|
`both`) **pulse** a barrier; the **`radarAlert`** event drives a non-barrier **indicator lamp**
|
||||||
|
(blink + camera-lock) on a spare relay. The entry button's **12 V light** is the canonical alert
|
||||||
|
relay, a 3-state indicator that combines a **[[hikvision-radar|radar]]** trigger input with the
|
||||||
|
**camera "car in zone"** signal:
|
||||||
|
|
||||||
|
| Trigger input (radar) | Camera (lane entry busy) | Alert lamp |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| active | **free** — no car confirmed | **BLINK** (~1 Hz) |
|
||||||
|
| active | **busy** — camera confirms a car | **SOLID on** |
|
||||||
|
| inactive | — | **OFF** |
|
||||||
|
|
||||||
|
It is a **disagreement indicator**: the radar sees *something* but the camera hasn't confirmed a
|
||||||
|
real vehicle → blink (attention / "pull forward"); both agree → solid; nothing there → off.
|
||||||
|
|
||||||
|
Because it's just another relay row, a controller can carry **several** alert relays (e.g. R3 and a
|
||||||
|
future R4), each with its own trigger input — no new config shape, no code change.
|
||||||
|
|
||||||
|
## Signals
|
||||||
|
|
||||||
|
- **Trigger** = the alert relay's own `triggerInput` edge (the [[hikvision-radar|radar]]). When
|
||||||
|
unset, it falls back to the controller's entry-relay `presenceInput` — the same edge the
|
||||||
|
[[entry-double-press|one-car-one-ticket]] gate observes, so the lamp and the gate agree on "a car
|
||||||
|
is here".
|
||||||
|
- **Lock (camera "car in zone")** = the existing **[[lpr-camera|lane status]]** (`LaneStatusEvent`,
|
||||||
|
from camera vehicle detection). Already advisory; already drives the booth's barrier lights. Each
|
||||||
|
lamp picks **which lane's camera** locks it via `relays[].lockLane: "entry"|"exit"` (default
|
||||||
|
entry) — so an **exit radar's lamp locks on the EXIT camera**, not the entry one. (Lane-busy is the
|
||||||
|
only lock *kind* wired today; the model leaves room for others later.)
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
An alert lamp is a `config.relays[]` row with `direction: "radarAlert"`, carrying
|
||||||
|
`{ relay, triggerInput?, blinkOnMs?, blinkOffMs? }`. No separate `buttonLight` block (that was the
|
||||||
|
pre-2026-06-28 shape — barriers and the lamp were two different configs; now they're one list).
|
||||||
|
Blink defaults to 500 ms / 500 ms. The operator picks a **spare** relay (an alert relay never opens
|
||||||
|
a barrier; every barrier resolver skips `radarAlert` rows).
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
`apps/server/src/button-light.ts` — `ButtonLightController` reads the `radarAlert` rows
|
||||||
|
(`alertRelaysOf()` in `device-resolve.ts`), subscribes to `deviceEvents.onInput` (radar) +
|
||||||
|
`onLaneStatus` (camera), computes the target state **per lamp** (keyed `controllerId:relay`, so
|
||||||
|
several alert relays on one controller are independent), and drives each lamp via a **device-agnostic
|
||||||
|
aux-output** capability.
|
||||||
|
|
||||||
|
- **Aux-output capability.** `AuxOutputDevice { setAux(channel, on) }` on the device interface (the
|
||||||
|
Dingtian driver implements it as a latch). Business logic drives the lamp through this — **never**
|
||||||
|
the driver's barrier methods.
|
||||||
|
- **Barrier-not-a-door is preserved.** The lamp is **not a barrier**, so holding / blinking it on a
|
||||||
|
timer is fine — the [[barrier-not-a-door]] rule forbids timing a *barrier* closed, and barriers
|
||||||
|
still only ever `pulseOpen`. The lamp uses the separate `setAux` latch.
|
||||||
|
- **Fails OFF.** On host loss, shutdown, or a `setAux` error the lamp defaults OFF — a dead lamp is
|
||||||
|
"no hint", never a misleading solid "go". SOLID is only ever held while busy + present is actively
|
||||||
|
true (never latched on through a crash path).
|
||||||
|
- **Serialized sends (must — UDP is unordered).** The first cut fired fire-and-forget `setAux` every
|
||||||
|
500 ms; over **unordered UDP** the on/off packets reordered/overlapped and the relay **latched on
|
||||||
|
whichever packet the device processed last** — the lamp got stuck on/off at random (observed on
|
||||||
|
hardware). Fix: a **desired-state + serialized worker** (`#pump`). The blink timer only flips a
|
||||||
|
`desiredOn` flag; the worker guarantees **one in-flight send per lamp** and, on completion,
|
||||||
|
re-converges to the latest desired state. So the **final state is always authoritative** and a
|
||||||
|
lost/stale packet self-corrects. This also de-dupes (it skips a send when `confirmedOn === desiredOn`),
|
||||||
|
so the input stream never spams the controller.
|
||||||
|
- **Hot-reloads the config (no restart).** The lamp map is reconciled against the live device config
|
||||||
|
at start AND before each event (mirroring [[device-status-monitoring|DeviceMonitor]], which re-reads
|
||||||
|
the device set each tick) — adding/updating/dropping lamps. So a button light added or re-pointed in
|
||||||
|
the setup UI takes effect on the **next radar edge**, not after a server restart. (The first cut
|
||||||
|
loaded the map once at boot, so a just-saved lamp silently did nothing until restart.)
|
||||||
|
|
||||||
|
## On-screen twin — the booth barrier lights
|
||||||
|
|
||||||
|
The booth's **Hyrje / Dalje (Entry / Exit) indicators** mirror the physical lamp with the SAME
|
||||||
|
3-state rule, per lane: radar-present + camera-free → **blink green↔red** (~1 Hz); camera-busy →
|
||||||
|
**solid red**; else **solid green**. So the operator sees the same "detected, not yet confirmed →
|
||||||
|
confirmed → clear" story on screen as the lamp shows on the post.
|
||||||
|
|
||||||
|
The radar half is sourced by a small server tracker, **`LanePresence` (`lane-presence.ts`)**, that
|
||||||
|
subscribes to `deviceEvents.onInput` and resolves each presence edge to its lane via
|
||||||
|
**`presenceLaneOf`** (`device-resolve.ts`) — direction-agnostic (entry **and** exit), unlike the
|
||||||
|
entry-gated `relayForPresence` the one-car-one-ticket gate uses. It emits a `lane-presence`
|
||||||
|
`{entry,exit}` bus event on change; the WS forwards it (hello snapshot + push) into the booth's
|
||||||
|
`live-store.radar`, and `BarrierLight` (`BoothScreen.tsx`) blinks via the `.lane-blink` keyframe
|
||||||
|
(`index.css`, holds solid-red under `prefers-reduced-motion`). The camera half is the existing
|
||||||
|
[[lpr-camera|lane status]]. Same inputs, same rule as the lamp, so screen and post never disagree.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Built 2026-06-24 for the first booth (button I1, radar I2, lamp on a spare relay); the serialized-send
|
||||||
|
+ hot-reload fixes landed the same day after the lamp stuck on/off on hardware. **Reframed
|
||||||
|
2026-06-28**: the dedicated `config.buttonLight` block was folded into the unified `relays[]` list as
|
||||||
|
a `radarAlert` event-relay (carrying its own `triggerInput`), so the operator can add arbitrary
|
||||||
|
event-driven blinkers (e.g. R4) without code changes; the 3-state machine itself is unchanged.
|
||||||
|
Covered by `apps/server/src/button-light.test.ts` (the truth table, blink toggling asserted on the
|
||||||
|
device's *confirmed* state, fail-OFF, de-dupe, lamp-added-after-start reconcile, and two independent
|
||||||
|
alert relays on one controller).
|
||||||
|
Related: [[hikvision-radar]], [[entry-double-press]], [[lpr-camera]], [[dingtian-relay]],
|
||||||
|
[[entry-exit-points]], [[barrier-not-a-door]].
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, device, monitoring, reliability, ui]
|
tags: [parking, device, monitoring, reliability, ui]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-18
|
updated: 2026-06-26
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,6 +25,15 @@ talks only to the adapter interfaces ([[device-adapter-pattern]]), never a drive
|
|||||||
- **Relays / readers / cameras** → the generic `Device.healthCheck()` **reachability** probe every
|
- **Relays / readers / cameras** → the generic `Device.healthCheck()` **reachability** probe every
|
||||||
adapter implements (`ready | degraded | offline`). This is presence/up-ness, not a deep fault
|
adapter implements (`ready | degraded | offline`). This is presence/up-ness, not a deep fault
|
||||||
model — a relay either answers or it doesn't.
|
model — a relay either answers or it doesn't.
|
||||||
|
> **Reader health was a LIE until 2026-06-26.** The QR-reader adapter (a PUSH device: it GETs our
|
||||||
|
> backend on each scan and exposes **no TCP port**) had a hardcoded `healthCheck → { ready, "stub" }`,
|
||||||
|
> so two genuinely-offline readers still showed **green**. A push device that's silent is
|
||||||
|
> indistinguishable from a dead one — so claiming `ready` unconditionally is the worst failure
|
||||||
|
> (false-healthy). Fix: an **optional reader IP** (monitor-only; scans still resolve by serial) +
|
||||||
|
> an **unprivileged ICMP ping** (`drivers/icmp.ts`, shells `/bin/ping` in SOCK_DGRAM mode — no
|
||||||
|
> CAP_NET_RAW, no native dep; the booth compose sets `net.ipv4.ping_group_range`). Reply → `ready`,
|
||||||
|
> no reply → `offline`; **no IP set → `degraded` ("set IP to monitor")**, never a false green.
|
||||||
|
> Verified on hardware: pinged the real readers on the device VLAN. See [[dingtian-dt008-reader]].
|
||||||
|
|
||||||
Both collapse to one **traffic-light**: `ready | degraded | offline`, plus a `detail` string. Fail
|
Both collapse to one **traffic-light**: `ready | degraded | offline`, plus a `detail` string. Fail
|
||||||
**toward "there's a problem"**, never false-healthy: a probe that throws or times out reads
|
**toward "there's a problem"**, never false-healthy: a probe that throws or times out reads
|
||||||
|
|||||||
@@ -26,10 +26,17 @@ press → print → press again issued a second ticket immediately. That is not
|
|||||||
The guard lives on the entry relay's spec (`config.relays[]` — see [[entry-exit-points]]), because
|
The guard lives on the entry relay's spec (`config.relays[]` — see [[entry-exit-points]]), because
|
||||||
whether real one-car-one-ticket is *possible* depends on the hardware at that lane. Two modes:
|
whether real one-car-one-ticket is *possible* depends on the hardware at that lane. Two modes:
|
||||||
|
|
||||||
### PRESENCE mode (preferred — when a vehicle loop is wired)
|
### PRESENCE mode (preferred — when a vehicle-presence sensor is wired)
|
||||||
`relays[].presenceInput` = the 1-based input terminal of an **induction loop / barrier presence
|
Inputs are a first-class `config.inputs[]` list (the twin of `relays[]`): each row is a terminal +
|
||||||
signal** on the same [[dingtian-relay|controller]] (the Dingtian's inputs are decoupled from its
|
a **role** (`button` / `presence` / `alertTrigger`) + the `relay` it serves. A **presence** row =
|
||||||
relays, and loops are already in the [[bom]]). The rule makes one-car-one-ticket **physical**:
|
the 1-based input terminal of a **vehicle-presence sensor** serving an entry/both relay on the same
|
||||||
|
[[dingtian-relay|controller]] (the Dingtian's inputs are decoupled from its relays). The sensor may
|
||||||
|
be an **induction loop** OR a **[[hikvision-radar|radar]]** (`inputs[].kind: "loop"|"radar"` — a
|
||||||
|
label; the gate behaviour is identical). A radar wired to idle opposite the button needs
|
||||||
|
`inputs[].activeLow: true` so its edge reads correctly. **Multiple radars (entry + exit) are just
|
||||||
|
multiple presence rows** — adding an exit radar is adding a row. (Pre-2026-06-28 configs wired this
|
||||||
|
on `relays[].presenceInput/presenceKind/presenceActiveLow`; the resolvers still read those as
|
||||||
|
back-compat, synthesizing inputs[] from them.) The rule makes one-car-one-ticket **physical**:
|
||||||
|
|
||||||
- A press prints **only while a car is present** on the loop.
|
- A press prints **only while a car is present** on the loop.
|
||||||
- After a ticket prints, the relay is **disarmed** — no second ticket — **until the loop CLEARS**
|
- After a ticket prints, the relay is **disarmed** — no second ticket — **until the loop CLEARS**
|
||||||
@@ -66,11 +73,14 @@ in telemetry if ever needed.
|
|||||||
host (single-writer); it is derived from live input edges, never the source of truth. A restart
|
host (single-writer); it is derived from live input edges, never the source of truth. A restart
|
||||||
starts armed (the first press after a restart works), which is the safe default.
|
starts armed (the first press after a restart works), which is the safe default.
|
||||||
|
|
||||||
## As-built (2026-06-19)
|
## As-built (2026-06-19; inputs[] 2026-06-28)
|
||||||
|
|
||||||
- `RelaySpec` gains `presenceInput?` + `entryCooldownSec?` (`device-resolve.ts`); `relayForButton`
|
- Inputs live in `config.inputs[] = [{ input, role, relay?, kind?, activeLow?, cooldownSec? }]`
|
||||||
carries them onto the `ResolvedRelay`, and a new `relayForPresence()` resolves a loop-input edge to
|
(`device-resolve.ts`). `inputsOf(row)` returns them, **or synthesizes** the list from the legacy
|
||||||
the entry relay it gates.
|
`relays[].button/presenceInput/...` fields when a controller predates inputs[] (one back-compat
|
||||||
|
shim; the UI no longer writes the legacy fields). `relayForButton`/`relayForPresence` resolve
|
||||||
|
through `inputsOf`, carry `presenceInput`/`entryCooldownSec` onto the `ResolvedRelay`, and only ever
|
||||||
|
gate entry/both relays. An exit radar = a `presence` row on the exit relay.
|
||||||
- `EntryFlow` (`entry-flow.ts`) keeps a `#guard` map keyed `controllerId:relay`: `#onPresenceEdge`
|
- `EntryFlow` (`entry-flow.ts`) keeps a `#guard` map keyed `controllerId:relay`: `#onPresenceEdge`
|
||||||
tracks the loop, `#suppressReason` decides presence/cooldown, `#recordSuppressedPress` writes the
|
tracks the loop, `#suppressReason` decides presence/cooldown, `#recordSuppressedPress` writes the
|
||||||
telemetry. The guard disarms + stamps the cooldown on **print success** (not on open).
|
telemetry. The guard disarms + stamps the cooldown on **print success** (not on open).
|
||||||
|
|||||||
@@ -104,7 +104,27 @@ Captured **after** the barrier opens, **never awaited** — a camera failure can
|
|||||||
open (the signed ledger is the decision). Stored as a **BLOB in the `snapshots` table** (single
|
open (the signed ledger is the decision). Stored as a **BLOB in the `snapshots` table** (single
|
||||||
backed-up DB, nothing scattered on disk), in its own table so hot telemetry scans don't drag image
|
backed-up DB, nothing scattered on disk), in its own table so hot telemetry scans don't drag image
|
||||||
bytes and images prune independently. Linked to the signed `vehicle_entry/exit` by `identity`.
|
bytes and images prune independently. Linked to the signed `vehicle_entry/exit` by `identity`.
|
||||||
Served read-only via `GET /api/snapshots/:id`. **Retention is unresolved** — see [[open-questions]].
|
Served read-only via `GET /api/snapshots/:id`.
|
||||||
|
|
||||||
|
**Re-encoded for storage (2026-06-28).** Cameras serve full-res JPEGs (a Hikvision main stream is
|
||||||
|
2688×1520 / ~600 KB); stored raw, snapshots dominated the appliance DB (measured ~72%). Each frame
|
||||||
|
is now **downscaled (long edge ≤ `SNAPSHOT_MAX_EDGE`=1280) + recompressed (`SNAPSHOT_JPEG_QUALITY`
|
||||||
|
=80)** before storage via [[technology-stack|sharp]] (~6–10× smaller, plate still readable). The
|
||||||
|
re-encode is **storage-only** — ANPR recognition runs on the **original full-res** bytes
|
||||||
|
(downscaling hurts OCR). Fail-soft: a re-encode error stores the original, never drops the snapshot
|
||||||
|
(`snapshot.ts` `encodeForStorage`).
|
||||||
|
|
||||||
|
**Retention (2026-06-28, resolves the old open question) — DISK-PRESSURE safety valve.** Snapshots
|
||||||
|
are unsigned/advisory, so they prune freely. The day-to-day shrink is the re-encode above; pruning is
|
||||||
|
a backstop that only fires under real disk pressure. A **daily** check (`snapshot-retention.ts`
|
||||||
|
`pruneSnapshots`, wired in `server.ts`) reads the DB filesystem's used%; if it's **≥
|
||||||
|
`SNAPSHOT_DISK_HIGH_PCT`=70%** it deletes the **OLDEST** snapshots until an estimated
|
||||||
|
`SNAPSHOT_DISK_FREE_TARGET_PCT`=10% of the disk is freed — never below the **`SNAPSHOT_MIN_KEEP`=500**
|
||||||
|
floor — then **`VACUUM`s once** to return the space to the OS (a row delete only frees SQLite pages;
|
||||||
|
the file doesn't shrink until VACUUM, which this prune now OWNS — daily, off-peak). Because a delete
|
||||||
|
doesn't move disk-used% until the VACUUM, the loop is driven by **estimated freed bytes**
|
||||||
|
(`SUM(length(bytes))` of deleted rows), not a live disk re-read. On a roomy booth disk this is a
|
||||||
|
near-permanent no-op. (Replaced the first cut's age/row-cap model the same day.)
|
||||||
|
|
||||||
### Refused entry/exit ALSO snapshots (2026-06-19)
|
### Refused entry/exit ALSO snapshots (2026-06-19)
|
||||||
A snapshot is evidence of **who was at the barrier** — which matters *most* when the barrier is
|
A snapshot is evidence of **who was at the barrier** — which matters *most* when the barrier is
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ There are **two populations** of users, and they map to **two integration paths*
|
|||||||
| [[wiegand]] reader → UHPPOTE port | The controller | Controller (onboard card list) | **Yes** — works if host down |
|
| [[wiegand]] reader → UHPPOTE port | The controller | Controller (onboard card list) | **Yes** — works if host down |
|
||||||
| Pure TCP/IP reader | Host only | Host, then UDP `open` to relay | No — host on critical path |
|
| Pure TCP/IP reader | Host only | Host, then UDP `open` to relay | No — host on critical path |
|
||||||
| [[lpr-camera|LPR]] / QR scanner | Host only | Host | No |
|
| [[lpr-camera|LPR]] / QR scanner | Host only | Host | No |
|
||||||
| **[[gee-qr-er80]] QR reader (serial)** | Host only | Host (reads serial → `read` bus) | No |
|
| **[[dingtian-dt008-reader|Dingtian DT-008]] QR reader** | Host only | Host (reads serial → `read` bus) | No |
|
||||||
|
|
||||||
> Concrete host-side reader on hand: the **[[gee-qr-er80]]** (QR over RS-232/RS-485). Note autonomy
|
> Concrete host-side reader on hand: the **[[dingtian-dt008-reader|Dingtian DT-008]]** (QR/RFID over HTTP push). Note autonomy
|
||||||
> is moot here anyway — the current relay ([[dingtian-relay]]) has **no onboard card list**, so even
|
> is moot here anyway — the current relay ([[dingtian-relay]]) has **no onboard card list**, so even
|
||||||
> a Wiegand reader would be host-decided. So we take the serial/QR path straight to the host's
|
> a Wiegand reader would be host-decided. So we take the serial/QR path straight to the host's
|
||||||
> `read` bus.
|
> `read` bus.
|
||||||
|
|||||||
@@ -62,7 +62,49 @@ then does the gated entry/exit + barrier open. Constructed in `server.ts` (the f
|
|||||||
above the hik-alarm registration so the bridge can take `subscriptionFlow`). Fail-soft throughout —
|
above the hik-alarm registration so the bridge can take `subscriptionFlow`). Fail-soft throughout —
|
||||||
any snapshot/vision error degrades to the subscriber's card/QR, never throws into the push handler.
|
any snapshot/vision error degrades to the subscriber's card/QR, never throws into the push handler.
|
||||||
Two new env knobs: `VISION_ENTRY_MIN_CONFIDENCE` (0.85), `ANPR_DEBOUNCE_MS` (12_000). Covered by
|
Two new env knobs: `VISION_ENTRY_MIN_CONFIDENCE` (0.85), `ANPR_DEBOUNCE_MS` (12_000). Covered by
|
||||||
`anpr-entry.test.ts` (7) + `hikvision-alarm.test.ts` wiring (3).
|
`anpr-entry.test.ts` + `hikvision-alarm.test.ts` wiring.
|
||||||
|
|
||||||
|
**Toggles — two levels (the auto-open is optional).** ANPR auto entry/exit can be turned off without
|
||||||
|
losing plate recognition:
|
||||||
|
- **Site-wide:** `site_config.anprEntryEnabled` (a Site Settings switch) gates the WHOLE bridge
|
||||||
|
(both directions); off ⇒ no camera auto-opens, recognition/lane-status unaffected.
|
||||||
|
- **Per-camera** (2026-06-27): `config.anprAutoTrigger` (absent ⇒ on when `anpr` is on). This
|
||||||
|
separates **recognition** (`config.anpr` — snapshots run through the recognizer, plate recorded,
|
||||||
|
BOTH directions) from **auto-open** (`anprAutoTrigger` — may this camera fire the barrier). The
|
||||||
|
case it solves: a **shared entry/exit lane** where ONE physical lane has both an entry and an exit
|
||||||
|
camera. A subscriber driving IN is admitted by the entry cam — but the exit cam sees the SAME car
|
||||||
|
leaving its frame and would **phantom-exit** the occurrence just opened (its back plate). Set the
|
||||||
|
exit cam's `anprAutoTrigger = false`: it still recognises plates for the record, but never
|
||||||
|
auto-opens. (The Setup checkbox "Auto open/close on subscriber plate" appears under ANPR.)
|
||||||
|
|
||||||
|
> **POLL-until-confident (2026-06-27).** The single-shot capture above was upgraded to a **poll
|
||||||
|
> loop**. The camera fires its vehicle alarm the INSTANT motion starts — the car is still
|
||||||
|
> APPROACHING, so the first frame's plate is small/blurry/half-in-frame and ANPR returns a
|
||||||
|
> low-confidence misread (observed live on the exit lane: `'111'`@0.20, `'AE18671'`@0.19 …, while
|
||||||
|
> the manual `test-anpr` on the SAME stopped car read `AA890XX`@1.00). The car then STOPS at the
|
||||||
|
> barrier waiting for it to open — the stationary, well-framed moment the test reads at ~100%. So
|
||||||
|
> the bridge now **pulls a FRESH frame every `ANPR_POLL_MS` (1000) and re-runs ANPR until one clears
|
||||||
|
> the floor, or `ANPR_POLL_WINDOW_MS` (8000) elapses** (car drove off / non-subscriber → give up
|
||||||
|
> cleanly). One loop per camera (`#polling` set) so the ~1Hz alarm re-fires JOIN it, not spawn N;
|
||||||
|
> each tick is a fresh `camera.captureSnapshot` (NOT `captureSnapshotShared`, whose TTL would
|
||||||
|
> re-serve the same bad frame). VERIFIED on hardware: 7 garbage approach frames → `AA890XX`@0.999
|
||||||
|
> at the barrier → signed `vehicle_exit`. This is what made subscriber **auto-exit** actually work
|
||||||
|
> on the DS-2CD1047G3H-LIU (whose alarm fires on approach, not at the readable moment — see
|
||||||
|
> [[lpr-camera]] "auto-enter but don't auto-exit"). Still advisory + fail-soft; a barrier never
|
||||||
|
> opens on a low-confidence read.
|
||||||
|
>
|
||||||
|
> **Two concurrency guards on the loop** (the edges a single push-triggered loop creates):
|
||||||
|
> 1. **Credential-mid-poll abort.** If the subscriber scans their card/QR at the reader DURING the
|
||||||
|
> loop, they've already transacted — the bridge watches their `openOccurrenceCount` (baselined
|
||||||
|
> once a frame reads the bound plate) and **aborts without emitting** if it moves, so it never
|
||||||
|
> double-acts (which would exit the NEXT open occurrence — bad for a fleet sub).
|
||||||
|
> 2. **SLIDING window for a different car arriving mid-poll.** A loop started by a far/early car
|
||||||
|
> must not (a) give up before the REAL car settles, nor (b) swallow the real car's pushes. So a
|
||||||
|
> push that joins a running loop **extends the deadline** (`lastPush + ANPR_POLL_WINDOW_MS`),
|
||||||
|
> capped at `start + ANPR_POLL_MAX_MS` (30s) so a continuously-busy lane can't slide forever.
|
||||||
|
> Because each tick pulls a FRESH frame, the loop naturally tracks whoever is at the barrier
|
||||||
|
> *now*, not the car that started it. (Knobs: `ANPR_POLL_MS`, `ANPR_POLL_WINDOW_MS`,
|
||||||
|
> `ANPR_POLL_MAX_MS`.)
|
||||||
|
|
||||||
The goal (narrowed deliberately — see Rejected below): **a subscriber's plate, read by the lane
|
The goal (narrowed deliberately — see Rejected below): **a subscriber's plate, read by the lane
|
||||||
camera, admits them through the same gated flow a QR/card scan uses.** Scope was cut to subscribers
|
camera, admits them through the same gated flow a QR/card scan uses.** Scope was cut to subscribers
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, device, printer, transport, usb, escpos, provisioning]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-24
|
||||||
|
status: settled
|
||||||
|
---
|
||||||
|
|
||||||
|
# Printer USB transport (kernel usblp, behind the ESC/POS layer)
|
||||||
|
|
||||||
|
The ESC/POS printer drivers ([[rongta-printer|rongta]], `cashino`) can deliver their byte stream
|
||||||
|
over **either a raw TCP socket (port 9100)** or a **local USB character device** (`/dev/usb/lp0`),
|
||||||
|
selected per device by `config.transport` (`"tcp-ip" | "usb"`). The original architecture always
|
||||||
|
intended one ESC/POS adapter to cover "USB **or** network" (parking-system-architecture §BOM); the
|
||||||
|
first implementation shipped TCP-only, and this closes that gap.
|
||||||
|
|
||||||
|
## The seam — render once, dispatch the transport
|
||||||
|
|
||||||
|
Every `render*()` function in `packages/devices/src/drivers/printer-escpos.ts` produces a
|
||||||
|
**transport-independent ESC/POS `Buffer`**. Only delivery differs. The transport is resolved **once**
|
||||||
|
per driver from config and every print/probe call site stays transport-blind:
|
||||||
|
|
||||||
|
- `transportFromConfig(config)` → a discriminated `Transport` (`{ kind: "tcp", host, port }` or
|
||||||
|
`{ kind: "usb", devicePath }`). Anything other than `transport: "usb"` is TCP, so **existing
|
||||||
|
host-only configs keep working unchanged** (no migration).
|
||||||
|
- `sendTo(t, payload, timeoutMs)` / `probeTo(t, timeoutMs)` dispatch to the TCP pair
|
||||||
|
(`sendRaw`/`probe`) or the USB pair (`sendRawUsb`/`probeUsb`).
|
||||||
|
|
||||||
|
Adding a transport = one more arm in the dispatcher; **not a single rendered byte changes**. This is
|
||||||
|
why the CP852 map, the Code128/QR builders, roles/failover, and the receipt/ticket/voucher layouts
|
||||||
|
are all untouched by USB support.
|
||||||
|
|
||||||
|
## USB transport = the in-box `usblp` char device
|
||||||
|
|
||||||
|
A USB ESC/POS printer plugged into the appliance enumerates as a **character device** (e.g.
|
||||||
|
`/dev/usb/lp0`) via the kernel's in-box **`usblp`** driver. We just **open it `O_WRONLY` and write
|
||||||
|
the same bytes**:
|
||||||
|
|
||||||
|
- **No native dependency.** A plain `fs` write — no libusb, no CUPS, no native addon. This keeps the
|
||||||
|
**MIT/Apache/BSD-only** dependency constraint and the **offline-first, minimal-deps appliance**
|
||||||
|
posture (see [[technology-stack]], [[offline-first]]).
|
||||||
|
- **`usblp` is raw.** Unlike the TCP path there is **no FIN/half-close dance** (the graceful-close
|
||||||
|
fix was a *TCP* concern — an early `destroy()` could RST-truncate the stream; see
|
||||||
|
[[rongta-printer]]). A single open + write delivers the job; we always close the handle.
|
||||||
|
- **Bounded by a timeout.** A wedged USB printer can block the write (or the open) indefinitely; a
|
||||||
|
stuck print must surface as a failure, not hang the entry flow. `withTimeout` rejects after
|
||||||
|
`timeoutMs`.
|
||||||
|
|
||||||
|
## Status over USB — reachability only (honesty rule)
|
||||||
|
|
||||||
|
`probeUsb` is "does the char device exist and open writable" — the **USB analogue of the TCP connect
|
||||||
|
probe**. A present, openable `/dev/usb/lp0` means `usblp` bound a powered, enumerated printer.
|
||||||
|
|
||||||
|
- The `cashino` driver is reachability-only on **both** transports (it never had a status page).
|
||||||
|
- The `rongta` driver's rich `readStatus()` scrapes the board's **HTTP** `/prn_stat.htm` — a
|
||||||
|
**network feature**. Over USB there is no such page, so `readStatus()` **degrades to the
|
||||||
|
reachability floor** (ready/offline only, never a guessed paper/cover state). A USB Rongta is
|
||||||
|
effectively a Cashino for monitoring. This preserves the standing honesty rule from
|
||||||
|
[[printer-status-monitoring]]: never report a paper/cover verdict the transport can't actually sense.
|
||||||
|
|
||||||
|
## Threat model
|
||||||
|
|
||||||
|
The USB path is a **local character device** the booth operator (the threat model's adversary)
|
||||||
|
cannot reach over the network — narrower attack surface than the unauthenticated TCP print socket on
|
||||||
|
the VLAN. Printers are advisory output; nothing about the signed [[append-only-event-chain|ledger]]
|
||||||
|
or barrier control is touched.
|
||||||
|
|
||||||
|
## Provisioning dependency (NOT app code) — see open-questions #14
|
||||||
|
|
||||||
|
Driving a USB printer depends on the appliance image:
|
||||||
|
1. the **`usblp`** kernel module is loaded (it is in-box on Ubuntu 26.04; CUPS can claim the
|
||||||
|
interface first — may need `usblp` to win, or CUPS masked for that device), and
|
||||||
|
2. a **udev rule** grants the server process write access to the node (e.g. a group on
|
||||||
|
`/dev/usb/lp*`), since the appliance server does not run as root.
|
||||||
|
|
||||||
|
This is a [[appliance-provisioning]] concern, recorded as **open-questions #14** until the on-site
|
||||||
|
printer is confirmed USB and the rule is baked into the image and verified on hardware.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Built 2026-06-24 behind the existing render layer. `sendRawUsb`/`probeUsb`/`transportFromConfig`/
|
||||||
|
`sendTo`/`probeTo` in `printer-escpos.ts`; `cashino` + `rongta` resolve a `Transport` and dispatch.
|
||||||
|
The setup UI offers a **Connection** select (Network / USB) + a **USB device** path field (default
|
||||||
|
`/dev/usb/lp0`); host/port are not-required so a USB printer needs neither. Covered by
|
||||||
|
`printer-escpos.test.ts` (USB writes the exact rendered bytes; probe present/absent;
|
||||||
|
`transportFromConfig` TCP back-compat) and `printer-cashino.test.ts` (a USB-configured driver prints
|
||||||
|
to the node and reports ready/offline). The on-hardware confirmation + the udev/usblp provisioning
|
||||||
|
are pending (open-questions #14).
|
||||||
|
|
||||||
|
Related: [[rongta-printer]], [[printer-status-monitoring]], [[printer-roles-failover]],
|
||||||
|
[[appliance-provisioning]], [[network-isolation]], [[technology-stack]].
|
||||||
@@ -80,9 +80,9 @@ must have:
|
|||||||
- The **scanner is a device behind an adapter** ([[device-adapter-pattern]]): a new `ReaderDevice`
|
- The **scanner is a device behind an adapter** ([[device-adapter-pattern]]): a new `ReaderDevice`
|
||||||
kind (QR/barcode imager) — likely the same `IdentitySource = "ticket"` / `"qr"` path. Keeps the
|
kind (QR/barcode imager) — likely the same `IdentitySource = "ticket"` / `"qr"` path. Keeps the
|
||||||
app device-agnostic; hardware model is procurement ([[bom]], [[open-questions]]).
|
app device-agnostic; hardware model is procurement ([[bom]], [[open-questions]]).
|
||||||
- **On hand:** the **[[gee-qr-er80]]** QR access reader (`-Q-W`: QR scanner, Wiegand/RS-232/RS-485,
|
- **On hand:** the **[[dingtian-dt008-reader|Dingtian DT-008]]** QR/RFID reader (Wiegand/TCP-IP/USB/RS485,
|
||||||
Linux-supported) — the concrete scanner for this path. A serial `ReaderDevice` adapter feeds the
|
Linux-supported) — the concrete scanner for this path. A serial `ReaderDevice` adapter feeds the
|
||||||
`read` bus; pending the reader's RS-232 frame/baud (see [[gee-qr-er80]] open questions).
|
`read` bus; via HTTP-GET push (see [[dingtian-dt008-reader]]).
|
||||||
|
|
||||||
## Ticketless alternative (plate as the ticket)
|
## Ticketless alternative (plate as the ticket)
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,18 @@
|
|||||||
type: reference
|
type: reference
|
||||||
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
|
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-23
|
updated: 2026-06-27
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
# Appliance provisioning runbook (booth PC)
|
# Appliance provisioning runbook (booth PC)
|
||||||
|
|
||||||
Step-by-step to take a booth PC from factory Windows to a hardened, encrypted, container-running
|
Step-by-step to take a booth PC from factory Windows to a hardened, encrypted, container-running
|
||||||
parking appliance. Written from the **first real provisioning, 2026-06-23**, on the actual hardware
|
parking appliance. Written from the **first real provisioning, 2026-06-23** (hardening) +
|
||||||
below — every command here was run and verified on that machine, including the firmware-specific
|
**first Komodo deploy, 2026-06-27** (the runtime) — every command here was run and verified on the
|
||||||
workaround. Companion to [[disk-os-hardening]] (the *why*), [[tpm]] (TPM analysis), and
|
actual hardware, including the firmware-specific workaround. Companion to [[disk-os-hardening]] (the
|
||||||
[[container-deployment]] (the images this runs).
|
*why*), [[tpm]] (TPM analysis), [[container-deployment]] (the images), and
|
||||||
|
[[fleet-deployment-komodo]] (the deploy control plane this runbook's §7 uses).
|
||||||
|
|
||||||
> ⚠ This box is the [[threat-model|outsider-with-the-box]] defence. The load-bearing anti-fraud
|
> ⚠ This box is the [[threat-model|outsider-with-the-box]] defence. The load-bearing anti-fraud
|
||||||
> control is still [[reconciliation]] over the [[append-only-event-chain|signed chain]] — disk
|
> control is still [[reconciliation]] over the [[append-only-event-chain|signed chain]] — disk
|
||||||
@@ -171,50 +172,140 @@ default) — `admin`+sudo IS the root path; enabling root adds risk, no gain.
|
|||||||
|
|
||||||
## 5b. Further hardening (TODO — not yet done)
|
## 5b. Further hardening (TODO — not yet done)
|
||||||
|
|
||||||
- **Key-based SSH only** (disable password auth) if SSH is enabled at all.
|
- **Key-based SSH only** (disable password auth) if SSH is enabled at all. Routine ops no longer
|
||||||
|
need SSH — Komodo Periphery (§7) drives deploys + gives a container terminal over the mesh — so
|
||||||
|
SSH can be locked down hard or disabled, leaving the mesh + Komodo as the management path.
|
||||||
- **No/locked-down desktop + kiosk autostart** — single-purpose; the operator never reaches a shell
|
- **No/locked-down desktop + kiosk autostart** — single-purpose; the operator never reaches a shell
|
||||||
([[desktop-shell-tauri]]).
|
([[desktop-shell-tauri]]).
|
||||||
- Consider moving the host **event-signing key into the TPM** (non-extractable) — [[tpm]], [[open-questions]] #12.
|
- Consider moving the host **event-signing key into the TPM** (non-extractable) — [[tpm]], [[open-questions]] #12.
|
||||||
- `sudo apt autoremove` the leftover old kernel once the new one is proven.
|
- `sudo apt autoremove` the leftover old kernel once the new one is proven.
|
||||||
|
|
||||||
## 6. Runtime — Docker stack (VERIFIED 2026-06-23)
|
## 6. Runtime — Docker engine (VERIFIED 2026-06-23)
|
||||||
|
|
||||||
Install Docker Engine + compose (as `admin`). NB Ubuntu 26.04 codename is **`resolute`**, which
|
Install Docker Engine + compose (as `admin`). NB Ubuntu 26.04 codename is **`resolute`**, which
|
||||||
download.docker.com may not yet publish — pin the repo line to `noble`, OR use Ubuntu's `docker.io`.
|
download.docker.com may not yet publish — pin the repo line to `noble`, OR use Ubuntu's `docker.io`.
|
||||||
Add only `admin` to the `docker` group (root-equivalent — NEVER the operator).
|
Add only `admin` to the `docker` group (root-equivalent — NEVER the operator).
|
||||||
|
|
||||||
Deploy from a standalone dir (hand-copied; no repo on the appliance), e.g. `/opt/parking_solution`:
|
This gives the appliance the engine. **How the stack gets ONTO it is step 7** — and as of
|
||||||
`docker-compose.yml` + `docker-compose.prod.yml` (the Caddy/prod override) + `Caddyfile` + a `.env`
|
2026-06-27 the primary path is **Komodo (remote, no-SSH)**, not a hand-copied dir. The manual
|
||||||
(chmod 600). The `.env` (driven into the containers by the base compose):
|
`docker compose` flow survives as a **break-glass fallback** (§7c).
|
||||||
|
|
||||||
```
|
## 7. Deploy the stack — Komodo Periphery (PRIMARY, 2026-06-27)
|
||||||
JWT_SECRET=<openssl rand -hex 32> # server REFUSES to boot without (>=32, no insecure default)
|
|
||||||
EVENT_SIGNING_KEY=<a DIFFERENT openssl rand -hex 32>
|
The booth is driven by a central **Komodo Core** over the **NetBird** mesh. The appliance runs a
|
||||||
COOKIE_SECURE=0 # CRITICAL on plain-http or the auth cookie never sends → no login
|
small **Periphery** agent that *dials out* to Core; Core then deploys the same compose files. No
|
||||||
WS_ALLOWED_ORIGINS=http://<name-or-ip> # any REMOTE origin admins use (same-origin always passes)
|
inbound port on the booth, no SSH for routine ops. Full rationale + threat model:
|
||||||
VISION_ENABLED=1
|
[[fleet-deployment-komodo]]. Verified end-to-end on the first booth (`park-buzi`) 2026-06-27.
|
||||||
# REGISTRY/TAG default to git.infra.msai.al/mca/parking_solution + dev; set TAG=main to pin.
|
|
||||||
```
|
### 7a. Install Periphery (on the booth, as `admin`)
|
||||||
|
|
||||||
|
Prereq: the booth is on the **NetBird** mesh and can reach Core's reverse-proxy URL
|
||||||
|
(`https://komodo.infra.msai.al`).
|
||||||
|
|
||||||
|
1. In Core: **Settings → Onboarding → + New Onboarding Key** (Name = the booth, e.g. `park-buzi`;
|
||||||
|
Expiry ~1 day; Pre-Existing Key empty). Copy the one-time `O-…` key. **Single-use** — delete it
|
||||||
|
after the agent connects.
|
||||||
|
2. On the booth, install Periphery in **user mode** (runs as `admin`, who is in `docker`; NO root
|
||||||
|
daemon; **outbound** → opens no inbound port):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker login git.infra.msai.al # a read-only package token, not the account password
|
curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/setup-periphery.py | python3 - --user \
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml config # dry-run: verify the merged env
|
--core-address="https://komodo.infra.msai.al" \
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
|
--connect-as="park-buzi" \
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
--onboarding-key="O-…"
|
||||||
# Seed the FIRST admin (DB starts empty → nobody can log in until this runs; idempotent):
|
sudo loginctl enable-linger admin # so the user service starts at boot without a login
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec \
|
|
||||||
-e ADMIN_USER=admin -e ADMIN_PASS='<strong-pw>' server node scripts/seed-admin.mjs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Healthy startup logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked
|
- `--connect-as` is the **Server name in Core** — unique, stable, site-meaningful (the fleet's
|
||||||
weights), server `[migrate] done` → `SPA static serving enabled` → `Server listening`. The transient
|
primary key). Booth #2 = a different name (e.g. `park-durres`); never reuse one.
|
||||||
|
- `--core-address` is Core's **reverse-proxy URL** (the URL you load the Core UI at over the mesh),
|
||||||
|
NOT `:9120` — Core's container port `9120` is exposed-not-published; the agent reaches it through
|
||||||
|
the proxy. (Gotcha #7 below.)
|
||||||
|
- Config lands at `~/.config/komodo/periphery.config.toml`. The key field is **`core_address`**
|
||||||
|
(singular); `root_directory` must be a path `admin` can write (user-mode default is fine — a
|
||||||
|
`/etc/komodo` default from a system install would `Permission denied` for the user service).
|
||||||
|
|
||||||
|
Verify: `systemctl --user status periphery` → active; the server **`park-buzi`** appears and goes
|
||||||
|
**OK/green** in Core → Servers. Then **delete the onboarding key**.
|
||||||
|
|
||||||
|
### 7b. Deploy the Stack (in Core — by hand once, then code)
|
||||||
|
|
||||||
|
Add **Registry Account** + **Git Account** for `git.infra.msai.al` (user `komodo`, tokens) in Core
|
||||||
|
so Periphery can clone the repo AND pull the private images. Two distinct credential types — the
|
||||||
|
git clone working does NOT imply the image pull is authed (gotcha #8). Per-booth secrets
|
||||||
|
(`park_<booth>_jwt_secret`, `park_<booth>_event_signing_key` — distinct values, `openssl rand -hex
|
||||||
|
32`) live in Core's **Variables/Secrets** store, referenced from the Stack as `[[…]]`.
|
||||||
|
|
||||||
|
Create a **Stack** (UI → Stacks → New), name = the booth (`park-buzi`):
|
||||||
|
|
||||||
|
- **Server:** `park-buzi` · **Source:** repo `mca/parking_solution`, branch `dev`, files
|
||||||
|
`docker-compose.yml` + `docker-compose.prod.yml` · **Registry account:** `komodo` (else the pull
|
||||||
|
is anonymous → `no basic auth credentials`).
|
||||||
|
- **Environment** (Komodo writes this to a `.env` on the booth at deploy, substituting `[[…]]`):
|
||||||
|
|
||||||
|
```
|
||||||
|
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||||
|
TAG=dev # moving tag (staging). PIN to dev-<sha> for a live booth.
|
||||||
|
COOKIE_SECURE=0 # CRITICAL on plain-http or the auth cookie never sends → no login
|
||||||
|
VISION_ENABLED=1
|
||||||
|
WS_ALLOWED_ORIGINS= # browser at the booth URL is same-origin; leave empty (the
|
||||||
|
# Tauri desktop app needs its origin here — separate task)
|
||||||
|
JWT_SECRET=[[park_buzi_jwt_secret]]
|
||||||
|
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy → Periphery pulls + `compose up`s. All containers (`proxy`/Caddy, `server`, `vision`) green.
|
||||||
|
Seed the FIRST admin (DB starts empty → nobody can log in until this runs; idempotent) **via
|
||||||
|
Komodo's terminal on the `server` container** (no SSH):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it -e ADMIN_USER=admin -e ADMIN_PASS='<strong-pw>' \
|
||||||
|
park-buzi-server-1 node scripts/seed-admin.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Secrets-on-disk note.** The generated `.env` lands on the booth with **cleartext** secrets
|
||||||
|
> (compose needs real values). That's why the disk is LUKS-encrypted (§3–4) and keys are per-booth
|
||||||
|
> — the encryption is the control, and a single-booth compromise leaks only that booth's key. See
|
||||||
|
> [[fleet-deployment-komodo]] (the `EVENT_SIGNING_KEY`-in-Core blast-radius caveat; ATECC608 is the
|
||||||
|
> intended long-term signer).
|
||||||
|
|
||||||
|
### 7b-bis. Fleet-as-code (`resources.toml`) — optional but recommended
|
||||||
|
|
||||||
|
The repo's `komodo/resources.toml` mirrors the working Stack. Pointing a Core **ResourceSync** at
|
||||||
|
it makes the fleet **git-managed**: booth #N is a copy-pasted `[[stack]]` block; an image bump is a
|
||||||
|
one-line `TAG=` edit + push + Execute; every change is an auditable commit; a rebuilt Core
|
||||||
|
re-creates everything from the file. Keep the sync **Unmanaged** + **Delete-Unmatched OFF** until
|
||||||
|
trusted. An **empty diff / disabled Execute = the file already matches the live Stack** (success,
|
||||||
|
not an error). See `komodo/README.md` and [[fleet-deployment-komodo]].
|
||||||
|
|
||||||
|
### 7c. Break-glass — manual compose (mesh/Core down)
|
||||||
|
|
||||||
|
When the mesh or Core is unreachable, the same compose files run locally via `scripts/booth.sh`
|
||||||
|
(or raw `docker compose`). Needs a local `.env` and a `docker login git.infra.msai.al` (a
|
||||||
|
read-only package token). This is the FALLBACK, not the routine path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login git.infra.msai.al
|
||||||
|
ENV=prod ./booth.sh config # dry-run the merged env
|
||||||
|
ENV=prod ./booth.sh up
|
||||||
|
```
|
||||||
|
|
||||||
|
`booth.sh` runs from wherever it sits next to the compose files (the booth deploys them flat, e.g.
|
||||||
|
`/opt/parking_systems/`). See [[container-deployment]].
|
||||||
|
|
||||||
|
### Healthy startup + web-access
|
||||||
|
|
||||||
|
Healthy logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked weights),
|
||||||
|
server `[migrate] done` → `SPA static serving enabled` → `Server listening`. The transient
|
||||||
`vision-service -> offline` at boot then `-> ready (fast_alpr)` ~8s later is normal (monitor polls
|
`vision-service -> offline` at boot then `-> ready (fast_alpr)` ~8s later is normal (monitor polls
|
||||||
before vision finishes loading). Reach the UI at **`http://<name-or-ip>/`** (Caddy on :80).
|
before vision finishes loading). Reach the UI at **`http://<name-or-ip>/`** (Caddy on :80).
|
||||||
|
|
||||||
**Web-access gotchas (all fixed in the images/compose — see [[container-deployment]] "Web access"):**
|
**Web-access gotchas (all fixed in the images/compose — see [[container-deployment]] "Web access"):**
|
||||||
the SPA uses a RELATIVE `/api` base (works from any host; do NOT bake a domain) + a Caddy proxy gives
|
the SPA uses a RELATIVE `/api` base (works from any host; do NOT bake a domain) + a Caddy proxy gives
|
||||||
the clean port-80 URL; the domain (`parksystems.msai.al`) is pointed at the booth's LAN IP via
|
the clean port-80 URL; the domain (`parksystems.msai.al`) is pointed at the booth's LAN IP via
|
||||||
`hosts`/DNS ON-SITE, never an image rebuild.
|
`hosts`/DNS ON-SITE, never an image rebuild. The **Tauri desktop app** is hardcoded to
|
||||||
|
`localhost:3000` (CSP + endpoints) and can't reach a remote booth without code changes — a browser
|
||||||
|
works; the desktop app is a separate workstream.
|
||||||
|
|
||||||
## Quick-reference: the gotchas, in order they bit us
|
## Quick-reference: the gotchas, in order they bit us
|
||||||
|
|
||||||
@@ -225,3 +316,19 @@ the clean port-80 URL; the domain (`parksystems.msai.al`) is pointed at the boot
|
|||||||
5. Always keep the **password slot** + an off-machine copy of the passphrase (TPM is never the only key).
|
5. Always keep the **password slot** + an off-machine copy of the passphrase (TPM is never the only key).
|
||||||
6. GRUB password MUST be **edit-only** (`--unrestricted` on entries) or it prompts on EVERY boot →
|
6. GRUB password MUST be **edit-only** (`--unrestricted` on entries) or it prompts on EVERY boot →
|
||||||
breaks unattended reboot. Verify `grep -c unrestricted /boot/grub/grub.cfg` ≥1 before rebooting.
|
breaks unattended reboot. Verify `grep -c unrestricted /boot/grub/grub.cfg` ≥1 before rebooting.
|
||||||
|
|
||||||
|
### Komodo deploy gotchas (2026-06-27)
|
||||||
|
|
||||||
|
7. Periphery `core_address` is **Core's reverse-proxy URL** (`https://komodo.infra.msai.al`), NOT
|
||||||
|
`100.x:9120`. Core's `9120` is exposed-not-published (`docker ps` shows `9120/tcp` with no `->`)
|
||||||
|
→ a direct dial gets `Connection refused`. Ping/SSH working over the mesh does NOT mean `:9120`
|
||||||
|
is reachable.
|
||||||
|
8. **Git auth ≠ registry auth.** The repo cloning fine does not mean image pull is authed — they're
|
||||||
|
separate Komodo credentials. A blank registry account on the Stack → anonymous pull →
|
||||||
|
`no basic auth credentials`. Set the Stack's **Registry Account** (`komodo`).
|
||||||
|
9. **User-mode Periphery + `/etc/komodo` `root_directory` = `Permission denied`** writing the agent
|
||||||
|
key. User-mode (runs as `admin`, no root daemon) must keep `root_directory` under `$HOME`.
|
||||||
|
10. The config key is **`core_address`** (singular). And `--core-address` derives `wss://` from
|
||||||
|
`https://` — if Core were plain-HTTP you'd need `http://` (→ `ws://`).
|
||||||
|
11. ResourceSync **Execute disabled + file shown clean in Info = empty diff = already in sync**
|
||||||
|
(success). Execute only enables when the file and Core diverge (e.g. you edit `TAG`).
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: decision
|
type: decision
|
||||||
tags: [parking, deployment, docker, ci, offline-first]
|
tags: [parking, deployment, docker, ci, offline-first]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-22
|
updated: 2026-06-24
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -12,6 +12,12 @@ How the parking system's runtime apps are packaged as containers, tagged, and pu
|
|||||||
Settled 2026-06-22. Companion to [[vision-service-packaging]] (which scopes the vision service
|
Settled 2026-06-22. Companion to [[vision-service-packaging]] (which scopes the vision service
|
||||||
into the monorepo) and the desktop [[desktop-shell-tauri]] (a separate, tag-only bundle).
|
into the monorepo) and the desktop [[desktop-shell-tauri]] (a separate, tag-only bundle).
|
||||||
|
|
||||||
|
> **The build/tag/registry pipeline below is current.** What changed (2026-06-27): the
|
||||||
|
> *deploy mechanism* is no longer "SSH in and run `booth.sh`". At fleet scale that's superseded
|
||||||
|
> by [[fleet-deployment-komodo]] (Komodo Periphery over a NetBird mesh, driving these same
|
||||||
|
> compose files). `scripts/booth.sh` is now a **break-glass local fallback**, not the primary
|
||||||
|
> deploy path.
|
||||||
|
|
||||||
## Two images (the desktop app is NOT containerized)
|
## Two images (the desktop app is NOT containerized)
|
||||||
|
|
||||||
- **`parking-server`** — the Fastify API **plus the built React SPA**. One container serves both:
|
- **`parking-server`** — the Fastify API **plus the built React SPA**. One container serves both:
|
||||||
@@ -36,6 +42,29 @@ The **desktop** app stays on its own tag-only `release.yml` (Tauri installers),
|
|||||||
`restart: always`, `fast_alpr`, vision kept internal). `REGISTRY`/`TAG` come from env, so a deploy
|
`restart: always`, `fast_alpr`, vision kept internal). `REGISTRY`/`TAG` come from env, so a deploy
|
||||||
on a branch pulls that branch's image — the branch→environment mapping IS the override file.
|
on a branch pulls that branch's image — the branch→environment mapping IS the override file.
|
||||||
|
|
||||||
|
## Booth operator wrapper — `scripts/booth.sh`
|
||||||
|
|
||||||
|
So the on-site operator runs one command instead of the long `docker compose -f … -f … --env-file …`
|
||||||
|
line, **`scripts/booth.sh`** wraps the base + override + env-file. **Prod by default** (the booth is
|
||||||
|
prod); `ENV=dev` switches to the dev override.
|
||||||
|
|
||||||
|
- `./scripts/booth.sh up` — start (detached). `down` / `restart` / `status` / `logs [service]` /
|
||||||
|
`pull` / `config` / `exec <svc> …` as expected.
|
||||||
|
- **`./scripts/booth.sh update`** — the "**I know there are new images**" path: `compose pull` the
|
||||||
|
moving branch tag, then `up -d --remove-orphans` (recreates only services whose image digest moved;
|
||||||
|
**named volumes — the SQLite ledger — are preserved**), then `docker image prune -f` to reclaim the
|
||||||
|
old layers. This is the routine update after a `dev`/`main` push republishes the branch tag.
|
||||||
|
- **Env handling.** Reads **`.env`** (copy from `.env.example`: `REGISTRY`, `TAG`, `JWT_SECRET`,
|
||||||
|
`EVENT_SIGNING_KEY`, `COOKIE_SECURE=0`, `WS_ALLOWED_ORIGINS`). Prod **refuses to run without
|
||||||
|
`.env`** (no safe `JWT_SECRET` default — `auth.ts` rejects weak ones). Dev with no `.env` injects
|
||||||
|
the documented benign local secret so `up` works out of the box. The base file makes `JWT_SECRET`
|
||||||
|
shell-required (`${JWT_SECRET:?}`), so the env-file is mandatory for both — the script surfaces that
|
||||||
|
early with a clear message rather than a raw compose interpolation error.
|
||||||
|
- **Safety:** `down` never passes `-v` (deleting `parking-data` would wipe the signed
|
||||||
|
[[append-only-event-chain|ledger]]); `help`/unknown-command short-circuit before any Docker/.env
|
||||||
|
requirement. The operator never types `JWT_SECRET` on the CLI — it lives in `.env` (the user
|
||||||
|
generates it with `openssl rand -hex 32`).
|
||||||
|
|
||||||
## Registry + CI
|
## Registry + CI
|
||||||
|
|
||||||
- Published to the house **Gitea registry** `git.infra.msai.al/mca/parking_solution/{parking-server,
|
- Published to the house **Gitea registry** `git.infra.msai.al/mca/parking_solution/{parking-server,
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
---
|
||||||
|
type: decision
|
||||||
|
tags: [parking, deployment, fleet, komodo, netbird, offline-first, threat-model]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-27
|
||||||
|
status: settled
|
||||||
|
---
|
||||||
|
|
||||||
|
# Fleet deployment — Komodo Periphery over a NetBird mesh
|
||||||
|
|
||||||
|
How the parking appliance is deployed and managed **at fleet scale**, superseding the
|
||||||
|
single-box, SSH-and-`booth.sh` model. The image build/tag/registry pipeline
|
||||||
|
([[container-deployment]]) is unchanged — this decides only the *control plane* that drives
|
||||||
|
those same compose files onto many booths. Settled 2026-06-27.
|
||||||
|
|
||||||
|
## The problem `booth.sh` couldn't solve
|
||||||
|
|
||||||
|
[[container-deployment|`scripts/booth.sh`]] is a thin wrapper over `docker compose -f base -f
|
||||||
|
prod --env-file .env`. It works for **one** appliance you can get a shell on, but as the fleet
|
||||||
|
grows (the stated direction is **many/growing** sites) it gives us none of:
|
||||||
|
|
||||||
|
- **Remote, no-SSH operation** — an update means someone gets a root shell on the booth.
|
||||||
|
- **A fleet view** — which booth runs which `dev-<sha>`, which is healthy/offline.
|
||||||
|
- **A deploy audit trail** — who deployed what, when.
|
||||||
|
- **One-click rollback** to a previous immutable `dev-<sha>`.
|
||||||
|
|
||||||
|
These are exactly the gaps a deployment controller fills. We already run every prerequisite
|
||||||
|
(a **Komodo Core**, a **NetBird** zero-trust mesh, the **Gitea registry**), so the marginal
|
||||||
|
cost is low.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Adopt **Komodo Periphery** on each appliance, driven by the existing **Komodo Core** over the
|
||||||
|
**NetBird** mesh. Keep the compose files and the [[container-deployment|image pipeline]]
|
||||||
|
verbatim — Komodo consumes them as a *Stack*; it does not replace them. `booth.sh` is demoted
|
||||||
|
to a **break-glass local fallback** for when the mesh/Core is unreachable.
|
||||||
|
|
||||||
|
```
|
||||||
|
Gitea push ─▶ build-images.yml ─▶ registry (parking-server:dev-<sha>, parking-vision:dev-<sha>)
|
||||||
|
│
|
||||||
|
Komodo Core (off-site) ──── NetBird mesh ─┼─▶ Periphery @ booth-A ─▶ docker compose up (pinned sha)
|
||||||
|
• fleet table / history / rollback ├─▶ Periphery @ booth-B
|
||||||
|
• per-booth secret injection └─▶ Periphery @ booth-C …
|
||||||
|
• NO deploy webhook (manual + pinned)
|
||||||
|
```
|
||||||
|
|
||||||
|
### The three load-bearing choices (settled with the user 2026-06-27)
|
||||||
|
|
||||||
|
1. **Fleet size: many/growing.** Komodo is treated as load-bearing infrastructure, not a
|
||||||
|
convenience. This is what tips the decision away from "SSH-over-NetBird + a playbook".
|
||||||
|
2. **Deploy trigger: always manual + pinned.** **No deploy webhook on a booth Stack.** A human
|
||||||
|
deploys a specific immutable `TAG=dev-<sha>` from Core. This preserves the determinism we
|
||||||
|
chose when pinning the booth tag (a moving `:dev` auto-redeploying a production booth is the
|
||||||
|
surprise we explicitly rejected). A *staging* booth MAY track `:dev`; a production booth
|
||||||
|
never does.
|
||||||
|
3. **Secrets: Komodo-managed (per-booth, unique).** Core's secret store injects `JWT_SECRET`
|
||||||
|
and `EVENT_SIGNING_KEY` into the Stack at deploy. This scales (no SSH-to-N-booths to rotate
|
||||||
|
a key) — but see the threat-model tension below; the keys MUST be **distinct per booth**.
|
||||||
|
|
||||||
|
## Why this is safe (against the project's two forces)
|
||||||
|
|
||||||
|
### Offline-first ([[offline-first]]) — Core is orchestration, never a runtime dependency
|
||||||
|
|
||||||
|
The booth must run **fully when the mesh is down**. Komodo's agent model satisfies this:
|
||||||
|
Periphery + the local containers keep operating if Core is unreachable; we lose *remote
|
||||||
|
management* until the mesh returns, **not operation**. There must be **no runtime path** from
|
||||||
|
booth operation to Core — Core only deploys. (Periphery's own liveness is irrelevant to entry/
|
||||||
|
exit; the Fastify server and SQLite ledger run independently of it.)
|
||||||
|
|
||||||
|
### Threat model — the adversary is the booth operator ([[threat-model]])
|
||||||
|
|
||||||
|
This is the sharp edge, and the reason this page is explicit rather than a footnote.
|
||||||
|
|
||||||
|
- **Periphery is a root-capable remote-exec agent on the appliance.** If the operator
|
||||||
|
compromises the box, the agent is a lever. Mitigations: bind Periphery **only to the NetBird
|
||||||
|
interface** (never `0.0.0.0`), enforce its **passkey + TLS**, and fold the agent into the
|
||||||
|
[[disk-os-hardening]] surface. It is part of the trusted computing base now.
|
||||||
|
- **`EVENT_SIGNING_KEY` is the anti-fraud root.** It signs the [[append-only-event-chain|
|
||||||
|
append-only ledger]] — the control between us and a booth operator forging entry/exit events.
|
||||||
|
Holding it in Core means **a Core compromise can forge any booth's ledger that shares a key**.
|
||||||
|
Two mitigations make central management acceptable:
|
||||||
|
- **Per-booth, unique keys.** Never reuse a signing key across sites, so a single leak taints
|
||||||
|
one booth, not the fleet.
|
||||||
|
- **The [[atecc608|ATECC608]] is the real long-term signer.** The
|
||||||
|
`EVENT_SIGNING_KEY` HMAC is the *interim* mechanism; once the secure element signs the
|
||||||
|
chain, the key in Core stops being the fraud root. Tracked in [[open-questions]].
|
||||||
|
- **Core becomes a Tier-0 asset.** It now holds login + ledger keys for the whole fleet, so it
|
||||||
|
must be hardened to the booths' bar: Komodo API bound to the NetBird mesh only, never a public
|
||||||
|
interface; access-controlled; backed up.
|
||||||
|
|
||||||
|
### Licensing — Komodo is GPL-3.0, and that's fine here
|
||||||
|
|
||||||
|
The hard MIT/Apache/BSD constraint ([[technology-stack]]) is about **shipped app dependencies**
|
||||||
|
(code we distribute/link). Komodo is **external ops tooling we self-host and don't distribute**,
|
||||||
|
so its GPL-3.0 does not taint the product — exactly like the [[vision-service|AGPL ANPR
|
||||||
|
exception]] reasoning (a separate process / external boundary, not a linked dependency). Noted
|
||||||
|
here so it isn't re-litigated.
|
||||||
|
|
||||||
|
## What lives where
|
||||||
|
|
||||||
|
| Concern | Where | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Image build + tags | Gitea CI ([[container-deployment]]) | unchanged: `:dev` moving + `:dev-<sha>` immutable |
|
||||||
|
| Compose files | the repo + on the booth | unchanged base + `docker-compose.prod.yml` |
|
||||||
|
| Stack / deploy definition | **Komodo Core** | git-synced from `komodo/` (infra-as-code) |
|
||||||
|
| Which sha is deployed | **Komodo Core**, manual | `TAG=dev-<sha>`, pinned, no webhook |
|
||||||
|
| `JWT_SECRET`, `EVENT_SIGNING_KEY` | **Komodo Core** secret store | **per-booth, unique** |
|
||||||
|
| `COOKIE_SECURE=0`, `TAG`, `REGISTRY` | Komodo Stack env | per-environment |
|
||||||
|
| Registry pull creds | **Komodo Core** | so Periphery can pull from Gitea |
|
||||||
|
| Local break-glass | `booth.sh` + a local `.env` | mesh-down fallback only |
|
||||||
|
|
||||||
|
## Setup outline
|
||||||
|
|
||||||
|
**On each appliance** (after [[appliance-provisioning]]):
|
||||||
|
1. Install **Komodo Periphery** (binary or container), bound **only** to the NetBird interface;
|
||||||
|
set its passkey/TLS.
|
||||||
|
2. Point its compose/stack dir at `/opt/parking_systems/` (the existing files).
|
||||||
|
3. Keep `booth.sh` + a minimal local `.env` (no real secrets) as break-glass.
|
||||||
|
|
||||||
|
**In Komodo Core:**
|
||||||
|
1. Add the booth as a **Server**, address = its **NetBird IP** (mesh, not LAN/WAN).
|
||||||
|
2. Define the **Stack** = base + `docker-compose.prod.yml`, env from Core's secret store, secrets
|
||||||
|
**per booth**.
|
||||||
|
3. **No deploy webhook** on the booth Stack — deploys are manual; set `TAG=dev-<sha>` explicitly.
|
||||||
|
4. Add Gitea registry creds so Periphery can pull.
|
||||||
|
5. Sync the Stack/Server definitions from the repo's `komodo/` directory (infra-as-code:
|
||||||
|
`komodo/resources.toml` + README) so the control plane is itself reviewable +
|
||||||
|
version-controlled.
|
||||||
|
|
||||||
|
## Open / not yet done
|
||||||
|
|
||||||
|
- **Per-booth secret generation + rotation flow** — how a new site's unique `EVENT_SIGNING_KEY`
|
||||||
|
is generated and registered in Core (vs. on-site `openssl rand`). Tie-in: [[open-questions]]
|
||||||
|
JWT-key item.
|
||||||
|
- **ATECC608 as the signer** supersedes `EVENT_SIGNING_KEY`-in-Core as the fraud root — until
|
||||||
|
then central secrets carry the blast-radius noted above.
|
||||||
|
- **Periphery hardening checklist** folded into [[disk-os-hardening]] (interface binding, passkey,
|
||||||
|
TLS, agent as TCB).
|
||||||
|
- **Staging vs production booth split** (a staging booth on `:dev` with a webhook; production
|
||||||
|
manual+pinned) — not yet modelled in `komodo/`.
|
||||||
|
- **Core backup / DR** — Core is now Tier-0; its loss = no fleet management (operation
|
||||||
|
unaffected, per offline-first). Backup story TBD.
|
||||||
|
|
||||||
|
## Supersedes / relates
|
||||||
|
|
||||||
|
- **Supersedes** the "SSH + `booth.sh` is the deploy mechanism" assumption in
|
||||||
|
[[container-deployment]] (that page's *build/tag/registry* content stands; its `booth.sh`-as-
|
||||||
|
primary-deploy framing is now the fallback). Cross-linked there.
|
||||||
|
- Companion: the `komodo/` infra-as-code sketch (in the repo, not the wiki),
|
||||||
|
[[appliance-provisioning]] (what runs *before* Periphery), [[disk-os-hardening]] (the
|
||||||
|
appliance's hardening surface).
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user