From 6734e9815ec0e1651011cff45cd4fe4893fb4183 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 28 Jun 2026 12:24:46 +0200 Subject: [PATCH] fix(booth): backfill the live-feed plate + make plate search work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two booth feed fixes: - Plate not showing until refresh. Plate recognition is async/advisory (snapshot.ts recognizePlate → a kind:"read" device_event keyed by the session identity), so it lands AFTER the entry/exit event already shipped over the WS without a plate; a refresh re-fetched via the bulk enrich path and showed it. Added a `plate-recognized` bus event (device-events.ts) emitted when the read is written; ws.ts forwards it; the client patchPlate(identity, plate) (live-store) backfills the already-rendered feed row in place and invalidates the Query-owned active-sessions list. No refresh. - Plate search didn't filter. Both the live-feed (BoothScreen) and active-sessions (ActiveSessions) search haystacks matched the wrong field — the displayed plate is the ENRICHED top-level e.plate/s.plate (set by enrichEvent), not payload.plate (the plate is unsigned, never in the signed payload). Switched the haystacks to the displayed field. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/server/src/device-events.ts | 22 ++++++++++++++++++++++ apps/server/src/routes/ws.ts | 15 +++++++++++++-- apps/server/src/snapshot.ts | 4 ++++ apps/web/src/ActiveSessions.tsx | 3 ++- apps/web/src/BoothScreen.tsx | 5 +++-- apps/web/src/lib/live-store.ts | 8 ++++++++ apps/web/src/lib/use-live-feed.ts | 11 +++++++++-- 7 files changed, 61 insertions(+), 7 deletions(-) diff --git a/apps/server/src/device-events.ts b/apps/server/src/device-events.ts index 9b6b6e8..ee2440d 100644 --- a/apps/server/src/device-events.ts +++ b/apps/server/src/device-events.ts @@ -86,6 +86,18 @@ export interface LaneStatusEvent { readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity) } +/** A plate was RECOGNIZED for a session AFTER its entry/exit event already shipped. Plate + * recognition is async/advisory (a vision round-trip off the snapshot), so it lands a + * moment after the signed event — too late for the event's own WS push to carry it. This + * notifies the booth so it can fill in the plate badge on the already-rendered feed row / + * active session in place, no refresh. Advisory; never touches the signed ledger. See + * snapshot.ts (recognizePlate) + event-enrich.ts. */ +export interface PlateRecognizedEvent { + readonly identity: string; // the session identity the plate is tied to + readonly plate: string; // normalized plate text (trimmed, upper) + readonly direction: "entry" | "exit"; +} + /** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the * entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has * confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink: @@ -168,6 +180,16 @@ class DeviceEventBus extends EventEmitter { 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. */ diff --git a/apps/server/src/routes/ws.ts b/apps/server/src/routes/ws.ts index c73987f..44f7a9d 100644 --- a/apps/server/src/routes/ws.ts +++ b/apps/server/src/routes/ws.ts @@ -2,7 +2,12 @@ import type { FastifyInstance } from "fastify"; import type { Db } from "@parking/db"; import type { LedgerEvent } from "@parking/shared"; import { roleHasPermissions } from "../auth.js"; -import { deviceEvents, type LaneStatusEvent, type LanePresenceEvent } from "../device-events.js"; +import { + deviceEvents, + type LaneStatusEvent, + type LanePresenceEvent, + type PlateRecognizedEvent, +} from "../device-events.js"; import { enrichEvent } from "../event-enrich.js"; import type { DeviceMonitor } from "../device-monitor.js"; import type { LaneStatus } from "../lane-status.js"; @@ -65,7 +70,8 @@ type OutMsg = | { kind: "printer-status"; event: unknown } | { kind: "device-status"; event: unknown } | { kind: "lane-status"; lanes: LaneStatusEvent } - | { kind: "lane-presence"; radar: LanePresenceEvent }; + | { kind: "lane-presence"; radar: LanePresenceEvent } + | { kind: "plate-recognized"; plate: PlateRecognizedEvent }; export async function wsRoutes( app: FastifyInstance, @@ -136,6 +142,10 @@ export async function wsRoutes( const offPresence = deviceEvents.onLanePresence((radar) => { send({ kind: "lane-presence", radar }); }); + // A late async plate recognition → backfill the badge on the matching feed row. Advisory. + const offPlate = deviceEvents.onPlateRecognized((plate) => { + send({ kind: "plate-recognized", plate }); + }); socket.on("close", () => { offLedger(); @@ -143,6 +153,7 @@ export async function wsRoutes( offDevice(); offLane(); offPresence(); + offPlate(); }); }, ); diff --git a/apps/server/src/snapshot.ts b/apps/server/src/snapshot.ts index 3092f82..cb69460 100644 --- a/apps/server/src/snapshot.ts +++ b/apps/server/src/snapshot.ts @@ -3,6 +3,7 @@ import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/ import { registry, type CameraDevice, type Snapshot } from "@parking/devices"; import type { FastifyBaseLogger } from "fastify"; import { devicesByDirection, type FlowDirection } from "./device-resolve.js"; +import { deviceEvents } from "./device-events.js"; import type { VisionClient } from "./vision-client.js"; // Camera snapshot capture, fired AFTER the barrier opens and never awaited on the @@ -138,6 +139,9 @@ async function recognizePlate( }) .run(); logger.info(`anpr plate '${plate}' (${result.plate.confidence.toFixed(3)}) for ${identity}`); + // The session's entry/exit event already shipped without this (async) plate — tell the + // booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched. + deviceEvents.emitPlateRecognized({ identity, plate, direction }); } catch (err) { logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`); } diff --git a/apps/web/src/ActiveSessions.tsx b/apps/web/src/ActiveSessions.tsx index a1ee7de..0ad8360 100644 --- a/apps/web/src/ActiveSessions.tsx +++ b/apps/web/src/ActiveSessions.tsx @@ -83,7 +83,8 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void if (kind === "subscription" && !s.subscription) return false; if (status && statusOf(s) !== status) return false; if (q) { - const hay = `${s.identity} ${s.subscriptionHolder ?? ""}`.toLowerCase(); + // Include the enriched plate (`s.plate`, the displayed badge) so a plate search hits. + const hay = `${s.identity} ${s.subscriptionHolder ?? ""} ${s.plate ?? ""}`.toLowerCase(); if (!hay.includes(q)) return false; } return true; diff --git a/apps/web/src/BoothScreen.tsx b/apps/web/src/BoothScreen.tsx index f52ee69..a0b0d49 100644 --- a/apps/web/src/BoothScreen.tsx +++ b/apps/web/src/BoothScreen.tsx @@ -207,7 +207,8 @@ export function BoothScreen() { // Apply the live-feed filters. Source maps to booth (operator-initiated `manual`) // vs reader (device-initiated: wiegand/lpr/qr/ticket). Search spans identity, - // subscriber label, and any advisory plate on the payload. + // subscriber label, and the enriched advisory plate (`e.plate` — the displayed field; + // the plate is NOT in the signed payload, so `payload.plate` would never match). const fq = feedSearch.trim().toLowerCase(); const events = scoped.filter((e) => { if (feedType && feedCat(e.type) !== feedType) return false; @@ -217,7 +218,7 @@ export function BoothScreen() { if (feedSrc === "booth" ? !isBooth : isBooth) return false; } if (fq) { - const hay = `${e.identity ?? ""} ${e.subscriberLabel ?? ""} ${e.payload?.plate ?? ""}`.toLowerCase(); + const hay = `${e.identity ?? ""} ${e.subscriberLabel ?? ""} ${e.plate ?? ""}`.toLowerCase(); if (!hay.includes(fq)) return false; } return true; diff --git a/apps/web/src/lib/live-store.ts b/apps/web/src/lib/live-store.ts index bbe5d29..be902bd 100644 --- a/apps/web/src/lib/live-store.ts +++ b/apps/web/src/lib/live-store.ts @@ -52,6 +52,9 @@ interface LiveState { 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; } @@ -80,5 +83,10 @@ export const useLiveStore = create((set) => ({ upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })), setLanes: (lanes) => set({ lanes }), 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 }), })); diff --git a/apps/web/src/lib/use-live-feed.ts b/apps/web/src/lib/use-live-feed.ts index ec68ef5..4098d38 100644 --- a/apps/web/src/lib/use-live-feed.ts +++ b/apps/web/src/lib/use-live-feed.ts @@ -19,12 +19,14 @@ type WsMessage = | { kind: "printer-status"; event: unknown } | { kind: "device-status"; event: DeviceStatus } | { kind: "lane-status"; lanes: LaneStatus } - | { kind: "lane-presence"; radar: LanePresence }; + | { kind: "lane-presence"; radar: LanePresence } + | { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } }; export function useLiveFeed(): void { const qc = useQueryClient(); - const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar } = useLiveStore(); + const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } = + useLiveStore(); // Hold the socket + reconnect timer across renders; guard against StrictMode // double-invoke and unmount. const sockRef = useRef(null); @@ -64,6 +66,11 @@ export function useLiveFeed(): void { setLanes(msg.lanes); } else if (msg.kind === "lane-presence") { setRadar(msg.radar); + } else if (msg.kind === "plate-recognized") { + // Backfill the badge on the already-rendered feed row, and refetch the + // Query-owned active-sessions list (re-runs enrichEvents → the now-written plate). + patchPlate(msg.plate.identity, msg.plate.plate); + void qc.invalidateQueries({ queryKey: qk.activeSessions }); } else if (msg.kind === "ledger") { setOccupancy(msg.occupancy); pushEvent(msg.event);