feat(devices): live device-status footer across all categories

Generalise printer-only monitoring to every configured device. New
DeviceMonitor polls all enabled devices each tick (default 8s): printers
via rich readStatus(), relays/readers/cameras via the generic healthCheck()
reachability probe, flattened to one traffic-light (ready/degraded/offline)
+ detail, deduped (emit on change only), fail-toward-offline.

- device-status bus event + GET /api/devices/status snapshot.
- Pushed over the existing /api/ws (hello carries the initial set;
  device-status frame per change).
- Web: live-store devices map, WS handler, DeviceFooter chip-per-device
  (role label not vendor; click a degraded/offline chip for an issues panel).

Verified roleKind resolution + change-only emit on a fresh DB.

Note: the footer's UI surface (api type, router mount, i18n devices) rides
in the subsequent subscription commit due to shared-file overlap.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-18 13:14:36 +02:00
parent 4e2e4feedb
commit f87e4c0d6b
11 changed files with 569 additions and 13 deletions
+40 -2
View File
@@ -17,7 +17,7 @@ export interface DeviceInputEvent {
}
// A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader.
// Drives identity-based flows (exit validation, permits, pay-station lookup). `kind`
// Drives identity-based flows (exit validation, subscriptions, pay-station lookup). `kind`
// mirrors IdentitySource. See parking-session.md.
export interface DeviceReadEvent {
readonly driverId: string;
@@ -35,7 +35,7 @@ export interface DeviceReadEvent {
export interface ReadOutcome {
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
readonly accepted: boolean;
/** Which way it went, when known (permit/exit infer this). */
/** 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;
@@ -49,6 +49,33 @@ export interface PrinterStatusEvent {
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";
/**
* 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
}
class DeviceEventBus extends EventEmitter {
emitInput(event: DeviceInputEvent): void {
this.emit("input", event);
@@ -76,6 +103,17 @@ class DeviceEventBus extends EventEmitter {
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