diff --git a/apps/server/src/device-events.ts b/apps/server/src/device-events.ts index 90e62b2..375b933 100644 --- a/apps/server/src/device-events.ts +++ b/apps/server/src/device-events.ts @@ -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 diff --git a/apps/server/src/device-monitor.ts b/apps/server/src/device-monitor.ts new file mode 100644 index 0000000..ce7bc5c --- /dev/null +++ b/apps/server/src/device-monitor.ts @@ -0,0 +1,156 @@ +import type { FastifyBaseLogger } from "fastify"; +import { devices, type Db, type DeviceRow } from "@parking/db"; +import { isMonitorable, registry } from "@parking/devices"; +import { deviceEvents, type DeviceStatusEvent } from "./device-events.js"; +import { directionOf, relaysOf } from "./device-resolve.js"; + +// Unified live DEVICE monitor — the source for the booth's device-status footer. +// Every enabled, configured device is probed on an interval, regardless of +// category: a printer via its rich readStatus() (paper/cover/cutter — reusing the +// same capability the PrinterMonitor uses), and a relay/reader/camera via the +// generic healthCheck() reachability probe every Device implements. The result is +// flattened to a common traffic-light (ready | degraded | offline) + a detail +// string, cached per device id, and emitted on the bus ONLY when it changes. +// +// This is device-agnostic (talks to the adapter interfaces, never a driver SDK) +// and read-only — polling a device never drives a relay or mutates the ledger. +// See wiki/concepts/device-status-monitoring.md, printer-status-monitoring.md. + +const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000); + +/** + * The device's ROLE descriptor for the footer (never the vendor). Direction-style + * tokens the client localises next to the category: + * - reader/camera → the direction inherited from its bound relay (entry/exit/both) + * - access → entry/exit/both from its relays[]; "mixed" if it spans more + * than one direction; null if it declares none yet + * - printer → "lane" (entry-dispenser) | "booth" (booth-receipt) + */ +function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] { + switch (row.category) { + case "reader": + case "camera": { + const d = directionOf(db, row); // entry | exit | both + return d; + } + case "access": { + const dirs = new Set(relaysOf(row).map((r) => r.direction)); + if (dirs.size === 0) return null; + if (dirs.size > 1) return "mixed"; + const only = [...dirs][0]; // entry | exit | both + return only ?? null; + } + case "printer": { + const role = (row.config as { role?: string }).role; + if (role === "booth-receipt") return "booth"; + if (role === "entry-dispenser") return "lane"; + return null; + } + default: + return null; + } +} + +export class DeviceMonitor { + readonly #db: Db; + readonly #log: FastifyBaseLogger; + readonly #pollMs: number; + /** Latest unified status per device id. */ + readonly #latest = new Map(); + #timer: ReturnType | null = null; + #ticking = false; + + constructor(db: Db, log: FastifyBaseLogger, pollMs = POLL_MS) { + this.#db = db; + this.#log = log; + this.#pollMs = pollMs; + } + + /** Begin polling. Idempotent. */ + start(): void { + if (this.#timer) return; + void this.#tick(); // immediate first pass so the footer fills without a wait + this.#timer = setInterval(() => void this.#tick(), this.#pollMs); + this.#timer.unref?.(); + this.#log.info(`device-monitor: polling every ${this.#pollMs}ms`); + } + + stop(): void { + if (this.#timer) { + clearInterval(this.#timer); + this.#timer = null; + } + } + + /** Current snapshot for the API / a freshly-connected WS client. */ + snapshot(): DeviceStatusEvent[] { + return [...this.#latest.values()]; + } + + async #tick(): Promise { + if (this.#ticking) return; // never overlap polls + this.#ticking = true; + try { + // Re-read the device set each tick so a newly-assigned/removed device is + // picked up without a restart. + const rows = await this.#db.select().from(devices).all(); + const enabled = rows.filter((r) => r.enabled); + const present = new Set(enabled.map((r) => r.id)); + + // Drop devices that are gone/disabled (so the footer doesn't show stale ones). + for (const id of [...this.#latest.keys()]) { + if (!present.has(id)) this.#latest.delete(id); + } + + await Promise.all(enabled.map((r) => this.#poll(r))); + } catch (err) { + this.#log.warn(`device-monitor tick failed: ${(err as Error).message}`); + } finally { + this.#ticking = false; + } + } + + async #poll(row: DeviceRow): Promise { + const cfg = (row.config ?? {}) as Record; + const base = { + deviceId: row.id, + driverId: row.driverId, + category: row.category, + roleKind: roleKindOf(this.#db, row), + }; + + let next: DeviceStatusEvent; + const driver = registry.get(row.driverId); + if (!driver) { + // Configured against a driver that's no longer registered — surface it, + // don't silently hide it. + next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() }; + } else { + try { + const device = driver.create(cfg as never); + // Printers expose richer paper/cover/cutter status; everything else uses + // the generic reachability probe. Both flatten to the same traffic-light. + if (isMonitorable(device)) { + const s = await device.readStatus(); + next = { ...base, state: s.status, detail: s.detail, checkedAt: s.checkedAt }; + } else { + const h = await device.healthCheck(); + next = { ...base, state: h.status, detail: h.detail, checkedAt: new Date().toISOString() }; + } + } catch (err) { + // A probe that throws (build error, timeout) reads as offline — never crash + // the tick, and fail toward "there's a problem" rather than false-healthy. + next = { ...base, state: "offline", detail: (err as Error).message, checkedAt: new Date().toISOString() }; + } + } + + const prev = this.#latest.get(row.id); + this.#latest.set(row.id, next); + if (!prev || prev.state !== next.state || prev.detail !== next.detail) { + this.#log.info( + `device-monitor: ${next.category}/${next.roleKind ?? "—"} ${row.id} -> ${next.state}${next.detail ? ` (${next.detail})` : ""}`, + ); + deviceEvents.emitDeviceStatus(next); + } + } +} diff --git a/apps/server/src/routes/device-status.ts b/apps/server/src/routes/device-status.ts new file mode 100644 index 0000000..d417685 --- /dev/null +++ b/apps/server/src/routes/device-status.ts @@ -0,0 +1,21 @@ +import type { FastifyInstance } from "fastify"; +import { requireRole } from "../auth.js"; +import type { DeviceMonitor } from "../device-monitor.js"; + +// Unified device-status snapshot for the booth footer. The DeviceMonitor polls all +// configured devices (relays/readers/cameras via healthCheck, printers via their +// rich readStatus) in the background; this exposes its cache. Live updates ride the +// booth WebSocket (kind:"device-status") — this REST route is the initial load / +// fallback. Any authenticated role may read (operational, not a setup action). +// See wiki/concepts/device-status-monitoring.md, booth-console.md. + +export async function deviceStatusRoutes( + app: FastifyInstance, + monitor: DeviceMonitor, +): Promise { + const guard = requireRole("admin", "operator", "cashier", "readonly"); + + app.get("/api/devices/status", { preHandler: guard }, async () => ({ + devices: monitor.snapshot(), + })); +} diff --git a/apps/server/src/routes/ws.ts b/apps/server/src/routes/ws.ts index e1c0e53..0b18d32 100644 --- a/apps/server/src/routes/ws.ts +++ b/apps/server/src/routes/ws.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify"; import type { Db } from "@parking/db"; import type { Role } from "@parking/shared"; import { deviceEvents } from "../device-events.js"; +import type { DeviceMonitor } from "../device-monitor.js"; import { getOccupancy } from "../occupancy.js"; // Live booth feed over a WebSocket. The booth UI opens ONE socket and receives @@ -49,11 +50,12 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined): } type OutMsg = - | { kind: "hello"; occupancy: ReturnType } + | { kind: "hello"; occupancy: ReturnType; devices: unknown } | { kind: "ledger"; event: unknown; occupancy: ReturnType } - | { kind: "printer-status"; event: unknown }; + | { kind: "printer-status"; event: unknown } + | { kind: "device-status"; event: unknown }; -export async function wsRoutes(app: FastifyInstance, db: Db): Promise { +export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: DeviceMonitor): Promise { app.get( "/api/ws", { @@ -83,8 +85,9 @@ export async function wsRoutes(app: FastifyInstance, db: Db): Promise { } }; - // Initial snapshot so the client renders immediately, before any event. - send({ kind: "hello", occupancy: getOccupancy(db) }); + // Initial snapshot so the client renders immediately, before any event: + // occupancy AND the current device-status set (for the footer). + send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot() }); // Subscribe to the live buses. Each handler recomputes occupancy from the // ledger (cheap fold) so the pushed count is always authoritative. @@ -94,10 +97,16 @@ export async function wsRoutes(app: FastifyInstance, db: Db): Promise { const offPrinter = deviceEvents.onPrinterStatus((event) => { send({ kind: "printer-status", event }); }); + // Unified device status (all categories) for the booth footer — pushed on + // change; the initial set rode the hello above. + const offDevice = deviceEvents.onDeviceStatus((event) => { + send({ kind: "device-status", event }); + }); socket.on("close", () => { offLedger(); offPrinter(); + offDevice(); }); }, ); diff --git a/apps/web/src/lib/live-store.ts b/apps/web/src/lib/live-store.ts index 50ccca2..9e758a4 100644 --- a/apps/web/src/lib/live-store.ts +++ b/apps/web/src/lib/live-store.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import type { LedgerEvent, Occupancy } from "../api.js"; +import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js"; // CLIENT state for the live booth feed — deliberately small. Server data (the // authoritative event list, occupancy totals) is owned by TanStack Query; this @@ -20,16 +20,31 @@ interface LiveState { occupancy: Occupancy | null; /** Newest-first tail of recently pushed ledger events (for the live ticker). */ feed: LedgerEvent[]; + /** Live device status keyed by device id (for the footer): set from the WS + * hello snapshot, then upserted per device on each device-status push. */ + devices: Record; setStatus: (s: WsStatus) => void; setOccupancy: (o: Occupancy) => void; pushEvent: (e: LedgerEvent) => void; + /** Replace the whole device-status set (WS hello / reconnect snapshot). */ + setDevices: (list: DeviceStatus[]) => void; + /** Upsert one device's status (a device-status push). */ + upsertDevice: (d: DeviceStatus) => void; reset: () => void; } +/** Index a device-status list by device id. */ +function byId(list: DeviceStatus[]): Record { + const m: Record = {}; + for (const d of list) m[d.deviceId] = d; + return m; +} + export const useLiveStore = create((set) => ({ status: "connecting", occupancy: null, feed: [], + devices: {}, setStatus: (status) => set({ status }), setOccupancy: (occupancy) => set({ occupancy }), pushEvent: (e) => @@ -37,5 +52,7 @@ export const useLiveStore = create((set) => ({ // Newest first; de-dupe by id (a reconnect can replay) and cap the length. feed: s.feed.some((x) => x.id === e.id) ? s.feed : [e, ...s.feed].slice(0, MAX_FEED), })), - reset: () => set({ status: "connecting", occupancy: null, feed: [] }), + setDevices: (list) => set({ devices: byId(list) }), + upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })), + reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {} }), })); diff --git a/apps/web/src/lib/query.ts b/apps/web/src/lib/query.ts index 85ea9ff..306bcb5 100644 --- a/apps/web/src/lib/query.ts +++ b/apps/web/src/lib/query.ts @@ -26,4 +26,5 @@ export const qk = { activeSessions: ["active-sessions"] as const, siteConfig: ["site-config"] as const, shift: ["shift"] as const, + deviceStatus: ["device-status"] as const, } as const; diff --git a/apps/web/src/lib/use-live-feed.ts b/apps/web/src/lib/use-live-feed.ts index 662496a..a3bcdc7 100644 --- a/apps/web/src/lib/use-live-feed.ts +++ b/apps/web/src/lib/use-live-feed.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; import { useQueryClient } from "@tanstack/react-query"; -import type { LedgerEvent, Occupancy } from "../api.js"; +import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js"; import { qk } from "./query.js"; import { useLiveStore } from "./live-store.js"; @@ -13,9 +13,10 @@ import { useLiveStore } from "./live-store.js"; /** Server → client message shapes (mirror routes/ws.ts OutMsg). */ type WsMessage = - | { kind: "hello"; occupancy: Occupancy } + | { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[] } | { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy } - | { kind: "printer-status"; event: unknown }; + | { kind: "printer-status"; event: unknown } + | { kind: "device-status"; event: DeviceStatus }; /** Build the ws:// or wss:// URL for the same origin the SPA is served from. */ function wsUrl(): string { @@ -25,7 +26,7 @@ function wsUrl(): string { export function useLiveFeed(): void { const qc = useQueryClient(); - const { setStatus, setOccupancy, pushEvent } = useLiveStore(); + const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice } = useLiveStore(); // Hold the socket + reconnect timer across renders; guard against StrictMode // double-invoke and unmount. const sockRef = useRef(null); @@ -55,6 +56,10 @@ export function useLiveFeed(): void { } if (msg.kind === "hello") { setOccupancy(msg.occupancy); + // Initial device-status snapshot for the footer. + if (Array.isArray(msg.devices)) setDevices(msg.devices); + } else if (msg.kind === "device-status") { + upsertDevice(msg.event); } else if (msg.kind === "ledger") { setOccupancy(msg.occupancy); pushEvent(msg.event); diff --git a/apps/web/src/ui/DeviceFooter.tsx b/apps/web/src/ui/DeviceFooter.tsx new file mode 100644 index 0000000..e3063cb --- /dev/null +++ b/apps/web/src/ui/DeviceFooter.tsx @@ -0,0 +1,195 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { fetchDeviceStatus, type DeviceStatus } from "../api.js"; +import { qk } from "../lib/query.js"; +import { useLiveStore } from "../lib/live-store.js"; + +// Fixed device-status footer for the booth chrome. One compact chip per configured +// device — relays, readers, cameras, printers — labelled by ROLE, never vendor +// (e.g. "Lexuesi hyrje", "Printer kabina", "Kamera dalje"), with a traffic-light +// dot. Fault detail does NOT pollute the footer: clicking opens a small panel that +// lists the degraded/offline devices and their issues. Status is fed by the +// DeviceMonitor over the WS (snapshot on connect + per-device pushes, held in the +// live store); a REST snapshot seeds it / fills in if the WS is briefly down. +// See wiki/concepts/device-status-monitoring.md, booth-console.md. + +const DOT: Record = { + ready: "bg-term-green", + degraded: "bg-term-amber", + offline: "bg-term-red", +}; + +const TEXT: Record = { + ready: "text-term-text", + degraded: "text-term-amber", + offline: "text-term-red", +}; + +/** i18n key for a device category. */ +const CATEGORY_KEY: Record = { + access: "devices.catAccess", + reader: "devices.catReader", + camera: "devices.catCamera", + printer: "devices.catPrinter", +}; + +/** i18n key for the role/direction token (null = no suffix). */ +function roleKey(roleKind: DeviceStatus["roleKind"]): string | null { + return roleKind ? `devices.role.${roleKind}` : null; +} + +/** Stable display order: access (barrier) first, then readers, cameras, printers. */ +const ORDER: Record = { + access: 0, + reader: 1, + camera: 2, + printer: 3, +}; + +/** "Lexuesi hyrje" — category word + localised role/direction (when known). */ +function useLabel() { + const { t } = useTranslation(); + return (d: DeviceStatus) => { + const cat = t(CATEGORY_KEY[d.category]); + const rk = roleKey(d.roleKind); + return rk ? `${cat} ${t(rk)}` : cat; + }; +} + +function sortDevices(list: DeviceStatus[]): DeviceStatus[] { + return [...list].sort( + (a, b) => ORDER[a.category] - ORDER[b.category] || (a.roleKind ?? "").localeCompare(b.roleKind ?? ""), + ); +} + +export function DeviceFooter() { + const { t } = useTranslation(); + const label = useLabel(); + // Seed/fallback from REST; the WS keeps the live store authoritative thereafter. + const seed = useQuery({ queryKey: qk.deviceStatus, queryFn: fetchDeviceStatus }); + const live = useLiveStore((s) => s.devices); + + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + // Close the issues panel on an outside click or Escape. + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + // Prefer the live store (WS); fall back to the REST snapshot before the first push. + const fromLive = Object.values(live); + const devices = sortDevices(fromLive.length > 0 ? fromLive : seed.data?.devices ?? []); + const problems = devices.filter((d) => d.state !== "ready"); + + return ( + + ); +} diff --git a/wiki/concepts/booth-console.md b/wiki/concepts/booth-console.md index 6d12f71..c60d5eb 100644 --- a/wiki/concepts/booth-console.md +++ b/wiki/concepts/booth-console.md @@ -83,6 +83,15 @@ the Active-Sessions "Open barrier" is disabled the same way. The server enforces (`requireShift` 409 `no_shift`) — the UI just front-runs the rejection. The live feed is **scoped to the open shift's window** (empty when no shift is open). See [[shift]] for the rule and the routes. +## The device-status footer + +A **fixed footer** in the app shell shows the live status of every configured device — relays, +readers, cameras, printers — one chip each (coloured dot + name + fault detail), with an "all ready +/ N offline" roll-up. Fed by the unified [[device-status-monitoring|DeviceMonitor]] over the same +`/api/ws` socket (`hello` carries the initial set; a `device-status` frame per change), held in the +live store keyed by device id, with `GET /api/devices/status` as the seed/fallback. Visible on every +screen, so the operator always sees the barrier relay's reachability and the printer's paper state. + ## Dev notes - Vite proxies `/api/ws` (`ws: true`) to the backend; the backend's Origin allowlist must include the dev SPA origin (`WS_ALLOWED_ORIGINS=http://localhost:5173`). In production Fastify serves the SPA diff --git a/wiki/concepts/device-status-monitoring.md b/wiki/concepts/device-status-monitoring.md new file mode 100644 index 0000000..8238c09 --- /dev/null +++ b/wiki/concepts/device-status-monitoring.md @@ -0,0 +1,100 @@ +--- +type: concept +tags: [parking, device, monitoring, reliability, ui] +sources: [] +updated: 2026-06-18 +status: open +--- + +# Device status monitoring (the booth footer) + +The booth shows a **fixed footer** with the live status of every configured device — relays, +readers, cameras, printers — so an operator sees at a glance that the barrier relay is reachable, +the exit scanner is up, and the ticket printer has paper. This generalises the printer-only +[[printer-status-monitoring]] to **all four [[device-adapter-pattern|device categories]]**. A +reliability control, not a threat-model one. (Built 2026-06-18.) + +## What gets polled, and how + +Every **enabled** row in `devices` is polled on an interval, regardless of category — the monitor +talks only to the adapter interfaces ([[device-adapter-pattern]]), never a driver SDK: + +- **Printers** → their rich `MonitorableDevice.readStatus()` (paper end / near-end, cover open, + cutter error, off-line) — the same capability the existing [[printer-status-monitoring|PrinterMonitor]] + uses. The footer surfaces the fault detail. +- **Relays / readers / cameras** → the generic `Device.healthCheck()` **reachability** probe every + adapter implements (`ready | degraded | offline`). This is presence/up-ness, not a deep fault + model — a relay either answers or it doesn't. + +Both collapse to one **traffic-light**: `ready | degraded | offline`, plus a `detail` string. Fail +**toward "there's a problem"**, never false-healthy: a probe that throws or times out reads +`offline` (consistent with [[printer-status-monitoring]]'s fail-safe mapping); a driver that's no +longer registered reads `offline` ("driver not registered") rather than vanishing. + +## The monitor (server) + +`DeviceMonitor` (`apps/server/src/device-monitor.ts`), modelled on the PrinterMonitor: + +- re-reads the device set each tick (a newly-assigned/removed device appears/disappears without a + restart); drops cached status for devices that are gone or disabled; +- polls every `DEVICE_POLL_MS` (default **8000ms**), never overlapping ticks; +- caches the latest unified status per device id; +- emits a `device-status` bus event **only when a device's state or detail changes** (deduped). + +> **Relationship to the PrinterMonitor.** Both run. The PrinterMonitor stays the authority for the +> printer-specific live detail + its SSE stream (`/api/printers/status*`) that the entry flow may +> later depend on for [[printer-roles-failover]]. The DeviceMonitor is the **unified footer feed** +> across all categories. They poll independently (printers get probed by both — cheap HTTP reads); +> the small duplication is deliberate, to avoid coupling the footer to printer internals. Could be +> consolidated later if the overlap ever matters. + +## API / live UI + +- `GET /api/devices/status` — cached snapshot of all devices (no device round-trip). Any + authenticated role (operational, not a setup action). +- Live updates ride the **one booth [[booth-console|WebSocket]]** (`/api/ws`): the `hello` frame + carries the initial device-status set; a `device-status` frame is pushed per change. The web + [[booth-console|live store]] holds the set keyed by device id; the REST snapshot seeds it / fills + in if the socket is briefly down. +- **`DeviceFooter`** (`apps/web/src/ui/DeviceFooter.tsx`) renders one **compact** chip per device — + a coloured dot + a **role label, never the vendor** — ordered access → reader → camera → printer, + with a right-aligned roll-up ("N with issues" / "all ready"). Mounted in the app shell so it's + visible on every screen. + +### Label = role, not vendor (refinement 2026-06-18) + +The chip shows **what the device does, not who made it**: the localised category + a role/direction +suffix → `Lexuesi hyrje`, `Printer kabina`, `Kamera dalje`. The server sends a structured +**`roleKind`** token (not a composed string), the client localises it: +- **reader / camera** → the direction inherited from its bound relay (`directionOf()` in + [[entry-exit-points|device-resolve]]): `entry | exit | both`. +- **access controller** → `entry | exit | both` from its `relays[]`, or **`mixed`** when it spans + more than one direction; `null` if it declares none yet. +- **printer** → `lane` (entry-dispenser) | `booth` (booth-receipt) — the [[printer-roles-failover]] role. +- `null` → the chip shows the category alone. + +### Detail does NOT pollute the footer (refinement 2026-06-18) + +Chips stay short — **no inline fault text**. A device that is `degraded`/`offline` is clickable (so +is the roll-up); clicking opens a small **issues panel** anchored above the footer that lists only +the problem devices with their role label, state, the `detail` string, and the last-checked time. +`ready` chips are non-interactive. The panel closes on outside-click / Escape (a lightweight +popover — no extra dependency; only Radix Dialog is installed). + +## Verified (2026-06-18) + +On a fresh DB seeded with a stub relay, a TCP reader, and two printers (one reachable, one not): +relay + reader → `ready` via `healthCheck`; the unreachable printer → `offline` (with a detail +string, never threw); the bus emitted once per device on first observation, and a second unchanged +tick was silent (change-only emit). Server + web build clean. + +## Open / not yet done + +- **Reachability ≠ correctness.** `healthCheck()` says a relay/reader answers, not that it's wired + to the right barrier or reading cards — that's a setup/precondition concern ([[first-run-setup]], + the Dingtian [[access-controller-button-flow|precondition checks]]). +- **No per-device history / alerting.** The footer is point-in-time; a flapping device isn't + tracked over time. Reconciliation-style alerting is out of scope here. +- **Cameras** only expose `healthCheck` reachability today; a "last snapshot age" health signal + could be richer ([[lpr-camera]], [[opencv-anpr-service]]). +- Possible later **consolidation** of PrinterMonitor + DeviceMonitor (see the note above). diff --git a/wiki/concepts/printer-status-monitoring.md b/wiki/concepts/printer-status-monitoring.md index 7607fe3..dab9410 100644 --- a/wiki/concepts/printer-status-monitoring.md +++ b/wiki/concepts/printer-status-monitoring.md @@ -7,6 +7,11 @@ updated: 2026-06-14 # Printer status monitoring +> **Generalised 2026-06-18:** the booth's all-device status **footer** is a separate, unified +> monitor across every category (relays/readers/cameras/printers) — see +> [[device-status-monitoring]]. This page remains the authority for the *printer-specific* rich +> status (paper/cover/cutter) + its SSE stream; both monitors run. + The booth must know a printer is in trouble **before** a driver presses the entry button and no ticket comes out. So the system polls each printer's live status (paper out, cover open, cutter jam, off-line) and pushes changes to the operator UI. A reliability control, like