diff --git a/apps/server/src/device-events.ts b/apps/server/src/device-events.ts index f2c97fb..bb7d224 100644 --- a/apps/server/src/device-events.ts +++ b/apps/server/src/device-events.ts @@ -8,19 +8,19 @@ import type { PrinterStatus } from "@parking/devices"; export interface DeviceInputEvent { readonly driverId: string; // e.g. "dingtian" - readonly deviceId: string; // which configured device (lane_devices id) + readonly deviceId: string; // which configured device (devices id) readonly input: number; // 1-based input/channel readonly edge: "on" | "off"; // active / inactive readonly at: string; // ISO-8601 (server receive time) readonly source: "push" | "poll"; } -// A credential read at a lane: a ticket scanned at exit, a plate from LPR, a card -// at a reader. Drives identity-based flows (exit validation, and later permits / -// pay-station lookup). `kind` mirrors IdentitySource. See parking-session.md. +// A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader. +// Drives identity-based flows (exit validation, permits, pay-station lookup). `kind` +// mirrors IdentitySource. See parking-session.md. export interface DeviceReadEvent { readonly driverId: string; - readonly deviceId: string; // lane_devices id of the reader/scanner/camera + readonly deviceId: string; // devices id of the reader/scanner/camera readonly value: string; // the ticket id / plate / card number readonly kind: "ticket" | "plate" | "qr" | "card"; readonly at: string; // ISO-8601 @@ -42,8 +42,7 @@ export interface ReadOutcome { /** A printer's status as tracked by the live monitor (status + identity). */ export interface PrinterStatusEvent { - readonly deviceId: string; // lane_devices id - readonly lane: number; + readonly deviceId: string; // devices id readonly driverId: string; readonly role?: string; // entry-dispenser | booth-receipt readonly status: PrinterStatus; @@ -58,7 +57,7 @@ class DeviceEventBus extends EventEmitter { return () => this.off("input", cb); } - /** A credential read (ticket scan, plate, card) at a lane. */ + /** A credential read (ticket scan, plate, card). */ emitRead(event: DeviceReadEvent): void { this.emit("read", event); } diff --git a/apps/server/src/device-resolve.ts b/apps/server/src/device-resolve.ts new file mode 100644 index 0000000..243da14 --- /dev/null +++ b/apps/server/src/device-resolve.ts @@ -0,0 +1,155 @@ +import { and, eq, devices, type Db, type DeviceRow } from "@parking/db"; + +// Device resolution for the pool-of-spaces model — NO lane. A parking lot is one +// pool with a flexible set of entry/exit points. Direction lives on each RELAY +// inside an access controller, and readers/cameras BIND to a (controller, relay). +// See wiki/concepts/entry-exit-points.md. + +/** A flow direction. "both" = one relay/barrier serving entry AND exit. */ +export type Direction = "entry" | "exit" | "both"; +/** A concrete flow a credential/button drives (never "both"). */ +export type FlowDirection = "entry" | "exit"; + +/** One relay on an access controller: which barrier it opens, in which direction, + * and (optionally) the input terminal its entry button is wired to. */ +export interface RelaySpec { + /** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */ + readonly relay: number; + readonly direction: Direction; + /** 1-based input terminal of the entry button that fires this relay (transient + * entry). Absent = no button at this barrier (subscriber/reader-driven only). */ + readonly button?: number; +} + +/** Access controller config (the `relays[]` map + connection fields). */ +interface AccessConfig { + readonly relays?: RelaySpec[]; + readonly [k: string]: unknown; +} + +/** Reader/camera config: optional binding to a controller relay. */ +interface BoundConfig { + /** The access `devices.id` this reader/camera sits at. */ + readonly controllerId?: string; + /** The relay on that controller it opens. */ + readonly relay?: number; + /** Fallback direction when not bound to a relay. */ + readonly direction?: Direction; + readonly [k: string]: unknown; +} + +/** A resolved barrier: the controller row + the specific relay to pulse. */ +export interface ResolvedRelay { + readonly controller: DeviceRow; + readonly relay: number; + readonly direction: Direction; +} + +/** All enabled access controller rows. */ +function accessRows(db: Db): DeviceRow[] { + return db + .select() + .from(devices) + .where(eq(devices.category, "access")) + .all() + .filter((r) => r.enabled); +} + +/** The relay specs declared on an access controller (defaults to none). */ +export function relaysOf(row: DeviceRow): RelaySpec[] { + const cfg = row.config as AccessConfig; + return Array.isArray(cfg.relays) ? cfg.relays : []; +} + +/** + * Resolve a button press to the relay it fires: the access controller with this + * deviceId, and the relay whose `button` terminal matches the pressed input. Only + * an ENTRY (or both) relay is a transient-entry trigger. Returns null otherwise. + */ +export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null { + const row = db + .select() + .from(devices) + .where(and(eq(devices.id, controllerId), eq(devices.category, "access"))) + .get(); + if (!row || !row.enabled) return null; + const spec = relaysOf(row).find((r) => r.button === terminal); + if (!spec) return null; + if (spec.direction !== "entry" && spec.direction !== "both") return null; + return { controller: row, relay: spec.relay, direction: spec.direction }; +} + +/** + * Resolve a reader/camera to the relay it opens. Preferred: its config binding + * (controllerId + relay) → exactly that barrier, direction inherited from the relay + * spec. Fallback (unbound): the device's config.direction + the first relay site- + * wide matching that direction — keeps the single-barrier case trivial. Null if + * nothing resolves (no barrier to open). + */ +export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | null { + const cfg = deviceRow.config as BoundConfig; + + // Bound: follow controllerId + relay to the exact barrier. + if (cfg.controllerId && typeof cfg.relay === "number") { + const controller = db + .select() + .from(devices) + .where(and(eq(devices.id, cfg.controllerId), eq(devices.category, "access"))) + .get(); + if (controller && controller.enabled) { + const spec = relaysOf(controller).find((r) => r.relay === cfg.relay); + if (spec) return { controller, relay: spec.relay, direction: spec.direction }; + } + return null; + } + + // Unbound: fall back to the device's declared direction + first matching relay. + const want = cfg.direction; + if (want === "entry" || want === "exit" || want === "both") { + return firstRelayByDirection(db, want === "both" ? "entry" : want); + } + return null; +} + +/** + * The first relay site-wide serving a direction ("both" relays match either). + * Used as the unbound fallback and where a flow only needs "an exit barrier". + */ +export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null { + for (const controller of accessRows(db)) { + const spec = relaysOf(controller).find( + (r) => r.direction === direction || r.direction === "both", + ); + if (spec) return { controller, relay: spec.relay, direction: spec.direction }; + } + return null; +} + +/** Enabled devices of a category whose direction matches `want` (or is "both"). + * Direction is inherited from each device's bound relay, else its config fallback. + * Used for snapshots: every entry/exit camera fires on an entry/exit. */ +export function devicesByDirection( + db: Db, + category: DeviceRow["category"], + want: FlowDirection, +): DeviceRow[] { + return db + .select() + .from(devices) + .where(eq(devices.category, category)) + .all() + .filter((r) => { + if (!r.enabled) return false; + const d = directionOf(db, r); + return d === want || d === "both"; + }); +} + +/** The direction a reader/camera operates in (inherited from its bound relay, or + * its config fallback). "both" when undetermined → the flow infers. */ +export function directionOf(db: Db, deviceRow: DeviceRow): Direction { + const resolved = relayForDevice(db, deviceRow); + if (resolved) return resolved.direction; + const cfg = deviceRow.config as BoundConfig; + return cfg.direction === "entry" || cfg.direction === "exit" ? cfg.direction : "both"; +} diff --git a/apps/server/src/entry-flow.ts b/apps/server/src/entry-flow.ts index e8984ba..a51509a 100644 --- a/apps/server/src/entry-flow.ts +++ b/apps/server/src/entry-flow.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { and, eq, laneDevices, sessions, type Db } from "@parking/db"; +import { sessions, type Db, type DeviceRow } from "@parking/db"; import { NoPrinterAvailableError, printWithFailover, @@ -13,11 +13,13 @@ import type { FastifyBaseLogger } from "fastify"; import type { DeviceInputEvent } from "./device-events.js"; import { getOccupancy } from "./occupancy.js"; import type { EventLog } from "./event-log.js"; -import type { LaneMap } from "./lane-map.js"; +import { devicesByDirection, relayForButton, type ResolvedRelay } from "./device-resolve.js"; +import { snapshotAsync } from "./snapshot.js"; // The transient ENTRY flow: a button press → print a ticket → sign a vehicle_entry -// → open the barrier. This is the step the device layer left dangling -// (wiki/concepts/device-input-flow.md "the entry flow itself is the next build"). +// → open the barrier. The button is wired into an access controller's input; the +// admin maps that input terminal to a relay (config.relays[].button), so a press +// resolves to exactly the entry relay it should open. See entry-exit-points.md. // // Two invariants from the threat model + safety analysis: // 1. SIGNED BEFORE OPEN — the vehicle_entry is appended to the signed ledger @@ -29,55 +31,46 @@ import type { LaneMap } from "./lane-map.js"; // Crucially, NO vehicle_entry is written in that case — we never record an // "entered" event for a car that didn't get in (decision 2026-06-15). // -// Ordering, therefore: print → (ok) sign vehicle_entry → pulseOpen → cache session. -// (fail) sign anomaly, stop. - -/** Map a 1-based entry input to the relay/door it opens. Default: same channel. */ -function doorForInput(input: number): number { - return input; -} +// Ordering: print → (ok) sign vehicle_entry → pulseOpen → snapshot → cache session. +// (fail) sign anomaly, stop. export class EntryFlow { readonly #db: Db; readonly #log: EventLog; - readonly #laneMap: LaneMap; readonly #logger: FastifyBaseLogger; /** Guard against double-fire from the same physical press (on edge only). */ readonly #inFlight = new Set(); - constructor(db: Db, log: EventLog, laneMap: LaneMap, logger: FastifyBaseLogger) { + constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) { this.#db = db; this.#log = log; - this.#laneMap = laneMap; this.#logger = logger; } /** Handle a device input edge. Acts only on the rising ("on") edge of an entry - * button in a lane that has an access (barrier) device. */ + * button — an input terminal mapped to an entry relay on its controller. */ async onInput(e: DeviceInputEvent): Promise { if (e.edge !== "on") return; // release edge is just telemetry - const lane = this.#laneMap.laneFor(e.deviceId); - if (lane == null) return; // unmapped device — telemetry already recorded, no entry - - // Only treat this as an entry trigger if the firing device IS the lane's - // access controller (a reader/printer input edge isn't an entry button). - const access = await this.#loadAccess(lane, e.deviceId); - if (!access) return; + // The firing device must be an access controller, and the pressed input terminal + // must map to an ENTRY (or both) relay — that's an entry button. Anything else + // (reader/printer edge, exit-only relay's input) is not a transient-entry trigger. + const resolved = relayForButton(this.#db, e.deviceId, e.input); + if (!resolved) return; const key = `${e.deviceId}:${e.input}`; if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing this.#inFlight.add(key); try { - await this.#runEntry(lane, e.input, access); + await this.#runEntry(resolved); } catch (err) { - this.#logger.error(`entry-flow failed (lane ${lane}): ${(err as Error).message}`); + this.#logger.error(`entry-flow failed: ${(err as Error).message}`); } finally { this.#inFlight.delete(key); } } - async #runEntry(lane: number, input: number, access: AccessControlDevice): Promise { + async #runEntry(resolved: ResolvedRelay): Promise { // CAPACITY GATE (transient only). When the lot is full, refuse transient entry: // no ticket, no vehicle_entry, no open — sign an anomaly. Permit holders are NOT // gated here (their flow ignores site-full; their own maxConcurrent applies), so @@ -87,24 +80,23 @@ export class EntryFlow { if (occ.full) { await this.#log.append({ type: "anomaly", - lane, payload: { reason: `transient entry refused — lot full (${occ.count}/${occ.capacity})`, entryRefused: true, full: true }, }); - this.#logger.warn(`transient entry REFUSED on lane ${lane}: full (${occ.count}/${occ.capacity})`); + this.#logger.warn(`transient entry REFUSED: full (${occ.count}/${occ.capacity})`); return; } const ticketId = newTicketId(); const issuedAt = new Date().toISOString(); - const printers = await this.#loadPrinters(lane); + const printers = this.#loadPrinters(); // 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry. - const ticket: TicketData = { ticketId, lane, issuedAt }; + const ticket: TicketData = { ticketId, issuedAt }; try { const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) => d.printTicket(ticket), ); - this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy} (lane ${lane})`); + this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy}`); } catch (err) { // HOLD: do not open, do not record a vehicle_entry. Sign an anomaly so the // failed attempt is in the tamper-evident record for the operator. @@ -112,18 +104,16 @@ export class EntryFlow { err instanceof NoPrinterAvailableError ? err.message : (err as Error).message; await this.#log.append({ type: "anomaly", - lane, identity: ticketId, payload: { reason: `entry held — ticket not printed: ${reason}`, ticketPrinted: false }, }); - this.#logger.warn(`entry HELD on lane ${lane}: ${reason} (barrier NOT opened)`); + this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`); return; } // 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant). await this.#log.append({ type: "vehicle_entry", - lane, direction: "entry", source: "ticket", identity: ticketId, @@ -131,15 +121,26 @@ export class EntryFlow { occurredAt: issuedAt, }); - // 3. OPEN the barrier (intent only; the barrier owns the close). - await access.pulseOpen(doorForInput(input)); + // 3. OPEN the resolved entry barrier (intent only; the barrier owns the close). + const access = this.#buildAccess(resolved.controller); + if (access) await access.pulseOpen(resolved.relay); + else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`); + + // 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate; + // a camera failure must not delay or block the already-open barrier). + void snapshotAsync({ + db: this.#db, + direction: "entry", + identity: ticketId, + logger: this.#logger, + }).catch((err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`)); // 4. Update the session projection cache (rebuildable from the ledger; this is // just a fast read-model, never the source of truth). try { this.#db .insert(sessions) - .values({ id: ticketId, lane, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" }) + .values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" }) .run(); } catch (err) { // Cache miss is non-fatal — the ledger is authoritative and the projection @@ -148,15 +149,8 @@ export class EntryFlow { } } - /** The lane's access device, but only if it's the one that fired (the entry - * button). Returns a live adapter or null. */ - async #loadAccess(lane: number, deviceId: string): Promise { - const row = await this.#db - .select() - .from(laneDevices) - .where(and(eq(laneDevices.id, deviceId), eq(laneDevices.category, "access"))) - .get(); - if (!row || !row.enabled || row.lane !== lane) return null; + /** Build a live access adapter from a resolved controller row, or null. */ + #buildAccess(row: DeviceRow): AccessControlDevice | null { const driver = registry.get(row.driverId); if (!driver) return null; try { @@ -166,16 +160,11 @@ export class EntryFlow { } } - /** Build live printer instances for a lane (for failover selection). */ - async #loadPrinters(lane: number): Promise { - const rows = await this.#db - .select() - .from(laneDevices) - .where(and(eq(laneDevices.category, "printer"), eq(laneDevices.lane, lane))) - .all(); + /** Build live ENTRY printer instances (for failover selection). */ + #loadPrinters(): PrinterInstance[] { + const rows = devicesByDirection(this.#db, "printer", "entry"); // already enabled-filtered const out: PrinterInstance[] = []; for (const row of rows) { - if (!row.enabled) continue; const driver = registry.get(row.driverId); if (!driver) continue; const cfg = row.config as Record; diff --git a/apps/server/src/event-log.ts b/apps/server/src/event-log.ts index 11f152a..3d0eda0 100644 --- a/apps/server/src/event-log.ts +++ b/apps/server/src/event-log.ts @@ -17,7 +17,6 @@ import type { Direction, IdentitySource, LedgerEventType, LedgerPayload, Signer export interface AppendInput { readonly type: LedgerEventType; - readonly lane: number; readonly direction?: Direction | null; readonly source?: IdentitySource | null; readonly identity?: string | null; @@ -38,7 +37,6 @@ export function canonicalize(e: { index: number; type: string; direction: string | null; - lane: number; source: string | null; identity: string | null; payload: Record | null; @@ -49,7 +47,6 @@ export function canonicalize(e: { e.index, e.type, e.direction ?? null, - e.lane, e.source ?? null, e.identity ?? null, // Payload is part of the signed form so business data is tamper-evident. @@ -120,7 +117,6 @@ export class EventLog { index, type: input.type, direction: input.direction ?? null, - lane: input.lane, source: input.source ?? null, identity: input.identity ?? null, payload, @@ -133,7 +129,6 @@ export class EventLog { index, type: input.type, direction: input.direction ?? null, - lane: input.lane, source: input.source ?? null, identity: input.identity ?? null, payload, diff --git a/apps/server/src/exit-flow.ts b/apps/server/src/exit-flow.ts index cc1a1bf..e3ef640 100644 --- a/apps/server/src/exit-flow.ts +++ b/apps/server/src/exit-flow.ts @@ -1,5 +1,7 @@ -import { and, eq, laneDevices, ledgerEvents, sessions, type Db } from "@parking/db"; +import { eq, ledgerEvents, sessions, type Db, type DeviceRow } from "@parking/db"; import { registry, type AccessControlDevice } from "@parking/devices"; +import type { ResolvedRelay } from "./device-resolve.js"; +import { snapshotAsync } from "./snapshot.js"; import type { LedgerPayload } from "@parking/shared"; import type { FastifyBaseLogger } from "fastify"; import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; @@ -25,7 +27,6 @@ import type { EventLog } from "./event-log.js"; interface SessionView { readonly identity: string; - readonly lane: number; readonly enteredAt: string; readonly open: boolean; // no vehicle_exit yet readonly paidAt: string | null; // latest payment time, if any @@ -44,23 +45,23 @@ export class ExitFlow { this.#logger = logger; } - /** Handle a transient-ticket read at a known exit lane (lane pre-resolved by the - * read dispatcher, which has already ruled out a permit match). */ - async handleAt(lane: number, e: DeviceReadEvent): Promise { + /** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the + * read dispatcher from the reader's binding, which has ruled out a permit match). */ + async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise { const key = `${e.deviceId}:${e.value}`; if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" }; this.#inFlight.add(key); try { - return await this.#runExit(lane, e); + return await this.#runExit(resolved, e); } catch (err) { - this.#logger.error(`exit-flow failed (lane ${lane}): ${(err as Error).message}`); + this.#logger.error(`exit-flow failed: ${(err as Error).message}`); return { accepted: false, reason: (err as Error).message }; } finally { this.#inFlight.delete(key); } } - async #runExit(lane: number, e: DeviceReadEvent): Promise { + async #runExit(resolved: ResolvedRelay, e: DeviceReadEvent): Promise { const view = this.#sessionFor(e.value); // No matching open session — unknown/duplicate ticket. Reject + log. @@ -68,11 +69,10 @@ export class ExitFlow { const reason = view ? "exit refused — session already closed" : "exit refused — no open session for credential"; await this.#log.append({ type: "anomaly", - lane, identity: e.value, payload: { reason, exitRefused: true }, }); - this.#logger.warn(`exit refused (lane ${lane}): no open session for ${e.value}`); + this.#logger.warn(`exit refused: no open session for ${e.value}`); return { accepted: false, direction: "exit", reason }; } @@ -89,30 +89,33 @@ export class ExitFlow { : "exit refused — walk-back grace expired (top-up required)"; await this.#log.append({ type: "anomaly", - lane, identity: e.value, payload: { reason, exitRefused: true, sessionRef: e.value }, }); - this.#logger.warn(`exit refused (lane ${lane}, ${e.value}): ${reason}`); + this.#logger.warn(`exit refused (${e.value}): ${reason}`); return { accepted: false, direction: "exit", reason }; } // Valid: sign the exit BEFORE opening, then open, then update the cache. await this.#log.append({ type: "vehicle_exit", - lane, direction: "exit", source: e.kind === "plate" ? "lpr" : "ticket", identity: e.value, payload: { sessionRef: e.value }, }); - const access = await this.#exitAccess(lane); - if (access) { - await access.pulseOpen(1); // exit barrier; door mapping is config-driven later - } else { - this.#logger.warn(`exit signed for ${e.value} but lane ${lane} has no access device to open`); - } + const access = this.#buildAccess(resolved.controller); + if (access) await access.pulseOpen(resolved.relay); + else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`); + + // SNAPSHOT — fire the exit camera(s), never awaited (evidence, not a gate). + void snapshotAsync({ + db: this.#db, + direction: "exit", + identity: e.value, + logger: this.#logger, + }).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`)); try { this.#db @@ -152,7 +155,6 @@ export class ExitFlow { return { identity, - lane: entry.lane, enteredAt: entry.occurredAt, open: !exited, paidAt, @@ -160,14 +162,8 @@ export class ExitFlow { }; } - /** The lane's access device, to open the exit barrier. */ - async #exitAccess(lane: number): Promise { - const row = await this.#db - .select() - .from(laneDevices) - .where(and(eq(laneDevices.category, "access"), eq(laneDevices.lane, lane))) - .get(); - if (!row || !row.enabled) return null; + /** Build a live access adapter from a resolved controller row, or null. */ + #buildAccess(row: DeviceRow): AccessControlDevice | null { const driver = registry.get(row.driverId); if (!driver) return null; try { diff --git a/apps/server/src/lane-map.ts b/apps/server/src/lane-map.ts deleted file mode 100644 index 4a586d2..0000000 --- a/apps/server/src/lane-map.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { and, eq, laneDevices, type Db } from "@parking/db"; - -// Resolves a device instance id (lane_devices.id) to its lane number. -// -// Device pushes/events carry the `lane_devices` id (which device fired), not a -// lane. The event log wants the lane, so we keep a small in-memory id->lane map -// rebuilt from the DB at startup and refreshed whenever assignments change -// (assign/unassign). It's tiny (one row per device) and read on the hot path of -// every input event, so a cached map beats a per-event DB lookup. -export class LaneMap { - readonly #db: Db; - #byDeviceId = new Map(); - - constructor(db: Db) { - this.#db = db; - } - - /** (Re)load the id->lane map from the lane_devices table. */ - refresh(): void { - const rows = this.#db.select().from(laneDevices).all(); - const next = new Map(); - for (const r of rows) next.set(r.id, r.lane); - this.#byDeviceId = next; - } - - /** Lane for a device instance id, or null if the device isn't known. */ - laneFor(deviceId: string): number | null { - return this.#byDeviceId.get(deviceId) ?? null; - } -} - -/** - * The lane a reader/scanner belongs to, IF that lane has an access (barrier) - * device to open — shared by the read-driven flows (exit + permit). A read is an - * identity signal; it only drives a barrier where there's one to drive. Returns - * the lane number or null. (Distinguishing entry- vs. exit-readers per lane is a - * later lane-direction model.) - */ -export async function readerLaneWithAccess(db: Db, deviceId: string): Promise { - const row = await db.select().from(laneDevices).where(eq(laneDevices.id, deviceId)).get(); - if (!row || !row.enabled) return null; - const access = await db - .select() - .from(laneDevices) - .where(and(eq(laneDevices.category, "access"), eq(laneDevices.lane, row.lane))) - .get(); - return access && access.enabled ? row.lane : null; -} diff --git a/apps/server/src/pay-station.ts b/apps/server/src/pay-station.ts index 6b399c8..7f1f200 100644 --- a/apps/server/src/pay-station.ts +++ b/apps/server/src/pay-station.ts @@ -83,7 +83,6 @@ export class PayStation { await this.#log.append({ type: "payment", - lane: -1, // payment happens at a central station, not a lane source: "manual", identity, payload: { diff --git a/apps/server/src/permit-flow.ts b/apps/server/src/permit-flow.ts index 1f54f52..311df53 100644 --- a/apps/server/src/permit-flow.ts +++ b/apps/server/src/permit-flow.ts @@ -1,8 +1,10 @@ -import { and, eq, laneDevices, ledgerEvents, permitCredentials, permitPlates, permits, sessions, type Db } from "@parking/db"; +import { eq, ledgerEvents, permitCredentials, permitPlates, permits, sessions, type Db, type DeviceRow } from "@parking/db"; import { registry, type AccessControlDevice } from "@parking/devices"; import type { FastifyBaseLogger } from "fastify"; import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; import type { EventLog } from "./event-log.js"; +import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js"; +import { snapshotAsync } from "./snapshot.js"; // PERMIT flow: a subscriber identified by card/QR/plate enters/exits without paying. // Reached from the read dispatcher when a read matches a permit (not an open ticket). @@ -57,22 +59,23 @@ export class PermitFlow { return null; } - /** Run the permit entry/exit for a matched read at a lane. */ - async run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise { + /** Run the permit entry/exit for a matched read at a barrier. `resolved` is the + * reader's bound relay; its direction constrains, "both" defers to session state. */ + async run(resolved: ResolvedRelay, e: DeviceReadEvent, m: PermitMatch): Promise { const key = `${m.permitId}:${m.carKey}`; if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" }; this.#inFlight.add(key); try { - return await this.#run(lane, e, m); + return await this.#run(resolved, e, m); } catch (err) { - this.#logger.error(`permit-flow failed (lane ${lane}): ${(err as Error).message}`); + this.#logger.error(`permit-flow failed: ${(err as Error).message}`); return { accepted: false, reason: (err as Error).message }; } finally { this.#inFlight.delete(key); } } - async #run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise { + async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: PermitMatch): Promise { const permit = this.#db.select().from(permits).where(eq(permits.id, m.permitId)).get(); if (!permit) return { accepted: false, reason: "permit not found" }; @@ -84,23 +87,33 @@ export class PermitFlow { (permit.validTo != null && now > permit.validTo); if (invalid) { const reason = `permit ${permit.status}/out-of-window`; - await this.#reject(lane, m, reason); + await this.#reject(m, reason); return { accepted: false, reason }; } + // Direction: the car's open-session state is the natural verb (in→exit, out→entry). + // The barrier the car is at (resolved.direction) must AGREE — a car at an exit + // barrier that isn't inside (or at an entry barrier while already in) is a + // wrong-barrier / anti-passback signal, refused + logged. A "both" barrier follows + // the session state. const carOpen = this.#carHasOpenSession(m.carKey); + const inferred: FlowDirection = carOpen ? "exit" : "entry"; + if (resolved.direction !== "both" && resolved.direction !== inferred) { + const reason = `permit wrong barrier — ${resolved.direction} barrier but car would ${inferred}`; + await this.#reject(m, reason); + return { accepted: false, direction: resolved.direction === "exit" ? "exit" : "entry", reason }; + } if (carOpen) { // EXIT: this car is already inside → the read is its exit. await this.#log.append({ type: "vehicle_exit", - lane, direction: "exit", source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand", identity: m.carKey, payload: { sessionRef: m.carKey, permitId: m.permitId }, }); - await this.#open(lane, m.carKey, "permit exit"); + await this.#open(resolved, "exit", m.carKey, "permit exit"); this.#closeCache(m.carKey); return { accepted: true, direction: "exit" }; } @@ -110,14 +123,13 @@ export class PermitFlow { const open = this.#permitOpenCount(m.permitId); if (open >= permit.maxConcurrent) { const reason = `permit at capacity (${open}/${permit.maxConcurrent} cars in)`; - await this.#reject(lane, m, reason); + await this.#reject(m, reason); return { accepted: false, direction: "entry", reason }; } } await this.#log.append({ type: "vehicle_entry", - lane, direction: "entry", source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand", identity: m.carKey, @@ -125,11 +137,11 @@ export class PermitFlow { payload: { sessionRef: m.carKey, permitId: m.permitId, permit: true }, occurredAt: now, }); - await this.#open(lane, m.carKey, "permit entry"); + await this.#open(resolved, "entry", m.carKey, "permit entry"); try { this.#db .insert(sessions) - .values({ id: m.carKey, lane, identity: m.carKey, source: m.via === "plate" ? "lpr" : "wiegand", permitId: m.permitId, enteredAt: now, state: "open" }) + .values({ id: m.carKey, identity: m.carKey, source: m.via === "plate" ? "lpr" : "wiegand", permitId: m.permitId, enteredAt: now, state: "open" }) .run(); } catch (err) { this.#logger.error(`session-cache insert failed for ${m.carKey}: ${(err as Error).message}`); @@ -155,7 +167,7 @@ export class PermitFlow { const rows = this.#db .select() .from(ledgerEvents) - .where(and(eq(ledgerEvents.type, "vehicle_entry"))) + .where(eq(ledgerEvents.type, "vehicle_entry")) .all() .filter((r) => (r.payload as { permitId?: string } | null)?.permitId === permitId); let open = 0; @@ -166,20 +178,27 @@ export class PermitFlow { return open; } - async #reject(lane: number, m: PermitMatch, reason: string): Promise { + async #reject(m: PermitMatch, reason: string): Promise { await this.#log.append({ type: "anomaly", - lane, identity: m.carKey, payload: { reason: `permit refused — ${reason}`, permitId: m.permitId, permitRefused: true }, }); - this.#logger.warn(`permit refused (lane ${lane}, ${m.carKey}): ${reason}`); + this.#logger.warn(`permit refused (${m.carKey}): ${reason}`); } - async #open(lane: number, carKey: string, what: string): Promise { - const access = await this.#access(lane); - if (access) await access.pulseOpen(1); - else this.#logger.warn(`${what} signed for ${carKey} but lane ${lane} has no access device`); + async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise { + const access = this.#buildAccess(resolved.controller); + if (access) await access.pulseOpen(resolved.relay); + else this.#logger.warn(`${what} signed for ${carKey} but the ${dir} relay won't build`); + + // SNAPSHOT — fire the directional camera(s), never awaited (evidence, not a gate). + void snapshotAsync({ + db: this.#db, + direction: dir, + identity: carKey, + logger: this.#logger, + }).catch((err) => this.#logger.error(`permit snapshot error: ${(err as Error).message}`)); } #closeCache(carKey: string): void { @@ -190,13 +209,8 @@ export class PermitFlow { } } - async #access(lane: number): Promise { - const row = await this.#db - .select() - .from(laneDevices) - .where(and(eq(laneDevices.category, "access"), eq(laneDevices.lane, lane))) - .get(); - if (!row || !row.enabled) return null; + /** Build a live access adapter from a resolved controller row, or null. */ + #buildAccess(row: DeviceRow): AccessControlDevice | null { const driver = registry.get(row.driverId); if (!driver) return null; try { diff --git a/apps/server/src/printer-monitor.ts b/apps/server/src/printer-monitor.ts index 3cff478..0532314 100644 --- a/apps/server/src/printer-monitor.ts +++ b/apps/server/src/printer-monitor.ts @@ -1,5 +1,5 @@ import type { FastifyBaseLogger } from "fastify"; -import { eq, laneDevices, type Db } from "@parking/db"; +import { eq, devices, type Db } from "@parking/db"; import { isMonitorable, registry, @@ -66,8 +66,8 @@ export class PrinterMonitor { async refreshDevices(): Promise { const rows = await this.#db .select() - .from(laneDevices) - .where(eq(laneDevices.category, "printer")) + .from(devices) + .where(eq(devices.category, "printer")) .all(); const seen = new Set(); @@ -89,7 +89,6 @@ export class PrinterMonitor { build: () => driver.create(cfg as never), meta: { deviceId: row.id, - lane: row.lane, driverId: row.driverId, role: typeof cfg.role === "string" ? cfg.role : undefined, }, @@ -139,7 +138,7 @@ export class PrinterMonitor { if (!prev || statusChanged(prev.status, status)) { this.#log.info( - `printer-monitor: ${entry.meta.role ?? "printer"} ${id} (lane ${entry.meta.lane}) -> ${status.status}${status.detail ? ` (${status.detail})` : ""}`, + `printer-monitor: ${entry.meta.role ?? "printer"} ${id} -> ${status.status}${status.detail ? ` (${status.detail})` : ""}`, ); deviceEvents.emitPrinterStatus(event); } diff --git a/apps/server/src/read-dispatch.ts b/apps/server/src/read-dispatch.ts index 2163836..7dc647a 100644 --- a/apps/server/src/read-dispatch.ts +++ b/apps/server/src/read-dispatch.ts @@ -1,17 +1,22 @@ -import type { Db } from "@parking/db"; +import { devices, eq, type Db } from "@parking/db"; import type { FastifyBaseLogger } from "fastify"; import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; import type { ExitFlow } from "./exit-flow.js"; import type { PermitFlow } from "./permit-flow.js"; -import { readerLaneWithAccess } from "./lane-map.js"; +import { relayForDevice } from "./device-resolve.js"; // Routes a credential read (ticket scan / plate / card) to the right flow. A read // can mean a permit entry/exit OR a transient exit, so we dispatch by WHAT the // credential is (decision 2026-06-15): -// - matches a permit (card/QR/bound plate) → PERMIT flow (direction inferred from -// the car's open-session state), +// - matches a permit (card/QR/bound plate) → PERMIT flow, // - else → transient EXIT flow (open ticket session → exit, else reject+log). -// Lane is resolved once here; both flows act on a known access-equipped lane. +// +// The reader is BOUND to a controller relay (config.controllerId + relay), so a read +// resolves to exactly the barrier it sits at, and the direction is inherited from +// that relay (see entry-exit-points.md). The resolved relay is handed to the flow so +// it opens that exact barrier. An "entry" reader drives the entry side, an "exit" +// reader the exit side; "both" defers to the flow's own inference (permit: session +// state; transient: exit). export class ReadDispatcher { readonly #db: Db; @@ -27,16 +32,25 @@ export class ReadDispatcher { } async dispatch(e: DeviceReadEvent): Promise { - const lane = await readerLaneWithAccess(this.#db, e.deviceId); - if (lane == null) { - return { accepted: false, reason: "reader not on an access-equipped lane" }; + const reader = this.#db.select().from(devices).where(eq(devices.id, e.deviceId)).get(); + if (!reader || !reader.enabled) { + return { accepted: false, reason: "read from unknown/disabled device" }; + } + const resolved = relayForDevice(this.#db, reader); + if (!resolved) { + return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" }; } const permit = this.#permit.match(e); if (permit) { - return this.#permit.run(lane, e, permit); + return this.#permit.run(resolved, e, permit); } - // Not a permit → transient ticket exit (the exit flow rejects+logs if unknown). - return this.#exit.handleAt(lane, e); + // Not a permit → transient ticket exit. An ENTRY reader can't produce a transient + // exit (transient entry is the button flow, not a reader), so reject+log rather + // than treat an entry scan as an exit. + if (resolved.direction === "entry") { + return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" }; + } + return this.#exit.handleAt(resolved, e); } } diff --git a/apps/server/src/routes/devices.ts b/apps/server/src/routes/devices.ts index 294ec1b..05ad141 100644 --- a/apps/server/src/routes/devices.ts +++ b/apps/server/src/routes/devices.ts @@ -1,5 +1,5 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; -import { eq, laneDevices, type Db } from "@parking/db"; +import { eq, devices, type Db } from "@parking/db"; import { deviceEvents } from "../device-events.js"; import { verifyDigest } from "../digest-auth.js"; @@ -36,7 +36,7 @@ export async function deviceRoutes(app: FastifyInstance, db: Db): Promise const handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => { const { deviceId, n, edge } = req.params; - const row = await db.select().from(laneDevices).where(eq(laneDevices.id, deviceId)).get(); + const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get(); const cfg = row?.config as DingtianDeviceConfig | undefined; // Unknown device / not a dingtian / no push creds / wrong source IP → 404. diff --git a/apps/server/src/routes/qr-reader.ts b/apps/server/src/routes/qr-reader.ts index b09665c..378caee 100644 --- a/apps/server/src/routes/qr-reader.ts +++ b/apps/server/src/routes/qr-reader.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { eq, laneDevices, type Db } from "@parking/db"; +import { eq, devices, type Db } from "@parking/db"; import type { DeviceReadEvent } from "../device-events.js"; import type { ReadDispatcher } from "../read-dispatch.js"; @@ -37,7 +37,7 @@ export async function qrReaderRoutes( // reader is assigned for that serial. (Small device set → scan in JS.) const readerRowIdForSerial = (serial: string): string | null => { if (!serial) return null; - const rows = db.select().from(laneDevices).where(eq(laneDevices.category, "reader")).all(); + const rows = db.select().from(devices).where(eq(devices.category, "reader")).all(); const match = rows.find((r) => r.enabled && (r.config as { serial?: string }).serial === serial); return match?.id ?? null; }; diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index d8c29ad..48563d7 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -1,6 +1,6 @@ import { randomBytes, randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; -import { eq, laneDevices, setupState, type Db } from "@parking/db"; +import { eq, devices, setupState, type Db } from "@parking/db"; import { hasPreconditions, hasPushConfig, @@ -10,6 +10,7 @@ import { registry, setDeviceLogSink, type DeviceCategory, + type DeviceConfig, } from "@parking/devices"; import { requireRole } from "../auth.js"; import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js"; @@ -18,10 +19,12 @@ import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js" // per lane. See wiki/concepts/first-run-setup.md. interface AssignBody { - lane: number; category: DeviceCategory; driverId: string; - config: Record; + // Driver config (opaque JSON, validated by the driver). Carries the model's + // direction/binding: access → config.relays=[{relay,direction,button?}]; + // reader/camera → config.controllerId + config.relay. See entry-exit-points.md. + config: DeviceConfig; /** Optional: the backend IP the device should push to (overrides auto-pick; * matters on multi-NIC hosts). */ backendIp?: string; @@ -48,13 +51,7 @@ function redactSecrets(config: Record): Record return out; } -export async function setupRoutes( - app: FastifyInstance, - db: Db, - // Called after the set of assignments changes (assign/unassign) so the caller - // can refresh anything derived from it — e.g. the device id->lane map. - onAssignmentsChanged: () => void = () => {}, -): Promise { +export async function setupRoutes(app: FastifyInstance, db: Db): Promise { registerBuiltinDrivers(); setDeviceLogSink((line) => app.log.info(line)); @@ -110,7 +107,7 @@ export async function setupRoutes( { preHandler: adminGuard }, async () => { const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get(); - const rows = await db.select().from(laneDevices).all(); + const rows = await db.select().from(devices).all(); const assignments = rows.map((r) => ({ ...r, config: redactSecrets(r.config) })); return { completedAt: state?.completedAt ?? null, assignments }; }, @@ -154,15 +151,15 @@ export async function setupRoutes( }, ); - // Assign a device to a lane. Validates the chosen driver + config, configures - // the device (fix preconditions + set up Digest-authenticated input push — no - // manual device-web-UI step by the admin), then persists. Fails the save if - // the device can't be configured. See wiki/concepts/device-input-flow.md. + // Assign a device. Validates the chosen driver + config, configures the device + // (fix preconditions + set up Digest-authenticated input push — no manual device- + // web-UI step by the admin), then persists. Fails the save if the device can't be + // configured. See wiki/concepts/device-input-flow.md, entry-exit-points.md. app.post<{ Body: AssignBody }>( "/api/setup/assign", { preHandler: adminGuard }, async (req, reply) => { - const { lane, category, driverId, config, backendIp } = req.body; + const { category, driverId, config, backendIp } = req.body; const driver = registry.get(driverId); if (!driver || driver.category !== category) { return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` }); @@ -252,14 +249,12 @@ export async function setupRoutes( const row = { id, - lane, category, driverId, config: fullConfig, enabled: true, }; - await db.insert(laneDevices).values(row); - onAssignmentsChanged(); // refresh derived state (device->lane map) + await db.insert(devices).values(row); // Don't echo device secrets back (push Digest password, web-UI login, …). return reply.code(201).send({ ...row, @@ -285,13 +280,12 @@ export async function setupRoutes( async (req, reply) => { const existing = await db .select() - .from(laneDevices) - .where(eq(laneDevices.id, req.params.id)) + .from(devices) + .where(eq(devices.id, req.params.id)) .get(); if (!existing) return reply.code(404).send({ error: "no such device assignment" }); - await db.delete(laneDevices).where(eq(laneDevices.id, req.params.id)); - onAssignmentsChanged(); // refresh derived state (device->lane map) - app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId}, lane ${existing.lane})`); + await db.delete(devices).where(eq(devices.id, req.params.id)); + app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId})`); return reply.code(204).send(); }, ); diff --git a/apps/server/src/routes/snapshots.ts b/apps/server/src/routes/snapshots.ts new file mode 100644 index 0000000..f0e116c --- /dev/null +++ b/apps/server/src/routes/snapshots.ts @@ -0,0 +1,49 @@ +import type { FastifyInstance } from "fastify"; +import { desc, eq, snapshots, type Db } from "@parking/db"; +import { requireRole } from "../auth.js"; + +// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see +// packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence +// tied to a signed vehicle_entry/exit by `identity`; the operator reviews them +// next to the event. Read-only — images are written only by the flows (snapshot.ts), +// never via the API. + +export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise { + const guard = requireRole("admin", "operator", "cashier", "readonly"); + + // Snapshot metadata for one session/credential identity (NOT the bytes), newest + // first — lets the UI show "entry/exit image" links beside an event. + app.get<{ Params: { identity: string } }>( + "/api/snapshots/by-identity/:identity", + { preHandler: guard }, + async (req) => { + const rows = db + .select({ + id: snapshots.id, + direction: snapshots.direction, + deviceId: snapshots.deviceId, + identity: snapshots.identity, + contentType: snapshots.contentType, + capturedAt: snapshots.capturedAt, + }) + .from(snapshots) + .where(eq(snapshots.identity, req.params.identity)) + .orderBy(desc(snapshots.capturedAt)) + .all(); + return { snapshots: rows }; + }, + ); + + // Stream one snapshot's image bytes by id. Returns the stored content type. + app.get<{ Params: { id: string } }>( + "/api/snapshots/:id", + { preHandler: guard }, + async (req, reply) => { + const row = db.select().from(snapshots).where(eq(snapshots.id, req.params.id)).get(); + if (!row) return reply.code(404).send({ error: "no such snapshot" }); + reply.header("content-type", row.contentType); + reply.header("cache-control", "private, max-age=31536000, immutable"); + return reply.send(row.bytes); + }, + ); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c2778f3..498579b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -12,7 +12,6 @@ import { PayStation } from "./pay-station.js"; import { PermitFlow } from "./permit-flow.js"; import { ShiftService } from "./shift-service.js"; import { ReadDispatcher } from "./read-dispatch.js"; -import { LaneMap } from "./lane-map.js"; import { PrinterMonitor } from "./printer-monitor.js"; import { buildSigner } from "./signer.js"; import { authRoutes } from "./routes/auth.js"; @@ -23,6 +22,7 @@ import { permitRoutes } from "./routes/permits.js"; import { qrReaderRoutes } from "./routes/qr-reader.js"; import { shiftRoutes } from "./routes/shift.js"; import { siteRoutes } from "./routes/site.js"; +import { snapshotRoutes } from "./routes/snapshots.js"; import { tariffRoutes } from "./routes/tariffs.js"; import { printerRoutes } from "./routes/printers.js"; import { setupRoutes } from "./routes/setup.js"; @@ -61,15 +61,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise lane resolver. Built from lane_devices at startup and refreshed - // by setupRoutes on assign/unassign, so device events can be stamped with the - // lane the device belongs to (events carry the device id, not a lane). - const laneMap = new LaneMap(db); - laneMap.refresh(); - - // Device-agnostic setup: the admin selects devices per lane from the driver - // catalog at first-run. See wiki/concepts/first-run-setup.md. - await setupRoutes(app, db, () => laneMap.refresh()); + // Device-agnostic setup: the admin adds controllers (with their relays + entry + // button) and binds readers/cameras to a controller relay at first-run. There is + // no lane — a parking lot is one pool with a flexible set of entry/exit points. + // See wiki/concepts/first-run-setup.md, entry-exit-points.md. + await setupRoutes(app, db); // Inbound device pushes (e.g. Dingtian Input Link URL → button events), // guarded by source-IP allowlist + a shared-secret path token, both read from @@ -93,11 +89,14 @@ export async function buildServer(opts: BuildOptions = {}): Promise { void entryFlow.onInput(e); }); @@ -141,19 +140,14 @@ export async function buildServer(opts: BuildOptions = {}): Promise { - // Resolve which lane the device belongs to. -1 marks "device fired but isn't - // mapped to a lane" (assigned without a lane, or a stale id) — still recorded - // faithfully rather than silently dropped or mis-stamped as lane 0 (a real lane). - const lane = laneMap.laneFor(e.deviceId) ?? -1; - if (lane === -1) { - app.log.warn(`input from unmapped device ${e.driverId}:${e.deviceId} — logged as lane -1`); - } + // Record every input edge as unsigned telemetry, keyed to the device that fired + // (provenance). No lane — the pool-of-spaces model has none. The entry flow + // (above) independently decides whether this edge is an entry button. try { db.insert(deviceEventsTable) .values({ id: randomUUID(), deviceId: e.deviceId, - lane, category: "access", kind: "input", detail: { driverId: e.driverId, input: e.input, edge: e.edge }, @@ -166,7 +160,5 @@ export async function buildServer(opts: BuildOptions = {}): Promise unsubscribeInput()); - // TODO: entry flow (device input → signed vehicle_entry → print → relay). - return app; } diff --git a/apps/server/src/shift-service.ts b/apps/server/src/shift-service.ts index b5ab2a9..467fd7a 100644 --- a/apps/server/src/shift-service.ts +++ b/apps/server/src/shift-service.ts @@ -1,4 +1,4 @@ -import { eq, laneDevices, ledgerEvents, type Db } from "@parking/db"; +import { eq, devices, ledgerEvents, type Db } from "@parking/db"; import { registry, type PrinterDevice } from "@parking/devices"; import type { LedgerPayload } from "@parking/shared"; import type { FastifyBaseLogger } from "fastify"; @@ -66,7 +66,6 @@ export class ShiftService { const startedAt = new Date().toISOString(); await this.#log.append({ type: "shift_open", - lane: -1, source: "manual", identity: operator, // the shift's operator; `identity` keys the shift to them payload: { operator }, @@ -105,7 +104,6 @@ export class ShiftService { await this.#log.append({ type: "shift_z_report", - lane: -1, source: "manual", identity: operator, payload: { @@ -163,9 +161,9 @@ export class ShiftService { } } - /** First enabled booth-receipt printer (any lane), or any enabled printer. */ + /** First enabled booth-receipt printer, or any enabled printer. */ async #boothPrinter(): Promise { - const rows = await this.#db.select().from(laneDevices).where(eq(laneDevices.category, "printer")).all(); + const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all(); const enabled = rows.filter((r) => r.enabled); const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0]; if (!booth) return null; diff --git a/apps/server/src/signer.ts b/apps/server/src/signer.ts index 6ac4ac5..918f318 100644 --- a/apps/server/src/signer.ts +++ b/apps/server/src/signer.ts @@ -14,7 +14,10 @@ export class SoftwareSigner implements Signer { readonly keyId: string; readonly #key: Buffer; - constructor(secret: string, keyId = "sw-hmac-v1") { + // v2 canonical form: `lane` dropped from the signed array (pool-of-spaces model, + // 2026-06-16). v1 events used a different field order and won't verify under v2 — + // that's intentional and gated by the per-event keyId. See event-log canonicalize(). + constructor(secret: string, keyId = "sw-hmac-v2") { this.#key = Buffer.from(secret, "utf8"); this.keyId = keyId; } diff --git a/apps/server/src/snapshot.ts b/apps/server/src/snapshot.ts new file mode 100644 index 0000000..7423a8b --- /dev/null +++ b/apps/server/src/snapshot.ts @@ -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 { + 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 => { + 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, + 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}`); + } +} diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index 7cd0802..b1f385a 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -12,28 +12,41 @@ import { type Catalog, type CatalogEntry, type DeviceCategory, + type DeviceConfig, + type Direction, type DiscoveredDevice, + type RelaySpec, type TestResult, } from "./api.js"; -// First-run setup wizard (scaffold). The admin assigns devices per lane from the -// driver catalog. The data model is multi-instance — one lane_devices row per -// instance — so EVERY category supports more than one device: each section lists -// the already-assigned instances (with Remove) and an "Add" form. Drivers that -// support LAN discovery get a "Scan" button. Auth is via the admin's session -// cookie. See wiki/concepts/first-run-setup.md and device-discovery.md. +// First-run setup wizard. The pool-of-spaces model: a parking lot is one pool with +// a flexible set of entry/exit points — NO lane. The admin adds CONTROLLERS (each +// declares its relays = entry/exit/both + which input terminal the entry button is +// on), then binds READERS / CAMERAS to a controller relay (the barrier they sit at). +// Direction is a property of the relay, inherited by bound devices. The data model +// is multi-instance — one `devices` row per instance. See entry-exit-points.md. -const CATEGORIES: { key: DeviceCategory; title: string; noun: string }[] = [ - { key: "access", title: "Access controllers", noun: "access controller" }, - { key: "reader", title: "Readers", noun: "reader" }, - { key: "camera", title: "Cameras (entry/exit snapshot)", noun: "camera" }, - { key: "printer", title: "Printers", noun: "printer" }, +const CONTROLLER: { key: DeviceCategory; title: string; noun: string } = { + key: "access", + title: "Controllers (barriers + entry button)", + noun: "controller", +}; +// Categories that BIND to a controller relay (direction inherited from the relay). +const BOUND: { key: DeviceCategory; title: string; noun: string }[] = [ + { key: "reader", title: "Readers (QR / RFID)", noun: "reader" }, + { key: "camera", title: "Cameras (snapshot + plate)", noun: "camera" }, + { key: "printer", title: "Printers (tickets / vouchers)", noun: "printer" }, ]; +const DIRECTION_LABELS: Record = { + entry: "Entry", + exit: "Exit", + both: "Both (entry + exit)", +}; + export function SetupWizard() { const [catalog, setCatalog] = useState(null); const [assignments, setAssignments] = useState(null); - const [lane, setLane] = useState(1); const [error, setError] = useState(null); const reloadState = useCallback(() => { @@ -50,36 +63,41 @@ export function SetupWizard() { if (error) return

Failed to load setup: {error}

; if (!catalog || !assignments) return

Loading device catalog…

; + // Controllers are needed before binding readers/cameras (they pick a controller relay). + const controllers = assignments.filter((a) => a.category === "access"); + return (

First-run setup

-
- - - Devices are added per lane. Switch lanes to configure another. - -
+

+ Add your barrier controllers first — set which relay is entry/exit and which + terminal the entry button is wired to. Then add readers, cameras and printers + and point each at the barrier it serves. +

- {CATEGORIES.map(({ key, title, noun }) => ( + + + {BOUND.map(({ key, title, noun }) => ( a.category === key && a.lane === lane)} + controllers={controllers} + assignments={assignments.filter((a) => a.category === key)} onChanged={reloadState} /> ))} @@ -88,39 +106,37 @@ export function SetupWizard() { } function CategorySection({ - lane, category, title, noun, entries, discoverableIds, pushCapableIds, + controllers, assignments, onChanged, }: { - lane: number; category: DeviceCategory; title: string; noun: string; entries: CatalogEntry[]; discoverableIds: string[]; pushCapableIds: string[]; + controllers: Assignment[]; assignments: Assignment[]; onChanged: () => Promise | void; }) { - // Show the add-form automatically when nothing is assigned yet; otherwise it's - // collapsed behind "Add another" so the list stays the focus. const [adding, setAdding] = useState(false); - // Warnings from the most recent save (e.g. "string protocol could not be - // disabled — finish in the device web UI"). Persist after the form closes. const [warnings, setWarnings] = useState([]); const showForm = adding || assignments.length === 0; + // Binding categories need a controller to point at first. + const isBound = category !== "access"; + const blockedNoController = isBound && controllers.length === 0; + return (
- - {title} · lane {lane} - + {title} {warnings.length > 0 && (
0 && (
    {assignments.map((a) => ( - + ))}
)} - {showForm ? ( + {blockedNoController ? ( +

Add a controller first — a {noun} points at one of its relays.

+ ) : showForm ? ( { setWarnings(w); await onChanged(); @@ -177,17 +195,17 @@ function CategorySection({ function AssignmentRow({ assignment, + controllers, onChanged, }: { assignment: Assignment; + controllers: Assignment[]; onChanged: () => Promise | void; }) { const [removing, setRemoving] = useState(false); const [error, setError] = useState(null); - // A short, human summary of the instance: role (if any) + host. - const cfg = assignment.config; - const role = typeof cfg.role === "string" ? cfg.role : null; + const cfg = assignment.config as Record; const host = typeof cfg.host === "string" ? cfg.host : null; async function remove() { @@ -214,8 +232,8 @@ function AssignmentRow({ }} > {assignment.driverId} - {role && {role}} {host && {host}} + {!assignment.enabled && (disabled)} {error && {error}} @@ -226,33 +244,66 @@ function AssignmentRow({ ); } +/** Inline summary of an assignment's direction/binding for the list. */ +function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) { + const cfg = assignment.config as Record; + if (assignment.category === "access") { + const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : []; + if (relays.length === 0) return no relays set; + return ( + + {relays.map((r) => ( + + ))} + + ); + } + // Bound device: show controller + relay it points at, with inherited direction. + const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null; + const relay = typeof cfg.relay === "number" ? cfg.relay : null; + if (!controllerId || relay == null) return unbound; + const controller = controllers.find((c) => c.id === controllerId); + const spec = controller + ? (((controller.config as Record).relays as RelaySpec[]) ?? []).find((r) => r.relay === relay) + : undefined; + return ( + + ); +} + function DeviceForm({ - lane, category, entries, discoverableIds, pushCapableIds, + controllers, onSaved, onCancel, }: { - lane: number; category: DeviceCategory; entries: CatalogEntry[]; discoverableIds: string[]; pushCapableIds: string[]; + controllers: Assignment[]; onSaved: (warnings: string[]) => Promise | void; onCancel?: () => void; }) { const [selectedId, setSelectedId] = useState(""); const selected = entries.find((e) => e.id === selectedId); const canDiscover = selected != null && discoverableIds.includes(selected.id); - // Only push-capable drivers (e.g. the Dingtian relay) call back to the - // backend and need a backend IP. Pull-only devices (cameras, commanded relays) - // must NOT show the field. See wiki/concepts/device-input-flow.md. const pushesToBackend = selected != null && pushCapableIds.includes(selected.id); + const isController = category === "access"; - // Config values (auto-filled by discovery, editable by hand). const [config, setConfig] = useState>({}); + // Controllers: the relay map (which relay = entry/exit/both, + entry button terminal). + const [relays, setRelays] = useState([{ relay: 1, direction: "both" }]); + // Bound devices: which controller + relay this device sits at. + const [controllerId, setControllerId] = useState(""); + const [boundRelay, setBoundRelay] = useState(""); + const [tested, setTested] = useState(null); const [testing, setTesting] = useState(false); const [testError, setTestError] = useState(null); @@ -262,17 +313,10 @@ function DeviceForm({ const [scanning, setScanning] = useState(false); const [scanError, setScanError] = useState(null); - // Backend push IP: which of OUR addresses the device should call back on. We - // auto-pick the NIC on the device's subnet, but surface it editable here so a - // multi-NIC host can be corrected (the chosen IP is baked into the device on - // save). Only relevant for drivers that push back to us (pushesToBackend). const [backendIps, setBackendIps] = useState(null); const [backendIp, setBackendIp] = useState(""); - // (Re)load backend-IP candidates whenever the device host changes after a - // successful test (the test confirms the host is real + reachable) — but only - // for push-capable drivers; a pull-only device never calls back. - const testedHost = tested ? String(mergedConfig().host ?? "") : ""; + const testedHost = tested ? String(mergedScalarConfig().host ?? "") : ""; useEffect(() => { if (!testedHost || !pushesToBackend) { setBackendIps(null); @@ -319,8 +363,8 @@ function DeviceForm({ resetStatus(); } - // Config the user actually entered, merged over driver defaults. - function mergedConfig(): Record { + /** Scalar config the user entered, merged over driver defaults (for test/push-IP). */ + function mergedScalarConfig(): Record { const out: Record = {}; for (const f of selected?.configFields ?? []) { const v = config[f.key] ?? (f.default as string | number | undefined); @@ -329,7 +373,22 @@ function DeviceForm({ return out; } - // Editing config invalidates a prior test. + /** Full config to persist: scalars + the model's direction/binding fields. */ + function mergedConfig(): DeviceConfig { + const out: DeviceConfig = { ...mergedScalarConfig() }; + if (isController) { + out.relays = relays.map((r) => ({ + relay: r.relay, + direction: r.direction, + ...(r.button ? { button: r.button } : {}), + })); + } else if (controllerId && boundRelay !== "") { + out.controllerId = controllerId; + out.relay = boundRelay; + } + return out; + } + function resetStatus() { setTested(null); setTestError(null); @@ -342,7 +401,7 @@ function DeviceForm({ setTestError(null); setTested(null); try { - setTested(await testDevice(selected.id, mergedConfig())); + setTested(await testDevice(selected.id, mergedScalarConfig())); } catch (e) { setTestError((e as Error).message); } finally { @@ -352,17 +411,21 @@ function DeviceForm({ async function save() { if (!selected) return; + // Bound devices must point at a controller relay (binding is optional in the + // model with a fallback, but the wizard guides the admin to bind explicitly). + if (!isController && (!controllerId || boundRelay === "")) { + setSaveError("Pick the controller and relay this device sits at."); + return; + } setSaving(true); setSaveError(null); try { const result = await assignDevice({ - lane, category, driverId: selected.id, config: mergedConfig(), ...(backendIp ? { backendIp } : {}), }); - // Hand warnings to the parent so they persist after this form unmounts. await onSaved(result.warnings ?? []); } catch (e) { setSaveError((e as Error).message); @@ -452,6 +515,23 @@ function DeviceForm({
))} + {/* CONTROLLER: the relay map — which relay opens which direction + entry button. */} + {isController && } + + {/* BOUND device: which controller + relay it sits at. */} + {!isController && ( + { + setControllerId(id); + setBoundRelay(""); + }} + onRelayChange={setBoundRelay} + /> + )} + {/* Test (no save/no device change) then Save (configures + persists). */}
)} - {/* Backend push IP — only for push-capable devices (candidates present). - Pre-filled with the auto-pick; editable for multi-NIC hosts. */} {backendIps && backendIps.length > 0 && (