Files
parking_solution/apps/server/src/device-events.ts
T
julian e0b9442acc feat(booth): live lane busy/free barrier lights from camera vehicle detection
A Hikvision vehicle detection (eventType=VMD, targetType=vehicle) on a
camera bound to entry/exit now marks that lane "busy" and shows it as a
barrier light beside the scan input on the booth (green=free, red=busy).
Advisory only — it gates nothing (never blocks a ticket or opens a barrier).

- Parse eventState (active/inactive) from the Hik payload.
- LaneStatus tracker: a vehicle `active` event marks the camera's bound lane
  busy + arms an auto-clear timer. This camera class sends no leave/`inactive`
  signal, so "free" is timeout-driven (LANE_BUSY_TTL_MS, default 90s; the
  camera re-fires `active` while a car sits there, refreshing the timer). A
  "both"-direction camera marks both lanes.
- Push lane-status over the existing booth WS (+ in the hello snapshot);
  live-store holds { entry, exit }; two BarrierLight icons render it.
- i18n booth.laneEntry/laneExit (sq + en).

Tests: lane-status.test.ts (7 — busy/free, TTL auto-clear, timer re-arm,
no re-emit while busy, both/exit direction, unknown device). server 120/120;
web + server build/lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 17:42:54 +02:00

155 lines
6.5 KiB
TypeScript

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";
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/gee-qr-er80.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)
}
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);
}
}
/** Process-wide device event bus. */
export const deviceEvents = new DeviceEventBus();