cdb55a8652
Surface the advisory ANPR plate (device_events kind="read", keyed by session identity — unsigned, prunable, never an access decision) next to entry/exit events in the live feed and on active-session rows. Resolved at serialize time (new plate-lookup.ts; prefers an entry read; one device_events scan per page) like subscriber-name enrichment — the signed ledger is untouched. Adds plate? to the shared LedgerEvent and to ActiveSession/SessionLookup; a small amber badge in the UI. Caveat: a vehicle_entry is signed + pushed over WS before the async ANPR read lands, so a fresh feed row may show no plate until reload; always present on active sessions. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
86 lines
3.5 KiB
TypeScript
86 lines
3.5 KiB
TypeScript
import { and, desc, deviceEvents, eq, type Db } from "@parking/db";
|
||
|
||
// READ-TIME plate resolution. A recognized licence plate is ADVISORY evidence — it
|
||
// lives in the unsigned, prunable `device_events` (kind="read") stream written by the
|
||
// ANPR-on-snapshot path (snapshot.ts → recognizePlate), keyed to the session `identity`.
|
||
// It is deliberately NOT on the signed ledger (a fuzzy camera read must never become a
|
||
// signed fact). To SHOW it next to a feed event or an active session we resolve it here,
|
||
// at serialize time, the same way subscriber names are resolved (see event-enrich.ts).
|
||
//
|
||
// Preference: an ENTRY read over an exit read (the plate as it arrived identifies the
|
||
// session); within a direction, the newest read wins. Returns the plate text only —
|
||
// confidence/region detail stays on the snapshot review panel, not the at-a-glance feed.
|
||
|
||
/** The best advisory plate observed for a session, for display. */
|
||
export interface PlateView {
|
||
readonly plate: string;
|
||
readonly confidence: number | null;
|
||
readonly direction: "entry" | "exit" | null;
|
||
}
|
||
|
||
interface ReadDetail {
|
||
identity?: string;
|
||
plate?: string;
|
||
confidence?: number;
|
||
direction?: string;
|
||
}
|
||
|
||
/** Best plate for one identity, or null. Prefers an entry read, then the newest read. */
|
||
export function plateForIdentity(db: Db, identity: string): PlateView | null {
|
||
const rows = db
|
||
.select({ detail: deviceEvents.detail })
|
||
.from(deviceEvents)
|
||
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
|
||
.orderBy(desc(deviceEvents.occurredAt))
|
||
.all();
|
||
return pickBest(rows.map((r) => (r.detail ?? {}) as ReadDetail), identity);
|
||
}
|
||
|
||
/** Resolve plates for MANY identities in one device_events scan (used by the active-
|
||
* sessions list and the feed page, which each carry tens–hundreds of rows). */
|
||
export function platesForIdentities(db: Db, identities: Iterable<string>): Map<string, PlateView> {
|
||
const want = new Set(identities);
|
||
const out = new Map<string, PlateView>();
|
||
if (want.size === 0) return out;
|
||
// Newest first so the first acceptable read per (identity,direction) is the freshest.
|
||
const rows = db
|
||
.select({ detail: deviceEvents.detail })
|
||
.from(deviceEvents)
|
||
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
|
||
.orderBy(desc(deviceEvents.occurredAt))
|
||
.all();
|
||
const byId = new Map<string, ReadDetail[]>();
|
||
for (const r of rows) {
|
||
const d = (r.detail ?? {}) as ReadDetail;
|
||
if (!d.identity || !d.plate || !want.has(d.identity)) continue;
|
||
let list = byId.get(d.identity);
|
||
if (!list) byId.set(d.identity, (list = []));
|
||
list.push(d);
|
||
}
|
||
for (const [id, reads] of byId) {
|
||
const best = pickBest(reads, id);
|
||
if (best) out.set(id, best);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Pick the best read for `identity` from a NEWEST-FIRST list: an entry read beats an
|
||
* exit read; otherwise the first (newest) acceptable read wins. */
|
||
function pickBest(reads: ReadDetail[], identity: string): PlateView | null {
|
||
let fallback: ReadDetail | null = null;
|
||
for (const d of reads) {
|
||
if (d.identity !== identity || !d.plate) continue;
|
||
if (d.direction === "entry") return toView(d);
|
||
if (!fallback) fallback = d;
|
||
}
|
||
return fallback ? toView(fallback) : null;
|
||
}
|
||
|
||
function toView(d: ReadDetail): PlateView {
|
||
return {
|
||
plate: d.plate!.trim().toUpperCase(),
|
||
confidence: typeof d.confidence === "number" ? d.confidence : null,
|
||
direction: d.direction === "entry" || d.direction === "exit" ? d.direction : null,
|
||
};
|
||
}
|