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 { export interface DeviceInputEvent {
readonly driverId: string; // e.g. "dingtian" 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 input: number; // 1-based input/channel
readonly edge: "on" | "off"; // active / inactive readonly edge: "on" | "off"; // active / inactive
readonly at: string; // ISO-8601 (server receive time) readonly at: string; // ISO-8601 (server receive time)
readonly source: "push" | "poll"; readonly source: "push" | "poll";
} }
// A credential read at a lane: a ticket scanned at exit, a plate from LPR, a card // A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader.
// at a reader. Drives identity-based flows (exit validation, and later permits / // Drives identity-based flows (exit validation, permits, pay-station lookup). `kind`
// pay-station lookup). `kind` mirrors IdentitySource. See parking-session.md. // mirrors IdentitySource. See parking-session.md.
export interface DeviceReadEvent { export interface DeviceReadEvent {
readonly driverId: string; 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 value: string; // the ticket id / plate / card number
readonly kind: "ticket" | "plate" | "qr" | "card"; readonly kind: "ticket" | "plate" | "qr" | "card";
readonly at: string; // ISO-8601 readonly at: string; // ISO-8601
@@ -42,8 +42,7 @@ export interface ReadOutcome {
/** A printer's status as tracked by the live monitor (status + identity). */ /** A printer's status as tracked by the live monitor (status + identity). */
export interface PrinterStatusEvent { export interface PrinterStatusEvent {
readonly deviceId: string; // lane_devices id readonly deviceId: string; // devices id
readonly lane: number;
readonly driverId: string; readonly driverId: string;
readonly role?: string; // entry-dispenser | booth-receipt readonly role?: string; // entry-dispenser | booth-receipt
readonly status: PrinterStatus; readonly status: PrinterStatus;
@@ -58,7 +57,7 @@ class DeviceEventBus extends EventEmitter {
return () => this.off("input", cb); 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 { emitRead(event: DeviceReadEvent): void {
this.emit("read", event); 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 { randomUUID } from "node:crypto";
import { and, eq, laneDevices, sessions, type Db } from "@parking/db"; import { sessions, type Db, type DeviceRow } from "@parking/db";
import { import {
NoPrinterAvailableError, NoPrinterAvailableError,
printWithFailover, printWithFailover,
@@ -13,11 +13,13 @@ import type { FastifyBaseLogger } from "fastify";
import type { DeviceInputEvent } from "./device-events.js"; import type { DeviceInputEvent } from "./device-events.js";
import { getOccupancy } from "./occupancy.js"; import { getOccupancy } from "./occupancy.js";
import type { EventLog } from "./event-log.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 // 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 // → open the barrier. The button is wired into an access controller's input; the
// (wiki/concepts/device-input-flow.md "the entry flow itself is the next build"). // 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: // Two invariants from the threat model + safety analysis:
// 1. SIGNED BEFORE OPEN — the vehicle_entry is appended to the signed ledger // 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 // 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). // "entered" event for a car that didn't get in (decision 2026-06-15).
// //
// Ordering, therefore: print → (ok) sign vehicle_entry → pulseOpen → cache session. // Ordering: print → (ok) sign vehicle_entry → pulseOpen → snapshot → cache session.
// (fail) sign anomaly, stop. // (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;
}
export class EntryFlow { export class EntryFlow {
readonly #db: Db; readonly #db: Db;
readonly #log: EventLog; readonly #log: EventLog;
readonly #laneMap: LaneMap;
readonly #logger: FastifyBaseLogger; readonly #logger: FastifyBaseLogger;
/** Guard against double-fire from the same physical press (on edge only). */ /** Guard against double-fire from the same physical press (on edge only). */
readonly #inFlight = new Set<string>(); 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.#db = db;
this.#log = log; this.#log = log;
this.#laneMap = laneMap;
this.#logger = logger; this.#logger = logger;
} }
/** Handle a device input edge. Acts only on the rising ("on") edge of an entry /** 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> { async onInput(e: DeviceInputEvent): Promise<void> {
if (e.edge !== "on") return; // release edge is just telemetry if (e.edge !== "on") return; // release edge is just telemetry
const lane = this.#laneMap.laneFor(e.deviceId); // The firing device must be an access controller, and the pressed input terminal
if (lane == null) return; // unmapped device — telemetry already recorded, no entry // 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.
// Only treat this as an entry trigger if the firing device IS the lane's const resolved = relayForButton(this.#db, e.deviceId, e.input);
// access controller (a reader/printer input edge isn't an entry button). if (!resolved) return;
const access = await this.#loadAccess(lane, e.deviceId);
if (!access) return;
const key = `${e.deviceId}:${e.input}`; const key = `${e.deviceId}:${e.input}`;
if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing
this.#inFlight.add(key); this.#inFlight.add(key);
try { try {
await this.#runEntry(lane, e.input, access); await this.#runEntry(resolved);
} catch (err) { } 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 { } finally {
this.#inFlight.delete(key); 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: // 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 // 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 // gated here (their flow ignores site-full; their own maxConcurrent applies), so
@@ -87,24 +80,23 @@ export class EntryFlow {
if (occ.full) { if (occ.full) {
await this.#log.append({ await this.#log.append({
type: "anomaly", type: "anomaly",
lane,
payload: { reason: `transient entry refused — lot full (${occ.count}/${occ.capacity})`, entryRefused: true, full: true }, 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; return;
} }
const ticketId = newTicketId(); const ticketId = newTicketId();
const issuedAt = new Date().toISOString(); 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. // 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 { try {
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) => const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
d.printTicket(ticket), 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) { } catch (err) {
// HOLD: do not open, do not record a vehicle_entry. Sign an anomaly so the // 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. // 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; err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
await this.#log.append({ await this.#log.append({
type: "anomaly", type: "anomaly",
lane,
identity: ticketId, identity: ticketId,
payload: { reason: `entry held — ticket not printed: ${reason}`, ticketPrinted: false }, 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; return;
} }
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant). // 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
await this.#log.append({ await this.#log.append({
type: "vehicle_entry", type: "vehicle_entry",
lane,
direction: "entry", direction: "entry",
source: "ticket", source: "ticket",
identity: ticketId, identity: ticketId,
@@ -131,15 +121,26 @@ export class EntryFlow {
occurredAt: issuedAt, occurredAt: issuedAt,
}); });
// 3. OPEN the barrier (intent only; the barrier owns the close). // 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
await access.pulseOpen(doorForInput(input)); 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 // 4. Update the session projection cache (rebuildable from the ledger; this is
// just a fast read-model, never the source of truth). // just a fast read-model, never the source of truth).
try { try {
this.#db this.#db
.insert(sessions) .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(); .run();
} catch (err) { } catch (err) {
// Cache miss is non-fatal — the ledger is authoritative and the projection // 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 /** Build a live access adapter from a resolved controller row, or null. */
* button). Returns a live adapter or null. */ #buildAccess(row: DeviceRow): AccessControlDevice | 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;
const driver = registry.get(row.driverId); const driver = registry.get(row.driverId);
if (!driver) return null; if (!driver) return null;
try { try {
@@ -166,16 +160,11 @@ export class EntryFlow {
} }
} }
/** Build live printer instances for a lane (for failover selection). */ /** Build live ENTRY printer instances (for failover selection). */
async #loadPrinters(lane: number): Promise<PrinterInstance[]> { #loadPrinters(): PrinterInstance[] {
const rows = await this.#db const rows = devicesByDirection(this.#db, "printer", "entry"); // already enabled-filtered
.select()
.from(laneDevices)
.where(and(eq(laneDevices.category, "printer"), eq(laneDevices.lane, lane)))
.all();
const out: PrinterInstance[] = []; const out: PrinterInstance[] = [];
for (const row of rows) { for (const row of rows) {
if (!row.enabled) continue;
const driver = registry.get(row.driverId); const driver = registry.get(row.driverId);
if (!driver) continue; if (!driver) continue;
const cfg = row.config as Record<string, unknown>; 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 { export interface AppendInput {
readonly type: LedgerEventType; readonly type: LedgerEventType;
readonly lane: number;
readonly direction?: Direction | null; readonly direction?: Direction | null;
readonly source?: IdentitySource | null; readonly source?: IdentitySource | null;
readonly identity?: string | null; readonly identity?: string | null;
@@ -38,7 +37,6 @@ export function canonicalize(e: {
index: number; index: number;
type: string; type: string;
direction: string | null; direction: string | null;
lane: number;
source: string | null; source: string | null;
identity: string | null; identity: string | null;
payload: Record<string, unknown> | null; payload: Record<string, unknown> | null;
@@ -49,7 +47,6 @@ export function canonicalize(e: {
e.index, e.index,
e.type, e.type,
e.direction ?? null, e.direction ?? null,
e.lane,
e.source ?? null, e.source ?? null,
e.identity ?? null, e.identity ?? null,
// Payload is part of the signed form so business data is tamper-evident. // Payload is part of the signed form so business data is tamper-evident.
@@ -120,7 +117,6 @@ export class EventLog {
index, index,
type: input.type, type: input.type,
direction: input.direction ?? null, direction: input.direction ?? null,
lane: input.lane,
source: input.source ?? null, source: input.source ?? null,
identity: input.identity ?? null, identity: input.identity ?? null,
payload, payload,
@@ -133,7 +129,6 @@ export class EventLog {
index, index,
type: input.type, type: input.type,
direction: input.direction ?? null, direction: input.direction ?? null,
lane: input.lane,
source: input.source ?? null, source: input.source ?? null,
identity: input.identity ?? null, identity: input.identity ?? null,
payload, 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 { 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 { LedgerPayload } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
@@ -25,7 +27,6 @@ import type { EventLog } from "./event-log.js";
interface SessionView { interface SessionView {
readonly identity: string; readonly identity: string;
readonly lane: number;
readonly enteredAt: string; readonly enteredAt: string;
readonly open: boolean; // no vehicle_exit yet readonly open: boolean; // no vehicle_exit yet
readonly paidAt: string | null; // latest payment time, if any readonly paidAt: string | null; // latest payment time, if any
@@ -44,23 +45,23 @@ export class ExitFlow {
this.#logger = logger; this.#logger = logger;
} }
/** Handle a transient-ticket read at a known exit lane (lane pre-resolved by the /** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the
* read dispatcher, which has already ruled out a permit match). */ * read dispatcher from the reader's binding, which has ruled out a permit match). */
async handleAt(lane: number, e: DeviceReadEvent): Promise<ReadOutcome> { async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
const key = `${e.deviceId}:${e.value}`; const key = `${e.deviceId}:${e.value}`;
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" }; if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
this.#inFlight.add(key); this.#inFlight.add(key);
try { try {
return await this.#runExit(lane, e); return await this.#runExit(resolved, e);
} catch (err) { } 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 }; return { accepted: false, reason: (err as Error).message };
} finally { } finally {
this.#inFlight.delete(key); 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); const view = this.#sessionFor(e.value);
// No matching open session — unknown/duplicate ticket. Reject + log. // 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"; const reason = view ? "exit refused — session already closed" : "exit refused — no open session for credential";
await this.#log.append({ await this.#log.append({
type: "anomaly", type: "anomaly",
lane,
identity: e.value, identity: e.value,
payload: { reason, exitRefused: true }, 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 }; return { accepted: false, direction: "exit", reason };
} }
@@ -89,30 +89,33 @@ export class ExitFlow {
: "exit refused — walk-back grace expired (top-up required)"; : "exit refused — walk-back grace expired (top-up required)";
await this.#log.append({ await this.#log.append({
type: "anomaly", type: "anomaly",
lane,
identity: e.value, identity: e.value,
payload: { reason, exitRefused: true, sessionRef: 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 }; return { accepted: false, direction: "exit", reason };
} }
// Valid: sign the exit BEFORE opening, then open, then update the cache. // Valid: sign the exit BEFORE opening, then open, then update the cache.
await this.#log.append({ await this.#log.append({
type: "vehicle_exit", type: "vehicle_exit",
lane,
direction: "exit", direction: "exit",
source: e.kind === "plate" ? "lpr" : "ticket", source: e.kind === "plate" ? "lpr" : "ticket",
identity: e.value, identity: e.value,
payload: { sessionRef: e.value }, payload: { sessionRef: e.value },
}); });
const access = await this.#exitAccess(lane); const access = this.#buildAccess(resolved.controller);
if (access) { if (access) await access.pulseOpen(resolved.relay);
await access.pulseOpen(1); // exit barrier; door mapping is config-driven later else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`);
} else {
this.#logger.warn(`exit signed for ${e.value} but lane ${lane} has no access device to open`); // 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 { try {
this.#db this.#db
@@ -152,7 +155,6 @@ export class ExitFlow {
return { return {
identity, identity,
lane: entry.lane,
enteredAt: entry.occurredAt, enteredAt: entry.occurredAt,
open: !exited, open: !exited,
paidAt, paidAt,
@@ -160,14 +162,8 @@ export class ExitFlow {
}; };
} }
/** The lane's access device, to open the exit barrier. */ /** Build a live access adapter from a resolved controller row, or null. */
async #exitAccess(lane: number): Promise<AccessControlDevice | null> { #buildAccess(row: DeviceRow): 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;
const driver = registry.get(row.driverId); const driver = registry.get(row.driverId);
if (!driver) return null; if (!driver) return null;
try { 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({ await this.#log.append({
type: "payment", type: "payment",
lane: -1, // payment happens at a central station, not a lane
source: "manual", source: "manual",
identity, identity,
payload: { 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 { registry, type AccessControlDevice } from "@parking/devices";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { EventLog } from "./event-log.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. // 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). // Reached from the read dispatcher when a read matches a permit (not an open ticket).
@@ -57,22 +59,23 @@ export class PermitFlow {
return null; return null;
} }
/** Run the permit entry/exit for a matched read at a lane. */ /** Run the permit entry/exit for a matched read at a barrier. `resolved` is the
async run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> { * 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}`; const key = `${m.permitId}:${m.carKey}`;
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" }; if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
this.#inFlight.add(key); this.#inFlight.add(key);
try { try {
return await this.#run(lane, e, m); return await this.#run(resolved, e, m);
} catch (err) { } 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 }; return { accepted: false, reason: (err as Error).message };
} finally { } finally {
this.#inFlight.delete(key); 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(); const permit = this.#db.select().from(permits).where(eq(permits.id, m.permitId)).get();
if (!permit) return { accepted: false, reason: "permit not found" }; if (!permit) return { accepted: false, reason: "permit not found" };
@@ -84,23 +87,33 @@ export class PermitFlow {
(permit.validTo != null && now > permit.validTo); (permit.validTo != null && now > permit.validTo);
if (invalid) { if (invalid) {
const reason = `permit ${permit.status}/out-of-window`; const reason = `permit ${permit.status}/out-of-window`;
await this.#reject(lane, m, reason); await this.#reject(m, reason);
return { accepted: false, 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 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) { if (carOpen) {
// EXIT: this car is already inside → the read is its exit. // EXIT: this car is already inside → the read is its exit.
await this.#log.append({ await this.#log.append({
type: "vehicle_exit", type: "vehicle_exit",
lane,
direction: "exit", direction: "exit",
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand", source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
identity: m.carKey, identity: m.carKey,
payload: { sessionRef: m.carKey, permitId: m.permitId }, 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); this.#closeCache(m.carKey);
return { accepted: true, direction: "exit" }; return { accepted: true, direction: "exit" };
} }
@@ -110,14 +123,13 @@ export class PermitFlow {
const open = this.#permitOpenCount(m.permitId); const open = this.#permitOpenCount(m.permitId);
if (open >= permit.maxConcurrent) { if (open >= permit.maxConcurrent) {
const reason = `permit at capacity (${open}/${permit.maxConcurrent} cars in)`; 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 }; return { accepted: false, direction: "entry", reason };
} }
} }
await this.#log.append({ await this.#log.append({
type: "vehicle_entry", type: "vehicle_entry",
lane,
direction: "entry", direction: "entry",
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand", source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
identity: m.carKey, identity: m.carKey,
@@ -125,11 +137,11 @@ export class PermitFlow {
payload: { sessionRef: m.carKey, permitId: m.permitId, permit: true }, payload: { sessionRef: m.carKey, permitId: m.permitId, permit: true },
occurredAt: now, occurredAt: now,
}); });
await this.#open(lane, m.carKey, "permit entry"); await this.#open(resolved, "entry", m.carKey, "permit entry");
try { try {
this.#db this.#db
.insert(sessions) .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(); .run();
} catch (err) { } catch (err) {
this.#logger.error(`session-cache insert failed for ${m.carKey}: ${(err as Error).message}`); 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 const rows = this.#db
.select() .select()
.from(ledgerEvents) .from(ledgerEvents)
.where(and(eq(ledgerEvents.type, "vehicle_entry"))) .where(eq(ledgerEvents.type, "vehicle_entry"))
.all() .all()
.filter((r) => (r.payload as { permitId?: string } | null)?.permitId === permitId); .filter((r) => (r.payload as { permitId?: string } | null)?.permitId === permitId);
let open = 0; let open = 0;
@@ -166,20 +178,27 @@ export class PermitFlow {
return open; return open;
} }
async #reject(lane: number, m: PermitMatch, reason: string): Promise<void> { async #reject(m: PermitMatch, reason: string): Promise<void> {
await this.#log.append({ await this.#log.append({
type: "anomaly", type: "anomaly",
lane,
identity: m.carKey, identity: m.carKey,
payload: { reason: `permit refused — ${reason}`, permitId: m.permitId, permitRefused: true }, 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> { async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise<void> {
const access = await this.#access(lane); const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(1); if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`${what} signed for ${carKey} but lane ${lane} has no access device`); 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 { #closeCache(carKey: string): void {
@@ -190,13 +209,8 @@ export class PermitFlow {
} }
} }
async #access(lane: number): Promise<AccessControlDevice | null> { /** Build a live access adapter from a resolved controller row, or null. */
const row = await this.#db #buildAccess(row: DeviceRow): AccessControlDevice | null {
.select()
.from(laneDevices)
.where(and(eq(laneDevices.category, "access"), eq(laneDevices.lane, lane)))
.get();
if (!row || !row.enabled) return null;
const driver = registry.get(row.driverId); const driver = registry.get(row.driverId);
if (!driver) return null; if (!driver) return null;
try { try {
+4 -5
View File
@@ -1,5 +1,5 @@
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import { eq, laneDevices, type Db } from "@parking/db"; import { eq, devices, type Db } from "@parking/db";
import { import {
isMonitorable, isMonitorable,
registry, registry,
@@ -66,8 +66,8 @@ export class PrinterMonitor {
async refreshDevices(): Promise<void> { async refreshDevices(): Promise<void> {
const rows = await this.#db const rows = await this.#db
.select() .select()
.from(laneDevices) .from(devices)
.where(eq(laneDevices.category, "printer")) .where(eq(devices.category, "printer"))
.all(); .all();
const seen = new Set<string>(); const seen = new Set<string>();
@@ -89,7 +89,6 @@ export class PrinterMonitor {
build: () => driver.create(cfg as never), build: () => driver.create(cfg as never),
meta: { meta: {
deviceId: row.id, deviceId: row.id,
lane: row.lane,
driverId: row.driverId, driverId: row.driverId,
role: typeof cfg.role === "string" ? cfg.role : undefined, role: typeof cfg.role === "string" ? cfg.role : undefined,
}, },
@@ -139,7 +138,7 @@ export class PrinterMonitor {
if (!prev || statusChanged(prev.status, status)) { if (!prev || statusChanged(prev.status, status)) {
this.#log.info( 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); 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 { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { ExitFlow } from "./exit-flow.js"; import type { ExitFlow } from "./exit-flow.js";
import type { PermitFlow } from "./permit-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 // 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 // can mean a permit entry/exit OR a transient exit, so we dispatch by WHAT the
// credential is (decision 2026-06-15): // credential is (decision 2026-06-15):
// - matches a permit (card/QR/bound plate) → PERMIT flow (direction inferred from // - matches a permit (card/QR/bound plate) → PERMIT flow,
// the car's open-session state),
// - else → transient EXIT flow (open ticket session → exit, else reject+log). // - 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 { export class ReadDispatcher {
readonly #db: Db; readonly #db: Db;
@@ -27,16 +32,25 @@ export class ReadDispatcher {
} }
async dispatch(e: DeviceReadEvent): Promise<ReadOutcome> { async dispatch(e: DeviceReadEvent): Promise<ReadOutcome> {
const lane = await readerLaneWithAccess(this.#db, e.deviceId); const reader = this.#db.select().from(devices).where(eq(devices.id, e.deviceId)).get();
if (lane == null) { if (!reader || !reader.enabled) {
return { accepted: false, reason: "reader not on an access-equipped lane" }; 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); const permit = this.#permit.match(e);
if (permit) { 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). // Not a permit → transient ticket exit. An ENTRY reader can't produce a transient
return this.#exit.handleAt(lane, e); // 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 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 { deviceEvents } from "../device-events.js";
import { verifyDigest } from "../digest-auth.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 handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => {
const { deviceId, n, edge } = req.params; 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; const cfg = row?.config as DingtianDeviceConfig | undefined;
// Unknown device / not a dingtian / no push creds / wrong source IP → 404. // 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 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 { DeviceReadEvent } from "../device-events.js";
import type { ReadDispatcher } from "../read-dispatch.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.) // reader is assigned for that serial. (Small device set → scan in JS.)
const readerRowIdForSerial = (serial: string): string | null => { const readerRowIdForSerial = (serial: string): string | null => {
if (!serial) return 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); const match = rows.find((r) => r.enabled && (r.config as { serial?: string }).serial === serial);
return match?.id ?? null; return match?.id ?? null;
}; };
+18 -24
View File
@@ -1,6 +1,6 @@
import { randomBytes, randomUUID } from "node:crypto"; import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { eq, laneDevices, setupState, type Db } from "@parking/db"; import { eq, devices, setupState, type Db } from "@parking/db";
import { import {
hasPreconditions, hasPreconditions,
hasPushConfig, hasPushConfig,
@@ -10,6 +10,7 @@ import {
registry, registry,
setDeviceLogSink, setDeviceLogSink,
type DeviceCategory, type DeviceCategory,
type DeviceConfig,
} from "@parking/devices"; } from "@parking/devices";
import { requireRole } from "../auth.js"; import { requireRole } from "../auth.js";
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.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. // per lane. See wiki/concepts/first-run-setup.md.
interface AssignBody { interface AssignBody {
lane: number;
category: DeviceCategory; category: DeviceCategory;
driverId: string; 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; /** Optional: the backend IP the device should push to (overrides auto-pick;
* matters on multi-NIC hosts). */ * matters on multi-NIC hosts). */
backendIp?: string; backendIp?: string;
@@ -48,13 +51,7 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
return out; return out;
} }
export async function setupRoutes( export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
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> {
registerBuiltinDrivers(); registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line)); setDeviceLogSink((line) => app.log.info(line));
@@ -110,7 +107,7 @@ export async function setupRoutes(
{ preHandler: adminGuard }, { preHandler: adminGuard },
async () => { async () => {
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get(); 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) })); const assignments = rows.map((r) => ({ ...r, config: redactSecrets(r.config) }));
return { completedAt: state?.completedAt ?? null, assignments }; 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 // Assign a device. Validates the chosen driver + config, configures the device
// the device (fix preconditions + set up Digest-authenticated input push — no // (fix preconditions + set up Digest-authenticated input push — no manual device-
// manual device-web-UI step by the admin), then persists. Fails the save if // web-UI step by the admin), then persists. Fails the save if the device can't be
// the device can't be configured. See wiki/concepts/device-input-flow.md. // configured. See wiki/concepts/device-input-flow.md, entry-exit-points.md.
app.post<{ Body: AssignBody }>( app.post<{ Body: AssignBody }>(
"/api/setup/assign", "/api/setup/assign",
{ preHandler: adminGuard }, { preHandler: adminGuard },
async (req, reply) => { async (req, reply) => {
const { lane, category, driverId, config, backendIp } = req.body; const { category, driverId, config, backendIp } = req.body;
const driver = registry.get(driverId); const driver = registry.get(driverId);
if (!driver || driver.category !== category) { if (!driver || driver.category !== category) {
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` }); return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
@@ -252,14 +249,12 @@ export async function setupRoutes(
const row = { const row = {
id, id,
lane,
category, category,
driverId, driverId,
config: fullConfig, config: fullConfig,
enabled: true, enabled: true,
}; };
await db.insert(laneDevices).values(row); await db.insert(devices).values(row);
onAssignmentsChanged(); // refresh derived state (device->lane map)
// Don't echo device secrets back (push Digest password, web-UI login, …). // Don't echo device secrets back (push Digest password, web-UI login, …).
return reply.code(201).send({ return reply.code(201).send({
...row, ...row,
@@ -285,13 +280,12 @@ export async function setupRoutes(
async (req, reply) => { async (req, reply) => {
const existing = await db const existing = await db
.select() .select()
.from(laneDevices) .from(devices)
.where(eq(laneDevices.id, req.params.id)) .where(eq(devices.id, req.params.id))
.get(); .get();
if (!existing) return reply.code(404).send({ error: "no such device assignment" }); if (!existing) return reply.code(404).send({ error: "no such device assignment" });
await db.delete(laneDevices).where(eq(laneDevices.id, req.params.id)); await db.delete(devices).where(eq(devices.id, req.params.id));
onAssignmentsChanged(); // refresh derived state (device->lane map) app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId})`);
app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId}, lane ${existing.lane})`);
return reply.code(204).send(); 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 { PermitFlow } from "./permit-flow.js";
import { ShiftService } from "./shift-service.js"; import { ShiftService } from "./shift-service.js";
import { ReadDispatcher } from "./read-dispatch.js"; import { ReadDispatcher } from "./read-dispatch.js";
import { LaneMap } from "./lane-map.js";
import { PrinterMonitor } from "./printer-monitor.js"; import { PrinterMonitor } from "./printer-monitor.js";
import { buildSigner } from "./signer.js"; import { buildSigner } from "./signer.js";
import { authRoutes } from "./routes/auth.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 { qrReaderRoutes } from "./routes/qr-reader.js";
import { shiftRoutes } from "./routes/shift.js"; import { shiftRoutes } from "./routes/shift.js";
import { siteRoutes } from "./routes/site.js"; import { siteRoutes } from "./routes/site.js";
import { snapshotRoutes } from "./routes/snapshots.js";
import { tariffRoutes } from "./routes/tariffs.js"; import { tariffRoutes } from "./routes/tariffs.js";
import { printerRoutes } from "./routes/printers.js"; import { printerRoutes } from "./routes/printers.js";
import { setupRoutes } from "./routes/setup.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. // Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
await authRoutes(app, db); await authRoutes(app, db);
// device id -> lane resolver. Built from lane_devices at startup and refreshed // Device-agnostic setup: the admin adds controllers (with their relays + entry
// by setupRoutes on assign/unassign, so device events can be stamped with the // button) and binds readers/cameras to a controller relay at first-run. There is
// lane the device belongs to (events carry the device id, not a lane). // no lane — a parking lot is one pool with a flexible set of entry/exit points.
const laneMap = new LaneMap(db); // See wiki/concepts/first-run-setup.md, entry-exit-points.md.
laneMap.refresh(); await setupRoutes(app, db);
// 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());
// Inbound device pushes (e.g. Dingtian Input Link URL → button events), // Inbound device pushes (e.g. Dingtian Input Link URL → button events),
// guarded by source-IP allowlist + a shared-secret path token, both read from // 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)); const eventLog = new EventLog(db, buildSigner(app.log));
await eventRoutes(app, db, eventLog); 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. // 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 // 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 // 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. // 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) => { const unsubscribeEntry = deviceEvents.onInput((e) => {
void entryFlow.onInput(e); void entryFlow.onInput(e);
}); });
@@ -141,19 +140,14 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
await siteRoutes(app, db); await siteRoutes(app, db);
const unsubscribeInput = deviceEvents.onInput((e) => { const unsubscribeInput = deviceEvents.onInput((e) => {
// Resolve which lane the device belongs to. -1 marks "device fired but isn't // Record every input edge as unsigned telemetry, keyed to the device that fired
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded // (provenance). No lane — the pool-of-spaces model has none. The entry flow
// faithfully rather than silently dropped or mis-stamped as lane 0 (a real lane). // (above) independently decides whether this edge is an entry button.
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`);
}
try { try {
db.insert(deviceEventsTable) db.insert(deviceEventsTable)
.values({ .values({
id: randomUUID(), id: randomUUID(),
deviceId: e.deviceId, deviceId: e.deviceId,
lane,
category: "access", category: "access",
kind: "input", kind: "input",
detail: { driverId: e.driverId, input: e.input, edge: e.edge }, 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()); app.addHook("onClose", async () => unsubscribeInput());
// TODO: entry flow (device input → signed vehicle_entry → print → relay).
return app; 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 { registry, type PrinterDevice } from "@parking/devices";
import type { LedgerPayload } from "@parking/shared"; import type { LedgerPayload } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
@@ -66,7 +66,6 @@ export class ShiftService {
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
await this.#log.append({ await this.#log.append({
type: "shift_open", type: "shift_open",
lane: -1,
source: "manual", source: "manual",
identity: operator, // the shift's operator; `identity` keys the shift to them identity: operator, // the shift's operator; `identity` keys the shift to them
payload: { operator }, payload: { operator },
@@ -105,7 +104,6 @@ export class ShiftService {
await this.#log.append({ await this.#log.append({
type: "shift_z_report", type: "shift_z_report",
lane: -1,
source: "manual", source: "manual",
identity: operator, identity: operator,
payload: { 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> { 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 enabled = rows.filter((r) => r.enabled);
const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0]; const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0];
if (!booth) return null; if (!booth) return null;
+4 -1
View File
@@ -14,7 +14,10 @@ export class SoftwareSigner implements Signer {
readonly keyId: string; readonly keyId: string;
readonly #key: Buffer; 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.#key = Buffer.from(secret, "utf8");
this.keyId = keyId; 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}`);
}
}
+301 -68
View File
@@ -12,28 +12,41 @@ import {
type Catalog, type Catalog,
type CatalogEntry, type CatalogEntry,
type DeviceCategory, type DeviceCategory,
type DeviceConfig,
type Direction,
type DiscoveredDevice, type DiscoveredDevice,
type RelaySpec,
type TestResult, type TestResult,
} from "./api.js"; } from "./api.js";
// First-run setup wizard (scaffold). The admin assigns devices per lane from the // First-run setup wizard. The pool-of-spaces model: a parking lot is one pool with
// driver catalog. The data model is multi-instance — one lane_devices row per // a flexible set of entry/exit points — NO lane. The admin adds CONTROLLERS (each
// instance — so EVERY category supports more than one device: each section lists // declares its relays = entry/exit/both + which input terminal the entry button is
// the already-assigned instances (with Remove) and an "Add" form. Drivers that // on), then binds READERS / CAMERAS to a controller relay (the barrier they sit at).
// support LAN discovery get a "Scan" button. Auth is via the admin's session // Direction is a property of the relay, inherited by bound devices. The data model
// cookie. See wiki/concepts/first-run-setup.md and device-discovery.md. // is multi-instance — one `devices` row per instance. See entry-exit-points.md.
const CATEGORIES: { key: DeviceCategory; title: string; noun: string }[] = [ const CONTROLLER: { key: DeviceCategory; title: string; noun: string } = {
{ key: "access", title: "Access controllers", noun: "access controller" }, key: "access",
{ key: "reader", title: "Readers", noun: "reader" }, title: "Controllers (barriers + entry button)",
{ key: "camera", title: "Cameras (entry/exit snapshot)", noun: "camera" }, noun: "controller",
{ key: "printer", title: "Printers", noun: "printer" }, };
// 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<Direction, string> = {
entry: "Entry",
exit: "Exit",
both: "Both (entry + exit)",
};
export function SetupWizard() { export function SetupWizard() {
const [catalog, setCatalog] = useState<Catalog | null>(null); const [catalog, setCatalog] = useState<Catalog | null>(null);
const [assignments, setAssignments] = useState<Assignment[] | null>(null); const [assignments, setAssignments] = useState<Assignment[] | null>(null);
const [lane, setLane] = useState(1);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const reloadState = useCallback(() => { const reloadState = useCallback(() => {
@@ -50,36 +63,41 @@ export function SetupWizard() {
if (error) return <p style={{ color: "crimson" }}>Failed to load setup: {error}</p>; if (error) return <p style={{ color: "crimson" }}>Failed to load setup: {error}</p>;
if (!catalog || !assignments) return <p>Loading device catalog…</p>; if (!catalog || !assignments) return <p>Loading device catalog…</p>;
// Controllers are needed before binding readers/cameras (they pick a controller relay).
const controllers = assignments.filter((a) => a.category === "access");
return ( return (
<section> <section>
<h2>First-run setup</h2> <h2>First-run setup</h2>
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}> <p style={{ color: "#666", fontSize: "0.9em" }}>
<label> Add your barrier controllers first — set which relay is entry/exit and which
Lane{" "} terminal the entry button is wired to. Then add readers, cameras and printers
<input and point each at the barrier it serves.
type="number" </p>
min={1}
value={lane}
onChange={(e) => setLane(Number(e.target.value))}
style={{ width: "4rem" }}
/>
</label>
<span style={{ color: "#666", fontSize: "0.85em" }}>
Devices are added per lane. Switch lanes to configure another.
</span>
</div>
{CATEGORIES.map(({ key, title, noun }) => ( <CategorySection
category={CONTROLLER.key}
title={CONTROLLER.title}
noun={CONTROLLER.noun}
entries={catalog[CONTROLLER.key]}
discoverableIds={catalog.discoverable}
pushCapableIds={catalog.pushCapable}
controllers={controllers}
assignments={controllers}
onChanged={reloadState}
/>
{BOUND.map(({ key, title, noun }) => (
<CategorySection <CategorySection
key={key} key={key}
lane={lane}
category={key} category={key}
title={title} title={title}
noun={noun} noun={noun}
entries={catalog[key]} entries={catalog[key]}
discoverableIds={catalog.discoverable} discoverableIds={catalog.discoverable}
pushCapableIds={catalog.pushCapable} pushCapableIds={catalog.pushCapable}
assignments={assignments.filter((a) => a.category === key && a.lane === lane)} controllers={controllers}
assignments={assignments.filter((a) => a.category === key)}
onChanged={reloadState} onChanged={reloadState}
/> />
))} ))}
@@ -88,39 +106,37 @@ export function SetupWizard() {
} }
function CategorySection({ function CategorySection({
lane,
category, category,
title, title,
noun, noun,
entries, entries,
discoverableIds, discoverableIds,
pushCapableIds, pushCapableIds,
controllers,
assignments, assignments,
onChanged, onChanged,
}: { }: {
lane: number;
category: DeviceCategory; category: DeviceCategory;
title: string; title: string;
noun: string; noun: string;
entries: CatalogEntry[]; entries: CatalogEntry[];
discoverableIds: string[]; discoverableIds: string[];
pushCapableIds: string[]; pushCapableIds: string[];
controllers: Assignment[];
assignments: Assignment[]; assignments: Assignment[];
onChanged: () => Promise<void> | void; onChanged: () => Promise<void> | 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); 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<string[]>([]); const [warnings, setWarnings] = useState<string[]>([]);
const showForm = adding || assignments.length === 0; 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 ( return (
<fieldset style={{ marginTop: "1rem" }}> <fieldset style={{ marginTop: "1rem" }}>
<legend> <legend>{title}</legend>
{title} <span style={{ color: "#888", fontWeight: 400 }}>· lane {lane}</span>
</legend>
{warnings.length > 0 && ( {warnings.length > 0 && (
<div <div
@@ -147,18 +163,20 @@ function CategorySection({
{assignments.length > 0 && ( {assignments.length > 0 && (
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}> <ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
{assignments.map((a) => ( {assignments.map((a) => (
<AssignmentRow key={a.id} assignment={a} onChanged={onChanged} /> <AssignmentRow key={a.id} assignment={a} controllers={controllers} onChanged={onChanged} />
))} ))}
</ul> </ul>
)} )}
{showForm ? ( {blockedNoController ? (
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p>
) : showForm ? (
<DeviceForm <DeviceForm
lane={lane}
category={category} category={category}
entries={entries} entries={entries}
discoverableIds={discoverableIds} discoverableIds={discoverableIds}
pushCapableIds={pushCapableIds} pushCapableIds={pushCapableIds}
controllers={controllers}
onSaved={async (w) => { onSaved={async (w) => {
setWarnings(w); setWarnings(w);
await onChanged(); await onChanged();
@@ -177,17 +195,17 @@ function CategorySection({
function AssignmentRow({ function AssignmentRow({
assignment, assignment,
controllers,
onChanged, onChanged,
}: { }: {
assignment: Assignment; assignment: Assignment;
controllers: Assignment[];
onChanged: () => Promise<void> | void; onChanged: () => Promise<void> | void;
}) { }) {
const [removing, setRemoving] = useState(false); const [removing, setRemoving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// A short, human summary of the instance: role (if any) + host. const cfg = assignment.config as Record<string, unknown>;
const cfg = assignment.config;
const role = typeof cfg.role === "string" ? cfg.role : null;
const host = typeof cfg.host === "string" ? cfg.host : null; const host = typeof cfg.host === "string" ? cfg.host : null;
async function remove() { async function remove() {
@@ -214,8 +232,8 @@ function AssignmentRow({
}} }}
> >
<strong>{assignment.driverId}</strong> <strong>{assignment.driverId}</strong>
{role && <span style={{ color: "#0369a1" }}>{role}</span>}
{host && <span style={{ color: "#666" }}>{host}</span>} {host && <span style={{ color: "#666" }}>{host}</span>}
<DeviceSummary assignment={assignment} controllers={controllers} />
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>} {!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
{error && <span style={{ color: "crimson" }}>{error}</span>} {error && <span style={{ color: "crimson" }}>{error}</span>}
@@ -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<string, unknown>;
if (assignment.category === "access") {
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
if (relays.length === 0) return <em style={{ color: "#b45309" }}>no relays set</em>;
return (
<span style={{ display: "flex", gap: "0.35rem" }}>
{relays.map((r) => (
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} />
))}
</span>
);
}
// 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 <em style={{ color: "#b45309" }}>unbound</em>;
const controller = controllers.find((c) => c.id === controllerId);
const spec = controller
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? []).find((r) => r.relay === relay)
: undefined;
return (
<DirectionBadge
direction={spec?.direction ?? "both"}
label={`${controller ? controller.driverId : "?"} · R${relay}`}
/>
);
}
function DeviceForm({ function DeviceForm({
lane,
category, category,
entries, entries,
discoverableIds, discoverableIds,
pushCapableIds, pushCapableIds,
controllers,
onSaved, onSaved,
onCancel, onCancel,
}: { }: {
lane: number;
category: DeviceCategory; category: DeviceCategory;
entries: CatalogEntry[]; entries: CatalogEntry[];
discoverableIds: string[]; discoverableIds: string[];
pushCapableIds: string[]; pushCapableIds: string[];
controllers: Assignment[];
onSaved: (warnings: string[]) => Promise<void> | void; onSaved: (warnings: string[]) => Promise<void> | void;
onCancel?: () => void; onCancel?: () => void;
}) { }) {
const [selectedId, setSelectedId] = useState<string>(""); const [selectedId, setSelectedId] = useState<string>("");
const selected = entries.find((e) => e.id === selectedId); const selected = entries.find((e) => e.id === selectedId);
const canDiscover = selected != null && discoverableIds.includes(selected.id); 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 pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
const isController = category === "access";
// Config values (auto-filled by discovery, editable by hand).
const [config, setConfig] = useState<Record<string, string | number>>({}); const [config, setConfig] = useState<Record<string, string | number>>({});
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
const [relays, setRelays] = useState<RelaySpec[]>([{ relay: 1, direction: "both" }]);
// Bound devices: which controller + relay this device sits at.
const [controllerId, setControllerId] = useState<string>("");
const [boundRelay, setBoundRelay] = useState<number | "">("");
const [tested, setTested] = useState<TestResult | null>(null); const [tested, setTested] = useState<TestResult | null>(null);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null); const [testError, setTestError] = useState<string | null>(null);
@@ -262,17 +313,10 @@ function DeviceForm({
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [scanError, setScanError] = useState<string | null>(null); const [scanError, setScanError] = useState<string | null>(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<BackendIpCandidate[] | null>(null); const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
const [backendIp, setBackendIp] = useState<string>(""); const [backendIp, setBackendIp] = useState<string>("");
// (Re)load backend-IP candidates whenever the device host changes after a const testedHost = tested ? String(mergedScalarConfig().host ?? "") : "";
// 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 ?? "") : "";
useEffect(() => { useEffect(() => {
if (!testedHost || !pushesToBackend) { if (!testedHost || !pushesToBackend) {
setBackendIps(null); setBackendIps(null);
@@ -319,8 +363,8 @@ function DeviceForm({
resetStatus(); resetStatus();
} }
// Config the user actually entered, merged over driver defaults. /** Scalar config the user entered, merged over driver defaults (for test/push-IP). */
function mergedConfig(): Record<string, string | number> { function mergedScalarConfig(): Record<string, string | number> {
const out: Record<string, string | number> = {}; const out: Record<string, string | number> = {};
for (const f of selected?.configFields ?? []) { for (const f of selected?.configFields ?? []) {
const v = config[f.key] ?? (f.default as string | number | undefined); const v = config[f.key] ?? (f.default as string | number | undefined);
@@ -329,7 +373,22 @@ function DeviceForm({
return out; 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() { function resetStatus() {
setTested(null); setTested(null);
setTestError(null); setTestError(null);
@@ -342,7 +401,7 @@ function DeviceForm({
setTestError(null); setTestError(null);
setTested(null); setTested(null);
try { try {
setTested(await testDevice(selected.id, mergedConfig())); setTested(await testDevice(selected.id, mergedScalarConfig()));
} catch (e) { } catch (e) {
setTestError((e as Error).message); setTestError((e as Error).message);
} finally { } finally {
@@ -352,17 +411,21 @@ function DeviceForm({
async function save() { async function save() {
if (!selected) return; 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); setSaving(true);
setSaveError(null); setSaveError(null);
try { try {
const result = await assignDevice({ const result = await assignDevice({
lane,
category, category,
driverId: selected.id, driverId: selected.id,
config: mergedConfig(), config: mergedConfig(),
...(backendIp ? { backendIp } : {}), ...(backendIp ? { backendIp } : {}),
}); });
// Hand warnings to the parent so they persist after this form unmounts.
await onSaved(result.warnings ?? []); await onSaved(result.warnings ?? []);
} catch (e) { } catch (e) {
setSaveError((e as Error).message); setSaveError((e as Error).message);
@@ -452,6 +515,23 @@ function DeviceForm({
</div> </div>
))} ))}
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
{/* BOUND device: which controller + relay it sits at. */}
{!isController && (
<BindingPicker
controllers={controllers}
controllerId={controllerId}
relay={boundRelay}
onControllerChange={(id) => {
setControllerId(id);
setBoundRelay("");
}}
onRelayChange={setBoundRelay}
/>
)}
{/* Test (no save/no device change) then Save (configures + persists). */} {/* Test (no save/no device change) then Save (configures + persists). */}
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", alignItems: "center" }}> <div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", alignItems: "center" }}>
<button type="button" onClick={test} disabled={testing}> <button type="button" onClick={test} disabled={testing}>
@@ -487,8 +567,6 @@ function DeviceForm({
</div> </div>
)} )}
{/* 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 && ( {backendIps && backendIps.length > 0 && (
<div style={{ margin: "0.5rem 0 0" }}> <div style={{ margin: "0.5rem 0 0" }}>
<label> <label>
@@ -523,6 +601,161 @@ function DeviceForm({
); );
} }
/** Controller relay map editor: each row = a relay + its direction + (optional)
* the input terminal its entry button is wired to. */
function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) {
function update(i: number, patch: Partial<RelaySpec>) {
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
}
function add() {
const nextRelay = (relays.reduce((m, r) => Math.max(m, r.relay), 0) || 0) + 1;
onChange([...relays, { relay: nextRelay, direction: "both" }]);
}
function remove(i: number) {
onChange(relays.filter((_, idx) => idx !== i));
}
return (
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
<strong style={{ fontSize: "0.9em" }}>Relays on this controller</strong>
<p style={{ margin: "0.15rem 0 0.5rem", color: "#666", fontSize: "0.8em" }}>
Each relay opens one barrier. Set its direction; for transient entry, set which input
terminal the entry button is wired to.
</p>
{relays.map((r, i) => (
<div key={i} style={{ display: "flex", gap: "0.5rem", alignItems: "center", margin: "0.25rem 0" }}>
<label>
Relay{" "}
<input
type="number"
min={1}
value={r.relay}
style={{ width: "3.5rem" }}
onChange={(e) => update(i, { relay: Number(e.target.value) })}
/>
</label>
<select value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}>
{(["entry", "exit", "both"] as Direction[]).map((d) => (
<option key={d} value={d}>
{DIRECTION_LABELS[d]}
</option>
))}
</select>
{(r.direction === "entry" || r.direction === "both") && (
<label>
Entry button on terminal{" "}
<input
type="number"
min={1}
value={r.button ?? ""}
placeholder="—"
style={{ width: "3.5rem" }}
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
)}
{relays.length > 1 && (
<button type="button" onClick={() => remove(i)}>
✕
</button>
)}
</div>
))}
<button type="button" onClick={add} style={{ marginTop: "0.25rem" }}>
+ Add relay
</button>
</div>
);
}
/** Binding picker for readers/cameras/printers: choose the controller + relay this
* device sits at. Direction is inherited from the chosen relay (shown). */
function BindingPicker({
controllers,
controllerId,
relay,
onControllerChange,
onRelayChange,
}: {
controllers: Assignment[];
controllerId: string;
relay: number | "";
onControllerChange: (id: string) => void;
onRelayChange: (relay: number) => void;
}) {
const controller = controllers.find((c) => c.id === controllerId);
const relays: RelaySpec[] = controller
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? [])
: [];
const chosen = relays.find((r) => r.relay === relay);
return (
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
<strong style={{ fontSize: "0.9em" }}>Which barrier does this device serve?</strong>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.35rem", flexWrap: "wrap" }}>
<label>
Controller{" "}
<select value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
<option value="" disabled>
Choose…
</option>
{controllers.map((c) => {
const host = (c.config as Record<string, unknown>).host;
return (
<option key={c.id} value={c.id}>
{c.driverId}
{typeof host === "string" ? ` (${host})` : ""}
</option>
);
})}
</select>
</label>
<label>
Relay{" "}
<select
value={relay === "" ? "" : String(relay)}
disabled={!controller}
onChange={(e) => onRelayChange(Number(e.target.value))}
>
<option value="" disabled>
Choose…
</option>
{relays.map((r) => (
<option key={r.relay} value={r.relay}>
Relay {r.relay} ({DIRECTION_LABELS[r.direction]})
</option>
))}
</select>
</label>
{chosen && <DirectionBadge direction={chosen.direction} label={`inherits ${chosen.direction}`} />}
</div>
{controller && relays.length === 0 && (
<p style={{ margin: "0.35rem 0 0", color: "#b45309", fontSize: "0.85em" }}>
This controller has no relays configured.
</p>
)}
</div>
);
}
function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) {
const color = direction === "entry" ? "#15803d" : direction === "exit" ? "#b45309" : "#6b7280";
return (
<span
style={{
color,
border: `1px solid ${color}`,
borderRadius: 4,
padding: "0 0.35rem",
fontSize: "0.75em",
fontWeight: 600,
}}
>
{label ?? direction}
</span>
);
}
function HealthBadge({ status }: { status: string }) { function HealthBadge({ status }: { status: string }) {
const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626"; const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626";
return <span style={{ color, fontWeight: 600 }}>● {status}</span>; return <span style={{ color, fontWeight: 600 }}>● {status}</span>;
+22 -3
View File
@@ -120,7 +120,26 @@ export async function discoverDevices(driverId: string): Promise<DiscoveredDevic
return body.devices; return body.devices;
} }
export type DeviceConfig = Record<string, string | number | boolean>; export type ConfigValue =
| string
| number
| boolean
| null
| ConfigValue[]
| { [k: string]: ConfigValue };
export type DeviceConfig = Record<string, ConfigValue>;
/** Direction a barrier/relay (or a device bound to it) serves. */
export type Direction = "entry" | "exit" | "both";
/** 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 {
relay: number;
direction: Direction;
/** Input terminal of the entry button that fires this relay (transient entry). */
button?: number;
}
export interface TestResult { export interface TestResult {
health: { status: string; detail?: string }; health: { status: string; detail?: string };
@@ -153,9 +172,10 @@ export function fetchBackendIps(
} }
export interface AssignBody { export interface AssignBody {
lane: number;
category: DeviceCategory; category: DeviceCategory;
driverId: string; driverId: string;
// Direction/binding lives in config: access → config.relays=[{relay,direction,button?}];
// reader/camera → config.controllerId + config.relay.
config: DeviceConfig; config: DeviceConfig;
/** Backend IP the device should push to (overrides auto-pick). */ /** Backend IP the device should push to (overrides auto-pick). */
backendIp?: string; backendIp?: string;
@@ -169,7 +189,6 @@ export function assignDevice(body: AssignBody): Promise<AssignResult> {
/** A persisted device assignment (one per instance; machine-only secrets stripped). */ /** A persisted device assignment (one per instance; machine-only secrets stripped). */
export interface Assignment { export interface Assignment {
id: string; id: string;
lane: number;
category: DeviceCategory; category: DeviceCategory;
driverId: string; driverId: string;
config: DeviceConfig; config: DeviceConfig;
+17 -5
View File
@@ -11,16 +11,14 @@ CREATE TABLE `blocklist` (
CREATE TABLE `device_events` ( CREATE TABLE `device_events` (
`id` text PRIMARY KEY NOT NULL, `id` text PRIMARY KEY NOT NULL,
`device_id` text, `device_id` text,
`lane` integer,
`category` text, `category` text,
`kind` text NOT NULL, `kind` text NOT NULL,
`detail` text, `detail` text,
`occurred_at` text DEFAULT (current_timestamp) NOT NULL `occurred_at` text DEFAULT (current_timestamp) NOT NULL
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `lane_devices` ( CREATE TABLE `devices` (
`id` text PRIMARY KEY NOT NULL, `id` text PRIMARY KEY NOT NULL,
`lane` integer NOT NULL,
`category` text NOT NULL, `category` text NOT NULL,
`driver_id` text NOT NULL, `driver_id` text NOT NULL,
`config` text NOT NULL, `config` text NOT NULL,
@@ -33,7 +31,6 @@ CREATE TABLE `ledger_events` (
`index` integer NOT NULL, `index` integer NOT NULL,
`type` text NOT NULL, `type` text NOT NULL,
`direction` text, `direction` text,
`lane` integer NOT NULL,
`source` text, `source` text,
`identity` text, `identity` text,
`payload` text, `payload` text,
@@ -70,7 +67,6 @@ CREATE TABLE `permits` (
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `sessions` ( CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL, `id` text PRIMARY KEY NOT NULL,
`lane` integer,
`identity` text, `identity` text,
`source` text, `source` text,
`permit_id` text, `permit_id` text,
@@ -85,6 +81,22 @@ CREATE TABLE `setup_state` (
`completed_at` text `completed_at` text
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `site_config` (
`id` integer PRIMARY KEY NOT NULL,
`capacity` integer,
`updated_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `snapshots` (
`id` text PRIMARY KEY NOT NULL,
`direction` text NOT NULL,
`device_id` text,
`identity` text,
`content_type` text NOT NULL,
`bytes` blob NOT NULL,
`captured_at` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `tariff_versions` ( CREATE TABLE `tariff_versions` (
`id` text PRIMARY KEY NOT NULL, `id` text PRIMARY KEY NOT NULL,
`tariff_id` text NOT NULL, `tariff_id` text NOT NULL,
-5
View File
@@ -1,5 +0,0 @@
CREATE TABLE `site_config` (
`id` integer PRIMARY KEY NOT NULL,
`capacity` integer,
`updated_at` text DEFAULT (current_timestamp) NOT NULL
);
+94 -31
View File
@@ -1,7 +1,7 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "cd09c11f-4306-4ac8-a335-7c050d080ab6", "id": "a6d81d46-c4a4-4ee7-8565-ec012bbe0252",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"tables": { "tables": {
"blocklist": { "blocklist": {
@@ -82,13 +82,6 @@
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": { "category": {
"name": "category", "name": "category",
"type": "text", "type": "text",
@@ -125,8 +118,8 @@
"uniqueConstraints": {}, "uniqueConstraints": {},
"checkConstraints": {} "checkConstraints": {}
}, },
"lane_devices": { "devices": {
"name": "lane_devices", "name": "devices",
"columns": { "columns": {
"id": { "id": {
"name": "id", "name": "id",
@@ -135,13 +128,6 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": { "category": {
"name": "category", "name": "category",
"type": "text", "type": "text",
@@ -217,13 +203,6 @@
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source": { "source": {
"name": "source", "name": "source",
"type": "text", "type": "text",
@@ -436,13 +415,6 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": { "identity": {
"name": "identity", "name": "identity",
"type": "text", "type": "text",
@@ -524,6 +496,97 @@
"uniqueConstraints": {}, "uniqueConstraints": {},
"checkConstraints": {} "checkConstraints": {}
}, },
"site_config": {
"name": "site_config",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"capacity": {
"name": "capacity",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"snapshots": {
"name": "snapshots",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"bytes": {
"name": "bytes",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"captured_at": {
"name": "captured_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariff_versions": { "tariff_versions": {
"name": "tariff_versions", "name": "tariff_versions",
"columns": { "columns": {
-724
View File
@@ -1,724 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "538cd745-2fe9-410b-bb9f-e4d3c2086211",
"prevId": "cd09c11f-4306-4ac8-a335-7c050d080ab6",
"tables": {
"blocklist": {
"name": "blocklist",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"added_by": {
"name": "added_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"added_at": {
"name": "added_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"device_events": {
"name": "device_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"detail": {
"name": "detail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"lane_devices": {
"name": "lane_devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"driver_id": {
"name": "driver_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ledger_events": {
"name": "ledger_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"index": {
"name": "index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prev_hash": {
"name": "prev_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_id": {
"name": "key_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"ledger_events_index_unique": {
"name": "ledger_events_index_unique",
"columns": [
"index"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_credentials": {
"name": "permit_credentials",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_plates": {
"name": "permit_plates",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plate": {
"name": "plate",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permits": {
"name": "permits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"holder_name": {
"name": "holder_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contact": {
"name": "contact",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_concurrent": {
"name": "max_concurrent",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 1
},
"valid_from": {
"name": "valid_from",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"valid_to": {
"name": "valid_to",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entered_at": {
"name": "entered_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"exited_at": {
"name": "exited_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"last_event_index": {
"name": "last_event_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_state": {
"name": "setup_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"site_config": {
"name": "site_config",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"capacity": {
"name": "capacity",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariff_versions": {
"name": "tariff_versions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tariff_id": {
"name": "tariff_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_from": {
"name": "effective_from",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"currency": {
"name": "currency",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"structure": {
"name": "structure",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariffs": {
"name": "tariffs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'site'"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+1 -8
View File
@@ -5,16 +5,9 @@
{ {
"idx": 0, "idx": 0,
"version": "6", "version": "6",
"when": 1781539958008, "when": 1781632874398,
"tag": "0000_baseline", "tag": "0000_baseline",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1781590195573,
"tag": "0001_site_config",
"breakpoints": true
} }
] ]
} }
+43 -16
View File
@@ -1,5 +1,5 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// Schema notes: // Schema notes:
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md): // - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
@@ -39,7 +39,6 @@ export const ledgerEvents = sqliteTable("ledger_events", {
index: integer("index").notNull().unique(), index: integer("index").notNull().unique(),
type: text("type").notNull(), type: text("type").notNull(),
direction: text("direction", { enum: ["entry", "exit"] }), direction: text("direction", { enum: ["entry", "exit"] }),
lane: integer("lane").notNull(),
source: text("source"), source: text("source"),
identity: text("identity"), identity: text("identity"),
// Type-specific business payload (JSON). Signed as part of the canonical form. // Type-specific business payload (JSON). Signed as part of the canonical form.
@@ -56,13 +55,12 @@ export const ledgerEvents = sqliteTable("ledger_events", {
// --- Device telemetry (unsigned, prunable) ------------------------------- // --- Device telemetry (unsigned, prunable) -------------------------------
// Operational monitoring, NOT anti-fraud: relay fired, printer paper-out, camera // Operational monitoring, NOT anti-fraud: relay fired, printer paper-out, camera
// offline, reader read, raw input edges. Keyed to a lane_devices instance; lane is // offline, reader read, raw input edges. Keyed to a `devices` instance. No
// resolved via the LaneMap. No prevHash/signature — this stream may rotate/prune. // prevHash/signature — this stream may rotate/prune.
export const deviceEvents = sqliteTable("device_events", { export const deviceEvents = sqliteTable("device_events", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
// The lane_devices instance that produced it (raw provenance). // The `devices` instance that produced it (raw provenance).
deviceId: text("device_id"), deviceId: text("device_id"),
lane: integer("lane"),
category: text("category", { category: text("category", {
enum: ["access", "reader", "camera", "printer"], enum: ["access", "reader", "camera", "printer"],
}), }),
@@ -75,13 +73,42 @@ export const deviceEvents = sqliteTable("device_events", {
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
}); });
// --- Per-lane device assignments (first-run setup) ----------------------- // --- Camera snapshots (unsigned, prunable, blob-in-DB) -------------------
// One row per (lane, category, instance). `driverId` references a driver in the // An entry/exit snapshot captured asynchronously AFTER the barrier opens — evidence,
// @parking/devices registry; `config` is that driver's JSON config. Keeps the // not a gate (camera failure never blocks an open; see entry/exit flows). Stored as a
// system device-agnostic + admin-configurable. See device-registry.md, first-run-setup.md. // BLOB so the appliance keeps a single backed-up file with nothing scattered on disk.
export const laneDevices = sqliteTable("lane_devices", { // Kept in its own table (not inline in device_events) so the hot telemetry scans don't
// drag image bytes, and so images can be pruned independently. The signed
// vehicle_entry/exit references a snapshot by `id` in its payload — the image is an
// independent record (anti-fraud), unsigned and prunable. Retention policy is an open
// question — see wiki/concepts/entry-exit-points.md. Served via GET /api/snapshots/:id.
export const snapshots = sqliteTable("snapshots", {
id: text("id").primaryKey(),
direction: text("direction", { enum: ["entry", "exit"] }).notNull(),
// The camera `devices` instance that captured it (raw provenance).
deviceId: text("device_id"),
// The session/credential ref (ticket id, plate, permit) — links to the ledger event.
identity: text("identity"),
contentType: text("content_type").notNull(),
bytes: blob("bytes").notNull().$type<Buffer>(),
capturedAt: text("captured_at").notNull(),
});
// --- Device assignments (first-run setup) --------------------------------
// One row per device instance. `driverId` references a driver in the @parking/devices
// registry; `config` is that driver's JSON config. There is NO lane: a parking lot is
// one pool of spaces with a flexible set of entry/exit points. Direction lives INSIDE
// the config, per the hardware:
// - access controller: config.relays = [{ relay, direction: entry|exit|both, button? }]
// — one physical board has several relays; each relay opens one barrier in one
// direction (or both). `button` = the input terminal the entry button is wired to
// (transient entry trigger; absent = no button at that barrier).
// - reader / camera: config.controllerId + config.relay BIND it to the barrier it sits
// at; its direction is INHERITED from that relay. Unbound → falls back to a
// direction picked in config.
// See device-registry.md, first-run-setup.md, wiki/concepts/entry-exit-points.md.
export const devices = sqliteTable("devices", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
lane: integer("lane").notNull(),
category: text("category", { category: text("category", {
enum: ["access", "reader", "camera", "printer"], enum: ["access", "reader", "camera", "printer"],
}).notNull(), }).notNull(),
@@ -120,8 +147,8 @@ export const siteConfig = sqliteTable("site_config", {
// `scope` lets multiple be added later without migration. See wiki/concepts/tariff.md. // `scope` lets multiple be added later without migration. See wiki/concepts/tariff.md.
export const tariffs = sqliteTable("tariffs", { export const tariffs = sqliteTable("tariffs", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
// Only "site" used now; "lane"/"zone" reserved for multi-tariff later. // Only "site" used now; "zone" reserved for multi-tariff later.
scope: text("scope", { enum: ["site", "lane", "zone"] }).notNull().default("site"), scope: text("scope", { enum: ["site", "zone"] }).notNull().default("site"),
name: text("name").notNull(), name: text("name").notNull(),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
@@ -204,7 +231,6 @@ export const blocklist = sqliteTable("blocklist", {
export const sessions = sqliteTable("sessions", { export const sessions = sqliteTable("sessions", {
// The session key = the entry's identity (ticket id or plate). // The session key = the entry's identity (ticket id or plate).
id: text("id").primaryKey(), id: text("id").primaryKey(),
lane: integer("lane"),
// Identity that opened the session, and how it was read. // Identity that opened the session, and how it was read.
identity: text("identity"), identity: text("identity"),
source: text("source"), source: text("source"),
@@ -224,7 +250,8 @@ export const sessions = sqliteTable("sessions", {
export type UserRow = typeof users.$inferSelect; export type UserRow = typeof users.$inferSelect;
export type LedgerEventRow = typeof ledgerEvents.$inferSelect; export type LedgerEventRow = typeof ledgerEvents.$inferSelect;
export type DeviceEventRow = typeof deviceEvents.$inferSelect; export type DeviceEventRow = typeof deviceEvents.$inferSelect;
export type LaneDeviceRow = typeof laneDevices.$inferSelect; export type SnapshotRow = typeof snapshots.$inferSelect;
export type DeviceRow = typeof devices.$inferSelect;
export type SetupStateRow = typeof setupState.$inferSelect; export type SetupStateRow = typeof setupState.$inferSelect;
export type SiteConfigRow = typeof siteConfig.$inferSelect; export type SiteConfigRow = typeof siteConfig.$inferSelect;
export type TariffRow = typeof tariffs.$inferSelect; export type TariffRow = typeof tariffs.$inferSelect;
+2 -2
View File
@@ -61,10 +61,10 @@ class HttpCamera implements CameraDevice {
const res = await this.#get(); const res = await this.#get();
if (res.status !== 200) { if (res.status !== 200) {
throw new Error( throw new Error(
`${this.driverId} snapshot failed (lane=${ctx.lane} ${ctx.direction}): HTTP ${res.status}`, `${this.driverId} snapshot failed (${ctx.direction}): HTTP ${res.status}`,
); );
} }
stubLog(this.driverId, `captureSnapshot lane=${ctx.lane} ${ctx.direction} (${res.body.length} bytes)`); stubLog(this.driverId, `captureSnapshot ${ctx.direction} (${res.body.length} bytes)`);
return { return {
bytes: res.body, bytes: res.body,
contentType: res.contentType || "image/jpeg", contentType: res.contentType || "image/jpeg",
@@ -71,8 +71,6 @@ function renderTicket(data: TicketData): Buffer {
DOUBLE_OFF, DOUBLE_OFF,
BOLD_OFF, BOLD_OFF,
line(), line(),
line(`Lane ${data.lane}`),
line(),
BOLD_ON, BOLD_ON,
line(data.ticketId), line(data.ticketId),
BOLD_OFF, BOLD_OFF,
@@ -217,7 +215,7 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
async printTicket(data: TicketData): Promise<void> { async printTicket(data: TicketData): Promise<void> {
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout); await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
stubLog(this.driverId, `printed ticket ${data.ticketId} (lane ${data.lane})`); stubLog(this.driverId, `printed ticket ${data.ticketId}`);
} }
async printReport(report: PrintReport): Promise<void> { async printReport(report: PrintReport): Promise<void> {
-2
View File
@@ -174,7 +174,6 @@ export interface CameraDevice extends Device {
} }
export interface SnapshotContext { export interface SnapshotContext {
readonly lane: number;
readonly direction: "entry" | "exit"; readonly direction: "entry" | "exit";
} }
@@ -193,7 +192,6 @@ export interface Snapshot {
// --- Printers (ticket dispenser / booth printer) ------------------------- // --- Printers (ticket dispenser / booth printer) -------------------------
export interface TicketData { export interface TicketData {
readonly ticketId: string; readonly ticketId: string;
readonly lane: number;
readonly issuedAt: string; // ISO-8601 readonly issuedAt: string; // ISO-8601
} }
+12 -1
View File
@@ -27,8 +27,19 @@ export interface ConfigField {
readonly help?: string; readonly help?: string;
} }
/** A JSON-serializable config value. Mostly flat scalars (host, port, credentials),
* but some configs carry nested structure — e.g. an access controller's
* `relays: [{ relay, direction, button? }]` map. See entry-exit-points.md. */
export type ConfigValue =
| string
| number
| boolean
| null
| ConfigValue[]
| { [k: string]: ConfigValue };
/** Opaque per-instance config the admin fills in (host, port, credentials…). */ /** Opaque per-instance config the admin fills in (host, port, credentials…). */
export type DeviceConfig = Record<string, string | number | boolean>; export type DeviceConfig = Record<string, ConfigValue>;
/** /**
* A driver: metadata describing a supported device model/family, the config * A driver: metadata describing a supported device model/family, the config
+6 -9
View File
@@ -89,16 +89,13 @@ The [[parking-session]] domain folds over these **signed ledger** events:
A session is a **projection** over this chain, never a mutable table — the same anti-fraud reason A session is a **projection** over this chain, never a mutable table — the same anti-fraud reason
the chain exists. See [[parking-session]]. the chain exists. See [[parking-session]].
### ⚠️ As-built vs. the table split (pending) ### As-built (table split done)
The current code records Dingtian **input (button) pushes** as `input_received` rows **in the The split above is implemented: raw Dingtian **input (button) pushes** are **device telemetry** in
signed chain** (with `lane` resolved via the `LaneMap`, `source` null, device provenance in **`device_events`** (unsigned, prunable), keyed to the firing `devices` instance. Only the business
`identity`). Per the 2026-06-15 split (above), a raw button press is **device telemetry** and `vehicle_entry` the press drives is signed into **`ledger_events`**. The signed events carry **no
belongs in **`device_events`**, *not* the signed ledger — only the business `vehicle_entry` it `lane`** — the pool-of-spaces model has none (dropped 2026-06-16; see [[entry-exit-points]]), and
drives gets signed. So `input_received`-in-the-ledger is **transitional**; the pending refactor the canonical form bumped `sw-hmac-v1` → `sw-hmac-v2` accordingly.
moves raw inputs to `device_events` and renames the chain table to `ledger_events`. (`LaneMap`
lane-resolution and the "never stamp `lane: 0` for an unmapped device" rule carry over to whichever
stream records the event.)
### ⚠️ Limitation: the log captures HOST-ORIGINATED actions only ### ⚠️ Limitation: the log captures HOST-ORIGINATED actions only
+1 -1
View File
@@ -33,6 +33,6 @@ principle. The choice of *which* adapter to trust is the [[trust-boundary]] deci
> **In practice** the adapters are made *selectable*: a [[device-registry]] catalogs the > **In practice** the adapters are made *selectable*: a [[device-registry]] catalogs the
> supported drivers (ZKTeco / ESP32 relay, Wiegand / TCP-IP readers, Hikvision / Dahua cameras), > supported drivers (ZKTeco / ESP32 relay, Wiegand / TCP-IP readers, Hikvision / Dahua cameras),
> and the admin assigns one per lane during [[first-run-setup]]. Adding hardware support = one > and the admin assigns instances during [[first-run-setup]]. Adding hardware support = one
> more registered driver, no business-logic change. (The implemented interfaces add a > more registered driver, no business-logic change. (The implemented interfaces add a
> `CameraDevice` for entry/exit snapshots alongside reader/relay/printer.) > `CameraDevice` for entry/exit snapshots alongside reader/relay/printer.)
+1 -1
View File
@@ -60,7 +60,7 @@ replies), so the driver **serializes** all controller I/O. Override the broadcas
2. Admin clicks **Scan** → `GET /api/setup/discover/:driverId` (admin-only). 2. Admin clicks **Scan** → `GET /api/setup/discover/:driverId` (admin-only).
3. The server runs `discover()` and **health-checks each found device** so the admin sees 3. The server runs `discover()` and **health-checks each found device** so the admin sees
reachability before assigning. reachability before assigning.
4. Selecting a result **auto-fills serial + host**; the admin then assigns it to a lane. 4. Selecting a result **auto-fills serial + host**; the admin then assigns + binds it.
## Deployment notes ## Deployment notes
+2 -2
View File
@@ -28,8 +28,8 @@ diagnostics, and live booth status — **not** anti-fraud.
ledger's integrity machinery. ledger's integrity machinery.
- **Disposable** — high-volume and churny; **may rotate/prune** on a retention policy (the ledger - **Disposable** — high-volume and churny; **may rotate/prune** on a retention policy (the ledger
never does). never does).
- **Device-keyed** — references the `lane_devices` instance; `lane` resolved via the same `LaneMap` - **Device-keyed** — references the `devices` instance (raw device provenance). No `lane`
as before. Stores raw device provenance. (pool-of-spaces model — see [[entry-exit-points]]).
## The boundary that matters ## The boundary that matters
+3 -3
View File
@@ -39,7 +39,7 @@ neither is the real boundary:
- **Relay control (host → device)** — UDP, now via the Dingtian **binary protocol on :60000 with a - **Relay control (host → device)** — UDP, now via the Dingtian **binary protocol on :60000 with a
`relay_pw`** (the only authenticated relay option; the string protocol has none). Set on the `relay_pw`** (the only authenticated relay option; the string protocol has none). Set on the
device + stored in `lane_devices` by the harden step (below). device + stored in `devices` by the harden step (below).
- **Input push (device → host)** — guarded by **HTTP Digest auth** + a **source-IP allowlist**. - **Input push (device → host)** — guarded by **HTTP Digest auth** + a **source-IP allowlist**.
- **The real guarantee is the signed log:** every barrier open is a host decision, recorded as a - **The real guarantee is the signed log:** every barrier open is a host decision, recorded as a
signed event BEFORE the relay fires ([[append-only-event-chain]]). An out-of-band open (which a signed event BEFORE the relay fires ([[append-only-event-chain]]). An out-of-band open (which a
@@ -55,7 +55,7 @@ fix preconditions (disable `input_link_relay`) → **harden** → set up input p
capability ([[device-registry|HardenableDevice]]): capability ([[device-registry|HardenableDevice]]):
- **Sets a random `relay_pw`** (1–9999) so binary relay commands need it; stores it in - **Sets a random `relay_pw`** (1–9999) so binary relay commands need it; stores it in
`lane_devices` so the backend can keep commanding the relay. `devices` so the backend can keep commanding the relay.
- **Disables unused protocol channels** (rs485, can, tcp×2, mqtt → `p:255`), keeping only UDP1 - **Disables unused protocol channels** (rs485, can, tcp×2, mqtt → `p:255`), keeping only UDP1
binary (relay control) + UDP2 string (status read) — fewer open doors. binary (relay control) + UDP2 string (status read) — fewer open doors.
@@ -81,7 +81,7 @@ the clear. We **empirically tested the device** to pick the strongest achievable
→ **HTTP Digest** (MD5, qop=auth). The password is never sent (only a nonce-keyed hash); nonces → **HTTP Digest** (MD5, qop=auth). The password is never sent (only a nonce-keyed hash); nonces
are **single-use** (replay resistance). Per-device credentials (`pushUser`/`pushPassword`) are are **single-use** (replay resistance). Per-device credentials (`pushUser`/`pushPassword`) are
generated by the backend on **device assign**, written to the device's `input_link_url` config, generated by the backend on **device assign**, written to the device's `input_link_url` config,
and stored in `lane_devices` — the admin never types a URL or secret. HTTPS would be stronger but and stored in `devices` — the admin never types a URL or secret. HTTPS would be stronger but
the device can't do it here; Digest + the signed log is the practical answer on a flat network. the device can't do it here; Digest + the signed log is the practical answer on a flat network.
See `apps/server/src/digest-auth.ts`. See `apps/server/src/digest-auth.ts`.
+5 -4
View File
@@ -10,7 +10,7 @@ updated: 2026-06-15
How the system goes from "device-agnostic in principle" ([[device-adapter-pattern]]) to How the system goes from "device-agnostic in principle" ([[device-adapter-pattern]]) to
"**admin picks the device at setup**" in practice. A **registry** holds a catalog of supported "**admin picks the device at setup**" in practice. A **registry** holds a catalog of supported
**drivers**, grouped by category; the [[first-run-setup]] UI reads it so an **drivers**, grouped by category; the [[first-run-setup]] UI reads it so an
operator can choose a device per lane and fill in its connection config. operator can choose a device and fill in its connection config.
> Implementation-derived (from `packages/devices`), not the source doc. > Implementation-derived (from `packages/devices`), not the source doc.
@@ -33,10 +33,11 @@ driver; **no business-logic change** — this is the [[device-adapter-pattern]]
## Why a registry (not hard-coded wiring) ## Why a registry (not hard-coded wiring)
- The admin chooses between **multiple devices per category** at install time, per lane - The admin chooses between **multiple devices per category** at install time
(mirrors the "mixable per lane" principle — see [[trust-boundary]], [[entry-exit-readers]]). (a controller's relays mix entry/exit; readers bind to them — see [[entry-exit-points]],
[[trust-boundary]], [[entry-exit-readers]]).
- Config is **validated against the driver's declared fields** before persisting. - Config is **validated against the driver's declared fields** before persisting.
- Selections persist in the `lane_devices` table and drive runtime adapter construction. - Selections persist in the `devices` table and drive runtime adapter construction.
- Drivers may optionally implement **[[device-discovery]]** (`discover()`), so the admin can scan - Drivers may optionally implement **[[device-discovery]]** (`discover()`), so the admin can scan
the LAN instead of typing connection details — no current driver uses it (the UHPPOTE did, the LAN instead of typing connection details — no current driver uses it (the UHPPOTE did,
before removal; the [[dingtian-relay]] uses a fixed IP). before removal; the [[dingtian-relay]] uses a fixed IP).
+110
View File
@@ -0,0 +1,110 @@
---
type: concept
tags: [parking, architecture, devices, setup]
sources: []
updated: 2026-06-16
---
# Entry / Exit Points (pool-of-spaces model)
A parking lot is **one pool of spaces** with a flexible set of **entry points** and **exit
points** — any number of each, in any combination (1 in + 1 out, 1 in + 2 out, 2 in + 1 out, …).
There is **no "lane"** concept anywhere in the system (dropped 2026-06-16 — see below).
## Direction lives on the relay, not the controller
An access controller (e.g. a [[dingtian-relay]] board) has **several relays** — each relay opens
one barrier. Direction is a property of **each relay**, declared in the controller's config:
```jsonc
// access `devices` row — one Dingtian board
config: {
host: "192.168.1.100",
relays: [
{ relay: 1, direction: "entry", button: 1 }, // entry barrier; entry button on input 1
{ relay: 2, direction: "exit" } // exit barrier; opened by a reader, no button
]
}
```
- `direction`: `entry` | `exit` | `both` (`both` = one barrier/relay serving in and out).
- `button`: the **input terminal** the transient **entry button** is wired to. Only entry/both
relays have one. Absent = no button at that barrier (subscriber/reader-driven only).
The four real layouts all fall out of this:
| Layout | Controllers | Relays |
| --- | --- | --- |
| 1 barrier, both directions | 1 | `{relay:1, both, button:1}` |
| 2 barriers, 1 board | 1 | `{relay:1, entry, button:1}`, `{relay:2, exit}` |
| 2 barriers far apart | 2 | board A `{relay:1, entry}`, board B `{relay:1, exit}` |
| 1 entry + 2 exit | 3 | A entry; B, C each exit |
## Readers / cameras BIND to a relay
A reader or camera points at the barrier it physically sits at, via its config:
```jsonc
config: { ...readerConfig, controllerId: "<access devices.id>", relay: 2 }
```
Its **direction is inherited** from that relay. So an exit read opens **exactly that relay** —
no ambiguity even with multiple exit barriers ("the relay at that reader", decided 2026-06-16).
Binding is optional: an unbound device falls back to a `config.direction` + the first relay
site-wide of that direction (keeps the single-barrier case trivial). LPR is a snapshot sink —
an ANPR service ([[opencv-anpr-service]]) POSTs the plate as a `plate` read to the reader
endpoint, flowing through the same dispatcher.
## Resolution (one module: `apps/server/src/device-resolve.ts`)
- **Button press** → `relayForButton(controllerId, terminal)` → the entry relay whose `button`
matches → entry flow → `pulseOpen(relay)`.
- **Reader/permit/LPR read** → `relayForDevice(reader)` → the bound relay → `pulseOpen(relay)`;
direction inherited.
- **Snapshots** → `devicesByDirection("camera", dir)` → every camera serving that direction.
A directional barrier that contradicts the car's open-session state (an exit barrier scanned by a
car not inside, or an entry barrier by a car already in) is a wrong-barrier / [[anti-passback]]
refusal. A `both` relay defers to session state.
## The flows
| Flow | Trigger | Opens |
| --- | --- | --- |
| Transient entry | entry **button** press | the entry relay (button-mapped) → ticket prints |
| Transient exit | voucher scan at exit reader | the exit relay (reader-bound), if paid+grace |
| Subscriber entry | QR/RFID/plate at entry reader | the entry relay (reader-bound), if permit valid |
| Subscriber exit | QR/RFID/plate at exit reader | the exit relay (reader-bound), if permit valid |
Every open also fires a [[camera snapshot|append-only-event-chain]] (async, never blocks the open).
## Why no lane
"Lane" was a leftover from a rows-of-gates mental model. It added nothing here:
- **Occupancy** is a site-wide fold over the ledger (entries − exits); it never grouped by lane.
- **Device grouping** is now done by the reader→relay binding, far more precisely than a lane key.
- **Anti-fraud** doesn't use it — the signed chain, the "open must match a signed event" check,
and [[reconciliation]] all work on *what happened*, not *which gate*. The relay's direction
already catches an exit firing an entry barrier, better than a lane number would.
Dropping it removed `lane` from `ledger_events`, `device_events`, `sessions`, and the device
table (renamed `lane_devices` → `devices`). Because `lane` was part of the **signed canonical
form**, this is a versioned change: the canonical array no longer includes lane, and the signer
keyId bumped `sw-hmac-v1` → `sw-hmac-v2`. v1 events won't verify under v2 — intentional, gated by
each event's stored `keyId` (done pre-deployment, on throwaway data, so zero real cost). See
[[append-only-event-chain]].
## Camera snapshots (evidence, not a gate)
Captured **after** the barrier opens, **never awaited** — a camera failure can't delay or block an
open (the signed ledger is the decision). Stored as a **BLOB in the `snapshots` table** (single
backed-up DB, nothing scattered on disk), in its own table so hot telemetry scans don't drag image
bytes and images prune independently. Linked to the signed `vehicle_entry/exit` by `identity`.
Served read-only via `GET /api/snapshots/:id`. **Retention is unresolved** — see [[open-questions]].
## Related
[[entry-exit-readers]] · [[device-events]] · [[parking-session]] · [[anti-passback]] ·
[[append-only-event-chain]] · [[barrier-not-a-door]] · [[opencv-anpr-service]] ·
[[dingtian-relay]] · [[first-run-setup]]
+4
View File
@@ -38,6 +38,10 @@ There are **two populations** of users, and they map to **two integration paths*
keeps autonomy + native event log. keeps autonomy + native event log.
- **Both models can share one relay** (valid Wiegand read **or** host `open` in "controlled" - **Both models can share one relay** (valid Wiegand read **or** host `open` in "controlled"
mode), so one lane serves permit + casual. mode), so one lane serves permit + casual.
- **Each reader BINDS to a controller relay** (`config.controllerId` + `relay`) — the barrier it
sits at — and inherits that relay's direction (entry/exit/both). An exit read opens exactly that
relay; an entry read the entry relay. This is how separate in/out readers are disambiguated, with
no "lane". See [[entry-exit-points]].
- **Host-in-the-loop is good for fraud detection** — two independent records (host's signed - **Host-in-the-loop is good for fraud detection** — two independent records (host's signed
[[append-only-event-chain]] entry + the UHPPOTE remote-open event) should reconcile 1:1; any [[append-only-event-chain]] entry + the UHPPOTE remote-open event) should reconcile 1:1; any
mismatch is an anomaly. mismatch is an anomaly.
+18 -16
View File
@@ -8,8 +8,10 @@ updated: 2026-06-15
# First-Run Setup (device selection) # First-Run Setup (device selection)
The admin install flow that makes the system **device-agnostic in practice**: on first run, an The admin install flow that makes the system **device-agnostic in practice**: on first run, an
admin assigns devices **per lane** by choosing from the [[device-registry]] catalog and entering admin adds **controllers** (each declaring its relays — entry/exit/both — and the entry-button
each device's connection config. terminal) and then **readers/cameras/printers** bound to a controller relay, choosing from the
[[device-registry]] catalog and entering each device's connection config. There is **no lane** —
the pool-of-spaces model; see [[entry-exit-points]].
> Implementation-derived (from `apps/server` + `apps/web`), not the source doc. > Implementation-derived (from `apps/server` + `apps/web`), not the source doc.
@@ -26,7 +28,7 @@ each device's connection config.
device**: fixes preconditions (e.g. disables `input_link_relay`) and sets up the Digest- device**: fixes preconditions (e.g. disables `input_link_relay`) and sets up the Digest-
authenticated input push ([[device-input-flow]]) — the admin never touches the device's own web authenticated input push ([[device-input-flow]]) — the admin never touches the device's own web
UI. **Fails the save** (no DB row) if the device can't be configured, so there are no UI. **Fails the save** (no DB row) if the device can't be configured, so there are no
orphan/half-configured rows. On success persists to `lane_devices`. orphan/half-configured rows. On success persists to `devices`.
4. **Remove** — `DELETE /api/setup/assign/:id` (admin-only) drops one instance's row. Only our 4. **Remove** — `DELETE /api/setup/assign/:id` (admin-only) drops one instance's row. Only our
row is removed; the device itself is not un-hardened/un-configured (a stale push from an row is removed; the device itself is not un-hardened/un-configured (a stale push from an
unknown device id is already rejected, and re-assigning reconfigures it). unknown device id is already rejected, and re-assigning reconfigures it).
@@ -34,25 +36,25 @@ each device's connection config.
## Config granularity — multi-instance per category ## Config granularity — multi-instance per category
The data model is **multi-instance**: `lane_devices` holds **one row per instance**, keyed by a The data model is **multi-instance**: `devices` holds **one row per instance**, keyed by a
generated `id`, with no one-per-(lane, category) constraint. So a lane can have **more than one of generated `id`. So the site can have **more than one of every category** — multiple controllers,
every category** — e.g. two printers (an entry dispenser + a booth printer; see readers, cameras, and printers (e.g. an entry dispenser + a booth printer; see
[[printer-roles-failover]]), multiple readers, multiple cameras. `assign` always inserts a new row [[printer-roles-failover]]). `assign` always inserts a new row (never an upsert), and `state`
(never an upsert), and `state` returns the full list. returns the full list.
The `SetupWizard` reflects this: each category shows the **list of assigned instances** for the The `SetupWizard` reflects this: each category shows the **list of assigned instances** (with
current lane (with **Remove**) plus an **Add another** form — not a single fixed slot. `select`-type **Remove**) plus an **Add another** form — not a single fixed slot. `select`-type config fields
config fields (e.g. a printer's role) render as dropdowns. (e.g. a printer's role) render as dropdowns.
Organized **per lane** — each lane gets its access controller(s), reader(s), camera(s), and There is **no lane**. Direction lives on each access **relay**; readers/cameras **bind** to a
printer(s), each with its own connection settings. Matches the architecture's "mixable per lane" controller relay (`config.controllerId` + `relay`) — the barrier they serve — and inherit its
reality (a lane can serve permit holders via [[wiegand]] and casual via host-side reads on one direction. The wizard adds controllers first, then binds the other devices to a relay. See
relay — see [[entry-exit-readers]]). [[entry-exit-points]], [[entry-exit-readers]].
## Security notes ## Security notes
- The assign/state/delete/complete endpoints require the **admin** role ([[local-jwt-auth]]). - The assign/state/delete/complete endpoints require the **admin** role ([[local-jwt-auth]]).
- Device **credentials are stored in `lane_devices.config`** — protect at rest - Device **credentials are stored in `devices.config`** — protect at rest
([[disk-os-hardening]]); device hosts belong on the isolated VLAN ([[network-isolation]]). ([[disk-os-hardening]]); device hosts belong on the isolated VLAN ([[network-isolation]]).
- **Secrets are stripped on the way out**: `assign` and `state` both redact `pushPassword`, - **Secrets are stripped on the way out**: `assign` and `state` both redact `pushPassword`,
`webPassword`, and `relayPassword` from the returned config (the UI lists devices; it never `webPassword`, and `relayPassword` from the returned config (the UI lists devices; it never
+6 -6
View File
@@ -28,8 +28,8 @@ adversary is the insider who can edit the database) and the [[append-only-event-
- A **session** is a **read-model folded from those events** — open when an entry has no matching - A **session** is a **read-model folded from those events** — open when an entry has no matching
exit, paid when a `payment` event references it, closed when an exit lands. It MAY be cached in exit, paid when a `payment` event references it, closed when an exit lands. It MAY be cached in
a table for query speed (dashboards, "cars currently in"), but that cache is **always rebuildable a table for query speed (dashboards, "cars currently in"), but that cache is **always rebuildable
from the chain and never authoritative**. Same pattern as the `LaneMap` from the chain and never authoritative** ([[append-only-event-chain]]), scaled to the business
([[append-only-event-chain]]), scaled to the business domain. domain.
- **Why this matters:** a mutable `sessions` row that stored "amount owed / paid" would reopen - **Why this matters:** a mutable `sessions` row that stored "amount owed / paid" would reopen
exactly the fraud hole the whole system exists to close (operator marks a session paid, pockets exactly the fraud hole the whole system exists to close (operator marks a session paid, pockets
the cash). With sessions as a projection, "paid" is a **signed `payment` event** an operator the cash). With sessions as a projection, "paid" is a **signed `payment` event** an operator
@@ -123,7 +123,7 @@ follow this page and [[tariff]]; the decision is recorded in [[session-model]].
- **The full transient loop now passes end to end** (verified): entry → quote → pay → exit opens, - **The full transient loop now passes end to end** (verified): entry → quote → pay → exit opens,
session closed, `verifyChain` ok. session closed, `verifyChain` ok.
> **Design gap (flagged):** `lane_devices` has **no entry/exit direction** model. Entry is > **Resolved (2026-06-16):** the earlier "no entry/exit direction" gap is closed by the
> button-driven and exit is read-driven, so they don't currently collide — but a lane with both an > [[entry-exit-points]] model. Direction lives on each access **relay**; readers/cameras bind to a
> entry reader and an exit reader can't yet be distinguished. A lane-direction/role model is needed > relay and inherit it. The "lane" concept was dropped entirely (pool-of-spaces) — separate in/out
> before multi-reader lanes (relates to [[open-questions]] #1 topology). > readers are distinguished by their relay binding, not a lane.
+1 -1
View File
@@ -13,7 +13,7 @@ still print when the outside dispenser jams or drops off the network.
## Roles ## Roles
Each printer instance (a `lane_devices` row, category `printer`) declares a **role** in its Each printer instance (a `devices` row, category `printer`) declares a **role** in its
config: config:
- **`entry-dispenser`** — outside, at the lane. Prints the entry ticket the driver takes. - **`entry-dispenser`** — outside, at the lane. Prints the entry ticket the driver takes.
+1 -1
View File
@@ -45,7 +45,7 @@ interface.
`PrinterMonitor` (`apps/server/src/printer-monitor.ts`): `PrinterMonitor` (`apps/server/src/printer-monitor.ts`):
- reloads the monitored set from `lane_devices` each tick (so a newly-assigned printer is picked - reloads the monitored set from `devices` each tick (so a newly-assigned printer is picked
up without a restart), keeping only enabled, monitorable printers; up without a restart), keeping only enabled, monitorable printers;
- polls every `PRINTER_POLL_MS` (default 5000ms), never overlapping ticks; - polls every `PRINTER_POLL_MS` (default 5000ms), never overlapping ticks;
- caches the latest status per device id; - caches the latest status per device id;
+6
View File
@@ -56,3 +56,9 @@ status: open
change what the `payment` event must store) and **refunds / overpayment / change** (cash change, change what the `payment` event must store) and **refunds / overpayment / change** (cash change,
"exact change only", a refund as a signed reversal event). Both depend on the unmanned-vs-manned "exact change only", a refund as a signed reversal event). Both depend on the unmanned-vs-manned
payment subsystem (#3) and the note/coin/card acceptor hardware. Revisit at procurement. payment subsystem (#3) and the note/coin/card acceptor hardware. Revisit at procurement.
10. **Snapshot retention.** _(Raised by the [[entry-exit-points]] camera-snapshot build, 2026-06-16.)_
Entry/exit snapshots are stored as BLOBs in the [[sqlite]] `snapshots` table. This grows the
single DB file fast (~100–300 KB per image × every entry **and** exit), and SQLite doesn't
reclaim deleted-blob pages without `VACUUM`. **Undecided:** pruning policy (age-based vs.
total-size cap), VACUUM cadence, and how this interacts with the #5 backup strategy (blobs
bloat every backup). Until decided, snapshots accumulate unbounded. See [[entry-exit-points]].
+12 -11
View File
@@ -65,9 +65,9 @@ barrier. ([[device-input-flow]] is the analogous push pattern; this one also ret
- The read flows were refactored to **return a `ReadOutcome` { accepted, direction, reason }** so the - The read flows were refactored to **return a `ReadOutcome` { accepted, direction, reason }** so the
endpoint's reply reflects the real accept/reject (the dispatcher decides AND opens the barrier via endpoint's reply reflects the real accept/reject (the dispatcher decides AND opens the barrier via
the flows). A fire-and-forget reader ignores the outcome. the flows). A fire-and-forget reader ignores the outcome.
- **Lane mapping:** the endpoint keys the reader's `lane_devices` id off the device **serial - **Reader identity:** the endpoint matches the device **serial (`cjihao`)** against each reader's
(`cjihao`)** for now — so assign the reader with `lane_devices.id = <serial>`. Refine when the `config.serial` to find its `devices` row; the dispatcher then resolves the relay that row is
setup wizard models the reader's server-side identity properly. **bound** to (`config.controllerId` + `relay`) and opens it. See [[entry-exit-points]].
- Verified via inject: valid permit QR → `status:1` + open; re-scan → permit exit (still valid); - Verified via inject: valid permit QR → `status:1` + open; re-scan → permit exit (still valid);
unknown QR → `status:0`; reader on a barrier-less lane → `status:0`. unknown QR → `status:0`; reader on a barrier-less lane → `status:0`.
@@ -84,8 +84,9 @@ from 10.0.10.7 (referer: http://www.fondvision.com — the OEM is Fondvision)
- **PATH carries the configured "server language" EXTENSION:** this unit is set to **JSP**, so it - **PATH carries the configured "server language" EXTENSION:** this unit is set to **JSP**, so it
GETs **`/qa/mcardsea.jsp`** — NOT `.php`. Our endpoint was registered at `.php` only → it would GETs **`/qa/mcardsea.jsp`** — NOT `.php`. Our endpoint was registered at `.php` only → it would
have 404'd the real reader. **Fixed:** the route now registers `php/jsp/asp/aspx/cgi`. have 404'd the real reader. **Fixed:** the route now registers `php/jsp/asp/aspx/cgi`.
- **`cjihao` = `H05M2AFA`** is the device **serial** — the value our endpoint keys the lane on. So - **`cjihao` = `H05M2AFA`** is the device **serial** — the value our endpoint matches against the
assign the reader with **`lane_devices.id = "H05M2AFA"`** (+ an access device on the same lane). reader's `config.serial`. So assign the reader with **`config.serial = "H05M2AFA"`** and bind it
to a controller relay.
- **`mjihao` = 1** (device id). `cardid` = the scanned barcode (`52020056`). `status=11`. - **`mjihao` = 1** (device id). `cardid` = the scanned barcode (`52020056`). `status=11`.
- The reader **beeped on the vendor reply with `status:0`** — so it acts on the reply; `0` = - The reader **beeped on the vendor reply with `status:0`** — so it acts on the reply; `0` =
invalid/1-beep as documented. A matching permit/session will return `status:1` → 2-beep accept. invalid/1-beep as documented. A matching permit/session will return `status:1` → 2-beep accept.
@@ -94,14 +95,14 @@ from 10.0.10.7 (referer: http://www.fondvision.com — the OEM is Fondvision)
A dedicated **`gee-qr-reader`** driver ([[device-registry]], reader category) models the push reader: A dedicated **`gee-qr-reader`** driver ([[device-registry]], reader category) models the push reader:
its one config field is **`serial`** (the `cjihao` the device reports). The admin assigns it in the its one config field is **`serial`** (the `cjihao` the device reports). The admin assigns it in the
[[first-run-setup|setup wizard]] like any device (normal UUID row id) and enters the serial. The QR [[first-run-setup|setup wizard]] like any device (normal UUID row id), enters the serial, and binds
endpoint resolves the lane by **matching `config.serial` to the scan's `cjihao`** — not by row id — it to a controller relay. The QR endpoint resolves the reader by **matching `config.serial` to the
so no DB hand-editing. Set the reader's server IP/port to this host in the **vendor tool**; assign + scan's `cjihao`** — not by row id — so no DB hand-editing. Set the reader's server IP/port to this
enter its serial here. host in the **vendor tool**; assign + enter its serial + bind it here.
- Verified via inject: assign `gee-qr-reader` {serial:"H05M2AFA"} on a lane w/ an access device → - Verified via inject: assign `gee-qr-reader` {serial:"H05M2AFA"} bound to an access relay →
a `.jsp` scan with that serial + a matching permit QR → `status:1` (2-beep accept) + open; re-scan a `.jsp` scan with that serial + a matching permit QR → `status:1` (2-beep accept) + open; re-scan
→ permit exit; unknown card → `status:0`; unassigned serial → `status:0` (no lane, graceful). → permit exit; unknown card → `status:0`; unassigned serial → `status:0` (no relay, graceful).
- Note `tcpip-reader` is the WRONG model for this device (host-connects-out, a stub) — use - Note `tcpip-reader` is the WRONG model for this device (host-connects-out, a stub) — use
`gee-qr-reader`. `gee-qr-reader`.
+3 -2
View File
@@ -57,11 +57,11 @@ Counts: 3 sources · 19 entities · 24 concepts · 5 decision records.
## Concepts — device architecture & safety ## Concepts — device architecture & safety
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter. - [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
- [[device-registry]] — catalog of selectable drivers per category (admin-configurable). - [[device-registry]] — catalog of selectable drivers per category (admin-configurable).
- [[first-run-setup]] — admin assigns devices per lane from the catalog at install. - [[first-run-setup]] — admin adds controllers + binds readers/cameras to relays from the catalog at install.
- [[device-input-flow]] — button → device push → backend decides → relay; backend is source of truth. - [[device-input-flow]] — button → device push → backend decides → relay; backend is source of truth.
- [[device-discovery]] — optional driver capability to scan the LAN (no current driver uses it; UHPPOTE was the example). - [[device-discovery]] — optional driver capability to scan the LAN (no current driver uses it; UHPPOTE was the example).
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware. - [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
- [[printer-roles-failover]] — ≥2 printers per lane by role; entry ticket falls back outside→booth. - [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth.
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI. - [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable. - [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog. - [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
@@ -72,6 +72,7 @@ Counts: 3 sources · 19 entities · 24 concepts · 5 decision records.
- [[event-log-ingestion]] — host-side index tracking that makes the UHPPOTE log trustworthy. - [[event-log-ingestion]] — host-side index tracking that makes the UHPPOTE log trustworthy.
- [[challenge-response-auth]] — asymmetric nonce scheme for the ESP32 (auth + anti-replay). - [[challenge-response-auth]] — asymmetric nonce scheme for the ESP32 (auth + anti-replay).
- [[entry-exit-readers]] — two populations, two integration paths; both can share a relay. - [[entry-exit-readers]] — two populations, two integration paths; both can share a relay.
- [[entry-exit-points]] — pool-of-spaces model (no lane); per-relay direction, reader→relay binding, camera snapshots.
- [[uhppote-vs-esp32]] — comparison: detection vs. prevention. - [[uhppote-vs-esp32]] — comparison: detection vs. prevention.
## Concepts — business domain ## Concepts — business domain
+44
View File
@@ -700,3 +700,47 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
`Connection: close` + shuts the socket. `Connection: close` + shuts the socket.
- Fix: endpoint sets `reply.header("connection","close")`. Verified the header is now sent. - Fix: endpoint sets `reply.header("connection","close")`. Verified the header is now sent.
- Updated [[gee-qr-er80]] (⚠️ Connection: close requirement). - Updated [[gee-qr-er80]] (⚠️ Connection: close requirement).
## [2026-06-16] feature | lane direction (per-device entry/exit) + camera snapshots
- Direction is per **device**, not per lane: added `direction` (`entry`/`exit`/`both`, default
`both`) to `lane_devices`. A lane's entry set = devices tagged entry|both, exit set likewise —
no join table; tagging in the wizard IS the grouping. Rejected a lane-level `lanes` table (can't
model one bidirectional lane). New [[lane-direction]] concept page; cross-linked [[entry-exit-readers]].
- All flows now resolve via `deviceRowsFor(lane, category, direction)` (lane-map.ts), replacing the
ad-hoc "first access on lane" lookups. Dispatcher trusts the **reader's own direction**; a
directional reader that contradicts the car's session state is a wrong-lane/anti-passback refusal.
`both` keeps the old infer-from-session behavior.
- Relay open channel is now config-driven (`config.openChannel`, default 1) since a lane can hold
an entry **and** an exit relay. LPR stays a snapshot sink — ANPR POSTs a `plate` read to the
reader endpoint (no camera-as-input coupling).
- Camera snapshots wired into all four paths (transient/permit × entry/exit): fired AFTER
pulseOpen, **never awaited** (evidence, not a gate — camera failure can't block an open). Stored
as BLOB in a new `snapshots` table (not files); telemetry `kind:"snapshot"` device_event per
capture/failure; linked to the signed event by `identity`. Served via `GET /api/snapshots/:id`.
- Migration `0002_wild_odin.sql` (additive). Snapshot **retention** left unresolved →
[[open-questions]] #10. Whole monorepo typechecks; no test suite exists in-repo.
## [2026-06-16] redesign | SUPERSEDES the above — pool-of-spaces, per-relay direction, NO lane
- User correction: direction is NOT a property of a device row. One Dingtian board has 2+ relays;
a single board drives both the entry barrier (relay 1) and the exit barrier (relay 2), and a
single relay can even serve **both**. So the row-level `direction` from the entry above was wrong.
- Further: the whole **"lane" concept was dropped**. Occupancy is site-wide, device grouping is now
the reader→relay binding, and anti-fraud never used lane. A parking lot = **one pool of spaces**
with a flexible set of entry/exit points (1 in + 2 out, etc). New [[entry-exit-points]] page
(replaces lane-direction); reworked [[entry-exit-readers]], [[parking-session]], [[first-run-setup]],
[[device-registry]].
- Model now: access `config.relays = [{ relay, direction: entry|exit|both, button? }]` (`button` =
the input terminal the entry button is wired to). Readers/cameras `config.controllerId + relay`
bind to the barrier they sit at; direction inherited ("the relay at that reader" opens on a read).
- Schema: dropped `lane` from `ledger_events`, `device_events`, `sessions`; renamed `lane_devices`
→ `devices` (no lane/direction columns). `lane` was in the SIGNED canonical form, so canonicalize()
dropped it and the signer keyId bumped **sw-hmac-v1 → sw-hmac-v2** (v1 events won't verify under
v2 — intentional, gated by per-event keyId; done pre-deployment on throwaway data). Migration
history reset to a fresh `0000_baseline` (dev DBs deleted + re-migrated).
- Resolvers in new `device-resolve.ts` (replaces lane-map.ts): `relayForButton`, `relayForDevice`,
`firstRelayByDirection`, `devicesByDirection`. `DeviceConfig` widened to nested JSON for `relays[]`.
- Wizard rewritten: no lane selector; Controllers section (relay map + entry-button terminal per
relay), then readers/cameras/printers bind to a controller relay. Whole monorepo typechecks +
builds; no test suite in-repo.
- Residual: incidental `lane_devices` / "per-lane" mentions remain in some secondary wiki pages
(device-events, device-input-flow, ticket-encoding, etc.) — flagged for a later lint pass.