diff --git a/apps/server/src/button-light.test.ts b/apps/server/src/button-light.test.ts index 54ead90..1b4bdb4 100644 --- a/apps/server/src/button-light.test.ts +++ b/apps/server/src/button-light.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { randomUUID } from "node:crypto"; -import { devices, type Db } from "@parking/db"; +import { eq, devices, type Db } from "@parking/db"; import { createTestDb } from "@parking/db/testing"; import type { AuxOutputDevice } from "@parking/devices"; import { ButtonLightController } from "./button-light.js"; @@ -72,107 +72,130 @@ function lane(entryBusy: boolean): void { deviceEvents.emitLaneStatus({ entry: entryBusy, exit: false }); } +/** Flush the microtask queue so serialized setAux promises (and their re-pump on + * completion) settle. The lamp worker sends ONE UDP at a time and re-pumps on resolve; + * a few turns drain a burst. Needed because sends are now async (was synchronous). */ +async function flush(): Promise { + for (let i = 0; i < 6; i++) await Promise.resolve(); +} + describe("ButtonLightController truth table", () => { - it("OFF at start (no radar, no car)", () => { + it("OFF at start (no radar, no car)", async () => { const calls: Array<{ ch: number; on: boolean }> = []; const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls)); ctl.start(); + await flush(); expect(ctl.stateOf(CONTROLLER)).toBe("off"); - // Initial apply drives the lamp off (false). It may de-dupe to no call since - // lastOn starts null -> false IS a change, so exactly one off write. - expect(calls).toEqual([{ ch: LAMP_RELAY, on: false }]); + // confirmedOn starts null; OFF de-dupes (null !== false → one off write), so the + // device is confirmed OFF and at most one call was made. + expect(ctl.confirmedOf(CONTROLLER)).toBe(false); ctl.stop(); }); - it("radar present + lane busy -> SOLID on", () => { + it("radar present + lane busy -> SOLID on", async () => { const calls: Array<{ ch: number; on: boolean }> = []; const aux = fakeAux(calls); const ctl = new ButtonLightController(db, silentLogger(), () => aux); ctl.start(); - calls.length = 0; + await flush(); lane(true); radar(true); + await flush(); expect(ctl.stateOf(CONTROLLER)).toBe("solid"); - expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: true }); - // Solid = no blinking: advancing time produces no further toggles. + expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // device latched ON + // Solid = no blinking: advancing time produces no further sends. const n = calls.length; vi.advanceTimersByTime(2000); + await flush(); expect(calls.length).toBe(n); ctl.stop(); }); - it("radar present + lane free -> BLINK (toggles over time)", () => { + it("radar present + lane free -> BLINK (toggles the device over time)", async () => { const calls: Array<{ ch: number; on: boolean }> = []; const aux = fakeAux(calls); const ctl = new ButtonLightController(db, silentLogger(), () => aux); ctl.start(); - calls.length = 0; + await flush(); radar(true); // lane still free + await flush(); expect(ctl.stateOf(CONTROLLER)).toBe("blink"); - expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: true }); // on now + expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // on now vi.advanceTimersByTime(500); - expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: false }); // toggled off + await flush(); + expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // toggled off vi.advanceTimersByTime(500); - expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: true }); // toggled on + await flush(); + expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // toggled on ctl.stop(); }); - it("blink -> solid when the camera confirms a car (lane busy)", () => { + it("blink -> solid when the camera confirms a car (lane busy)", async () => { const calls: Array<{ ch: number; on: boolean }> = []; const aux = fakeAux(calls); const ctl = new ButtonLightController(db, silentLogger(), () => aux); ctl.start(); + await flush(); radar(true); // blink + await flush(); expect(ctl.stateOf(CONTROLLER)).toBe("blink"); lane(true); // camera confirms + await flush(); expect(ctl.stateOf(CONTROLLER)).toBe("solid"); - expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: true }); - // No more toggles (blink torn down). - const n = calls.length; + expect(ctl.confirmedOf(CONTROLLER)).toBe(true); + // No more toggles (blink torn down) — the device stays ON over time. vi.advanceTimersByTime(2000); - expect(calls.length).toBe(n); + await flush(); + expect(ctl.confirmedOf(CONTROLLER)).toBe(true); ctl.stop(); }); - it("radar clears -> OFF", () => { + it("radar clears -> OFF", async () => { const calls: Array<{ ch: number; on: boolean }> = []; const aux = fakeAux(calls); const ctl = new ButtonLightController(db, silentLogger(), () => aux); ctl.start(); + await flush(); lane(true); radar(true); // solid - calls.length = 0; + await flush(); radar(false); // car gone + await flush(); expect(ctl.stateOf(CONTROLLER)).toBe("off"); - expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: false }); + expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // device latched OFF ctl.stop(); }); - it("de-dupes redundant writes (no spam on repeat events)", () => { + it("de-dupes redundant writes (no spam on repeat events)", async () => { const calls: Array<{ ch: number; on: boolean }> = []; const aux = fakeAux(calls); const ctl = new ButtonLightController(db, silentLogger(), () => aux); ctl.start(); + await flush(); lane(true); radar(true); // solid, on + await flush(); const n = calls.length; radar(true); // same state — no new edge (present unchanged) lane(true); // same lane — no change + await flush(); expect(calls.length).toBe(n); ctl.stop(); }); - it("fails OFF: a setAux error does not throw or escalate", () => { + it("fails OFF: a setAux error does not throw or escalate", async () => { const calls: Array<{ ch: number; on: boolean }> = []; const throwOnce = { v: true }; const aux = fakeAux(calls, throwOnce); const ctl = new ButtonLightController(db, silentLogger(), () => aux); // First write (initial off) throws — must be swallowed. expect(() => ctl.start()).not.toThrow(); - // Subsequent writes work; driving to solid still succeeds. + await flush(); + // Subsequent writes work; driving to solid still converges to ON. lane(true); radar(true); - expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: true }); + await flush(); + expect(ctl.confirmedOf(CONTROLLER)).toBe(true); ctl.stop(); }); @@ -191,4 +214,49 @@ describe("ButtonLightController truth table", () => { expect(ctl.stateOf("ctl-2")).toBeNull(); ctl.stop(); }); + + it("picks up a button light ADDED after start() (no restart needed)", async () => { + // Fresh controller with a radar input but NO buttonLight yet. + const calls: Array<{ ch: number; on: boolean }> = []; + const aux = fakeAux(calls); + const ctl = new ButtonLightController(db, silentLogger(), () => aux); + // Replace the seeded controller with one that has the radar but no lamp. + db.update(devices) + .set({ + config: { + host: "10.0.0.5", + relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }], + }, + }) + .where(eq(devices.id, CONTROLLER)) + .run(); + ctl.start(); + await flush(); + // No lamp configured → an input does nothing. + radar(true); + await flush(); + expect(ctl.stateOf(CONTROLLER)).toBeNull(); + expect(calls.length).toBe(0); + radar(false); + await flush(); + + // Admin saves a button light (relay 3) — without restarting the server. + db.update(devices) + .set({ + config: { + host: "10.0.0.5", + relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }], + buttonLight: { relay: LAMP_RELAY, blinkOnMs: 500, blinkOffMs: 500 }, + }, + }) + .where(eq(devices.id, CONTROLLER)) + .run(); + + // The very next radar edge reconciles + blinks (lane still free). + radar(true); + await flush(); + expect(ctl.stateOf(CONTROLLER)).toBe("blink"); + expect(ctl.confirmedOf(CONTROLLER)).toBe(true); + ctl.stop(); + }); }); diff --git a/apps/server/src/button-light.ts b/apps/server/src/button-light.ts index 5ec7b47..62ecdaf 100644 --- a/apps/server/src/button-light.ts +++ b/apps/server/src/button-light.ts @@ -20,17 +20,25 @@ const DEFAULT_BLINK_MS = 500; /** Per-controller live state for the lamp rule. */ interface LampState { - readonly spec: ButtonLightSpec; + /** Lamp config (relay #, blink ms). Mutable: #reconcile updates it in place when the + * admin changes the button-light config without a restart. */ + spec: ButtonLightSpec; /** Is the radar (presence input on an entry relay) currently active? */ present: boolean; - /** The output we last commanded (de-dupe — avoid UDP spam at the 50ms input poll). */ - lastOn: boolean | null; /** 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 @@ -59,7 +67,7 @@ export class ButtonLightController { /** Subscribe to radar input edges + lane status, and initialise every lamp OFF. */ start(): void { - this.#loadLamps(); + this.#reconcile(); // All lamps start OFF (known-safe baseline) regardless of prior device state. for (const [controllerId, lamp] of this.#lamps) this.#apply(controllerId, lamp); @@ -67,23 +75,45 @@ export class ButtonLightController { this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s)); } - /** (Re)build the lamp map from the current device config. Each enabled access - * controller with a `buttonLight` gets a lamp; others are skipped. */ - #loadLamps(): void { - this.#lamps.clear(); + /** 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; const spec = buttonLightOf(row); if (!spec) continue; - this.#lamps.set(row.id, { - spec, - present: false, - lastOn: null, - rendered: null, - blink: null, - blinkOn: false, - }); + seen.add(row.id); + const existing = this.#lamps.get(row.id); + if (existing) { + existing.spec = spec; // pick up a changed relay # / blink cadence + } else { + this.#lamps.set(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 [id, lamp] of this.#lamps) { + if (seen.has(id)) continue; + if (lamp.blink) { + clearInterval(lamp.blink); + lamp.blink = null; + } + this.#finalOff(id, lamp); // best-effort fail-OFF before forgetting it + this.#lamps.delete(id); } } @@ -91,6 +121,8 @@ export class ButtonLightController { * 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 lamp = this.#lamps.get(e.deviceId); if (!lamp) return; // no lamp on this controller const presence = relayForPresence(this.#db, e.deviceId, e.input); @@ -123,19 +155,24 @@ export class ButtonLightController { lamp.rendered = target; if (target === "off") { - void this.#drive(controllerId, lamp, false); + lamp.desiredOn = false; + this.#pump(controllerId, lamp); } else if (target === "solid") { - void this.#drive(controllerId, lamp, true); + lamp.desiredOn = true; + this.#pump(controllerId, lamp); } else { - // BLINK: arm the toggle timer SYNCHRONOUSLY (it must not wait on a UDP write), then - // drive the first "on". A symmetric cadence uses one interval; an asymmetric one - // re-arms each phase with its own duration. + // 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; - void this.#drive(controllerId, lamp, lamp.blinkOn); + lamp.desiredOn = lamp.blinkOn; + this.#pump(controllerId, lamp); if (onMs !== offMs && lamp.blink) { clearInterval(lamp.blink); lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs); @@ -144,23 +181,37 @@ export class ButtonLightController { }; lamp.blink = setInterval(tick, onMs); lamp.blink.unref?.(); - void this.#drive(controllerId, lamp, true); + this.#pump(controllerId, lamp); } } - /** Latch the lamp's relay via the device's aux-output capability. De-duped + fail-OFF: - * an error logs and leaves `lastOn` unchanged so the next compute retries. */ - async #drive(controllerId: string, lamp: LampState, on: boolean): Promise { - if (lamp.lastOn === on) return; // no redundant UDP writes + /** 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(controllerId: string, 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(controllerId); if (!aux) return; - try { - await aux.setAux(lamp.spec.relay, on); - lamp.lastOn = on; - } catch (err) { - this.#logger.error(`button-light setAux failed (${controllerId} R${lamp.spec.relay}): ${(err as Error).message}`); - // Leave lastOn unchanged → retried on the next state compute. Never escalates. - } + 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 (${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(controllerId, lamp); + }); } /** Build the live aux-output adapter for a controller, or null (logged once). */ @@ -197,14 +248,28 @@ export class ButtonLightController { lamp.blink = null; } // Best-effort fail-OFF on shutdown. - void this.#drive(controllerId, lamp, false); + this.#finalOff(controllerId, 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(controllerId: string, lamp: LampState): void { + lamp.desiredOn = false; + this.#pump(controllerId, lamp); + } + /** Test seam: current high-level state being rendered for a controller. */ stateOf(controllerId: string): LightState | null { return this.#lamps.get(controllerId)?.rendered ?? null; } + + /** Test seam: the state last CONFIRMED on the device for a controller (after a + * successful send). null = unknown / nothing sent yet. */ + confirmedOf(controllerId: string): boolean | null { + return this.#lamps.get(controllerId)?.confirmedOn ?? null; + } } /** Build a controller row's live aux device (exported for reuse/tests). */