Files
parking_solution/packages/devices/src/interfaces.ts
T
julian 2915d141aa feat(devices): radar presence input + button-light output on the controller
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
2026-06-24 11:45:22 +02:00

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";
}