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:
2026-06-16 20:29:38 +02:00
parent 15d3e1ba08
commit 1efa77bf56
46 changed files with 1221 additions and 1167 deletions
+7 -8
View File
@@ -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);
}
+155
View File
@@ -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";
}
+42 -53
View File
@@ -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<string>();
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<void> {
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<void> {
async #runEntry(resolved: ResolvedRelay): Promise<void> {
// 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<AccessControlDevice | null> {
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<PrinterInstance[]> {
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<string, unknown>;
-5
View File
@@ -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<string, unknown> | 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,
+24 -28
View File
@@ -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<ReadOutcome> {
/** 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<ReadOutcome> {
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<ReadOutcome> {
async #runExit(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
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<AccessControlDevice | null> {
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 {
-48
View File
@@ -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<string, number>();
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<string, number>();
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<number | null> {
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;
}
-1
View File
@@ -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: {
+42 -28
View File
@@ -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<ReadOutcome> {
/** 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<ReadOutcome> {
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<ReadOutcome> {
async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> {
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<void> {
async #reject(m: PermitMatch, reason: string): Promise<void> {
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<void> {
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<void> {
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<AccessControlDevice | null> {
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 {
+4 -5
View File
@@ -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<void> {
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<string>();
@@ -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);
}
+25 -11
View File
@@ -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<ReadOutcome> {
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);
}
}
+2 -2
View File
@@ -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<void>
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.
+2 -2
View File
@@ -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;
};
+18 -24
View File
@@ -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<string, string | number | boolean>;
// 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<string, unknown>): Record<string, unknown>
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<void> {
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
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();
},
);
+49
View File
@@ -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<void> {
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);
},
);
}
+13 -21
View File
@@ -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<FastifyInsta
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
await authRoutes(app, db);
// device id -> 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<FastifyInsta
const eventLog = new EventLog(db, buildSigner(app.log));
await eventRoutes(app, db, eventLog);
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
await snapshotRoutes(app, db);
// Entry flow: a button press → print ticket → signed vehicle_entry → pulseOpen.
// Subscribes to the SAME input bus as the telemetry writer below; the two are
// independent (telemetry always records; the entry flow acts only on an access
// device's rising edge). See wiki/concepts/device-input-flow.md + parking-session.md.
const entryFlow = new EntryFlow(db, eventLog, laneMap, app.log);
const entryFlow = new EntryFlow(db, eventLog, app.log);
const unsubscribeEntry = deviceEvents.onInput((e) => {
void entryFlow.onInput(e);
});
@@ -141,19 +140,14 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
await siteRoutes(app, db);
const unsubscribeInput = deviceEvents.onInput((e) => {
// 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<FastifyInsta
});
app.addHook("onClose", async () => unsubscribeInput());
// TODO: entry flow (device input → signed vehicle_entry → print → relay).
return app;
}
+3 -5
View File
@@ -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<PrinterDevice | null> {
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;
+4 -1
View File
@@ -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;
}
+115
View File
@@ -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}`);
}
}