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:
@@ -86,6 +86,18 @@ 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
|
/** 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
|
* 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:
|
* 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);
|
this.on("lane-presence", cb);
|
||||||
return () => this.off("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. */
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ 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, 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 { 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";
|
||||||
@@ -65,7 +70,8 @@ type OutMsg =
|
|||||||
| { 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: "lane-presence"; radar: LanePresenceEvent }
|
||||||
|
| { kind: "plate-recognized"; plate: PlateRecognizedEvent };
|
||||||
|
|
||||||
export async function wsRoutes(
|
export async function wsRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
@@ -136,6 +142,10 @@ export async function wsRoutes(
|
|||||||
const offPresence = deviceEvents.onLanePresence((radar) => {
|
const offPresence = deviceEvents.onLanePresence((radar) => {
|
||||||
send({ kind: "lane-presence", 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();
|
||||||
@@ -143,6 +153,7 @@ export async function wsRoutes(
|
|||||||
offDevice();
|
offDevice();
|
||||||
offLane();
|
offLane();
|
||||||
offPresence();
|
offPresence();
|
||||||
|
offPlate();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/
|
|||||||
import { registry, type CameraDevice, type Snapshot } 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
|
||||||
@@ -138,6 +139,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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
if (kind === "subscription" && !s.subscription) return false;
|
if (kind === "subscription" && !s.subscription) return false;
|
||||||
if (status && statusOf(s) !== status) 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;
|
||||||
|
|||||||
@@ -207,7 +207,8 @@ 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;
|
||||||
@@ -217,7 +218,7 @@ export function BoothScreen() {
|
|||||||
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;
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ interface LiveState {
|
|||||||
setLanes: (l: LaneStatus) => void;
|
setLanes: (l: LaneStatus) => void;
|
||||||
/** Set lane radar presence (WS hello + each lane-presence push). */
|
/** Set lane radar presence (WS hello + each lane-presence push). */
|
||||||
setRadar: (r: LanePresence) => void;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,5 +83,10 @@ export const useLiveStore = create<LiveState>((set) => ({
|
|||||||
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 }),
|
||||||
setRadar: (radar) => set({ radar }),
|
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 }),
|
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null, radar: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ type WsMessage =
|
|||||||
| { 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: "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, setRadar } = 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);
|
||||||
@@ -64,6 +66,11 @@ export function useLiveFeed(): void {
|
|||||||
setLanes(msg.lanes);
|
setLanes(msg.lanes);
|
||||||
} else if (msg.kind === "lane-presence") {
|
} else if (msg.kind === "lane-presence") {
|
||||||
setRadar(msg.radar);
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user