import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { randomUUID } from "node:crypto"; 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"; import { deviceEvents } from "./device-events.js"; import { silentLogger } from "./test-helpers.js"; // ButtonLightController: alert (radarAlert) relays — the entry-button lamp on a spare // relay, driven by the lamp's trigger input vs. the camera lane status. Truth table: // trigger active + lane busy -> SOLID on // trigger active + lane free -> BLINK (~1 Hz) // otherwise -> OFF // Lamp is a non-barrier aux output; fails OFF; de-dupes redundant writes. A controller may // carry several alert relays (each its own row + trigger input), keyed independently. let db: Db; const CONTROLLER = "ctl-1"; const RADAR_INPUT = 2; // I2 const LAMP_RELAY = 3; // spare relay R3 /** A fake aux device recording setAux calls (channel,on). Optionally throws. */ function fakeAux(record: Array<{ ch: number; on: boolean }>, throwOnce = { v: false }): AuxOutputDevice { return { async setAux(channel: number, on: boolean): Promise { if (throwOnce.v) { throwOnce.v = false; throw new Error("UDP down"); } record.push({ ch: channel, on }); }, }; } beforeEach(() => { ({ db } = createTestDb()); vi.useFakeTimers(); // One controller: entry relay 1 with radar on I2; lamp on spare relay 3. db.insert(devices).values({ id: CONTROLLER, category: "access", driverId: "dingtian", config: { host: "10.0.0.5", relays: [ { relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" }, { relay: 2, direction: "exit" }, { relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 }, ], }, enabled: true, }).run(); }); afterEach(() => { vi.useRealTimers(); }); /** Emit a radar (presence input) edge for the controller. */ function radar(present: boolean): void { deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: RADAR_INPUT, edge: present ? "on" : "off", at: new Date().toISOString(), source: "poll", }); } /** Emit a lane status (entry busy/free). */ 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)", 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"); // 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", 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); await flush(); expect(ctl.stateOf(CONTROLLER)).toBe("solid"); 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 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(); await flush(); radar(true); // lane still free await flush(); expect(ctl.stateOf(CONTROLLER)).toBe("blink"); expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // on now vi.advanceTimersByTime(500); await flush(); expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // toggled off vi.advanceTimersByTime(500); await flush(); expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // toggled on ctl.stop(); }); 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(ctl.confirmedOf(CONTROLLER)).toBe(true); // No more toggles (blink torn down) — the device stays ON over time. vi.advanceTimersByTime(2000); await flush(); expect(ctl.confirmedOf(CONTROLLER)).toBe(true); ctl.stop(); }); 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 await flush(); radar(false); // car gone await flush(); expect(ctl.stateOf(CONTROLLER)).toBe("off"); expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // device latched OFF ctl.stop(); }); 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", 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(); await flush(); // The failure arms a backoff (1s) rather than retrying inline; desired-state // changes during the window just update the target the retry will assert. lane(true); radar(true); await flush(); expect(ctl.confirmedOf(CONTROLLER)).toBeNull(); // still backing off await vi.advanceTimersByTimeAsync(1000); // retry fires; aux is healthy again expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // converged to solid ON ctl.stop(); }); it("an unreachable controller backs off (1s→30s), not a hot retry loop", async () => { let attempts = 0; const aux: AuxOutputDevice = { async setAux() { attempts += 1; throw new Error("send ENETUNREACH 10.0.10.5:60000"); }, }; const errors: string[] = []; const logger = silentLogger(); (logger as { error: (msg: string) => void }).error = (msg) => errors.push(msg); const ctl = new ButtonLightController(db, logger, () => aux); ctl.start(); // initial OFF write → attempt 1 fails at t=0 await flush(); expect(attempts).toBe(1); // the old code hot-looped here // Failures at t≈0,1,3,7,15,31 (doubling, capped 30s) → 6 attempts in the first // minute instead of thousands. await vi.advanceTimersByTimeAsync(60_000); expect(attempts).toBeGreaterThanOrEqual(5); expect(attempts).toBeLessThanOrEqual(7); // Only the FIRST failure was logged so far; the next log is a ≥60s summary. expect(errors).toHaveLength(1); await vi.advanceTimersByTimeAsync(35_000); // t≈95s → the t=61s attempt logged a summary expect(errors.length).toBe(2); expect(errors[1]).toContain("still failing"); ctl.stop(); }); it("logs a single recovery line and resets the backoff after success", async () => { let failing = true; let attempts = 0; const aux: AuxOutputDevice = { async setAux() { attempts += 1; if (failing) throw new Error("send ENETUNREACH 10.0.10.5:60000"); }, }; const infos: string[] = []; const logger = silentLogger(); (logger as { info: (msg: string) => void }).info = (msg) => infos.push(msg); const ctl = new ButtonLightController(db, logger, () => aux); ctl.start(); await flush(); await vi.advanceTimersByTimeAsync(3_000); // attempts at t=0,1,3 all fail const failed = attempts; expect(failed).toBeGreaterThanOrEqual(3); failing = false; // controller reachable again await vi.advanceTimersByTimeAsync(8_000); // next armed retry succeeds expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // OFF asserted on the device expect(infos.filter((m) => m.includes("recovered"))).toHaveLength(1); // Backoff reset: a fresh state change sends immediately (no lingering retryAt). const before = attempts; lane(true); radar(true); await flush(); expect(ctl.confirmedOf(CONTROLLER)).toBe(true); expect(attempts).toBe(before + 1); ctl.stop(); }); it("ignores controllers without an alert relay", () => { // A second controller, no alert relay. db.insert(devices).values({ id: "ctl-2", category: "access", driverId: "dingtian", config: { host: "10.0.0.6", relays: [{ relay: 1, direction: "entry", presenceInput: 2 }] }, enabled: true, }).run(); const calls: Array<{ ch: number; on: boolean }> = []; const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls)); ctl.start(); expect(ctl.stateOf("ctl-2")).toBeNull(); ctl.stop(); }); it("picks up an alert relay ADDED after start() (no restart needed)", async () => { // Fresh controller with a radar input but NO alert relay 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 an alert relay (relay 3, trigger I2) — without restarting the server. db.update(devices) .set({ config: { host: "10.0.0.5", relays: [ { relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }, { relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, 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(); }); it("drives two alert relays on one controller independently", async () => { const R3 = 3; const R4 = 4; const I2 = 2; const I3 = 3; // Controller with two alert lamps, each on its own trigger input. db.update(devices) .set({ config: { host: "10.0.0.5", relays: [ { relay: 1, direction: "entry", presenceInput: I2, presenceKind: "radar" }, { relay: R3, direction: "radarAlert", triggerInput: I2, blinkOnMs: 500, blinkOffMs: 500 }, { relay: R4, direction: "radarAlert", triggerInput: I3, blinkOnMs: 500, blinkOffMs: 500 }, ], }, }) .where(eq(devices.id, CONTROLLER)) .run(); const calls: Array<{ ch: number; on: boolean }> = []; const aux = fakeAux(calls); const ctl = new ButtonLightController(db, silentLogger(), () => aux); ctl.start(); await flush(); expect(ctl.stateOf(CONTROLLER, R3)).toBe("off"); expect(ctl.stateOf(CONTROLLER, R4)).toBe("off"); // I2 active → only R3 blinks; R4 stays off (different trigger). deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "on", at: new Date().toISOString(), source: "poll" }); await flush(); expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink"); expect(ctl.stateOf(CONTROLLER, R4)).toBe("off"); // I3 active → R4 blinks too, independently. deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I3, edge: "on", at: new Date().toISOString(), source: "poll" }); await flush(); expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink"); expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink"); // Camera confirms a car → BOTH lock solid (lane-busy is site-wide). lane(true); await flush(); expect(ctl.stateOf(CONTROLLER, R3)).toBe("solid"); expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid"); // I2 clears → R3 off, R4 still solid (its trigger still active). deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "off", at: new Date().toISOString(), source: "poll" }); await flush(); expect(ctl.stateOf(CONTROLLER, R3)).toBe("off"); expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid"); ctl.stop(); }); it("an EXIT alert lamp locks on the EXIT camera, not entry", async () => { const R4 = 4; const I5 = 5; // exit radar db.update(devices) .set({ config: { host: "10.0.0.5", relays: [ { relay: 1, direction: "entry" }, { relay: 2, direction: "exit" }, // Exit alert lamp: triggers on the exit radar, locks on the EXIT camera. { relay: R4, direction: "radarAlert", triggerInput: I5, lockLane: "exit", blinkOnMs: 500, blinkOffMs: 500 }, ], }, }) .where(eq(devices.id, CONTROLLER)) .run(); const aux = fakeAux([]); const ctl = new ButtonLightController(db, silentLogger(), () => aux); ctl.start(); await flush(); // Exit radar active → blink. deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I5, edge: "on", at: new Date().toISOString(), source: "poll" }); await flush(); expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink"); // ENTRY camera busy must NOT lock this exit lamp — it still blinks. deviceEvents.emitLaneStatus({ entry: true, exit: false }); await flush(); expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink"); // EXIT camera busy → SOLID. deviceEvents.emitLaneStatus({ entry: true, exit: true }); await flush(); expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid"); ctl.stop(); }); });