2915d141aa
Model the entry button (I1) and a Hikvision radar (I2) as named children of the access controller, and drive the button's 12V lamp on a spare relay. - Radar = the existing relays[].presenceInput one-car-one-ticket gate, now labelled presenceKind: loop|radar. A radar may idle opposite the button, so add a per-input active-level override: relays[].presenceActiveLow -> driver inputActiveLow set, inverting just that terminal (pure helper inputActive()). The Dingtian has one board-wide resting level otherwise. - AuxOutputDevice.setAux(channel,on) capability on the device interface (Dingtian latch) so business logic drives a NON-barrier lamp through the interface. Barriers still only pulseOpen — barrier-not-a-door preserved. - ButtonLightController: subscribes to the radar input edge + the camera lane status and drives a 3-state lamp — radar+car=solid, radar-only=blink (~1Hz), else off. Fails OFF on host loss/error; de-duped. A radar detection never opens a barrier on its own (advisory; threat model). - SetupWizard: presence kind + active-low + a button-light relay picker; sq+en i18n. Tests: button-light.test.ts (truth table + blink + fail-OFF + de-dupe), access-dingtian.test.ts (active-level inversion). Workspace build+lint+test green (158 server tests). Wiki: hikvision-radar, button-light-indicator + updates. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
345 lines
15 KiB
TypeScript
345 lines
15 KiB
TypeScript
// Device-agnostic adapter interfaces.
|
|
//
|
|
// Business logic talks ONLY to these interfaces, never to a device SDK. Swapping
|
|
// hardware means writing a new adapter that implements one of these — nothing
|
|
// else changes. See wiki/concepts/device-adapter-pattern.md.
|
|
//
|
|
// SAFETY: a barrier is NOT a door. The relay interface expresses INTENT only
|
|
// (`pulseOpen`); it never times or forces a close against a vehicle. Physical
|
|
// safety (induction loops, anti-crush, auto-reverse) lives in the barrier
|
|
// operator's own firmware. See wiki/concepts/barrier-not-a-door.md.
|
|
|
|
/** The four device categories an admin configures per lane. */
|
|
export type DeviceCategory = "access" | "reader" | "camera" | "printer";
|
|
|
|
/** Lifecycle shared by every device adapter. */
|
|
export interface Device {
|
|
/** Stable id of the driver that produced this instance (e.g. "dingtian"). */
|
|
readonly driverId: string;
|
|
connect(): Promise<void>;
|
|
disconnect(): Promise<void>;
|
|
/** Liveness/health probe used by setup ("Test connection") and monitoring. */
|
|
healthCheck(): Promise<DeviceHealth>;
|
|
}
|
|
|
|
export interface DeviceHealth {
|
|
readonly status: "ready" | "offline" | "degraded";
|
|
readonly detail?: string;
|
|
}
|
|
|
|
// --- Access control (barrier relay) --------------------------------------
|
|
// The Dingtian relay board (and any future relay controller) implements this.
|
|
export interface AccessControlDevice extends Device {
|
|
/** Express intent to open. NEVER timed/forced closed against a vehicle. */
|
|
pulseOpen(doorId: number): Promise<void>;
|
|
getDoorStatus(doorId: number): Promise<"open" | "closed">;
|
|
}
|
|
|
|
// --- Auxiliary outputs (non-barrier latched signals) ---------------------
|
|
// Optional capability for controllers with SPARE relays wired to something that
|
|
// is NOT a barrier — a button lamp, a "wait"/"go" sign. setAux LATCHES the output
|
|
// on or off and holds it (unlike pulseOpen, which is momentary). The
|
|
// barrier-not-a-door rule does NOT apply here: this output never gates a vehicle,
|
|
// so holding/blinking it is fine. Business logic drives indicators through THIS,
|
|
// never the driver's own relay methods. See wiki/concepts/button-light-indicator.md.
|
|
export interface AuxOutputDevice {
|
|
/** Latch an auxiliary output on/off. 1-based channel (a spare relay). */
|
|
setAux(channel: number, on: boolean): Promise<void>;
|
|
}
|
|
|
|
/** Feature-detect the aux-output capability on a built device adapter. */
|
|
export function hasAuxOutput(d: unknown): d is AuxOutputDevice {
|
|
return typeof (d as Partial<AuxOutputDevice>)?.setAux === "function";
|
|
}
|
|
|
|
// --- Inputs (buttons / dry contacts) -------------------------------------
|
|
// Optional capability for controllers that expose host-readable inputs SEPARATE
|
|
// from their relays — e.g. the Dingtian board. This is what enables host-in-the-
|
|
// loop entry: a button press is reported to the host, which decides (print a
|
|
// ticket) before commanding the relay — instead of the input auto-firing the
|
|
// relay. See wiki/decisions/access-controller-button-flow.md.
|
|
export interface InputDevice {
|
|
/** Read the current state of all inputs (true = active/pressed). */
|
|
readInputs(): Promise<boolean[]>;
|
|
/**
|
|
* Subscribe to input edges. Returns an unsubscribe fn. Implementations may
|
|
* back this with hardware push or polling — the consumer doesn't care.
|
|
*/
|
|
onInput(cb: (event: InputEvent) => void): () => void;
|
|
}
|
|
|
|
export interface InputEvent {
|
|
/** 1-based input/channel index. */
|
|
readonly input: number;
|
|
/** Edge: pressed = went active, released = went inactive. */
|
|
readonly edge: "pressed" | "released";
|
|
readonly at: string; // ISO-8601
|
|
}
|
|
|
|
/** Type guard: does this device expose host-readable inputs? */
|
|
export function hasInputs(device: Device): device is Device & InputDevice {
|
|
return (
|
|
typeof (device as Partial<InputDevice>).readInputs === "function" &&
|
|
typeof (device as Partial<InputDevice>).onInput === "function"
|
|
);
|
|
}
|
|
|
|
// --- Preconditions (device must be configured a certain way) -------------
|
|
// Optional capability: a device that depends on specific on-device configuration
|
|
// to work correctly for parking can report it. Example: the Dingtian board must
|
|
// have `input_link_relay` DISABLED, else a button press auto-fires the relay and
|
|
// defeats host-in-the-loop entry (the same trap as the UHPPOTE, but fixable here).
|
|
// The app does not own full device config (that's the vendor's web UI) — it only
|
|
// checks the few preconditions our flow depends on, and optionally fixes them.
|
|
// See wiki/decisions/access-controller-button-flow.md.
|
|
export interface PreconditionDevice {
|
|
checkPreconditions(): Promise<PreconditionResult>;
|
|
/** Apply automatic fixes for fixable issues; returns the re-checked result. */
|
|
fixPreconditions(): Promise<PreconditionResult>;
|
|
}
|
|
|
|
export interface PreconditionResult {
|
|
readonly ok: boolean;
|
|
readonly issues: PreconditionIssue[];
|
|
}
|
|
|
|
export interface PreconditionIssue {
|
|
readonly key: string;
|
|
readonly message: string;
|
|
/** True if fixPreconditions() can correct this automatically. */
|
|
readonly fixable: boolean;
|
|
}
|
|
|
|
export function hasPreconditions(
|
|
device: Device,
|
|
): device is Device & PreconditionDevice {
|
|
return typeof (device as Partial<PreconditionDevice>).checkPreconditions === "function";
|
|
}
|
|
|
|
// --- Push configuration (device → backend) -------------------------------
|
|
// Optional capability: a device that can be told to HTTP-push its input/button
|
|
// events to our backend (vs. the host polling it). The backend configures the
|
|
// device with where to call and a shared-secret token embedded in the path.
|
|
// The Dingtian board implements this via its "Input Link URL" feature.
|
|
// See wiki/concepts/device-input-flow.md.
|
|
export interface PushConfigurableDevice {
|
|
configureInputPush(opts: PushConfig): Promise<void>;
|
|
}
|
|
|
|
export interface PushConfig {
|
|
/** Backend host the device should call (our IP on the device's subnet). */
|
|
readonly host: string;
|
|
readonly port: number;
|
|
/** Path prefix the device appends `/<input>/<on|off>` to,
|
|
* e.g. `/api/devices/dingtian/<deviceId>/input`. */
|
|
readonly pathBase: string;
|
|
/** HTTP Digest credentials the device authenticates the push with. */
|
|
readonly auth: { user: string; password: string };
|
|
}
|
|
|
|
export function hasPushConfig(
|
|
device: Device,
|
|
): device is Device & PushConfigurableDevice {
|
|
return typeof (device as Partial<PushConfigurableDevice>).configureInputPush === "function";
|
|
}
|
|
|
|
// --- Hardening (lock the device down) ------------------------------------
|
|
// Optional capability: a device that can be hardened against a flat (no-VLAN)
|
|
// network — disable unused protocols/channels, set a relay password, and change
|
|
// the default web/config login. Returns any secrets the backend must persist to
|
|
// keep talking to the device. See wiki/concepts/device-input-flow.md.
|
|
export interface HardenableDevice {
|
|
harden(): Promise<HardenResult>;
|
|
}
|
|
|
|
export interface HardenResult {
|
|
/** Secrets to persist in lane_devices so the backend can keep operating the
|
|
* device (relay password, new web login). The backend merges these into the
|
|
* stored config. */
|
|
readonly secrets: Record<string, string | number>;
|
|
/** Human-readable summary of what was changed (for logging/UI). */
|
|
readonly applied: string[];
|
|
/** Hardening steps that could NOT be applied (e.g. a firmware quirk), so the
|
|
* admin knows a residual risk remains. Best-effort steps report here instead
|
|
* of failing the whole harden. */
|
|
readonly warnings?: string[];
|
|
}
|
|
|
|
export function isHardenable(device: Device): device is Device & HardenableDevice {
|
|
return typeof (device as Partial<HardenableDevice>).harden === "function";
|
|
}
|
|
|
|
// --- Readers (RF / optical; TCP-IP or Wiegand) ---------------------------
|
|
export interface ReaderDevice extends Device {
|
|
/** Emits when a credential is read (card number, plate, QR payload, …). */
|
|
onRead(cb: (read: ReaderEvent) => void): void;
|
|
}
|
|
|
|
export interface ReaderEvent {
|
|
readonly value: string;
|
|
readonly kind: "card" | "plate" | "qr" | "ticket";
|
|
readonly door: number;
|
|
readonly at: string; // ISO-8601
|
|
}
|
|
|
|
// --- Cameras (entry/exit snapshot) ---------------------------------------
|
|
// Hikvision / Dahua implement this. Snapshot-on-event: the host asks for an
|
|
// image at entry/exit; the image is stored and referenced from the signed event
|
|
// as an independent record (anti-fraud). See wiki/concepts/append-only-event-chain.
|
|
export interface CameraDevice extends Device {
|
|
captureSnapshot(ctx: SnapshotContext): Promise<Snapshot>;
|
|
}
|
|
|
|
export function isCamera(device: Device): device is Device & CameraDevice {
|
|
return typeof (device as Partial<CameraDevice>).captureSnapshot === "function";
|
|
}
|
|
|
|
export interface SnapshotContext {
|
|
readonly direction: "entry" | "exit";
|
|
}
|
|
|
|
export interface Snapshot {
|
|
/** The captured image bytes. The DRIVER fetches them over the network; the
|
|
* CALLER (entry/exit flow) owns storage and minting a durable reference —
|
|
* keeping the device adapter free of any filesystem/blob-store dependency. */
|
|
readonly bytes: Buffer;
|
|
readonly contentType: string;
|
|
readonly capturedAt: string; // ISO-8601
|
|
/** Storage reference (file path / blob id), set once the caller has stored
|
|
* the bytes. Absent on the value the driver returns. */
|
|
readonly imageRef?: string;
|
|
}
|
|
|
|
// --- Printers (ticket dispenser / booth printer) -------------------------
|
|
/** Optional park identity printed at the top of a ticket/receipt. All fields
|
|
* optional — the driver prints only what's set. Sourced from site_config; an
|
|
* Albanian parking receipt commonly must show the park name + NIUS. */
|
|
export interface TicketHeader {
|
|
readonly parkName?: string | null;
|
|
readonly operatorName?: string | null;
|
|
/** NIUS — Albanian tax/identification number. */
|
|
readonly nius?: string | null;
|
|
readonly address?: string | null;
|
|
readonly phone?: string | null;
|
|
}
|
|
|
|
export interface TicketData {
|
|
readonly ticketId: string;
|
|
readonly issuedAt: string; // ISO-8601
|
|
/** Park identity for the header. Absent → driver prints the generic "PARKING". */
|
|
readonly header?: TicketHeader;
|
|
}
|
|
|
|
/** A PAYMENT RECEIPT handed to the customer after a completed payment — the
|
|
* transparency record: when they entered, when they paid, how long they stayed,
|
|
* and how much they paid. Printed in two modes (see `voucher`):
|
|
* - voucher mode: ALSO carries the scannable ticket-id barcode + the walk-back
|
|
* grace window, so the same slip both proves payment AND self-exits at a
|
|
* distant exit reader (replaces the old barcode-only voucher);
|
|
* - standalone mode: detail-only (no barcode), printed at payment when the booth
|
|
* is at the exit and no voucher is issued.
|
|
* Money is integer MINOR units + an ISO-4217 currency (never a float) — the
|
|
* driver formats it. See wiki/concepts/booth-exit-flow.md, tariff.md. */
|
|
export interface ReceiptData {
|
|
readonly ticketId: string;
|
|
readonly enteredAt: string; // ISO-8601
|
|
readonly paidAt: string; // ISO-8601
|
|
readonly amountMinor: number;
|
|
readonly currency: string; // ISO-4217 (e.g. "ALL")
|
|
readonly tender: "cash" | "card";
|
|
/** Voucher mode: print the scannable barcode + emphasise the walk-back grace. */
|
|
readonly voucher: boolean;
|
|
/** Minutes the customer has to reach the exit after paying (voucher mode only). */
|
|
readonly graceExitMin?: number | null;
|
|
readonly header?: TicketHeader;
|
|
}
|
|
|
|
/** A subscription card: the customer's keepsake, printed at the booth on creation
|
|
* (and re-printable). The driver renders the `code` as a SCANNABLE QR (the
|
|
* subscriber scans it every entry/exit) plus the code as text + the holder/validity.
|
|
* See wiki/entities/subscription.md. */
|
|
export interface SubscriptionCardData {
|
|
/** The credential value to encode in the QR (e.g. "SUB-…"). */
|
|
readonly code: string;
|
|
readonly holderName?: string | null;
|
|
/** Coverage window, for the printed card (human-readable already, or ISO). */
|
|
readonly validFrom?: string | null;
|
|
readonly validTo?: string | null;
|
|
readonly header?: TicketHeader;
|
|
}
|
|
|
|
/** An ADVISORY "out-of-window" slip for a subscriber who entered/exited outside
|
|
* their plan's allowed hours. NOT a payable ticket and carries NO final amount —
|
|
* the total is computed at the booth on settlement. It carries the OCCURRENCE id as
|
|
* a SCANNABLE Code128 + QR so the operator scans it straight into the booth pay
|
|
* modal (which then quotes the window charge) instead of hand-keying it — the same
|
|
* scan path as a transient ticket. See wiki/entities/subscription.md. */
|
|
export interface WindowChargeNoticeData {
|
|
/** The occurrence id (e.g. "SUBSESS-…") — the session identity the booth pay
|
|
* modal looks up. Encoded as the scannable code. */
|
|
readonly occurrenceId: string;
|
|
readonly holderName?: string | null;
|
|
/** When the scan happened (ISO-8601), printed as the human stamp. */
|
|
readonly at: string;
|
|
/** Entry (early) vs exit (late) — selects the wording. */
|
|
readonly edge: "entry" | "exit";
|
|
/** Minutes-from-midnight the allowed window opens, when known (entry slips). */
|
|
readonly windowOpensMin?: number | null;
|
|
readonly header?: TicketHeader;
|
|
}
|
|
|
|
export interface PrinterDevice extends Device {
|
|
printTicket(data: TicketData): Promise<void>;
|
|
/** Print a free-form text report (a shift Z-report, a receipt). `lines` are
|
|
* printed as-is; the driver adds a header/cut. Kept generic so the business
|
|
* layer composes the content. See wiki/concepts/shift.md. */
|
|
printReport(report: PrintReport): Promise<void>;
|
|
/** Print a subscription card: a scannable QR of the code + holder/validity. */
|
|
printSubscriptionCard(data: SubscriptionCardData): Promise<void>;
|
|
/** Print a payment receipt (transparency: entry/paid/duration/amount). In
|
|
* voucher mode it also carries the ticket-id barcode + grace window so it
|
|
* doubles as the self-exit voucher. See ReceiptData. */
|
|
printReceipt(data: ReceiptData): Promise<void>;
|
|
/** Print the advisory out-of-window slip with a scannable occurrence-id code. */
|
|
printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void>;
|
|
}
|
|
|
|
export interface PrintReport {
|
|
readonly title: string;
|
|
readonly lines: readonly string[];
|
|
}
|
|
|
|
// --- Live printer status (consumable / mechanical faults) ----------------
|
|
// Optional capability: a printer that reports the operator-actionable faults a
|
|
// basic `healthCheck` (reachability) can't see — paper out, cover open, cutter
|
|
// jam. Used by the live status monitor so the booth knows BEFORE a driver presses
|
|
// the entry button and no ticket comes out. The Rongta board exposes these via
|
|
// its own status web page (it decodes the ESC/POS bits for us — more reliable
|
|
// than trusting a clone's DLE EOT bit layout). See wiki/concepts/printer-status-monitoring.md.
|
|
export interface PrinterStatus {
|
|
/** Reachable + no fault = ready; reachable + fault = degraded; unreachable = offline. */
|
|
readonly status: "ready" | "degraded" | "offline";
|
|
/** Out of paper — the printer cannot print. */
|
|
readonly paperEnd?: boolean;
|
|
/** Paper low — still prints, but warn the operator to reload. */
|
|
readonly paperNearEnd?: boolean;
|
|
/** Cover/lid open — will not print. */
|
|
readonly coverOpen?: boolean;
|
|
/** Cutter jammed/errored. */
|
|
readonly cutterError?: boolean;
|
|
/** Printer reports itself off-line (its own flag, distinct from unreachable). */
|
|
readonly offline?: boolean;
|
|
/** Human-readable summary (e.g. "paper out", or the unreachable error). */
|
|
readonly detail?: string;
|
|
readonly checkedAt: string; // ISO-8601
|
|
}
|
|
|
|
export interface MonitorableDevice {
|
|
/** Richer, operator-actionable status beyond reachability. */
|
|
readStatus(): Promise<PrinterStatus>;
|
|
}
|
|
|
|
export function isMonitorable(device: Device): device is Device & MonitorableDevice {
|
|
return typeof (device as Partial<MonitorableDevice>).readStatus === "function";
|
|
}
|