import { eq, devices, type Db, type DeviceRow } from "@parking/db"; import type { FastifyBaseLogger } from "fastify"; import { hasAuxOutput, registry, type AuxOutputDevice } from "@parking/devices"; import { deviceEvents, type DeviceInputEvent, type LaneStatusEvent } from "./device-events.js"; import { alertRelaysOf, relayForPresence, type RelaySpec } from "./device-resolve.js"; // Alert (radarAlert) relays — non-barrier indicator lamps, e.g. the entry button's 12 V // light. Each lamp is a `relays[]` row with event `radarAlert`, driven by ITS trigger // input vs. the camera "car in zone" signal (the advisory lane-status). A disagreement // indicator: // trigger active + lane busy (camera confirms a car) → SOLID on // trigger active + lane free (radar sees something, no car) → BLINK (~1 Hz) // otherwise → OFF // The lamp is a NON-barrier aux output (setAux latch), so holding/blinking it is fine // — barrier-not-a-door applies only to barriers, which still only pulseOpen. The lamp // FAILS OFF: any error / shutdown leaves it off, so a dead lamp is "no hint", never a // misleading solid "go". A controller may have several alert relays (each its own row + // trigger input), keyed independently. See wiki/concepts/button-light-indicator.md. type LightState = "off" | "solid" | "blink"; const DEFAULT_BLINK_MS = 500; /** Per-lamp live state for the alert rule (one per radarAlert relay). */ interface LampState { /** The controller this lamp lives on (its deviceId) — for resolving the aux adapter. */ readonly controllerId: string; /** Alert relay row (relay #, triggerInput, blink ms). Mutable: #reconcile updates it in * place when the admin changes the alert config without a restart. */ spec: RelaySpec; /** Is the lamp's trigger input (the radar) currently active? */ present: boolean; /** The high-level state we're rendering (to avoid restarting a running blink). */ rendered: LightState | null; /** Active blink timer, if blinking. */ blink: ReturnType | null; /** Blink phase (true = currently on). */ blinkOn: boolean; /** The output we WANT the relay to be in. The serialized worker drives the device * toward this. The blink timer only flips this flag — it never sends directly. */ desiredOn: boolean; /** The output we last CONFIRMED on the device (after a successful send). null = unknown. */ confirmedOn: boolean | null; /** True while a send is in flight for this lamp — serializes UDP so on/off can't * overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */ sending: boolean; } /** Resolves a controller's live aux-output adapter. The default goes through the * driver registry; tests inject a spy. Returns null when the controller has no * aux-output capability (or won't build). */ export type AuxResolver = (controllerId: string) => AuxOutputDevice | null; export class ButtonLightController { readonly #db: Db; readonly #logger: FastifyBaseLogger; readonly #resolveAux: AuxResolver; /** Per-lamp state, keyed by `${controllerId}:${relay}` (a controller may have several). */ readonly #lamps = new Map(); /** Latest lane status — a camera-confirmed car in the entry / exit zone. A lamp locks * SOLID off its OWN lane's camera (`spec.lockLane`), so an exit radar's lamp tracks the * exit camera, not the entry one. */ #entryBusy = false; #exitBusy = false; /** Controllers we've already warned lack the aux-output capability (warn once). */ readonly #warned = new Set(); #unsubInput: (() => void) | null = null; #unsubLane: (() => void) | null = null; constructor(db: Db, logger: FastifyBaseLogger, resolveAux?: AuxResolver) { this.#db = db; this.#logger = logger; this.#resolveAux = resolveAux ?? ((id) => this.#auxFromRegistry(id)); } /** Subscribe to radar input edges + lane status, and initialise every lamp OFF. */ start(): void { this.#reconcile(); // All lamps start OFF (known-safe baseline) regardless of prior device state. for (const lamp of this.#lamps.values()) this.#apply(lamp); this.#unsubInput = deviceEvents.onInput((e) => this.#onInput(e)); this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s)); } /** Reconcile the lamp map with the CURRENT device config (the booth can add/change a * button light without a server restart). Mirrors DeviceMonitor, which re-reads the * device set each tick. Adds lamps for newly-configured controllers, updates the spec * (relay #, blink ms) in place — preserving live `present`/blink state — and drops * lamps whose controller lost its buttonLight or was disabled. Called at start() and * before handling each event, so a just-saved lamp takes effect immediately. */ #reconcile(): void { const rows = this.#db.select().from(devices).where(eq(devices.category, "access")).all(); const seen = new Set(); for (const row of rows) { if (!row.enabled) continue; for (const spec of alertRelaysOf(row)) { const key = lampKey(row.id, spec.relay); seen.add(key); const existing = this.#lamps.get(key); if (existing) { existing.spec = spec; // pick up a changed trigger input / blink cadence } else { this.#lamps.set(key, { controllerId: row.id, spec, present: false, rendered: null, blink: null, blinkOn: false, desiredOn: false, confirmedOn: null, sending: false, }); } } } // Drop lamps whose controller no longer declares one (or was disabled/removed). for (const [key, lamp] of this.#lamps) { if (seen.has(key)) continue; if (lamp.blink) { clearInterval(lamp.blink); lamp.blink = null; } this.#finalOff(lamp); // best-effort fail-OFF before forgetting it this.#lamps.delete(key); } } /** A radar (presence) edge updates that controller's `present` flag. We resolve the * edge the SAME way the entry flow does (relayForPresence on an entry/both relay), * so the lamp and the one-car-one-ticket gate always agree on "a car is here". */ #onInput(e: DeviceInputEvent): void { // Reconcile first so a lamp added/changed since boot (no restart) is picked up. this.#reconcile(); const present = e.edge === "on"; for (const lamp of this.#lamps.values()) { if (lamp.controllerId !== e.deviceId) continue; // A lamp's trigger is its own `triggerInput`; if unset, fall back to the controller's // entry-relay presence terminal (resolved the SAME way the entry flow does) so the // lamp and the one-car-one-ticket gate always agree on "a car is here". const trigger = lamp.spec.triggerInput ?? relayForPresence(this.#db, e.deviceId, e.input)?.presenceInput; if (trigger !== e.input) continue; // not this lamp's trigger terminal if (present === lamp.present) continue; lamp.present = present; this.#apply(lamp); } } /** Lane status changed: a camera-confirmed car in the entry and/or exit zone. */ #onLane(s: LaneStatusEvent): void { if (s.entry === this.#entryBusy && s.exit === this.#exitBusy) return; this.#entryBusy = s.entry; this.#exitBusy = s.exit; // Re-render every lamp (each picks its own lane's camera in #apply). for (const lamp of this.#lamps.values()) this.#apply(lamp); } /** Compute + render the target state for one lamp. Drives are fire-and-forget (the * timer/state machine is synchronous; the UDP write resolves on its own). */ #apply(lamp: LampState): void { // SOLID only once THIS lamp's lane camera confirms a car (default entry). const laneBusy = lamp.spec.lockLane === "exit" ? this.#exitBusy : this.#entryBusy; const target: LightState = !lamp.present ? "off" : laneBusy ? "solid" : "blink"; if (target === lamp.rendered) return; // already rendering this state // Tear down any running blink before switching states. if (lamp.blink) { clearInterval(lamp.blink); lamp.blink = null; } lamp.rendered = target; if (target === "off") { lamp.desiredOn = false; this.#pump(lamp); } else if (target === "solid") { lamp.desiredOn = true; this.#pump(lamp); } else { // BLINK: a wall-clock timer flips ONLY the desired flag; #pump does the actual // (serialized) UDP send. A symmetric cadence uses one interval; an asymmetric one // re-arms each phase with its own duration. Sends never overlap or reorder, so the // relay can't get stuck on a stale packet. const onMs = lamp.spec.blinkOnMs && lamp.spec.blinkOnMs > 0 ? lamp.spec.blinkOnMs : DEFAULT_BLINK_MS; const offMs = lamp.spec.blinkOffMs && lamp.spec.blinkOffMs > 0 ? lamp.spec.blinkOffMs : DEFAULT_BLINK_MS; lamp.blinkOn = true; lamp.desiredOn = true; const tick = () => { lamp.blinkOn = !lamp.blinkOn; lamp.desiredOn = lamp.blinkOn; this.#pump(lamp); if (onMs !== offMs && lamp.blink) { clearInterval(lamp.blink); lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs); lamp.blink.unref?.(); } }; lamp.blink = setInterval(tick, onMs); lamp.blink.unref?.(); this.#pump(lamp); } } /** Serialized per-lamp worker: drive the relay toward `desiredOn`, one UDP send at a * time. Because UDP is unordered, concurrent on/off sends previously raced and left * the relay stuck on a stale packet. Here a single in-flight send is guaranteed * (`sending` guard); when it resolves, if the desired state moved on we send again — * so the LAST desired state is always the one finally asserted on the device. */ #pump(lamp: LampState): void { if (lamp.sending) return; // a send is already in flight; it'll re-check on completion if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP const aux = this.#resolveAux(lamp.controllerId); if (!aux) return; const target = lamp.desiredOn; lamp.sending = true; void aux .setAux(lamp.spec.relay, target) .then(() => { lamp.confirmedOn = target; }) .catch((err: unknown) => { // Leave confirmedOn unchanged so the next pump retries this state. Never escalates. this.#logger.error(`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}`); }) .finally(() => { lamp.sending = false; // Desired state may have changed (or the send failed) while we were busy — // re-pump to converge. This is what makes the final state authoritative. if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(lamp); }); } /** Build the live aux-output adapter for a controller, or null (logged once). */ #auxFromRegistry(controllerId: string): AuxOutputDevice | null { const row = this.#db.select().from(devices).where(eq(devices.id, controllerId)).get(); if (!row) return null; const driver = registry.get(row.driverId); if (!driver) return null; let device: unknown; try { device = driver.create(row.config as never); } catch { return null; } if (!hasAuxOutput(device)) { if (!this.#warned.has(controllerId)) { this.#warned.add(controllerId); this.#logger.warn(`button-light: controller ${controllerId} (${row.driverId}) has no aux-output — lamp ignored`); } return null; } return device; } /** Unsubscribe, stop all blink timers, and best-effort drive every lamp OFF. */ stop(): void { this.#unsubInput?.(); this.#unsubLane?.(); this.#unsubInput = null; this.#unsubLane = null; for (const lamp of this.#lamps.values()) { if (lamp.blink) { clearInterval(lamp.blink); lamp.blink = null; } // Best-effort fail-OFF on shutdown. this.#finalOff(lamp); } } /** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired * OFF and pump. The serialized worker still applies, so this can't collide with an * in-flight send — it converges to OFF. */ #finalOff(lamp: LampState): void { lamp.desiredOn = false; this.#pump(lamp); } /** Test seam: current high-level state being rendered for a lamp (controller + relay). * `relay` defaults to the controller's only/first alert relay for single-lamp tests. */ stateOf(controllerId: string, relay?: number): LightState | null { return this.#lamp(controllerId, relay)?.rendered ?? null; } /** Test seam: the state last CONFIRMED on the device for a lamp (after a successful * send). null = unknown / nothing sent yet. `relay` defaults to the only alert relay. */ confirmedOf(controllerId: string, relay?: number): boolean | null { return this.#lamp(controllerId, relay)?.confirmedOn ?? null; } /** Resolve a lamp by controller + relay. When `relay` is omitted, returns the * controller's single lamp (the common single-alert case); ambiguous if several. */ #lamp(controllerId: string, relay?: number): LampState | undefined { if (relay != null) return this.#lamps.get(lampKey(controllerId, relay)); for (const lamp of this.#lamps.values()) if (lamp.controllerId === controllerId) return lamp; return undefined; } } /** Composite key for the lamp map (a controller may carry several alert relays). */ function lampKey(controllerId: string, relay: number): string { return `${controllerId}:${relay}`; } /** Build a controller row's live aux device (exported for reuse/tests). */ export function buildAux(db: Db, row: DeviceRow): AuxOutputDevice | null { const driver = registry.get(row.driverId); if (!driver) return null; try { const device = driver.create(row.config as never); return hasAuxOutput(device) ? device : null; } catch { return null; } }