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:
@@ -8,19 +8,19 @@ import type { PrinterStatus } from "@parking/devices";
|
||||
|
||||
export interface DeviceInputEvent {
|
||||
readonly driverId: string; // e.g. "dingtian"
|
||||
readonly deviceId: string; // which configured device (lane_devices id)
|
||||
readonly deviceId: string; // which configured device (devices id)
|
||||
readonly input: number; // 1-based input/channel
|
||||
readonly edge: "on" | "off"; // active / inactive
|
||||
readonly at: string; // ISO-8601 (server receive time)
|
||||
readonly source: "push" | "poll";
|
||||
}
|
||||
|
||||
// A credential read at a lane: a ticket scanned at exit, a plate from LPR, a card
|
||||
// at a reader. Drives identity-based flows (exit validation, and later permits /
|
||||
// pay-station lookup). `kind` mirrors IdentitySource. See parking-session.md.
|
||||
// A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader.
|
||||
// Drives identity-based flows (exit validation, permits, pay-station lookup). `kind`
|
||||
// mirrors IdentitySource. See parking-session.md.
|
||||
export interface DeviceReadEvent {
|
||||
readonly driverId: string;
|
||||
readonly deviceId: string; // lane_devices id of the reader/scanner/camera
|
||||
readonly deviceId: string; // devices id of the reader/scanner/camera
|
||||
readonly value: string; // the ticket id / plate / card number
|
||||
readonly kind: "ticket" | "plate" | "qr" | "card";
|
||||
readonly at: string; // ISO-8601
|
||||
@@ -42,8 +42,7 @@ export interface ReadOutcome {
|
||||
|
||||
/** A printer's status as tracked by the live monitor (status + identity). */
|
||||
export interface PrinterStatusEvent {
|
||||
readonly deviceId: string; // lane_devices id
|
||||
readonly lane: number;
|
||||
readonly deviceId: string; // devices id
|
||||
readonly driverId: string;
|
||||
readonly role?: string; // entry-dispenser | booth-receipt
|
||||
readonly status: PrinterStatus;
|
||||
@@ -58,7 +57,7 @@ class DeviceEventBus extends EventEmitter {
|
||||
return () => this.off("input", cb);
|
||||
}
|
||||
|
||||
/** A credential read (ticket scan, plate, card) at a lane. */
|
||||
/** A credential read (ticket scan, plate, card). */
|
||||
emitRead(event: DeviceReadEvent): void {
|
||||
this.emit("read", event);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, laneDevices, sessions, type Db } from "@parking/db";
|
||||
import { sessions, type Db, type DeviceRow } from "@parking/db";
|
||||
import {
|
||||
NoPrinterAvailableError,
|
||||
printWithFailover,
|
||||
@@ -13,11 +13,13 @@ import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceInputEvent } from "./device-events.js";
|
||||
import { getOccupancy } from "./occupancy.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import type { LaneMap } from "./lane-map.js";
|
||||
import { devicesByDirection, relayForButton, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { snapshotAsync } from "./snapshot.js";
|
||||
|
||||
// The transient ENTRY flow: a button press → print a ticket → sign a vehicle_entry
|
||||
// → open the barrier. This is the step the device layer left dangling
|
||||
// (wiki/concepts/device-input-flow.md "the entry flow itself is the next build").
|
||||
// → open the barrier. The button is wired into an access controller's input; the
|
||||
// admin maps that input terminal to a relay (config.relays[].button), so a press
|
||||
// resolves to exactly the entry relay it should open. See entry-exit-points.md.
|
||||
//
|
||||
// Two invariants from the threat model + safety analysis:
|
||||
// 1. SIGNED BEFORE OPEN — the vehicle_entry is appended to the signed ledger
|
||||
@@ -29,55 +31,46 @@ import type { LaneMap } from "./lane-map.js";
|
||||
// Crucially, NO vehicle_entry is written in that case — we never record an
|
||||
// "entered" event for a car that didn't get in (decision 2026-06-15).
|
||||
//
|
||||
// Ordering, therefore: print → (ok) sign vehicle_entry → pulseOpen → cache session.
|
||||
// (fail) sign anomaly, stop.
|
||||
|
||||
/** Map a 1-based entry input to the relay/door it opens. Default: same channel. */
|
||||
function doorForInput(input: number): number {
|
||||
return input;
|
||||
}
|
||||
// Ordering: print → (ok) sign vehicle_entry → pulseOpen → snapshot → cache session.
|
||||
// (fail) sign anomaly, stop.
|
||||
|
||||
export class EntryFlow {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
readonly #laneMap: LaneMap;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
/** Guard against double-fire from the same physical press (on edge only). */
|
||||
readonly #inFlight = new Set<string>();
|
||||
|
||||
constructor(db: Db, log: EventLog, laneMap: LaneMap, logger: FastifyBaseLogger) {
|
||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#log = log;
|
||||
this.#laneMap = laneMap;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Handle a device input edge. Acts only on the rising ("on") edge of an entry
|
||||
* button in a lane that has an access (barrier) device. */
|
||||
* button — an input terminal mapped to an entry relay on its controller. */
|
||||
async onInput(e: DeviceInputEvent): Promise<void> {
|
||||
if (e.edge !== "on") return; // release edge is just telemetry
|
||||
|
||||
const lane = this.#laneMap.laneFor(e.deviceId);
|
||||
if (lane == null) return; // unmapped device — telemetry already recorded, no entry
|
||||
|
||||
// Only treat this as an entry trigger if the firing device IS the lane's
|
||||
// access controller (a reader/printer input edge isn't an entry button).
|
||||
const access = await this.#loadAccess(lane, e.deviceId);
|
||||
if (!access) return;
|
||||
// The firing device must be an access controller, and the pressed input terminal
|
||||
// must map to an ENTRY (or both) relay — that's an entry button. Anything else
|
||||
// (reader/printer edge, exit-only relay's input) is not a transient-entry trigger.
|
||||
const resolved = relayForButton(this.#db, e.deviceId, e.input);
|
||||
if (!resolved) return;
|
||||
|
||||
const key = `${e.deviceId}:${e.input}`;
|
||||
if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing
|
||||
this.#inFlight.add(key);
|
||||
try {
|
||||
await this.#runEntry(lane, e.input, access);
|
||||
await this.#runEntry(resolved);
|
||||
} catch (err) {
|
||||
this.#logger.error(`entry-flow failed (lane ${lane}): ${(err as Error).message}`);
|
||||
this.#logger.error(`entry-flow failed: ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async #runEntry(lane: number, input: number, access: AccessControlDevice): Promise<void> {
|
||||
async #runEntry(resolved: ResolvedRelay): Promise<void> {
|
||||
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
|
||||
// no ticket, no vehicle_entry, no open — sign an anomaly. Permit holders are NOT
|
||||
// gated here (their flow ignores site-full; their own maxConcurrent applies), so
|
||||
@@ -87,24 +80,23 @@ export class EntryFlow {
|
||||
if (occ.full) {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
lane,
|
||||
payload: { reason: `transient entry refused — lot full (${occ.count}/${occ.capacity})`, entryRefused: true, full: true },
|
||||
});
|
||||
this.#logger.warn(`transient entry REFUSED on lane ${lane}: full (${occ.count}/${occ.capacity})`);
|
||||
this.#logger.warn(`transient entry REFUSED: full (${occ.count}/${occ.capacity})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const ticketId = newTicketId();
|
||||
const issuedAt = new Date().toISOString();
|
||||
const printers = await this.#loadPrinters(lane);
|
||||
const printers = this.#loadPrinters();
|
||||
|
||||
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
||||
const ticket: TicketData = { ticketId, lane, issuedAt };
|
||||
const ticket: TicketData = { ticketId, issuedAt };
|
||||
try {
|
||||
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
|
||||
d.printTicket(ticket),
|
||||
);
|
||||
this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy} (lane ${lane})`);
|
||||
this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy}`);
|
||||
} catch (err) {
|
||||
// HOLD: do not open, do not record a vehicle_entry. Sign an anomaly so the
|
||||
// failed attempt is in the tamper-evident record for the operator.
|
||||
@@ -112,18 +104,16 @@ export class EntryFlow {
|
||||
err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
lane,
|
||||
identity: ticketId,
|
||||
payload: { reason: `entry held — ticket not printed: ${reason}`, ticketPrinted: false },
|
||||
});
|
||||
this.#logger.warn(`entry HELD on lane ${lane}: ${reason} (barrier NOT opened)`);
|
||||
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
||||
await this.#log.append({
|
||||
type: "vehicle_entry",
|
||||
lane,
|
||||
direction: "entry",
|
||||
source: "ticket",
|
||||
identity: ticketId,
|
||||
@@ -131,15 +121,26 @@ export class EntryFlow {
|
||||
occurredAt: issuedAt,
|
||||
});
|
||||
|
||||
// 3. OPEN the barrier (intent only; the barrier owns the close).
|
||||
await access.pulseOpen(doorForInput(input));
|
||||
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
|
||||
const access = this.#buildAccess(resolved.controller);
|
||||
if (access) await access.pulseOpen(resolved.relay);
|
||||
else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
|
||||
|
||||
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate;
|
||||
// a camera failure must not delay or block the already-open barrier).
|
||||
void snapshotAsync({
|
||||
db: this.#db,
|
||||
direction: "entry",
|
||||
identity: ticketId,
|
||||
logger: this.#logger,
|
||||
}).catch((err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`));
|
||||
|
||||
// 4. Update the session projection cache (rebuildable from the ledger; this is
|
||||
// just a fast read-model, never the source of truth).
|
||||
try {
|
||||
this.#db
|
||||
.insert(sessions)
|
||||
.values({ id: ticketId, lane, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
|
||||
.values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
|
||||
.run();
|
||||
} catch (err) {
|
||||
// Cache miss is non-fatal — the ledger is authoritative and the projection
|
||||
@@ -148,15 +149,8 @@ export class EntryFlow {
|
||||
}
|
||||
}
|
||||
|
||||
/** The lane's access device, but only if it's the one that fired (the entry
|
||||
* button). Returns a live adapter or null. */
|
||||
async #loadAccess(lane: number, deviceId: string): Promise<AccessControlDevice | null> {
|
||||
const row = await this.#db
|
||||
.select()
|
||||
.from(laneDevices)
|
||||
.where(and(eq(laneDevices.id, deviceId), eq(laneDevices.category, "access")))
|
||||
.get();
|
||||
if (!row || !row.enabled || row.lane !== lane) return null;
|
||||
/** Build a live access adapter from a resolved controller row, or null. */
|
||||
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) return null;
|
||||
try {
|
||||
@@ -166,16 +160,11 @@ export class EntryFlow {
|
||||
}
|
||||
}
|
||||
|
||||
/** Build live printer instances for a lane (for failover selection). */
|
||||
async #loadPrinters(lane: number): Promise<PrinterInstance[]> {
|
||||
const rows = await this.#db
|
||||
.select()
|
||||
.from(laneDevices)
|
||||
.where(and(eq(laneDevices.category, "printer"), eq(laneDevices.lane, lane)))
|
||||
.all();
|
||||
/** Build live ENTRY printer instances (for failover selection). */
|
||||
#loadPrinters(): PrinterInstance[] {
|
||||
const rows = devicesByDirection(this.#db, "printer", "entry"); // already enabled-filtered
|
||||
const out: PrinterInstance[] = [];
|
||||
for (const row of rows) {
|
||||
if (!row.enabled) continue;
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) continue;
|
||||
const cfg = row.config as Record<string, unknown>;
|
||||
|
||||
@@ -17,7 +17,6 @@ import type { Direction, IdentitySource, LedgerEventType, LedgerPayload, Signer
|
||||
|
||||
export interface AppendInput {
|
||||
readonly type: LedgerEventType;
|
||||
readonly lane: number;
|
||||
readonly direction?: Direction | null;
|
||||
readonly source?: IdentitySource | null;
|
||||
readonly identity?: string | null;
|
||||
@@ -38,7 +37,6 @@ export function canonicalize(e: {
|
||||
index: number;
|
||||
type: string;
|
||||
direction: string | null;
|
||||
lane: number;
|
||||
source: string | null;
|
||||
identity: string | null;
|
||||
payload: Record<string, unknown> | null;
|
||||
@@ -49,7 +47,6 @@ export function canonicalize(e: {
|
||||
e.index,
|
||||
e.type,
|
||||
e.direction ?? null,
|
||||
e.lane,
|
||||
e.source ?? null,
|
||||
e.identity ?? null,
|
||||
// Payload is part of the signed form so business data is tamper-evident.
|
||||
@@ -120,7 +117,6 @@ export class EventLog {
|
||||
index,
|
||||
type: input.type,
|
||||
direction: input.direction ?? null,
|
||||
lane: input.lane,
|
||||
source: input.source ?? null,
|
||||
identity: input.identity ?? null,
|
||||
payload,
|
||||
@@ -133,7 +129,6 @@ export class EventLog {
|
||||
index,
|
||||
type: input.type,
|
||||
direction: input.direction ?? null,
|
||||
lane: input.lane,
|
||||
source: input.source ?? null,
|
||||
identity: input.identity ?? null,
|
||||
payload,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { and, eq, laneDevices, ledgerEvents, sessions, type Db } from "@parking/db";
|
||||
import { eq, ledgerEvents, sessions, type Db, type DeviceRow } from "@parking/db";
|
||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||
import type { ResolvedRelay } from "./device-resolve.js";
|
||||
import { snapshotAsync } from "./snapshot.js";
|
||||
import type { LedgerPayload } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||
@@ -25,7 +27,6 @@ import type { EventLog } from "./event-log.js";
|
||||
|
||||
interface SessionView {
|
||||
readonly identity: string;
|
||||
readonly lane: number;
|
||||
readonly enteredAt: string;
|
||||
readonly open: boolean; // no vehicle_exit yet
|
||||
readonly paidAt: string | null; // latest payment time, if any
|
||||
@@ -44,23 +45,23 @@ export class ExitFlow {
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Handle a transient-ticket read at a known exit lane (lane pre-resolved by the
|
||||
* read dispatcher, which has already ruled out a permit match). */
|
||||
async handleAt(lane: number, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||
/** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the
|
||||
* read dispatcher from the reader's binding, which has ruled out a permit match). */
|
||||
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||
const key = `${e.deviceId}:${e.value}`;
|
||||
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
|
||||
this.#inFlight.add(key);
|
||||
try {
|
||||
return await this.#runExit(lane, e);
|
||||
return await this.#runExit(resolved, e);
|
||||
} catch (err) {
|
||||
this.#logger.error(`exit-flow failed (lane ${lane}): ${(err as Error).message}`);
|
||||
this.#logger.error(`exit-flow failed: ${(err as Error).message}`);
|
||||
return { accepted: false, reason: (err as Error).message };
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async #runExit(lane: number, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||
async #runExit(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||
const view = this.#sessionFor(e.value);
|
||||
|
||||
// No matching open session — unknown/duplicate ticket. Reject + log.
|
||||
@@ -68,11 +69,10 @@ export class ExitFlow {
|
||||
const reason = view ? "exit refused — session already closed" : "exit refused — no open session for credential";
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
lane,
|
||||
identity: e.value,
|
||||
payload: { reason, exitRefused: true },
|
||||
});
|
||||
this.#logger.warn(`exit refused (lane ${lane}): no open session for ${e.value}`);
|
||||
this.#logger.warn(`exit refused: no open session for ${e.value}`);
|
||||
return { accepted: false, direction: "exit", reason };
|
||||
}
|
||||
|
||||
@@ -89,30 +89,33 @@ export class ExitFlow {
|
||||
: "exit refused — walk-back grace expired (top-up required)";
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
lane,
|
||||
identity: e.value,
|
||||
payload: { reason, exitRefused: true, sessionRef: e.value },
|
||||
});
|
||||
this.#logger.warn(`exit refused (lane ${lane}, ${e.value}): ${reason}`);
|
||||
this.#logger.warn(`exit refused (${e.value}): ${reason}`);
|
||||
return { accepted: false, direction: "exit", reason };
|
||||
}
|
||||
|
||||
// Valid: sign the exit BEFORE opening, then open, then update the cache.
|
||||
await this.#log.append({
|
||||
type: "vehicle_exit",
|
||||
lane,
|
||||
direction: "exit",
|
||||
source: e.kind === "plate" ? "lpr" : "ticket",
|
||||
identity: e.value,
|
||||
payload: { sessionRef: e.value },
|
||||
});
|
||||
|
||||
const access = await this.#exitAccess(lane);
|
||||
if (access) {
|
||||
await access.pulseOpen(1); // exit barrier; door mapping is config-driven later
|
||||
} else {
|
||||
this.#logger.warn(`exit signed for ${e.value} but lane ${lane} has no access device to open`);
|
||||
}
|
||||
const access = this.#buildAccess(resolved.controller);
|
||||
if (access) await access.pulseOpen(resolved.relay);
|
||||
else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`);
|
||||
|
||||
// SNAPSHOT — fire the exit camera(s), never awaited (evidence, not a gate).
|
||||
void snapshotAsync({
|
||||
db: this.#db,
|
||||
direction: "exit",
|
||||
identity: e.value,
|
||||
logger: this.#logger,
|
||||
}).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`));
|
||||
|
||||
try {
|
||||
this.#db
|
||||
@@ -152,7 +155,6 @@ export class ExitFlow {
|
||||
|
||||
return {
|
||||
identity,
|
||||
lane: entry.lane,
|
||||
enteredAt: entry.occurredAt,
|
||||
open: !exited,
|
||||
paidAt,
|
||||
@@ -160,14 +162,8 @@ export class ExitFlow {
|
||||
};
|
||||
}
|
||||
|
||||
/** The lane's access device, to open the exit barrier. */
|
||||
async #exitAccess(lane: number): Promise<AccessControlDevice | null> {
|
||||
const row = await this.#db
|
||||
.select()
|
||||
.from(laneDevices)
|
||||
.where(and(eq(laneDevices.category, "access"), eq(laneDevices.lane, lane)))
|
||||
.get();
|
||||
if (!row || !row.enabled) return null;
|
||||
/** Build a live access adapter from a resolved controller row, or null. */
|
||||
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) return null;
|
||||
try {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -83,7 +83,6 @@ export class PayStation {
|
||||
|
||||
await this.#log.append({
|
||||
type: "payment",
|
||||
lane: -1, // payment happens at a central station, not a lane
|
||||
source: "manual",
|
||||
identity,
|
||||
payload: {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { and, eq, laneDevices, ledgerEvents, permitCredentials, permitPlates, permits, sessions, type Db } from "@parking/db";
|
||||
import { eq, ledgerEvents, permitCredentials, permitPlates, permits, sessions, type Db, type DeviceRow } from "@parking/db";
|
||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { snapshotAsync } from "./snapshot.js";
|
||||
|
||||
// PERMIT flow: a subscriber identified by card/QR/plate enters/exits without paying.
|
||||
// Reached from the read dispatcher when a read matches a permit (not an open ticket).
|
||||
@@ -57,22 +59,23 @@ export class PermitFlow {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Run the permit entry/exit for a matched read at a lane. */
|
||||
async run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> {
|
||||
/** Run the permit entry/exit for a matched read at a barrier. `resolved` is the
|
||||
* reader's bound relay; its direction constrains, "both" defers to session state. */
|
||||
async run(resolved: ResolvedRelay, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> {
|
||||
const key = `${m.permitId}:${m.carKey}`;
|
||||
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
|
||||
this.#inFlight.add(key);
|
||||
try {
|
||||
return await this.#run(lane, e, m);
|
||||
return await this.#run(resolved, e, m);
|
||||
} catch (err) {
|
||||
this.#logger.error(`permit-flow failed (lane ${lane}): ${(err as Error).message}`);
|
||||
this.#logger.error(`permit-flow failed: ${(err as Error).message}`);
|
||||
return { accepted: false, reason: (err as Error).message };
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async #run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> {
|
||||
async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> {
|
||||
const permit = this.#db.select().from(permits).where(eq(permits.id, m.permitId)).get();
|
||||
if (!permit) return { accepted: false, reason: "permit not found" };
|
||||
|
||||
@@ -84,23 +87,33 @@ export class PermitFlow {
|
||||
(permit.validTo != null && now > permit.validTo);
|
||||
if (invalid) {
|
||||
const reason = `permit ${permit.status}/out-of-window`;
|
||||
await this.#reject(lane, m, reason);
|
||||
await this.#reject(m, reason);
|
||||
return { accepted: false, reason };
|
||||
}
|
||||
|
||||
// Direction: the car's open-session state is the natural verb (in→exit, out→entry).
|
||||
// The barrier the car is at (resolved.direction) must AGREE — a car at an exit
|
||||
// barrier that isn't inside (or at an entry barrier while already in) is a
|
||||
// wrong-barrier / anti-passback signal, refused + logged. A "both" barrier follows
|
||||
// the session state.
|
||||
const carOpen = this.#carHasOpenSession(m.carKey);
|
||||
const inferred: FlowDirection = carOpen ? "exit" : "entry";
|
||||
if (resolved.direction !== "both" && resolved.direction !== inferred) {
|
||||
const reason = `permit wrong barrier — ${resolved.direction} barrier but car would ${inferred}`;
|
||||
await this.#reject(m, reason);
|
||||
return { accepted: false, direction: resolved.direction === "exit" ? "exit" : "entry", reason };
|
||||
}
|
||||
|
||||
if (carOpen) {
|
||||
// EXIT: this car is already inside → the read is its exit.
|
||||
await this.#log.append({
|
||||
type: "vehicle_exit",
|
||||
lane,
|
||||
direction: "exit",
|
||||
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
|
||||
identity: m.carKey,
|
||||
payload: { sessionRef: m.carKey, permitId: m.permitId },
|
||||
});
|
||||
await this.#open(lane, m.carKey, "permit exit");
|
||||
await this.#open(resolved, "exit", m.carKey, "permit exit");
|
||||
this.#closeCache(m.carKey);
|
||||
return { accepted: true, direction: "exit" };
|
||||
}
|
||||
@@ -110,14 +123,13 @@ export class PermitFlow {
|
||||
const open = this.#permitOpenCount(m.permitId);
|
||||
if (open >= permit.maxConcurrent) {
|
||||
const reason = `permit at capacity (${open}/${permit.maxConcurrent} cars in)`;
|
||||
await this.#reject(lane, m, reason);
|
||||
await this.#reject(m, reason);
|
||||
return { accepted: false, direction: "entry", reason };
|
||||
}
|
||||
}
|
||||
|
||||
await this.#log.append({
|
||||
type: "vehicle_entry",
|
||||
lane,
|
||||
direction: "entry",
|
||||
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
|
||||
identity: m.carKey,
|
||||
@@ -125,11 +137,11 @@ export class PermitFlow {
|
||||
payload: { sessionRef: m.carKey, permitId: m.permitId, permit: true },
|
||||
occurredAt: now,
|
||||
});
|
||||
await this.#open(lane, m.carKey, "permit entry");
|
||||
await this.#open(resolved, "entry", m.carKey, "permit entry");
|
||||
try {
|
||||
this.#db
|
||||
.insert(sessions)
|
||||
.values({ id: m.carKey, lane, identity: m.carKey, source: m.via === "plate" ? "lpr" : "wiegand", permitId: m.permitId, enteredAt: now, state: "open" })
|
||||
.values({ id: m.carKey, identity: m.carKey, source: m.via === "plate" ? "lpr" : "wiegand", permitId: m.permitId, enteredAt: now, state: "open" })
|
||||
.run();
|
||||
} catch (err) {
|
||||
this.#logger.error(`session-cache insert failed for ${m.carKey}: ${(err as Error).message}`);
|
||||
@@ -155,7 +167,7 @@ export class PermitFlow {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(and(eq(ledgerEvents.type, "vehicle_entry")))
|
||||
.where(eq(ledgerEvents.type, "vehicle_entry"))
|
||||
.all()
|
||||
.filter((r) => (r.payload as { permitId?: string } | null)?.permitId === permitId);
|
||||
let open = 0;
|
||||
@@ -166,20 +178,27 @@ export class PermitFlow {
|
||||
return open;
|
||||
}
|
||||
|
||||
async #reject(lane: number, m: PermitMatch, reason: string): Promise<void> {
|
||||
async #reject(m: PermitMatch, reason: string): Promise<void> {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
lane,
|
||||
identity: m.carKey,
|
||||
payload: { reason: `permit refused — ${reason}`, permitId: m.permitId, permitRefused: true },
|
||||
});
|
||||
this.#logger.warn(`permit refused (lane ${lane}, ${m.carKey}): ${reason}`);
|
||||
this.#logger.warn(`permit refused (${m.carKey}): ${reason}`);
|
||||
}
|
||||
|
||||
async #open(lane: number, carKey: string, what: string): Promise<void> {
|
||||
const access = await this.#access(lane);
|
||||
if (access) await access.pulseOpen(1);
|
||||
else this.#logger.warn(`${what} signed for ${carKey} but lane ${lane} has no access device`);
|
||||
async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise<void> {
|
||||
const access = this.#buildAccess(resolved.controller);
|
||||
if (access) await access.pulseOpen(resolved.relay);
|
||||
else this.#logger.warn(`${what} signed for ${carKey} but the ${dir} relay won't build`);
|
||||
|
||||
// SNAPSHOT — fire the directional camera(s), never awaited (evidence, not a gate).
|
||||
void snapshotAsync({
|
||||
db: this.#db,
|
||||
direction: dir,
|
||||
identity: carKey,
|
||||
logger: this.#logger,
|
||||
}).catch((err) => this.#logger.error(`permit snapshot error: ${(err as Error).message}`));
|
||||
}
|
||||
|
||||
#closeCache(carKey: string): void {
|
||||
@@ -190,13 +209,8 @@ export class PermitFlow {
|
||||
}
|
||||
}
|
||||
|
||||
async #access(lane: number): Promise<AccessControlDevice | null> {
|
||||
const row = await this.#db
|
||||
.select()
|
||||
.from(laneDevices)
|
||||
.where(and(eq(laneDevices.category, "access"), eq(laneDevices.lane, lane)))
|
||||
.get();
|
||||
if (!row || !row.enabled) return null;
|
||||
/** Build a live access adapter from a resolved controller row, or null. */
|
||||
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) return null;
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { eq, laneDevices, type Db } from "@parking/db";
|
||||
import { eq, devices, type Db } from "@parking/db";
|
||||
import {
|
||||
isMonitorable,
|
||||
registry,
|
||||
@@ -66,8 +66,8 @@ export class PrinterMonitor {
|
||||
async refreshDevices(): Promise<void> {
|
||||
const rows = await this.#db
|
||||
.select()
|
||||
.from(laneDevices)
|
||||
.where(eq(laneDevices.category, "printer"))
|
||||
.from(devices)
|
||||
.where(eq(devices.category, "printer"))
|
||||
.all();
|
||||
|
||||
const seen = new Set<string>();
|
||||
@@ -89,7 +89,6 @@ export class PrinterMonitor {
|
||||
build: () => driver.create(cfg as never),
|
||||
meta: {
|
||||
deviceId: row.id,
|
||||
lane: row.lane,
|
||||
driverId: row.driverId,
|
||||
role: typeof cfg.role === "string" ? cfg.role : undefined,
|
||||
},
|
||||
@@ -139,7 +138,7 @@ export class PrinterMonitor {
|
||||
|
||||
if (!prev || statusChanged(prev.status, status)) {
|
||||
this.#log.info(
|
||||
`printer-monitor: ${entry.meta.role ?? "printer"} ${id} (lane ${entry.meta.lane}) -> ${status.status}${status.detail ? ` (${status.detail})` : ""}`,
|
||||
`printer-monitor: ${entry.meta.role ?? "printer"} ${id} -> ${status.status}${status.detail ? ` (${status.detail})` : ""}`,
|
||||
);
|
||||
deviceEvents.emitPrinterStatus(event);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import type { Db } from "@parking/db";
|
||||
import { devices, eq, type Db } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||
import type { ExitFlow } from "./exit-flow.js";
|
||||
import type { PermitFlow } from "./permit-flow.js";
|
||||
import { readerLaneWithAccess } from "./lane-map.js";
|
||||
import { relayForDevice } from "./device-resolve.js";
|
||||
|
||||
// Routes a credential read (ticket scan / plate / card) to the right flow. A read
|
||||
// can mean a permit entry/exit OR a transient exit, so we dispatch by WHAT the
|
||||
// credential is (decision 2026-06-15):
|
||||
// - matches a permit (card/QR/bound plate) → PERMIT flow (direction inferred from
|
||||
// the car's open-session state),
|
||||
// - matches a permit (card/QR/bound plate) → PERMIT flow,
|
||||
// - else → transient EXIT flow (open ticket session → exit, else reject+log).
|
||||
// Lane is resolved once here; both flows act on a known access-equipped lane.
|
||||
//
|
||||
// The reader is BOUND to a controller relay (config.controllerId + relay), so a read
|
||||
// resolves to exactly the barrier it sits at, and the direction is inherited from
|
||||
// that relay (see entry-exit-points.md). The resolved relay is handed to the flow so
|
||||
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
|
||||
// reader the exit side; "both" defers to the flow's own inference (permit: session
|
||||
// state; transient: exit).
|
||||
|
||||
export class ReadDispatcher {
|
||||
readonly #db: Db;
|
||||
@@ -27,16 +32,25 @@ export class ReadDispatcher {
|
||||
}
|
||||
|
||||
async dispatch(e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||
const lane = await readerLaneWithAccess(this.#db, e.deviceId);
|
||||
if (lane == null) {
|
||||
return { accepted: false, reason: "reader not on an access-equipped lane" };
|
||||
const reader = this.#db.select().from(devices).where(eq(devices.id, e.deviceId)).get();
|
||||
if (!reader || !reader.enabled) {
|
||||
return { accepted: false, reason: "read from unknown/disabled device" };
|
||||
}
|
||||
const resolved = relayForDevice(this.#db, reader);
|
||||
if (!resolved) {
|
||||
return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" };
|
||||
}
|
||||
|
||||
const permit = this.#permit.match(e);
|
||||
if (permit) {
|
||||
return this.#permit.run(lane, e, permit);
|
||||
return this.#permit.run(resolved, e, permit);
|
||||
}
|
||||
// Not a permit → transient ticket exit (the exit flow rejects+logs if unknown).
|
||||
return this.#exit.handleAt(lane, e);
|
||||
// Not a permit → transient ticket exit. An ENTRY reader can't produce a transient
|
||||
// exit (transient entry is the button flow, not a reader), so reject+log rather
|
||||
// than treat an entry scan as an exit.
|
||||
if (resolved.direction === "entry") {
|
||||
return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" };
|
||||
}
|
||||
return this.#exit.handleAt(resolved, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { eq, laneDevices, type Db } from "@parking/db";
|
||||
import { eq, devices, type Db } from "@parking/db";
|
||||
import { deviceEvents } from "../device-events.js";
|
||||
import { verifyDigest } from "../digest-auth.js";
|
||||
|
||||
@@ -36,7 +36,7 @@ export async function deviceRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
const handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => {
|
||||
const { deviceId, n, edge } = req.params;
|
||||
|
||||
const row = await db.select().from(laneDevices).where(eq(laneDevices.id, deviceId)).get();
|
||||
const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||
const cfg = row?.config as DingtianDeviceConfig | undefined;
|
||||
|
||||
// Unknown device / not a dingtian / no push creds / wrong source IP → 404.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, laneDevices, type Db } from "@parking/db";
|
||||
import { eq, devices, type Db } from "@parking/db";
|
||||
import type { DeviceReadEvent } from "../device-events.js";
|
||||
import type { ReadDispatcher } from "../read-dispatch.js";
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function qrReaderRoutes(
|
||||
// reader is assigned for that serial. (Small device set → scan in JS.)
|
||||
const readerRowIdForSerial = (serial: string): string | null => {
|
||||
if (!serial) return null;
|
||||
const rows = db.select().from(laneDevices).where(eq(laneDevices.category, "reader")).all();
|
||||
const rows = db.select().from(devices).where(eq(devices.category, "reader")).all();
|
||||
const match = rows.find((r) => r.enabled && (r.config as { serial?: string }).serial === serial);
|
||||
return match?.id ?? null;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, laneDevices, setupState, type Db } from "@parking/db";
|
||||
import { eq, devices, setupState, type Db } from "@parking/db";
|
||||
import {
|
||||
hasPreconditions,
|
||||
hasPushConfig,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
registry,
|
||||
setDeviceLogSink,
|
||||
type DeviceCategory,
|
||||
type DeviceConfig,
|
||||
} from "@parking/devices";
|
||||
import { requireRole } from "../auth.js";
|
||||
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
||||
@@ -18,10 +19,12 @@ import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js"
|
||||
// per lane. See wiki/concepts/first-run-setup.md.
|
||||
|
||||
interface AssignBody {
|
||||
lane: number;
|
||||
category: DeviceCategory;
|
||||
driverId: string;
|
||||
config: Record<string, string | number | boolean>;
|
||||
// Driver config (opaque JSON, validated by the driver). Carries the model's
|
||||
// direction/binding: access → config.relays=[{relay,direction,button?}];
|
||||
// reader/camera → config.controllerId + config.relay. See entry-exit-points.md.
|
||||
config: DeviceConfig;
|
||||
/** Optional: the backend IP the device should push to (overrides auto-pick;
|
||||
* matters on multi-NIC hosts). */
|
||||
backendIp?: string;
|
||||
@@ -48,13 +51,7 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function setupRoutes(
|
||||
app: FastifyInstance,
|
||||
db: Db,
|
||||
// Called after the set of assignments changes (assign/unassign) so the caller
|
||||
// can refresh anything derived from it — e.g. the device id->lane map.
|
||||
onAssignmentsChanged: () => void = () => {},
|
||||
): Promise<void> {
|
||||
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
registerBuiltinDrivers();
|
||||
setDeviceLogSink((line) => app.log.info(line));
|
||||
|
||||
@@ -110,7 +107,7 @@ export async function setupRoutes(
|
||||
{ preHandler: adminGuard },
|
||||
async () => {
|
||||
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get();
|
||||
const rows = await db.select().from(laneDevices).all();
|
||||
const rows = await db.select().from(devices).all();
|
||||
const assignments = rows.map((r) => ({ ...r, config: redactSecrets(r.config) }));
|
||||
return { completedAt: state?.completedAt ?? null, assignments };
|
||||
},
|
||||
@@ -154,15 +151,15 @@ export async function setupRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// Assign a device to a lane. Validates the chosen driver + config, configures
|
||||
// the device (fix preconditions + set up Digest-authenticated input push — no
|
||||
// manual device-web-UI step by the admin), then persists. Fails the save if
|
||||
// the device can't be configured. See wiki/concepts/device-input-flow.md.
|
||||
// Assign a device. Validates the chosen driver + config, configures the device
|
||||
// (fix preconditions + set up Digest-authenticated input push — no manual device-
|
||||
// web-UI step by the admin), then persists. Fails the save if the device can't be
|
||||
// configured. See wiki/concepts/device-input-flow.md, entry-exit-points.md.
|
||||
app.post<{ Body: AssignBody }>(
|
||||
"/api/setup/assign",
|
||||
{ preHandler: adminGuard },
|
||||
async (req, reply) => {
|
||||
const { lane, category, driverId, config, backendIp } = req.body;
|
||||
const { category, driverId, config, backendIp } = req.body;
|
||||
const driver = registry.get(driverId);
|
||||
if (!driver || driver.category !== category) {
|
||||
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
|
||||
@@ -252,14 +249,12 @@ export async function setupRoutes(
|
||||
|
||||
const row = {
|
||||
id,
|
||||
lane,
|
||||
category,
|
||||
driverId,
|
||||
config: fullConfig,
|
||||
enabled: true,
|
||||
};
|
||||
await db.insert(laneDevices).values(row);
|
||||
onAssignmentsChanged(); // refresh derived state (device->lane map)
|
||||
await db.insert(devices).values(row);
|
||||
// Don't echo device secrets back (push Digest password, web-UI login, …).
|
||||
return reply.code(201).send({
|
||||
...row,
|
||||
@@ -285,13 +280,12 @@ export async function setupRoutes(
|
||||
async (req, reply) => {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(laneDevices)
|
||||
.where(eq(laneDevices.id, req.params.id))
|
||||
.from(devices)
|
||||
.where(eq(devices.id, req.params.id))
|
||||
.get();
|
||||
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
|
||||
await db.delete(laneDevices).where(eq(laneDevices.id, req.params.id));
|
||||
onAssignmentsChanged(); // refresh derived state (device->lane map)
|
||||
app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId}, lane ${existing.lane})`);
|
||||
await db.delete(devices).where(eq(devices.id, req.params.id));
|
||||
app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId})`);
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
@@ -12,7 +12,6 @@ import { PayStation } from "./pay-station.js";
|
||||
import { PermitFlow } from "./permit-flow.js";
|
||||
import { ShiftService } from "./shift-service.js";
|
||||
import { ReadDispatcher } from "./read-dispatch.js";
|
||||
import { LaneMap } from "./lane-map.js";
|
||||
import { PrinterMonitor } from "./printer-monitor.js";
|
||||
import { buildSigner } from "./signer.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
@@ -23,6 +22,7 @@ import { permitRoutes } from "./routes/permits.js";
|
||||
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
||||
import { shiftRoutes } from "./routes/shift.js";
|
||||
import { siteRoutes } from "./routes/site.js";
|
||||
import { snapshotRoutes } from "./routes/snapshots.js";
|
||||
import { tariffRoutes } from "./routes/tariffs.js";
|
||||
import { printerRoutes } from "./routes/printers.js";
|
||||
import { setupRoutes } from "./routes/setup.js";
|
||||
@@ -61,15 +61,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
|
||||
await authRoutes(app, db);
|
||||
|
||||
// device id -> lane resolver. Built from lane_devices at startup and refreshed
|
||||
// by setupRoutes on assign/unassign, so device events can be stamped with the
|
||||
// lane the device belongs to (events carry the device id, not a lane).
|
||||
const laneMap = new LaneMap(db);
|
||||
laneMap.refresh();
|
||||
|
||||
// Device-agnostic setup: the admin selects devices per lane from the driver
|
||||
// catalog at first-run. See wiki/concepts/first-run-setup.md.
|
||||
await setupRoutes(app, db, () => laneMap.refresh());
|
||||
// Device-agnostic setup: the admin adds controllers (with their relays + entry
|
||||
// button) and binds readers/cameras to a controller relay at first-run. There is
|
||||
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
||||
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
||||
await setupRoutes(app, db);
|
||||
|
||||
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
||||
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
||||
@@ -93,11 +89,14 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
const eventLog = new EventLog(db, buildSigner(app.log));
|
||||
await eventRoutes(app, db, eventLog);
|
||||
|
||||
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
||||
await snapshotRoutes(app, db);
|
||||
|
||||
// Entry flow: a button press → print ticket → signed vehicle_entry → pulseOpen.
|
||||
// Subscribes to the SAME input bus as the telemetry writer below; the two are
|
||||
// independent (telemetry always records; the entry flow acts only on an access
|
||||
// device's rising edge). See wiki/concepts/device-input-flow.md + parking-session.md.
|
||||
const entryFlow = new EntryFlow(db, eventLog, laneMap, app.log);
|
||||
const entryFlow = new EntryFlow(db, eventLog, app.log);
|
||||
const unsubscribeEntry = deviceEvents.onInput((e) => {
|
||||
void entryFlow.onInput(e);
|
||||
});
|
||||
@@ -141,19 +140,14 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
await siteRoutes(app, db);
|
||||
|
||||
const unsubscribeInput = deviceEvents.onInput((e) => {
|
||||
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
|
||||
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded
|
||||
// faithfully rather than silently dropped or mis-stamped as lane 0 (a real lane).
|
||||
const lane = laneMap.laneFor(e.deviceId) ?? -1;
|
||||
if (lane === -1) {
|
||||
app.log.warn(`input from unmapped device ${e.driverId}:${e.deviceId} — logged as lane -1`);
|
||||
}
|
||||
// Record every input edge as unsigned telemetry, keyed to the device that fired
|
||||
// (provenance). No lane — the pool-of-spaces model has none. The entry flow
|
||||
// (above) independently decides whether this edge is an entry button.
|
||||
try {
|
||||
db.insert(deviceEventsTable)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
deviceId: e.deviceId,
|
||||
lane,
|
||||
category: "access",
|
||||
kind: "input",
|
||||
detail: { driverId: e.driverId, input: e.input, edge: e.edge },
|
||||
@@ -166,7 +160,5 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
});
|
||||
app.addHook("onClose", async () => unsubscribeInput());
|
||||
|
||||
// TODO: entry flow (device input → signed vehicle_entry → print → relay).
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq, laneDevices, ledgerEvents, type Db } from "@parking/db";
|
||||
import { eq, devices, ledgerEvents, type Db } from "@parking/db";
|
||||
import { registry, type PrinterDevice } from "@parking/devices";
|
||||
import type { LedgerPayload } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
@@ -66,7 +66,6 @@ export class ShiftService {
|
||||
const startedAt = new Date().toISOString();
|
||||
await this.#log.append({
|
||||
type: "shift_open",
|
||||
lane: -1,
|
||||
source: "manual",
|
||||
identity: operator, // the shift's operator; `identity` keys the shift to them
|
||||
payload: { operator },
|
||||
@@ -105,7 +104,6 @@ export class ShiftService {
|
||||
|
||||
await this.#log.append({
|
||||
type: "shift_z_report",
|
||||
lane: -1,
|
||||
source: "manual",
|
||||
identity: operator,
|
||||
payload: {
|
||||
@@ -163,9 +161,9 @@ export class ShiftService {
|
||||
}
|
||||
}
|
||||
|
||||
/** First enabled booth-receipt printer (any lane), or any enabled printer. */
|
||||
/** First enabled booth-receipt printer, or any enabled printer. */
|
||||
async #boothPrinter(): Promise<PrinterDevice | null> {
|
||||
const rows = await this.#db.select().from(laneDevices).where(eq(laneDevices.category, "printer")).all();
|
||||
const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
|
||||
const enabled = rows.filter((r) => r.enabled);
|
||||
const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0];
|
||||
if (!booth) return null;
|
||||
|
||||
@@ -14,7 +14,10 @@ export class SoftwareSigner implements Signer {
|
||||
readonly keyId: string;
|
||||
readonly #key: Buffer;
|
||||
|
||||
constructor(secret: string, keyId = "sw-hmac-v1") {
|
||||
// v2 canonical form: `lane` dropped from the signed array (pool-of-spaces model,
|
||||
// 2026-06-16). v1 events used a different field order and won't verify under v2 —
|
||||
// that's intentional and gated by the per-event keyId. See event-log canonicalize().
|
||||
constructor(secret: string, keyId = "sw-hmac-v2") {
|
||||
this.#key = Buffer.from(secret, "utf8");
|
||||
this.keyId = keyId;
|
||||
}
|
||||
|
||||
@@ -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
@@ -12,28 +12,41 @@ import {
|
||||
type Catalog,
|
||||
type CatalogEntry,
|
||||
type DeviceCategory,
|
||||
type DeviceConfig,
|
||||
type Direction,
|
||||
type DiscoveredDevice,
|
||||
type RelaySpec,
|
||||
type TestResult,
|
||||
} from "./api.js";
|
||||
|
||||
// First-run setup wizard (scaffold). The admin assigns devices per lane from the
|
||||
// driver catalog. The data model is multi-instance — one lane_devices row per
|
||||
// instance — so EVERY category supports more than one device: each section lists
|
||||
// the already-assigned instances (with Remove) and an "Add" form. Drivers that
|
||||
// support LAN discovery get a "Scan" button. Auth is via the admin's session
|
||||
// cookie. See wiki/concepts/first-run-setup.md and device-discovery.md.
|
||||
// First-run setup wizard. The pool-of-spaces model: a parking lot is one pool with
|
||||
// a flexible set of entry/exit points — NO lane. The admin adds CONTROLLERS (each
|
||||
// declares its relays = entry/exit/both + which input terminal the entry button is
|
||||
// on), then binds READERS / CAMERAS to a controller relay (the barrier they sit at).
|
||||
// Direction is a property of the relay, inherited by bound devices. The data model
|
||||
// is multi-instance — one `devices` row per instance. See entry-exit-points.md.
|
||||
|
||||
const CATEGORIES: { key: DeviceCategory; title: string; noun: string }[] = [
|
||||
{ key: "access", title: "Access controllers", noun: "access controller" },
|
||||
{ key: "reader", title: "Readers", noun: "reader" },
|
||||
{ key: "camera", title: "Cameras (entry/exit snapshot)", noun: "camera" },
|
||||
{ key: "printer", title: "Printers", noun: "printer" },
|
||||
const CONTROLLER: { key: DeviceCategory; title: string; noun: string } = {
|
||||
key: "access",
|
||||
title: "Controllers (barriers + entry button)",
|
||||
noun: "controller",
|
||||
};
|
||||
// Categories that BIND to a controller relay (direction inherited from the relay).
|
||||
const BOUND: { key: DeviceCategory; title: string; noun: string }[] = [
|
||||
{ key: "reader", title: "Readers (QR / RFID)", noun: "reader" },
|
||||
{ key: "camera", title: "Cameras (snapshot + plate)", noun: "camera" },
|
||||
{ key: "printer", title: "Printers (tickets / vouchers)", noun: "printer" },
|
||||
];
|
||||
|
||||
const DIRECTION_LABELS: Record<Direction, string> = {
|
||||
entry: "Entry",
|
||||
exit: "Exit",
|
||||
both: "Both (entry + exit)",
|
||||
};
|
||||
|
||||
export function SetupWizard() {
|
||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
|
||||
const [lane, setLane] = useState(1);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reloadState = useCallback(() => {
|
||||
@@ -50,36 +63,41 @@ export function SetupWizard() {
|
||||
if (error) return <p style={{ color: "crimson" }}>Failed to load setup: {error}</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 (
|
||||
<section>
|
||||
<h2>First-run setup</h2>
|
||||
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
|
||||
<label>
|
||||
Lane{" "}
|
||||
<input
|
||||
type="number"
|
||||
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>
|
||||
<p style={{ color: "#666", fontSize: "0.9em" }}>
|
||||
Add your barrier controllers first — set which relay is entry/exit and which
|
||||
terminal the entry button is wired to. Then add readers, cameras and printers
|
||||
and point each at the barrier it serves.
|
||||
</p>
|
||||
|
||||
{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
|
||||
key={key}
|
||||
lane={lane}
|
||||
category={key}
|
||||
title={title}
|
||||
noun={noun}
|
||||
entries={catalog[key]}
|
||||
discoverableIds={catalog.discoverable}
|
||||
pushCapableIds={catalog.pushCapable}
|
||||
assignments={assignments.filter((a) => a.category === key && a.lane === lane)}
|
||||
controllers={controllers}
|
||||
assignments={assignments.filter((a) => a.category === key)}
|
||||
onChanged={reloadState}
|
||||
/>
|
||||
))}
|
||||
@@ -88,39 +106,37 @@ export function SetupWizard() {
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
lane,
|
||||
category,
|
||||
title,
|
||||
noun,
|
||||
entries,
|
||||
discoverableIds,
|
||||
pushCapableIds,
|
||||
controllers,
|
||||
assignments,
|
||||
onChanged,
|
||||
}: {
|
||||
lane: number;
|
||||
category: DeviceCategory;
|
||||
title: string;
|
||||
noun: string;
|
||||
entries: CatalogEntry[];
|
||||
discoverableIds: string[];
|
||||
pushCapableIds: string[];
|
||||
controllers: Assignment[];
|
||||
assignments: Assignment[];
|
||||
onChanged: () => Promise<void> | void;
|
||||
}) {
|
||||
// Show the add-form automatically when nothing is assigned yet; otherwise it's
|
||||
// collapsed behind "Add another" so the list stays the focus.
|
||||
const [adding, setAdding] = useState(false);
|
||||
// Warnings from the most recent save (e.g. "string protocol could not be
|
||||
// disabled — finish in the device web UI"). Persist after the form closes.
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
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 (
|
||||
<fieldset style={{ marginTop: "1rem" }}>
|
||||
<legend>
|
||||
{title} <span style={{ color: "#888", fontWeight: 400 }}>· lane {lane}</span>
|
||||
</legend>
|
||||
<legend>{title}</legend>
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div
|
||||
@@ -147,18 +163,20 @@ function CategorySection({
|
||||
{assignments.length > 0 && (
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
|
||||
{assignments.map((a) => (
|
||||
<AssignmentRow key={a.id} assignment={a} onChanged={onChanged} />
|
||||
<AssignmentRow key={a.id} assignment={a} controllers={controllers} onChanged={onChanged} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{showForm ? (
|
||||
{blockedNoController ? (
|
||||
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p>
|
||||
) : showForm ? (
|
||||
<DeviceForm
|
||||
lane={lane}
|
||||
category={category}
|
||||
entries={entries}
|
||||
discoverableIds={discoverableIds}
|
||||
pushCapableIds={pushCapableIds}
|
||||
controllers={controllers}
|
||||
onSaved={async (w) => {
|
||||
setWarnings(w);
|
||||
await onChanged();
|
||||
@@ -177,17 +195,17 @@ function CategorySection({
|
||||
|
||||
function AssignmentRow({
|
||||
assignment,
|
||||
controllers,
|
||||
onChanged,
|
||||
}: {
|
||||
assignment: Assignment;
|
||||
controllers: Assignment[];
|
||||
onChanged: () => Promise<void> | void;
|
||||
}) {
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// A short, human summary of the instance: role (if any) + host.
|
||||
const cfg = assignment.config;
|
||||
const role = typeof cfg.role === "string" ? cfg.role : null;
|
||||
const cfg = assignment.config as Record<string, unknown>;
|
||||
const host = typeof cfg.host === "string" ? cfg.host : null;
|
||||
|
||||
async function remove() {
|
||||
@@ -214,8 +232,8 @@ function AssignmentRow({
|
||||
}}
|
||||
>
|
||||
<strong>{assignment.driverId}</strong>
|
||||
{role && <span style={{ color: "#0369a1" }}>{role}</span>}
|
||||
{host && <span style={{ color: "#666" }}>{host}</span>}
|
||||
<DeviceSummary assignment={assignment} controllers={controllers} />
|
||||
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
|
||||
<span style={{ flex: 1 }} />
|
||||
{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({
|
||||
lane,
|
||||
category,
|
||||
entries,
|
||||
discoverableIds,
|
||||
pushCapableIds,
|
||||
controllers,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
lane: number;
|
||||
category: DeviceCategory;
|
||||
entries: CatalogEntry[];
|
||||
discoverableIds: string[];
|
||||
pushCapableIds: string[];
|
||||
controllers: Assignment[];
|
||||
onSaved: (warnings: string[]) => Promise<void> | void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const selected = entries.find((e) => e.id === selectedId);
|
||||
const canDiscover = selected != null && discoverableIds.includes(selected.id);
|
||||
// Only push-capable drivers (e.g. the Dingtian relay) call back to the
|
||||
// backend and need a backend IP. Pull-only devices (cameras, commanded relays)
|
||||
// must NOT show the field. See wiki/concepts/device-input-flow.md.
|
||||
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
|
||||
const isController = category === "access";
|
||||
|
||||
// Config values (auto-filled by discovery, editable by hand).
|
||||
const [config, setConfig] = useState<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 [testing, setTesting] = useState(false);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
@@ -262,17 +313,10 @@ function DeviceForm({
|
||||
const [scanning, setScanning] = useState(false);
|
||||
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 [backendIp, setBackendIp] = useState<string>("");
|
||||
|
||||
// (Re)load backend-IP candidates whenever the device host changes after a
|
||||
// successful test (the test confirms the host is real + reachable) — but only
|
||||
// for push-capable drivers; a pull-only device never calls back.
|
||||
const testedHost = tested ? String(mergedConfig().host ?? "") : "";
|
||||
const testedHost = tested ? String(mergedScalarConfig().host ?? "") : "";
|
||||
useEffect(() => {
|
||||
if (!testedHost || !pushesToBackend) {
|
||||
setBackendIps(null);
|
||||
@@ -319,8 +363,8 @@ function DeviceForm({
|
||||
resetStatus();
|
||||
}
|
||||
|
||||
// Config the user actually entered, merged over driver defaults.
|
||||
function mergedConfig(): Record<string, string | number> {
|
||||
/** Scalar config the user entered, merged over driver defaults (for test/push-IP). */
|
||||
function mergedScalarConfig(): Record<string, string | number> {
|
||||
const out: Record<string, string | number> = {};
|
||||
for (const f of selected?.configFields ?? []) {
|
||||
const v = config[f.key] ?? (f.default as string | number | undefined);
|
||||
@@ -329,7 +373,22 @@ function DeviceForm({
|
||||
return out;
|
||||
}
|
||||
|
||||
// Editing config invalidates a prior test.
|
||||
/** Full config to persist: scalars + the model's direction/binding fields. */
|
||||
function mergedConfig(): DeviceConfig {
|
||||
const out: DeviceConfig = { ...mergedScalarConfig() };
|
||||
if (isController) {
|
||||
out.relays = relays.map((r) => ({
|
||||
relay: r.relay,
|
||||
direction: r.direction,
|
||||
...(r.button ? { button: r.button } : {}),
|
||||
}));
|
||||
} else if (controllerId && boundRelay !== "") {
|
||||
out.controllerId = controllerId;
|
||||
out.relay = boundRelay;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resetStatus() {
|
||||
setTested(null);
|
||||
setTestError(null);
|
||||
@@ -342,7 +401,7 @@ function DeviceForm({
|
||||
setTestError(null);
|
||||
setTested(null);
|
||||
try {
|
||||
setTested(await testDevice(selected.id, mergedConfig()));
|
||||
setTested(await testDevice(selected.id, mergedScalarConfig()));
|
||||
} catch (e) {
|
||||
setTestError((e as Error).message);
|
||||
} finally {
|
||||
@@ -352,17 +411,21 @@ function DeviceForm({
|
||||
|
||||
async function save() {
|
||||
if (!selected) return;
|
||||
// Bound devices must point at a controller relay (binding is optional in the
|
||||
// model with a fallback, but the wizard guides the admin to bind explicitly).
|
||||
if (!isController && (!controllerId || boundRelay === "")) {
|
||||
setSaveError("Pick the controller and relay this device sits at.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const result = await assignDevice({
|
||||
lane,
|
||||
category,
|
||||
driverId: selected.id,
|
||||
config: mergedConfig(),
|
||||
...(backendIp ? { backendIp } : {}),
|
||||
});
|
||||
// Hand warnings to the parent so they persist after this form unmounts.
|
||||
await onSaved(result.warnings ?? []);
|
||||
} catch (e) {
|
||||
setSaveError((e as Error).message);
|
||||
@@ -452,6 +515,23 @@ function DeviceForm({
|
||||
</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). */}
|
||||
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||
<button type="button" onClick={test} disabled={testing}>
|
||||
@@ -487,8 +567,6 @@ function DeviceForm({
|
||||
</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 && (
|
||||
<div style={{ margin: "0.5rem 0 0" }}>
|
||||
<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 }) {
|
||||
const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626";
|
||||
return <span style={{ color, fontWeight: 600 }}>● {status}</span>;
|
||||
|
||||
+22
-3
@@ -120,7 +120,26 @@ export async function discoverDevices(driverId: string): Promise<DiscoveredDevic
|
||||
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 {
|
||||
health: { status: string; detail?: string };
|
||||
@@ -153,9 +172,10 @@ export function fetchBackendIps(
|
||||
}
|
||||
|
||||
export interface AssignBody {
|
||||
lane: number;
|
||||
category: DeviceCategory;
|
||||
driverId: string;
|
||||
// Direction/binding lives in config: access → config.relays=[{relay,direction,button?}];
|
||||
// reader/camera → config.controllerId + config.relay.
|
||||
config: DeviceConfig;
|
||||
/** Backend IP the device should push to (overrides auto-pick). */
|
||||
backendIp?: string;
|
||||
@@ -169,7 +189,6 @@ export function assignDevice(body: AssignBody): Promise<AssignResult> {
|
||||
/** A persisted device assignment (one per instance; machine-only secrets stripped). */
|
||||
export interface Assignment {
|
||||
id: string;
|
||||
lane: number;
|
||||
category: DeviceCategory;
|
||||
driverId: string;
|
||||
config: DeviceConfig;
|
||||
|
||||
Reference in New Issue
Block a user