// 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; disconnect(): Promise; /** Liveness/health probe used by setup ("Test connection") and monitoring. */ healthCheck(): Promise; } 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; 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; } /** Feature-detect the aux-output capability on a built device adapter. */ export function hasAuxOutput(d: unknown): d is AuxOutputDevice { return typeof (d as Partial)?.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; /** * 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).readInputs === "function" && typeof (device as Partial).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; /** Apply automatic fixes for fixable issues; returns the re-checked result. */ fixPreconditions(): Promise; } 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).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; } 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 `//` to, * e.g. `/api/devices/dingtian//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).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; } 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; /** 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).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; } export function isCamera(device: Device): device is Device & CameraDevice { return typeof (device as Partial).captureSnapshot === "function"; } /** Outcome of a camera clock sync attempt (see ClockSyncDevice). */ export interface ClockSyncResult { /** Camera-vs-host drift in whole seconds at check time; null = the camera's * reply was unparseable (treated as infinite drift → sync). */ readonly driftSeconds: number | null; /** True when the camera clock was actually set (drift exceeded the threshold). */ readonly synced: boolean; } /** Optional capability: a device whose clock the HOST can read + set. Hikvision * cameras lose their clock on power cuts (no/dead RTC battery, reboot at the 1970 * epoch) and only heal when a human logs into the web UI — so the device monitor * re-syncs them from the host clock at the offline→ready edge + a daily backstop. * See wiki/entities/lpr-camera.md (clock sync). */ export interface ClockSyncDevice { /** Compare the device clock to `localIso` (the site's wall-clock now, WITH utc * offset) and set it when drift exceeds `maxDriftSec`. */ syncClock(localIso: string, maxDriftSec: number): Promise; } export function isClockSyncable(device: Device): device is Device & ClockSyncDevice { return typeof (device as Partial).syncClock === "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; /** Merchant validations (bar/lavazh): the PRE-discount fee and the per-validation * lines. When present, `amountMinor` is the NET actually paid and the receipt * shows the full gross → discounts → net story. See validation-discounts.md. */ readonly grossMinor?: number | null; readonly validationLines?: readonly { label: string; discountMinor: number }[]; 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; /** 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; /** Print a subscription card: a scannable QR of the code + holder/validity. */ printSubscriptionCard(data: SubscriptionCardData): Promise; /** 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; /** Print the advisory out-of-window slip with a scannable occurrence-id code. */ printWindowChargeNotice(data: WindowChargeNoticeData): Promise; } 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; } export function isMonitorable(device: Device): device is Device & MonitorableDevice { return typeof (device as Partial).readStatus === "function"; }