e2d5105da2
An unreachable controller rejects the UDP send instantly, and #pump's failure re-pump retried inline: a tight loop logging hundreds of identical errors per minute (park-buzi, 2026-07-07). Failed sends now arm a 1s→30s exponential retry (reset on success); desiredOn keeps tracking the truth table meanwhile and the armed retry converges to it. Logging is rate-limited: first failure of a streak in full, then one summary/minute, one info line on recovery. #finalOff waives the backoff so the last-gasp OFF on drop/shutdown still gets an immediate try. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
425 lines
16 KiB
TypeScript
425 lines
16 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
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();
|
|
});
|
|
});
|