devices: pool-of-spaces model — drop lane, per-relay direction
A parking lot is one pool of spaces with a flexible set of entry/exit
points — no "lane". Direction is a property of each RELAY inside an access
controller; readers/cameras bind to a controller relay and inherit it.
Schema:
- drop `lane` from ledger_events, device_events, sessions
- rename lane_devices -> devices (no lane/direction columns)
- access config.relays=[{relay,direction,button?}]; reader/camera
config.controllerId+relay binding
- fresh 0000_baseline migration (history reset; dev data was throwaway)
Signed ledger:
- remove `lane` from canonicalize(); bump signer keyId sw-hmac-v1 -> v2
(v1 events won't verify under v2 — intentional, gated per-event by keyId)
Server:
- new device-resolve.ts (replaces lane-map.ts): relayForButton,
relayForDevice, firstRelayByDirection, devicesByDirection
- entry-flow: button terminal -> its relay; exit/permit: reader's bound
relay; dispatcher resolves the bound relay + inherited direction
- camera snapshots fire by direction site-wide, async, never block open
- DeviceConfig widened to nested JSON for relays[]
Web:
- wizard: no lane selector; add controllers (relay map + entry-button
terminal) first, then bind readers/cameras/printers to a controller relay
Wiki: new entry-exit-points.md (replaces lane-direction); reworked
entry-exit-readers, parking-session, first-run-setup, device-registry,
append-only-event-chain, device-events; removed stale lane/LaneMap mentions.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
||||
import { registry, type CameraDevice } from "@parking/devices";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
||||
|
||||
// Camera snapshot capture, fired AFTER the barrier opens and never awaited on the
|
||||
// open path (decision 2026-06-16): a snapshot is EVIDENCE, not a gate. A camera
|
||||
// failure must never delay or prevent an open — the signed ledger is the decision,
|
||||
// the image is an independent, prunable record stored as a BLOB in `snapshots`.
|
||||
// See wiki/concepts/entry-exit-points.md and append-only-event-chain.md.
|
||||
//
|
||||
// Every camera serving the firing direction (entry/exit, or both) snapshots. Each
|
||||
// capture is independent — one camera down doesn't stop the others. A captured image
|
||||
// → a `snapshots` row + a `kind:"snapshot"` telemetry device_event; a failure → a
|
||||
// telemetry device_event only. The caller passes the session `identity` so the image
|
||||
// links to the signed vehicle_entry/exit.
|
||||
|
||||
interface SnapshotJob {
|
||||
readonly db: Db;
|
||||
readonly direction: FlowDirection;
|
||||
/** Session/credential ref (ticket id, plate, permit car key) — links to the ledger. */
|
||||
readonly identity: string;
|
||||
readonly logger: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire snapshots for the directional camera set. Returns immediately with a promise
|
||||
* the caller MAY ignore (fire-and-forget) — it resolves to the captured snapshot ids.
|
||||
* The caller must NOT block its open path on this.
|
||||
*/
|
||||
export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
||||
const { db, direction, identity, logger } = job;
|
||||
const rows = devicesByDirection(db, "camera", direction);
|
||||
if (rows.length === 0) return Promise.resolve([]);
|
||||
|
||||
return Promise.all(
|
||||
rows.map(async (row): Promise<string | null> => {
|
||||
const camera = buildCamera(row);
|
||||
if (!camera) {
|
||||
recordFailure(db, direction, row.id, identity, "camera config won't build", logger);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const shot = await camera.captureSnapshot({ direction });
|
||||
const id: string = randomUUID();
|
||||
db.insert(snapshots)
|
||||
.values({
|
||||
id,
|
||||
direction,
|
||||
deviceId: row.id,
|
||||
identity,
|
||||
contentType: shot.contentType,
|
||||
bytes: shot.bytes,
|
||||
capturedAt: shot.capturedAt,
|
||||
})
|
||||
.run();
|
||||
// Telemetry breadcrumb pointing at the stored image (NOT the bytes).
|
||||
recordEvent(db, direction, row.id, identity, { snapshotId: id, ok: true }, logger);
|
||||
return id;
|
||||
} catch (err) {
|
||||
recordFailure(db, direction, row.id, identity, (err as Error).message, logger);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
).then((ids) => ids.filter((id): id is string => id != null));
|
||||
}
|
||||
|
||||
/** Build a live camera adapter from a resolved devices row, or null. */
|
||||
function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) return null;
|
||||
try {
|
||||
return driver.create(row.config as never) as CameraDevice;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function recordFailure(
|
||||
db: Db,
|
||||
direction: FlowDirection,
|
||||
deviceId: string,
|
||||
identity: string,
|
||||
error: string,
|
||||
logger: FastifyBaseLogger,
|
||||
): void {
|
||||
logger.warn(`snapshot failed (${direction}, ${identity}): ${error}`);
|
||||
recordEvent(db, direction, deviceId, identity, { ok: false, error }, logger);
|
||||
}
|
||||
|
||||
function recordEvent(
|
||||
db: Db,
|
||||
direction: FlowDirection,
|
||||
deviceId: string,
|
||||
identity: string,
|
||||
detail: Record<string, unknown>,
|
||||
logger: FastifyBaseLogger,
|
||||
): void {
|
||||
try {
|
||||
db.insert(deviceEventsTable)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
deviceId,
|
||||
category: "camera",
|
||||
kind: "snapshot",
|
||||
detail: { ...detail, direction, identity },
|
||||
occurredAt: new Date().toISOString(),
|
||||
})
|
||||
.run();
|
||||
} catch (err) {
|
||||
// Telemetry is best-effort; never let it surface on the (already-open) path.
|
||||
logger.error(`snapshot device-event insert failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user