4418594af0
The controller new/edit modal hardcoded both its outputs and its inputs, so an
operator could neither add a generic event-driven relay nor a free-standing input
(e.g. a second radar at the exit). This unifies both into symmetric, first-class
lists. Behaviour for existing booths is unchanged (back-compat, no DB migration).
Outputs — one event→action relays[] list:
- A relay is "when EVENT X happens, do its action": entry/exit/both pulse a
barrier; a new `radarAlert` event drives a non-barrier alert lamp (blink while
its trigger input is active, SOLID once the camera confirms a car).
- Dropped the separate config.buttonLight block — the lamp is just a relays[] row
with direction:"radarAlert" (triggerInput + blink cadence). `alertRelaysOf()`
replaces `buttonLightOf()`; ButtonLightController keeps its proven 3-state
machine (serialized UDP, fail-OFF, hot-reload), now keyed per controllerId:relay
so several alert lamps on one controller run independently. Every barrier
resolver skips radarAlert rows (no auto-open; barrier-not-a-door intact).
Inputs — one first-class config.inputs[] list (the twin of relays[]):
- Each row is { input, role, relay?, kind?, activeLow?, cooldownSec? } with a
"+ Add input" button. role ∈ button | presence | alertTrigger; button/presence
name the relay they serve. An exit radar is just another presence row.
- Keystone `inputsOf(row)`: returns config.inputs[] or SYNTHESIZES it from the
legacy relays[].button/presenceInput/... fields, so relayForButton /
relayForPresence resolve identically from either shape — zero-downtime, no
migration. entry-flow.ts is unchanged (resolves through the same functions).
- Fixed a latent bug this exposed: the alert lamp's camera lock was hardcoded to
the ENTRY camera. Added relays[].lockLane ("entry"|"exit", default entry); the
lamp now locks on its own lane's camera, so an exit radar's lamp tracks the exit
camera. button-light tracks both #entryBusy/#exitBusy.
- Driver: extracted activeLowFrom(config) — merges inputs[] activeLow, legacy
relays[].presenceActiveLow, and the inputActiveLow escape hatch.
UI: the relay dropdown gained a "Radar alert" option (reveals trigger/lock/blink
inputs); InputEditor is rewritten to a generic list (role select folds loop/radar);
i18n sq+en kept at type-parity.
Tests: new device-resolve.test.ts (inputs[] resolution + legacy fallback identical
+ exit-radar resolves to the exit relay); button-light gains a two-independent-
alert-relays case and an exit-lamp lockLane case; access-dingtian gains
activeLowFrom cases. Full workspace build/lint/test green (i18n parity included).
Wiki + memory updated (button-light-indicator, entry-double-press, dingtian-relay).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
198 lines
7.7 KiB
TypeScript
198 lines
7.7 KiB
TypeScript
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";
|
|
import type { VisionClient } from "./vision-client.js";
|
|
|
|
/** Synthetic device id for the vision service in the status footer (it's a service,
|
|
* not a device row, but shares the footer's traffic-light + WS plumbing). */
|
|
const VISION_STATUS_ID = "vision-service";
|
|
|
|
// 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": {
|
|
// Only barrier relays carry a role direction; alert (radarAlert) relays don't.
|
|
const dirs = new Set(
|
|
relaysOf(row)
|
|
.map((r) => r.direction)
|
|
.filter((d): d is "entry" | "exit" | "both" => d !== "radarAlert"),
|
|
);
|
|
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<string, DeviceStatusEvent>();
|
|
#timer: ReturnType<typeof setInterval> | null = null;
|
|
#ticking = false;
|
|
|
|
/** Optional: the vision service client. When present + enabled, the monitor probes
|
|
* its /health each tick and shows it as a "vision" chip in the footer. */
|
|
readonly #vision: VisionClient | null;
|
|
|
|
constructor(db: Db, log: FastifyBaseLogger, pollMs = POLL_MS, vision: VisionClient | null = null) {
|
|
this.#db = db;
|
|
this.#log = log;
|
|
this.#pollMs = pollMs;
|
|
this.#vision = vision;
|
|
}
|
|
|
|
/** 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<void> {
|
|
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));
|
|
|
|
// The vision service is a pseudo-device — keep it in the present set when enabled
|
|
// so the cleanup below doesn't evict it.
|
|
if (this.#vision?.enabled) present.add(VISION_STATUS_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)), this.#pollVision()]);
|
|
} catch (err) {
|
|
this.#log.warn(`device-monitor tick failed: ${(err as Error).message}`);
|
|
} finally {
|
|
this.#ticking = false;
|
|
}
|
|
}
|
|
|
|
async #poll(row: DeviceRow): Promise<void> {
|
|
const cfg = (row.config ?? {}) as Record<string, unknown>;
|
|
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() };
|
|
}
|
|
}
|
|
|
|
this.#publish(row.id, next);
|
|
}
|
|
|
|
/** Probe the vision service /health and publish it as a "vision" footer chip. Skipped
|
|
* entirely when no client is wired or it's disabled (no chip then). */
|
|
async #pollVision(): Promise<void> {
|
|
if (!this.#vision?.enabled) return;
|
|
const h = await this.#vision.health();
|
|
const state: DeviceStatusEvent["state"] = h.ok && h.ready ? "ready" : h.ready ? "degraded" : "offline";
|
|
this.#publish(VISION_STATUS_ID, {
|
|
deviceId: VISION_STATUS_ID,
|
|
driverId: "vision",
|
|
category: "vision",
|
|
roleKind: null,
|
|
state,
|
|
detail: h.ready ? h.recognizer : (h.detail ?? "not ready"),
|
|
checkedAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
/** Cache + emit a status, but only when it CHANGED (state or detail). */
|
|
#publish(id: string, next: DeviceStatusEvent): void {
|
|
const prev = this.#latest.get(id);
|
|
this.#latest.set(id, next);
|
|
if (!prev || prev.state !== next.state || prev.detail !== next.detail) {
|
|
this.#log.info(
|
|
`device-monitor: ${next.category}/${next.roleKind ?? "—"} ${id} -> ${next.state}${next.detail ? ` (${next.detail})` : ""}`,
|
|
);
|
|
deviceEvents.emitDeviceStatus(next);
|
|
}
|
|
}
|
|
}
|