import { EventEmitter } from "node:events"; import type { PrinterStatus } from "@parking/devices"; import type { LedgerEventRow } from "@parking/db"; // Internal event bus for device-originated events (button presses, etc.). // Hardware drivers / inbound device pushes emit here; business logic (entry // flow, event-log) subscribes — keeping the HTTP/transport layer thin and the // app device-agnostic. See wiki/entities/fastify.md. export interface DeviceInputEvent { readonly driverId: string; // e.g. "dingtian" 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: a ticket scanned at exit, a plate from LPR, a card at a reader. // Drives identity-based flows (exit validation, subscriptions, pay-station lookup). `kind` // mirrors IdentitySource. See parking-session.md. export interface DeviceReadEvent { readonly driverId: string; 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"; /** The CONFIRMED physical channel the value arrived on, when the reader tags it * (the DT-008 output prefixes — see routes/qr-reader.ts). `optical` = decoded by * the barcode/QR engine; `rf` = read from a card/chip. Undefined = legacy reader * with no prefixes configured (channel unknown — flows must not assume). Lets the * subscription match refuse an OPTICAL decode claiming an RF credential (a printed * copy of a card's UID must not clone the card). */ readonly channel?: "optical" | "rf"; readonly at: string; // ISO-8601 } /** * The decision a read produced. Returned by the read flows so a SYNCHRONOUS reader * (e.g. the QR reader, whose HTTP reply drives its beep + output) can answer the * device. A fire-and-forget reader simply ignores it. See wiki/entities/dingtian-dt008-reader.md. */ export interface ReadOutcome { /** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */ readonly accepted: boolean; /** Which way it went, when known (subscription/exit infer this). */ readonly direction?: "entry" | "exit"; /** Human-readable reason (for logs / the reader UI), esp. on reject. */ readonly reason?: string; } /** A printer's status as tracked by the live monitor (status + identity). */ export interface PrinterStatusEvent { readonly deviceId: string; // devices id readonly driverId: string; readonly role?: string; // entry-dispenser | booth-receipt readonly status: PrinterStatus; } /** * The unified live status of ANY configured device — what the booth footer shows. * Every enabled device is polled: printers via their rich `readStatus()` * (paper/cover/cutter), all other categories via the generic `healthCheck()` * reachability probe. `state` is the common traffic-light; `detail` carries the * human summary (e.g. "paper out", or an unreachable error). See device-monitor.ts * and wiki/concepts/device-status-monitoring.md. */ export interface DeviceStatusEvent { readonly deviceId: string; // devices id readonly driverId: string; readonly category: "access" | "reader" | "camera" | "printer" | "vision"; /** * The device's ROLE descriptor for the footer label — NOT the vendor. A * direction-style token the client localises and pairs with the category, so the * chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina": * - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay) * - access: "entry" | "exit" | "both" | "mixed" (from its relays[]) * - printer: "lane" (entry-dispenser) | "booth" (booth-receipt) * - undetermined: null (chip shows the category alone) */ readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null; readonly state: "ready" | "degraded" | "offline"; readonly detail?: string; readonly checkedAt: string; // ISO-8601 } /** Lane occupancy from a camera's vehicle detection — a per-direction "busy/free" * the booth shows as barrier lights. ADVISORY ONLY: a detection is a hint, never a * gate (it never blocks a ticket or opens a barrier). "busy" is set by a vehicle * `active` event; it auto-clears to "free" after a timeout (this camera class sends * no leave/`inactive` signal — see wiki/entities/lpr-camera.md). */ export interface LaneStatusEvent { readonly entry: boolean; // true = busy (a vehicle is at the entry vicinity) readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity) } /** A plate was RECOGNIZED for a session AFTER its entry/exit event already shipped. Plate * recognition is async/advisory (a vision round-trip off the snapshot), so it lands a * moment after the signed event — too late for the event's own WS push to carry it. This * notifies the booth so it can fill in the plate badge on the already-rendered feed row / * active session in place, no refresh. Advisory; never touches the signed ledger. See * snapshot.ts (recognizePlate) + event-enrich.ts. */ export interface PlateRecognizedEvent { readonly identity: string; // the session identity the plate is tied to readonly plate: string; // normalized plate text (trimmed, upper) readonly direction: "entry" | "exit"; } /** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the * entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has * confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink: * radar-present + camera-not-busy. Drives the booth's barrier light blink. Advisory only — * it gates nothing. See wiki/concepts/button-light-indicator.md. */ export interface LanePresenceEvent { readonly entry: boolean; // true = a presence input on an entry barrier is active readonly exit: boolean; // true = a presence input on an exit barrier is active } class DeviceEventBus extends EventEmitter { emitInput(event: DeviceInputEvent): void { this.emit("input", event); } onInput(cb: (event: DeviceInputEvent) => void): () => void { this.on("input", cb); return () => this.off("input", cb); } /** A credential read (ticket scan, plate, card). */ emitRead(event: DeviceReadEvent): void { this.emit("read", event); } onRead(cb: (event: DeviceReadEvent) => void): () => void { this.on("read", cb); return () => this.off("read", cb); } /** Emitted by the printer monitor whenever a printer's status CHANGES. */ emitPrinterStatus(event: PrinterStatusEvent): void { this.emit("printer-status", event); } onPrinterStatus(cb: (event: PrinterStatusEvent) => void): () => void { this.on("printer-status", cb); return () => this.off("printer-status", cb); } /** Emitted by the device monitor whenever ANY device's unified status CHANGES * (all categories — relays, readers, cameras, printers). Drives the booth * device-status footer over the WS. */ emitDeviceStatus(event: DeviceStatusEvent): void { this.emit("device-status", event); } onDeviceStatus(cb: (event: DeviceStatusEvent) => void): () => void { this.on("device-status", cb); return () => this.off("device-status", cb); } /** * Emitted AFTER a signed business event is appended to the ledger (entry, exit, * payment, void, …). The payload is the persisted row — business facts only, no * secrets — so it is safe to fan out to authenticated booth clients over the WS. * This is a read-side notification ONLY: it never feeds back into append/sign/ * chain logic. See event-log.ts (emitted from EventLog.append) and routes/ws.ts. */ emitLedger(event: LedgerEventRow): void { this.emit("ledger", event); } onLedger(cb: (event: LedgerEventRow) => void): () => void { this.on("ledger", cb); return () => this.off("ledger", cb); } /** Emitted whenever a lane's busy/free state CHANGES (from camera vehicle * detection). Drives the booth's barrier lights. Advisory only. */ emitLaneStatus(event: LaneStatusEvent): void { this.emit("lane-status", event); } onLaneStatus(cb: (event: LaneStatusEvent) => void): () => void { this.on("lane-status", cb); return () => this.off("lane-status", cb); } /** Emitted whenever a lane's RADAR presence CHANGES (a presence input shorted/cleared * at an entry/exit barrier). Drives the booth barrier light's blink. Advisory only. */ emitLanePresence(event: LanePresenceEvent): void { this.emit("lane-presence", event); } onLanePresence(cb: (event: LanePresenceEvent) => void): () => void { this.on("lane-presence", cb); return () => this.off("lane-presence", cb); } /** Emitted when an async plate recognition completes for a session (after its event * already shipped). Lets the booth backfill the plate badge in place. Advisory only. */ emitPlateRecognized(event: PlateRecognizedEvent): void { this.emit("plate-recognized", event); } onPlateRecognized(cb: (event: PlateRecognizedEvent) => void): () => void { this.on("plate-recognized", cb); return () => this.off("plate-recognized", cb); } } /** Process-wide device event bus. */ export const deviceEvents = new DeviceEventBus();