fix(booth): backfill the live-feed plate + make plate search work

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
This commit is contained in:
2026-06-28 12:24:46 +02:00
parent 38481f105f
commit 6734e9815e
7 changed files with 61 additions and 7 deletions
+22
View File
@@ -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. */
+13 -2
View File
@@ -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();
});
},
);
+4
View File
@@ -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}`);
}