From e0b9442accb038fc4e277d5a398d7fe91e10b570 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Mon, 22 Jun 2026 17:42:54 +0200 Subject: [PATCH] feat(booth): live lane busy/free barrier lights from camera vehicle detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Hikvision vehicle detection (eventType=VMD, targetType=vehicle) on a camera bound to entry/exit now marks that lane "busy" and shows it as a barrier light beside the scan input on the booth (green=free, red=busy). Advisory only — it gates nothing (never blocks a ticket or opens a barrier). - Parse eventState (active/inactive) from the Hik payload. - LaneStatus tracker: a vehicle `active` event marks the camera's bound lane busy + arms an auto-clear timer. This camera class sends no leave/`inactive` signal, so "free" is timeout-driven (LANE_BUSY_TTL_MS, default 90s; the camera re-fires `active` while a car sits there, refreshing the timer). A "both"-direction camera marks both lanes. - Push lane-status over the existing booth WS (+ in the hello snapshot); live-store holds { entry, exit }; two BarrierLight icons render it. - i18n booth.laneEntry/laneExit (sq + en). Tests: lane-status.test.ts (7 — busy/free, TTL auto-clear, timer re-arm, no re-emit while busy, both/exit direction, unknown device). server 120/120; web + server build/lint green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/server/src/device-events.ts | 20 ++++ apps/server/src/lane-status.test.ts | 130 ++++++++++++++++++++++ apps/server/src/lane-status.ts | 98 ++++++++++++++++ apps/server/src/routes/hikvision-alarm.ts | 22 +++- apps/server/src/routes/ws.ts | 22 +++- apps/server/src/server.ts | 13 ++- apps/web/src/BoothScreen.tsx | 48 +++++++- apps/web/src/lib/i18n/en.ts | 2 + apps/web/src/lib/i18n/sq.ts | 2 + apps/web/src/lib/live-store.ts | 14 ++- apps/web/src/lib/use-live-feed.ts | 12 +- 11 files changed, 366 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/lane-status.test.ts create mode 100644 apps/server/src/lane-status.ts diff --git a/apps/server/src/device-events.ts b/apps/server/src/device-events.ts index d6e10bd..1e68e83 100644 --- a/apps/server/src/device-events.ts +++ b/apps/server/src/device-events.ts @@ -76,6 +76,16 @@ export interface DeviceStatusEvent { readonly checkedAt: string; // ISO-8601 } +/** Lane occupancy from a camera's vehicle detection — a per-direction "busy/free" + * the booth shows as barrier lights. ADVISORY ONLY: a detection is a hint, never a + * gate (it never blocks a ticket or opens a barrier). "busy" is set by a vehicle + * `active` event; it auto-clears to "free" after a timeout (this camera class sends + * no leave/`inactive` signal — see wiki/entities/lpr-camera.md). */ +export interface LaneStatusEvent { + readonly entry: boolean; // true = busy (a vehicle is at the entry vicinity) + readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity) +} + class DeviceEventBus extends EventEmitter { emitInput(event: DeviceInputEvent): void { this.emit("input", event); @@ -128,6 +138,16 @@ class DeviceEventBus extends EventEmitter { this.on("ledger", cb); return () => this.off("ledger", cb); } + + /** Emitted whenever a lane's busy/free state CHANGES (from camera vehicle + * detection). Drives the booth's barrier lights. Advisory only. */ + emitLaneStatus(event: LaneStatusEvent): void { + this.emit("lane-status", event); + } + onLaneStatus(cb: (event: LaneStatusEvent) => void): () => void { + this.on("lane-status", cb); + return () => this.off("lane-status", cb); + } } /** Process-wide device event bus. */ diff --git a/apps/server/src/lane-status.test.ts b/apps/server/src/lane-status.test.ts new file mode 100644 index 0000000..39db715 --- /dev/null +++ b/apps/server/src/lane-status.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { randomUUID } from "node:crypto"; +import { devices, type Db } from "@parking/db"; +import { createTestDb } from "@parking/db/testing"; +import { LaneStatus } from "./lane-status.js"; +import { deviceEvents, type LaneStatusEvent } from "./device-events.js"; +import { silentLogger } from "./test-helpers.js"; + +// LaneStatus: a camera's vehicle detection marks its bound lane busy, then auto-clears +// after a timeout (this camera class sends no leave signal). Advisory; emits a +// lane-status change only when the busy/free state actually flips. + +let db: Db; +beforeEach(() => { + ({ db } = createTestDb()); + vi.useFakeTimers(); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +/** Seed a controller (relay 1=entry, 2=exit, 3=both) + a camera bound to the relay + * whose direction we want, so directionOf resolves from the real bound relay. */ +function seedCamera(direction: "entry" | "exit" | "both"): string { + const controllerId = randomUUID(); + db.insert(devices).values({ + id: controllerId, + category: "access", + driverId: "dingtian", + config: { + host: "10.0.0.5", + relays: [ + { relay: 1, direction: "entry" }, + { relay: 2, direction: "exit" }, + { relay: 3, direction: "both" }, + ], + }, + enabled: true, + }).run(); + const relay = direction === "entry" ? 1 : direction === "exit" ? 2 : 3; + const camId = randomUUID(); + db.insert(devices).values({ + id: camId, + category: "camera", + driverId: "hikvision", + config: { host: "10.0.0.9", controllerId, relay }, + enabled: true, + }).run(); + return camId; +} + +/** Capture lane-status events emitted during `fn`. */ +function captureEmits(fn: () => void): LaneStatusEvent[] { + const got: LaneStatusEvent[] = []; + const off = deviceEvents.onLaneStatus((e) => got.push(e)); + try { + fn(); + } finally { + off(); + } + return got; +} + +describe("LaneStatus", () => { + it("marks the camera's bound lane busy on a vehicle detection, free until then", () => { + const cam = seedCamera("entry"); + const lane = new LaneStatus(db, silentLogger(), 90_000); + expect(lane.snapshot()).toEqual({ entry: false, exit: false }); + + const emits = captureEmits(() => lane.vehicleDetected(cam)); + expect(lane.snapshot()).toEqual({ entry: true, exit: false }); + expect(emits).toEqual([{ entry: true, exit: false }]); // emitted on the flip + }); + + it("auto-clears to free after the TTL (no leave signal from the camera)", () => { + const cam = seedCamera("entry"); + const lane = new LaneStatus(db, silentLogger(), 90_000); + lane.vehicleDetected(cam); + expect(lane.snapshot().entry).toBe(true); + + const emits = captureEmits(() => vi.advanceTimersByTime(90_001)); + expect(lane.snapshot().entry).toBe(false); + expect(emits).toEqual([{ entry: false, exit: false }]); + }); + + it("re-arms the timer on each detection (a parked car keeps the lane busy)", () => { + const cam = seedCamera("entry"); + const lane = new LaneStatus(db, silentLogger(), 90_000); + lane.vehicleDetected(cam); + // Re-fire just before the TTL — should NOT clear, and should push the clear out. + vi.advanceTimersByTime(80_000); + lane.vehicleDetected(cam); + vi.advanceTimersByTime(80_000); // 160s total, but only 80s since the last detect + expect(lane.snapshot().entry).toBe(true); + // Now let it lapse fully. + vi.advanceTimersByTime(90_001); + expect(lane.snapshot().entry).toBe(false); + }); + + it("does NOT re-emit on a repeat detection while already busy (only state flips)", () => { + const cam = seedCamera("entry"); + const lane = new LaneStatus(db, silentLogger(), 90_000); + lane.vehicleDetected(cam); // flip -> emits + const emits = captureEmits(() => { + lane.vehicleDetected(cam); // already busy -> no emit + lane.vehicleDetected(cam); + }); + expect(emits).toEqual([]); + }); + + it("a 'both'-direction camera marks BOTH lanes busy", () => { + const cam = seedCamera("both"); + const lane = new LaneStatus(db, silentLogger(), 90_000); + lane.vehicleDetected(cam); + expect(lane.snapshot()).toEqual({ entry: true, exit: true }); + }); + + it("exit camera marks only the exit lane", () => { + const cam = seedCamera("exit"); + const lane = new LaneStatus(db, silentLogger(), 90_000); + lane.vehicleDetected(cam); + expect(lane.snapshot()).toEqual({ entry: false, exit: true }); + }); + + it("ignores an unknown device id", () => { + const lane = new LaneStatus(db, silentLogger(), 90_000); + lane.vehicleDetected("nope"); + expect(lane.snapshot()).toEqual({ entry: false, exit: false }); + }); +}); diff --git a/apps/server/src/lane-status.ts b/apps/server/src/lane-status.ts new file mode 100644 index 0000000..82924d4 --- /dev/null +++ b/apps/server/src/lane-status.ts @@ -0,0 +1,98 @@ +import { eq, devices, type Db } from "@parking/db"; +import type { FastifyBaseLogger } from "fastify"; +import { deviceEvents, type LaneStatusEvent } from "./device-events.js"; +import { directionOf } from "./device-resolve.js"; + +// Lane busy/free, driven by a camera's vehicle detection. ADVISORY ONLY — a detection +// is a hint the booth shows as barrier lights; it never gates a ticket or opens a +// barrier (see wiki/entities/lpr-camera.md, the advisory-only rule). +// +// A vehicle `active` event on a camera bound to entry/exit marks THAT lane busy and +// (re)arms an auto-clear timer. This camera class sends NO leave/`inactive` signal, so +// "free" is timeout-driven: the camera re-fires `active` while a car sits in the zone +// (each refreshing the timer); once the car leaves, the actives stop and the lane +// flips free after BUSY_TTL_MS. A "both"-direction camera marks BOTH lanes. + +/** How long after the last vehicle detection a lane stays "busy" before clearing. + * Must exceed the camera's `active` re-fire interval (observed ~30-80s on the test + * unit) so a parked car keeps the lane busy. Override with LANE_BUSY_TTL_MS. */ +export function busyTtlMs(): number { + const raw = Number(process.env.LANE_BUSY_TTL_MS ?? 90_000); + return Number.isFinite(raw) && raw > 0 ? raw : 90_000; +} + +export class LaneStatus { + readonly #db: Db; + readonly #logger: FastifyBaseLogger; + readonly #ttlMs: number; + #entry = false; + #exit = false; + #entryTimer: ReturnType | null = null; + #exitTimer: ReturnType | null = null; + + constructor(db: Db, logger: FastifyBaseLogger, ttlMs = busyTtlMs()) { + this.#db = db; + this.#logger = logger; + this.#ttlMs = ttlMs; + } + + /** Current snapshot (for the WS hello). */ + snapshot(): LaneStatusEvent { + return { entry: this.#entry, exit: this.#exit }; + } + + /** + * A vehicle was detected by camera `deviceId`. Resolves the camera's bound direction + * and marks that lane busy + (re)arms its auto-clear. Best-effort: an unknown camera + * or a non-vehicle caller is the caller's concern — this only handles a confirmed + * vehicle detection. Emits a lane-status change only when the state actually flips. + */ + vehicleDetected(deviceId: string): void { + const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get(); + if (!row) return; + const dir = directionOf(this.#db, row); + if (dir === "entry" || dir === "both") this.#mark("entry"); + if (dir === "exit" || dir === "both") this.#mark("exit"); + } + + #mark(lane: "entry" | "exit"): void { + const was = lane === "entry" ? this.#entry : this.#exit; + if (lane === "entry") this.#entry = true; + else this.#exit = true; + + // (Re)arm the auto-clear — each detection pushes the free-flip further out. + const existing = lane === "entry" ? this.#entryTimer : this.#exitTimer; + if (existing) clearTimeout(existing); + const timer = setTimeout(() => this.#clear(lane), this.#ttlMs); + timer.unref?.(); // never hold the process open + if (lane === "entry") this.#entryTimer = timer; + else this.#exitTimer = timer; + + if (!was) { + this.#logger.info(`lane-status: ${lane} -> busy`); + this.#emit(); + } + } + + #clear(lane: "entry" | "exit"): void { + if (lane === "entry") { + this.#entry = false; + this.#entryTimer = null; + } else { + this.#exit = false; + this.#exitTimer = null; + } + this.#logger.info(`lane-status: ${lane} -> free`); + this.#emit(); + } + + #emit(): void { + deviceEvents.emitLaneStatus(this.snapshot()); + } + + /** Clear timers on shutdown. */ + stop(): void { + if (this.#entryTimer) clearTimeout(this.#entryTimer); + if (this.#exitTimer) clearTimeout(this.#exitTimer); + } +} diff --git a/apps/server/src/routes/hikvision-alarm.ts b/apps/server/src/routes/hikvision-alarm.ts index 1b1b898..15753ff 100644 --- a/apps/server/src/routes/hikvision-alarm.ts +++ b/apps/server/src/routes/hikvision-alarm.ts @@ -4,6 +4,7 @@ import { desc, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db import { deviceEvents } from "../device-events.js"; import { requirePermission } from "../auth.js"; import { verifyDigest } from "../digest-auth.js"; +import type { LaneStatus } from "../lane-status.js"; // Hikvision "Alarm Server" event PUSH ingress. The newer-firmware cameras (Event → // Smart/VCA with "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm @@ -53,6 +54,9 @@ function isOn(v: unknown): boolean { * payload" — the raw body is always stored so nothing is lost. */ interface AlarmSummary { eventType?: string; + /** `active` (target entered the region) | `inactive` (target left). The edge that + * drives lane busy/free — see [[lpr-camera]] / hikvision-alarm.ts. */ + eventState?: string; target?: string; plate?: string; dateTime?: string; @@ -79,6 +83,7 @@ function pick(s: string, re: RegExp): string | undefined { function summarize(body: string): AlarmSummary { return { eventType: pick(body, /([^<]+)<\/eventType>/i) ?? pick(body, /"eventType"\s*:\s*"([^"]+)"/i), + eventState: pick(body, /([^<]+)<\/eventState>/i) ?? pick(body, /"eventState"\s*:\s*"([^"]+)"/i), target: pick(body, /<(?:detectionTarget|targetType|objectType)>([^<]+)<\//i) ?? pick(body, /"(?:detectionTarget|targetType|objectType)"\s*:\s*"([^"]+)"/i), @@ -90,7 +95,7 @@ function summarize(body: string): AlarmSummary { }; } -export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promise { +export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db, laneStatus?: LaneStatus): Promise { // Accept ANY content-type as a raw Buffer (the camera may POST application/xml, // multipart/form-data with a JPEG, or text). Fastify's default JSON parser would 415 // or empty these — we want the bytes verbatim. Scoped to THIS app instance via a @@ -186,10 +191,22 @@ export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promis // Loud log so the operator can SEE the payload during testing. app.log.info( `[hik-alarm:${deviceId}] ACCEPTED ${method} ${ip} ${contentType} ${raw.length}B ` + - `event=${summary.eventType ?? "?"} target=${summary.target ?? "?"} plate=${summary.plate ?? "-"}`, + `event=${summary.eventType ?? "?"}/${summary.eventState ?? "?"} target=${summary.target ?? "?"} plate=${summary.plate ?? "-"}`, ); record({ deviceId, method, accepted: true, ip, contentType, raw, summary }); + // Lane busy/free: a VEHICLE detection marks the camera's bound lane busy (advisory, + // for the booth barrier lights). Only on a vehicle target that's `active` — an + // `inactive` (leave) isn't sent by this camera class, so the lane auto-clears on a + // timeout in LaneStatus. We filter to vehicle per the booth's "vehicle only" intent. + if ( + laneStatus && + (summary.target ?? "").toLowerCase() === "vehicle" && + (summary.eventState ?? "active").toLowerCase() !== "inactive" + ) { + laneStatus.vehicleDetected(deviceId); + } + // Surface on the in-process bus as a generic breadcrumb so a live listener can show // "camera saw a vehicle". NOT a DeviceReadEvent yet — that (plate identity driving // entry/exit) is the deliberate next step once we know the real payload. @@ -237,6 +254,7 @@ export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promis contentType: (d.contentType as string) ?? null, bytes: (d.bytes as number) ?? 0, eventType: (d.eventType as string) ?? null, + eventState: (d.eventState as string) ?? null, target: (d.target as string) ?? null, plate: (d.plate as string) ?? null, rawHead: (d.rawHead as string) ?? null, diff --git a/apps/server/src/routes/ws.ts b/apps/server/src/routes/ws.ts index d0b2b10..332e89b 100644 --- a/apps/server/src/routes/ws.ts +++ b/apps/server/src/routes/ws.ts @@ -2,9 +2,10 @@ import type { FastifyInstance } from "fastify"; import type { Db } from "@parking/db"; import type { LedgerEvent } from "@parking/shared"; import { roleHasPermissions } from "../auth.js"; -import { deviceEvents } from "../device-events.js"; +import { deviceEvents, type LaneStatusEvent } from "../device-events.js"; import { enrichEvent } from "../event-enrich.js"; import type { DeviceMonitor } from "../device-monitor.js"; +import type { LaneStatus } from "../lane-status.js"; import { getOccupancy } from "../occupancy.js"; // Live booth feed over a WebSocket. The booth UI opens ONE socket and receives @@ -52,12 +53,18 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined): } type OutMsg = - | { kind: "hello"; occupancy: ReturnType; devices: unknown } + | { kind: "hello"; occupancy: ReturnType; devices: unknown; lanes: LaneStatusEvent } | { kind: "ledger"; event: unknown; occupancy: ReturnType } | { kind: "printer-status"; event: unknown } - | { kind: "device-status"; event: unknown }; + | { kind: "device-status"; event: unknown } + | { kind: "lane-status"; lanes: LaneStatusEvent }; -export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: DeviceMonitor): Promise { +export async function wsRoutes( + app: FastifyInstance, + db: Db, + deviceMonitor: DeviceMonitor, + laneStatus: LaneStatus, +): Promise { app.get( "/api/ws", { @@ -89,7 +96,7 @@ export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: Devi // Initial snapshot so the client renders immediately, before any event: // occupancy AND the current device-status set (for the footer). - send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot() }); + send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot(), lanes: laneStatus.snapshot() }); // Subscribe to the live buses. Each handler recomputes occupancy from the // ledger (cheap fold) so the pushed count is always authoritative. @@ -106,11 +113,16 @@ export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: Devi const offDevice = deviceEvents.onDeviceStatus((event) => { send({ kind: "device-status", event }); }); + // Lane busy/free (camera vehicle detection → booth barrier lights). Advisory. + const offLane = deviceEvents.onLaneStatus((lanes) => { + send({ kind: "lane-status", lanes }); + }); socket.on("close", () => { offLedger(); offPrinter(); offDevice(); + offLane(); }); }, ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 5dffe1f..558d15f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -25,6 +25,7 @@ import { userRoutes } from "./routes/users.js"; import { roleRoutes } from "./routes/roles.js"; import { deviceRoutes } from "./routes/devices.js"; import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js"; +import { LaneStatus } from "./lane-status.js"; import { eventRoutes } from "./routes/events.js"; import { reportRoutes } from "./routes/reports.js"; import { recycleBinRoutes } from "./routes/recycle-bin.js"; @@ -115,10 +116,16 @@ export async function buildServer(opts: BuildOptions = {}): Promise laneStatus.stop()); + // Hikvision "Alarm Server" event push: the camera POSTs an EventNotificationAlert on // each detected target (vehicle). Source-IP guarded + optional Digest; records the raw - // payload as a `kind:"alarm"` device_event (discovery-first). See routes/hikvision-alarm.ts. - await hikvisionAlarmRoutes(app, db); + // payload as a `kind:"alarm"` device_event AND drives lane busy/free for vehicles. + // See routes/hikvision-alarm.ts. + await hikvisionAlarmRoutes(app, db, laneStatus); // Live printer-status monitor: polls printers (paper/cover/cutter/offline) and // pushes changes to the booth UI. setupRoutes() has already registered the @@ -160,7 +167,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise void }) { ); } +/** One barrier light — green = free, red = busy (a vehicle is at the lane vicinity, + * from camera detection). Advisory only; it gates nothing. */ +function BarrierLight({ label, busy }: { label: string; busy: boolean }) { + return ( +
+ {/* Barrier glyph: a post + an arm. Colour carries the state. */} + + + + + +
+
{label}
+
+ {busy ? "●" : "○"} +
+
+
+ ); +} + +/** The two lane barrier lights (entry / exit) fed by the live lane-status. */ +function LaneIndicators() { + const { t } = useTranslation(); + const lanes = useLiveStore((s) => s.lanes); + return ( +
+ + +
+ ); +} + export function BoothScreen() { const { t } = useTranslation(); // The site-wide shift drives the log scope: the feed shows ONLY the open shift's @@ -198,10 +236,16 @@ export function BoothScreen() { return (
- {/* Ticket input spans both columns at the top — the operator's primary action. */} + {/* Ticket input spans both columns at the top — the operator's primary action. + The lane barrier lights sit beside it (live vehicle-detection busy/free). */}
- +
+
+ +
+ +
diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index a236f3a..b37a9f6 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -95,6 +95,8 @@ export const en: Catalog = { booth: { processTicket: "Process ticket", scanPlaceholder: "Scan or type ticket number…", + laneEntry: "Entry", + laneExit: "Exit", open: "Open", occupancy: "Occupancy", occUnavailable: "occupancy unavailable", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 90d4e33..13dd07e 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -97,6 +97,8 @@ export const sq = { booth: { processTicket: "Proceso biletën", scanPlaceholder: "Skano ose shkruaj numrin e biletës…", + laneEntry: "Hyrje", + laneExit: "Dalje", open: "Hap", occupancy: "Prania", occUnavailable: "zënia e padisponueshme", diff --git a/apps/web/src/lib/live-store.ts b/apps/web/src/lib/live-store.ts index 9e758a4..c19fceb 100644 --- a/apps/web/src/lib/live-store.ts +++ b/apps/web/src/lib/live-store.ts @@ -10,6 +10,12 @@ import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js"; /** Connection state of the booth WebSocket, for a status indicator in the UI. */ export type WsStatus = "connecting" | "open" | "closed"; +/** Per-lane busy/free from camera vehicle detection (advisory barrier lights). */ +export interface LaneStatus { + entry: boolean; // true = busy + exit: boolean; // true = busy +} + /** Cap the in-memory live feed so a long-running booth session can't grow it * unbounded — the full history is always available via the /api/events query. */ const MAX_FEED = 200; @@ -23,6 +29,8 @@ interface LiveState { /** Live device status keyed by device id (for the footer): set from the WS * hello snapshot, then upserted per device on each device-status push. */ devices: Record; + /** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */ + lanes: LaneStatus | null; setStatus: (s: WsStatus) => void; setOccupancy: (o: Occupancy) => void; pushEvent: (e: LedgerEvent) => void; @@ -30,6 +38,8 @@ interface LiveState { setDevices: (list: DeviceStatus[]) => void; /** Upsert one device's status (a device-status push). */ upsertDevice: (d: DeviceStatus) => void; + /** Set lane busy/free (WS hello + each lane-status push). */ + setLanes: (l: LaneStatus) => void; reset: () => void; } @@ -45,6 +55,7 @@ export const useLiveStore = create((set) => ({ occupancy: null, feed: [], devices: {}, + lanes: null, setStatus: (status) => set({ status }), setOccupancy: (occupancy) => set({ occupancy }), pushEvent: (e) => @@ -54,5 +65,6 @@ export const useLiveStore = create((set) => ({ })), setDevices: (list) => set({ devices: byId(list) }), upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })), - reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {} }), + setLanes: (lanes) => set({ lanes }), + reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null }), })); diff --git a/apps/web/src/lib/use-live-feed.ts b/apps/web/src/lib/use-live-feed.ts index c517766..bd1223a 100644 --- a/apps/web/src/lib/use-live-feed.ts +++ b/apps/web/src/lib/use-live-feed.ts @@ -2,7 +2,7 @@ import { useEffect, useRef } from "react"; import { useQueryClient } from "@tanstack/react-query"; import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js"; import { qk } from "./query.js"; -import { useLiveStore } from "./live-store.js"; +import { useLiveStore, type LaneStatus } from "./live-store.js"; import { wsUrl } from "./origin.js"; // Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into @@ -14,15 +14,16 @@ import { wsUrl } from "./origin.js"; /** Server → client message shapes (mirror routes/ws.ts OutMsg). */ type WsMessage = - | { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[] } + | { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus } | { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy } | { kind: "printer-status"; event: unknown } - | { kind: "device-status"; event: DeviceStatus }; + | { kind: "device-status"; event: DeviceStatus } + | { kind: "lane-status"; lanes: LaneStatus }; export function useLiveFeed(): void { const qc = useQueryClient(); - const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice } = useLiveStore(); + const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes } = useLiveStore(); // Hold the socket + reconnect timer across renders; guard against StrictMode // double-invoke and unmount. const sockRef = useRef(null); @@ -54,8 +55,11 @@ export function useLiveFeed(): void { setOccupancy(msg.occupancy); // Initial device-status snapshot for the footer. if (Array.isArray(msg.devices)) setDevices(msg.devices); + if (msg.lanes) setLanes(msg.lanes); } else if (msg.kind === "device-status") { upsertDevice(msg.event); + } else if (msg.kind === "lane-status") { + setLanes(msg.lanes); } else if (msg.kind === "ledger") { setOccupancy(msg.occupancy); pushEvent(msg.event);