38481f105f
The on-screen Hyrje/Dalje barrier lights were 2-state (green=free / red=busy)
off the camera lane-status only — they couldn't show the radar-only "detected,
not yet confirmed" state that makes the physical button lamp (relay 3) blink.
Now they mirror the lamp's 3-state rule per lane:
radar present + camera not busy → BLINK green↔red (~1 Hz)
camera busy → SOLID red
otherwise → SOLID green
End-to-end:
- LanePresence (lane-presence.ts): subscribes to deviceEvents.onInput, resolves
each presence edge to its lane via the new direction-agnostic presenceLaneOf()
(device-resolve.ts) — entry AND exit, unlike the entry-gated relayForPresence
the one-car-one-ticket gate uses — and emits a lane-presence {entry,exit} bus
event on change. Wired in server.ts (start + onClose).
- WS forwards it (hello snapshot + push) into live-store.radar.
- BarrierLight (BoothScreen.tsx) is now 3-state; blinks via the .lane-blink
keyframe (index.css), which holds solid-red under prefers-reduced-motion.
Same input + same rule as the lamp, so the screen and the post never disagree.
A new test (lane-presence.test.ts) caught a real bug: the first cut reused
relayForPresence, so the EXIT lane never resolved (it's entry-gated) and never
blinked — presenceLaneOf fixes it. Covers entry/exit independence, de-dupe
across several radars on one lane, and ignoring non-presence inputs.
Full workspace build/lint/test green (185 server tests). Updated the
button-light-indicator wiki page ("On-screen twin").
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
145 lines
4.3 KiB
TypeScript
145 lines
4.3 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { eq, devices, type Db } from "@parking/db";
|
|
import { createTestDb } from "@parking/db/testing";
|
|
import { LanePresence } from "./lane-presence.js";
|
|
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
|
|
import { silentLogger } from "./test-helpers.js";
|
|
|
|
// LanePresence: a vehicle-presence INPUT edge (loop/radar) on an entry/exit barrier marks
|
|
// that lane "present" — the same signal that blinks the physical button lamp (relay 3). It
|
|
// resolves the edge via relayForPresence (the SAME path relay 3 + the entry gate use), and
|
|
// emits a lane-presence change only when a lane's present/clear state actually flips.
|
|
|
|
let db: Db;
|
|
const CTL = "ctl-1";
|
|
const ENTRY_RADAR = 2;
|
|
const EXIT_RADAR = 5;
|
|
|
|
beforeEach(() => {
|
|
({ db } = createTestDb());
|
|
// Entry relay 1 with a radar on I2; exit relay 2 with a radar on I5.
|
|
db.insert(devices).values({
|
|
id: CTL,
|
|
category: "access",
|
|
driverId: "dingtian",
|
|
config: {
|
|
host: "10.0.0.5",
|
|
relays: [
|
|
{ relay: 1, direction: "entry" },
|
|
{ relay: 2, direction: "exit" },
|
|
],
|
|
inputs: [
|
|
{ input: ENTRY_RADAR, role: "presence", relay: 1, kind: "radar" },
|
|
{ input: EXIT_RADAR, role: "presence", relay: 2, kind: "radar" },
|
|
],
|
|
},
|
|
enabled: true,
|
|
}).run();
|
|
});
|
|
|
|
function edge(input: number, on: boolean): void {
|
|
const e: DeviceInputEvent = {
|
|
driverId: "dingtian",
|
|
deviceId: CTL,
|
|
input,
|
|
edge: on ? "on" : "off",
|
|
at: new Date().toISOString(),
|
|
source: "poll",
|
|
};
|
|
deviceEvents.emitInput(e);
|
|
}
|
|
|
|
/** Collect lane-presence emissions while running `fn`. */
|
|
function capture(fn: () => void): LanePresenceEvent[] {
|
|
const seen: LanePresenceEvent[] = [];
|
|
const off = deviceEvents.onLanePresence((p) => seen.push(p));
|
|
try {
|
|
fn();
|
|
} finally {
|
|
off();
|
|
}
|
|
return seen;
|
|
}
|
|
|
|
describe("LanePresence", () => {
|
|
it("starts clear and snapshots clear", () => {
|
|
const lp = new LanePresence(db, silentLogger());
|
|
lp.start();
|
|
expect(lp.snapshot()).toEqual({ entry: false, exit: false });
|
|
lp.stop();
|
|
});
|
|
|
|
it("an ENTRY radar edge marks the entry lane present, then clears", () => {
|
|
const lp = new LanePresence(db, silentLogger());
|
|
lp.start();
|
|
const events = capture(() => {
|
|
edge(ENTRY_RADAR, true);
|
|
edge(ENTRY_RADAR, false);
|
|
});
|
|
expect(events).toEqual([
|
|
{ entry: true, exit: false },
|
|
{ entry: false, exit: false },
|
|
]);
|
|
lp.stop();
|
|
});
|
|
|
|
it("an EXIT radar edge marks the exit lane independently", () => {
|
|
const lp = new LanePresence(db, silentLogger());
|
|
lp.start();
|
|
const events = capture(() => {
|
|
edge(EXIT_RADAR, true);
|
|
});
|
|
expect(events).toEqual([{ entry: false, exit: true }]);
|
|
expect(lp.snapshot()).toEqual({ entry: false, exit: true });
|
|
lp.stop();
|
|
});
|
|
|
|
it("de-dupes: a second 'on' from another presence input on the same lane emits once", () => {
|
|
// Two radars both serving the entry lane.
|
|
db.update(devices)
|
|
.set({
|
|
config: {
|
|
host: "10.0.0.5",
|
|
relays: [{ relay: 1, direction: "entry" }],
|
|
inputs: [
|
|
{ input: 2, role: "presence", relay: 1, kind: "radar" },
|
|
{ input: 3, role: "presence", relay: 1, kind: "radar" },
|
|
],
|
|
},
|
|
})
|
|
.where(eq(devices.id, CTL))
|
|
.run();
|
|
const lp = new LanePresence(db, silentLogger());
|
|
lp.start();
|
|
const events = capture(() => {
|
|
edge(2, true); // entry → present (emit)
|
|
edge(3, true); // still present (no emit — same lane)
|
|
edge(2, false); // still present via I3 (no emit)
|
|
edge(3, false); // now clear (emit)
|
|
});
|
|
expect(events).toEqual([
|
|
{ entry: true, exit: false },
|
|
{ entry: false, exit: false },
|
|
]);
|
|
lp.stop();
|
|
});
|
|
|
|
it("ignores a non-presence input (e.g. a button terminal)", () => {
|
|
db.update(devices)
|
|
.set({
|
|
config: {
|
|
host: "10.0.0.5",
|
|
relays: [{ relay: 1, direction: "entry" }],
|
|
inputs: [{ input: 1, role: "button", relay: 1 }],
|
|
},
|
|
})
|
|
.where(eq(devices.id, CTL))
|
|
.run();
|
|
const lp = new LanePresence(db, silentLogger());
|
|
lp.start();
|
|
const events = capture(() => edge(1, true));
|
|
expect(events).toEqual([]);
|
|
lp.stop();
|
|
});
|
|
});
|