feat(booth): blink the Entry/Exit lights on radar presence (mirror relay 3)
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
This commit is contained in:
@@ -86,6 +86,16 @@ export interface LaneStatusEvent {
|
|||||||
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
|
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the
|
||||||
|
* entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has
|
||||||
|
* confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink:
|
||||||
|
* radar-present + camera-not-busy. Drives the booth's barrier light blink. Advisory only —
|
||||||
|
* it gates nothing. See wiki/concepts/button-light-indicator.md. */
|
||||||
|
export interface LanePresenceEvent {
|
||||||
|
readonly entry: boolean; // true = a presence input on an entry barrier is active
|
||||||
|
readonly exit: boolean; // true = a presence input on an exit barrier is active
|
||||||
|
}
|
||||||
|
|
||||||
class DeviceEventBus extends EventEmitter {
|
class DeviceEventBus extends EventEmitter {
|
||||||
emitInput(event: DeviceInputEvent): void {
|
emitInput(event: DeviceInputEvent): void {
|
||||||
this.emit("input", event);
|
this.emit("input", event);
|
||||||
@@ -148,6 +158,16 @@ class DeviceEventBus extends EventEmitter {
|
|||||||
this.on("lane-status", cb);
|
this.on("lane-status", cb);
|
||||||
return () => this.off("lane-status", cb);
|
return () => this.off("lane-status", cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Emitted whenever a lane's RADAR presence CHANGES (a presence input shorted/cleared
|
||||||
|
* at an entry/exit barrier). Drives the booth barrier light's blink. Advisory only. */
|
||||||
|
emitLanePresence(event: LanePresenceEvent): void {
|
||||||
|
this.emit("lane-presence", event);
|
||||||
|
}
|
||||||
|
onLanePresence(cb: (event: LanePresenceEvent) => void): () => void {
|
||||||
|
this.on("lane-presence", cb);
|
||||||
|
return () => this.off("lane-presence", cb);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Process-wide device event bus. */
|
/** Process-wide device event bus. */
|
||||||
|
|||||||
@@ -224,6 +224,26 @@ export function alertRelaysOf(row: DeviceRow): RelaySpec[] {
|
|||||||
return relaysOf(row).filter((r) => r.direction === "radarAlert" && typeof r.relay === "number");
|
return relaysOf(row).filter((r) => r.direction === "radarAlert" && typeof r.relay === "number");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which LANE a presence input belongs to — for the booth's barrier-light blink (advisory).
|
||||||
|
* Unlike `relayForPresence` (entry-gated, for the one-car-one-ticket gate), this resolves a
|
||||||
|
* presence input on ANY barrier: entry/both → "entry", exit → "exit". Returns null if the
|
||||||
|
* terminal isn't a presence input on a barrier relay. See lane-presence.ts.
|
||||||
|
*/
|
||||||
|
export function presenceLaneOf(db: Db, controllerId: string, terminal: number): FlowDirection | null {
|
||||||
|
const row = db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||||
|
.get();
|
||||||
|
if (!row || !row.enabled) return null;
|
||||||
|
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
|
||||||
|
if (!presence || typeof presence.relay !== "number") return null;
|
||||||
|
const relay = relaysOf(row).find((r) => r.relay === presence.relay);
|
||||||
|
if (!relay) return null;
|
||||||
|
return relay.direction === "exit" ? "exit" : relay.direction === "radarAlert" ? null : "entry";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a reader/camera to the relay it opens. Preferred: its config binding
|
* Resolve a reader/camera to the relay it opens. Preferred: its config binding
|
||||||
* (controllerId + relay) → exactly that barrier, direction inherited from the relay
|
* (controllerId + relay) → exactly that barrier, direction inherited from the relay
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
|
||||||
|
import { presenceLaneOf } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// Per-lane RADAR presence for the booth's barrier lights. A vehicle-presence INPUT
|
||||||
|
// (loop/radar) shorted at an entry/exit barrier means "something is in the lane vicinity"
|
||||||
|
// BEFORE the camera confirms a vehicle. This is the SAME signal that makes the physical
|
||||||
|
// button lamp (relay 3) blink — see button-light.ts (#onInput) — so the on-screen light
|
||||||
|
// and the lamp stay in lockstep: both react to a presence edge resolved the SAME way
|
||||||
|
// (relayForPresence, on an entry/both relay). ADVISORY ONLY: it gates nothing.
|
||||||
|
//
|
||||||
|
// A radar serving an entry (or "both") barrier marks the ENTRY lane present; an exit radar
|
||||||
|
// marks EXIT. The lane is resolved via `presenceLaneOf` (direction-agnostic — unlike the
|
||||||
|
// entry-gated `relayForPresence` the one-car-one-ticket gate uses), so both lanes blink.
|
||||||
|
|
||||||
|
export class LanePresence {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
/** Active presence terminals per lane, keyed `${deviceId}:${input}` (several radars may
|
||||||
|
* serve one lane). A lane is "present" while its set is non-empty. */
|
||||||
|
readonly #entry = new Set<string>();
|
||||||
|
readonly #exit = new Set<string>();
|
||||||
|
#unsub: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(db: Db, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to presence input edges. */
|
||||||
|
start(): void {
|
||||||
|
this.#unsub = deviceEvents.onInput((e) => this.#onInput(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current snapshot (for the WS hello). */
|
||||||
|
snapshot(): LanePresenceEvent {
|
||||||
|
return { entry: this.#entry.size > 0, exit: this.#exit.size > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
#onInput(e: DeviceInputEvent): void {
|
||||||
|
const lane = presenceLaneOf(this.#db, e.deviceId, e.input);
|
||||||
|
if (!lane) return; // not a presence terminal on a barrier relay
|
||||||
|
const key = `${e.deviceId}:${e.input}`;
|
||||||
|
const set = lane === "entry" ? this.#entry : this.#exit;
|
||||||
|
const before = set.size > 0;
|
||||||
|
if (e.edge === "on") set.add(key);
|
||||||
|
else set.delete(key);
|
||||||
|
const after = set.size > 0;
|
||||||
|
if (before !== after) {
|
||||||
|
this.#logger.info(`lane-presence: ${lane} -> ${after ? "present" : "clear"}`);
|
||||||
|
deviceEvents.emitLanePresence(this.snapshot());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsubscribe on shutdown. */
|
||||||
|
stop(): void {
|
||||||
|
this.#unsub?.();
|
||||||
|
this.#unsub = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,11 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import type { Db } from "@parking/db";
|
import type { Db } from "@parking/db";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
import { roleHasPermissions } from "../auth.js";
|
import { roleHasPermissions } from "../auth.js";
|
||||||
import { deviceEvents, type LaneStatusEvent } from "../device-events.js";
|
import { deviceEvents, type LaneStatusEvent, type LanePresenceEvent } from "../device-events.js";
|
||||||
import { enrichEvent } from "../event-enrich.js";
|
import { enrichEvent } from "../event-enrich.js";
|
||||||
import type { DeviceMonitor } from "../device-monitor.js";
|
import type { DeviceMonitor } from "../device-monitor.js";
|
||||||
import type { LaneStatus } from "../lane-status.js";
|
import type { LaneStatus } from "../lane-status.js";
|
||||||
|
import type { LanePresence } from "../lane-presence.js";
|
||||||
import { getOccupancy } from "../occupancy.js";
|
import { getOccupancy } from "../occupancy.js";
|
||||||
|
|
||||||
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
|
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
|
||||||
@@ -53,17 +54,25 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OutMsg =
|
type OutMsg =
|
||||||
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown; lanes: LaneStatusEvent }
|
| {
|
||||||
|
kind: "hello";
|
||||||
|
occupancy: ReturnType<typeof getOccupancy>;
|
||||||
|
devices: unknown;
|
||||||
|
lanes: LaneStatusEvent;
|
||||||
|
radar: LanePresenceEvent;
|
||||||
|
}
|
||||||
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
||||||
| { kind: "printer-status"; event: unknown }
|
| { kind: "printer-status"; event: unknown }
|
||||||
| { kind: "device-status"; event: unknown }
|
| { kind: "device-status"; event: unknown }
|
||||||
| { kind: "lane-status"; lanes: LaneStatusEvent };
|
| { kind: "lane-status"; lanes: LaneStatusEvent }
|
||||||
|
| { kind: "lane-presence"; radar: LanePresenceEvent };
|
||||||
|
|
||||||
export async function wsRoutes(
|
export async function wsRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
deviceMonitor: DeviceMonitor,
|
deviceMonitor: DeviceMonitor,
|
||||||
laneStatus: LaneStatus,
|
laneStatus: LaneStatus,
|
||||||
|
lanePresence: LanePresence,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
app.get(
|
app.get(
|
||||||
"/api/ws",
|
"/api/ws",
|
||||||
@@ -96,7 +105,13 @@ export async function wsRoutes(
|
|||||||
|
|
||||||
// Initial snapshot so the client renders immediately, before any event:
|
// Initial snapshot so the client renders immediately, before any event:
|
||||||
// occupancy AND the current device-status set (for the footer).
|
// occupancy AND the current device-status set (for the footer).
|
||||||
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot(), lanes: laneStatus.snapshot() });
|
send({
|
||||||
|
kind: "hello",
|
||||||
|
occupancy: getOccupancy(db),
|
||||||
|
devices: deviceMonitor.snapshot(),
|
||||||
|
lanes: laneStatus.snapshot(),
|
||||||
|
radar: lanePresence.snapshot(),
|
||||||
|
});
|
||||||
|
|
||||||
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
||||||
// ledger (cheap fold) so the pushed count is always authoritative.
|
// ledger (cheap fold) so the pushed count is always authoritative.
|
||||||
@@ -117,12 +132,17 @@ export async function wsRoutes(
|
|||||||
const offLane = deviceEvents.onLaneStatus((lanes) => {
|
const offLane = deviceEvents.onLaneStatus((lanes) => {
|
||||||
send({ kind: "lane-status", lanes });
|
send({ kind: "lane-status", lanes });
|
||||||
});
|
});
|
||||||
|
// Lane RADAR presence (presence-input edge → barrier-light blink). Advisory.
|
||||||
|
const offPresence = deviceEvents.onLanePresence((radar) => {
|
||||||
|
send({ kind: "lane-presence", radar });
|
||||||
|
});
|
||||||
|
|
||||||
socket.on("close", () => {
|
socket.on("close", () => {
|
||||||
offLedger();
|
offLedger();
|
||||||
offPrinter();
|
offPrinter();
|
||||||
offDevice();
|
offDevice();
|
||||||
offLane();
|
offLane();
|
||||||
|
offPresence();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { roleRoutes } from "./routes/roles.js";
|
|||||||
import { deviceRoutes } from "./routes/devices.js";
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
|
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
|
||||||
import { LaneStatus } from "./lane-status.js";
|
import { LaneStatus } from "./lane-status.js";
|
||||||
|
import { LanePresence } from "./lane-presence.js";
|
||||||
import { AnprBridge } from "./anpr-entry.js";
|
import { AnprBridge } from "./anpr-entry.js";
|
||||||
import { eventRoutes } from "./routes/events.js";
|
import { eventRoutes } from "./routes/events.js";
|
||||||
import { reportRoutes } from "./routes/reports.js";
|
import { reportRoutes } from "./routes/reports.js";
|
||||||
@@ -125,6 +126,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
const laneStatus = new LaneStatus(db, app.log);
|
const laneStatus = new LaneStatus(db, app.log);
|
||||||
app.addHook("onClose", async () => laneStatus.stop());
|
app.addHook("onClose", async () => laneStatus.stop());
|
||||||
|
|
||||||
|
// Per-lane RADAR presence (presence-input edges → barrier-light blink). Mirrors the
|
||||||
|
// physical button lamp (relay 3): the SAME presence signal, surfaced to the booth UI.
|
||||||
|
const lanePresence = new LanePresence(db, app.log);
|
||||||
|
lanePresence.start();
|
||||||
|
app.addHook("onClose", async () => lanePresence.stop());
|
||||||
|
|
||||||
// NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read
|
// NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read
|
||||||
// flows are constructed — because the ANPR bridge they carry depends on the
|
// flows are constructed — because the ANPR bridge they carry depends on the
|
||||||
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
|
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
|
||||||
@@ -169,7 +176,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
|
|
||||||
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
||||||
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
||||||
await wsRoutes(app, db, deviceMonitor, laneStatus);
|
await wsRoutes(app, db, deviceMonitor, laneStatus, lanePresence);
|
||||||
|
|
||||||
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
||||||
await snapshotRoutes(app, db);
|
await snapshotRoutes(app, db);
|
||||||
|
|||||||
@@ -112,40 +112,45 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One barrier light — green = free, red = busy (a vehicle is at the lane vicinity,
|
/** One barrier light — a 3-state indicator mirroring the physical button lamp (relay 3):
|
||||||
* from camera detection). Advisory only; it gates nothing. */
|
* - radar present + camera NOT busy → BLINK green↔red (~1 Hz): "detected, not yet confirmed"
|
||||||
function BarrierLight({ label, busy }: { label: string; busy: boolean }) {
|
* - camera busy → SOLID red: a vehicle is confirmed at the lane vicinity
|
||||||
|
* - otherwise → SOLID green: free
|
||||||
|
* Advisory only; it gates nothing. The blink uses the `.lane-blink` keyframe (index.css),
|
||||||
|
* whose children inherit the alternating colour via `currentColor`. */
|
||||||
|
function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; radar: boolean }) {
|
||||||
|
// Blink only when the radar sees something the camera hasn't confirmed.
|
||||||
|
const blinking = radar && !busy;
|
||||||
|
const solid = busy ? "border-term-red bg-term-red/10 text-term-red" : "border-term-green bg-term-green/10 text-term-green";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${
|
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${blinking ? "lane-blink" : solid}`}
|
||||||
busy ? "border-term-red bg-term-red/10" : "border-term-green bg-term-green/10"
|
|
||||||
}`}
|
|
||||||
title={label}
|
title={label}
|
||||||
>
|
>
|
||||||
{/* Barrier glyph: a post + an arm. Colour carries the state. */}
|
{/* Barrier glyph: a post + an arm. `currentColor` follows the (possibly blinking) state. */}
|
||||||
<svg viewBox="0 0 24 24" className={`h-5 w-5 ${busy ? "text-term-red" : "text-term-green"}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
<line x1="5" y1="21" x2="5" y2="9" />
|
<line x1="5" y1="21" x2="5" y2="9" />
|
||||||
<line x1="5" y1="10" x2="21" y2="6" />
|
<line x1="5" y1="10" x2="21" y2="6" />
|
||||||
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
|
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
|
||||||
</svg>
|
</svg>
|
||||||
<div className="leading-tight">
|
<div className="leading-tight">
|
||||||
<div className="text-[10px] uppercase tracking-wider text-term-muted">{label}</div>
|
<div className="text-[10px] uppercase tracking-wider text-term-muted">{label}</div>
|
||||||
<div className={`text-xs font-bold ${busy ? "text-term-red" : "text-term-green"}`}>
|
<div className="text-xs font-bold">{busy ? "●" : blinking ? "◐" : "○"}</div>
|
||||||
{busy ? "●" : "○"}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The two lane barrier lights (entry / exit) fed by the live lane-status. */
|
/** The two lane barrier lights (entry / exit) fed by the live lane-status (camera busy/free)
|
||||||
|
* and lane-presence (radar). */
|
||||||
function LaneIndicators() {
|
function LaneIndicators() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const lanes = useLiveStore((s) => s.lanes);
|
const lanes = useLiveStore((s) => s.lanes);
|
||||||
|
const radar = useLiveStore((s) => s.radar);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} />
|
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} radar={radar?.entry ?? false} />
|
||||||
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} />
|
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} radar={radar?.exit ?? false} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -428,3 +428,30 @@ html.theme-light .btn:hover:not(:disabled) {
|
|||||||
html.theme-light .btn-primary {
|
html.theme-light .btn-primary {
|
||||||
color: #fafaf7;
|
color: #fafaf7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Barrier-light blink ────────────────────────────────────────────────────
|
||||||
|
The booth Entry/Exit indicator blinks green↔red (~1 Hz) when the radar/presence
|
||||||
|
input is active but the camera hasn't confirmed a vehicle yet — mirroring the
|
||||||
|
physical button lamp (relay 3). Toggles a CSS var the component maps onto its
|
||||||
|
border / tint / glyph, so green and red alternate every 500 ms. */
|
||||||
|
@keyframes lane-blink {
|
||||||
|
0%, 49% { --lane-c: var(--color-term-green); --lane-tint: color-mix(in srgb, var(--color-term-green) 10%, transparent); }
|
||||||
|
50%, 100% { --lane-c: var(--color-term-red); --lane-tint: color-mix(in srgb, var(--color-term-red) 10%, transparent); }
|
||||||
|
}
|
||||||
|
.lane-blink {
|
||||||
|
animation: lane-blink 1s steps(1, end) infinite;
|
||||||
|
border-color: var(--lane-c);
|
||||||
|
background: var(--lane-tint);
|
||||||
|
color: var(--lane-c);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
/* No flashing for motion-sensitive users — hold the "attention" (red) state. */
|
||||||
|
.lane-blink {
|
||||||
|
animation: none;
|
||||||
|
--lane-c: var(--color-term-red);
|
||||||
|
--lane-tint: color-mix(in srgb, var(--color-term-red) 10%, transparent);
|
||||||
|
border-color: var(--lane-c);
|
||||||
|
background: var(--lane-tint);
|
||||||
|
color: var(--lane-c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ export interface LaneStatus {
|
|||||||
exit: boolean; // true = busy
|
exit: boolean; // true = busy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-lane RADAR presence — a presence input (loop/radar) is shorted at the barrier,
|
||||||
|
* i.e. "something is in the lane" BEFORE the camera confirms a vehicle. Drives the
|
||||||
|
* barrier light's BLINK (the same signal as the physical button lamp / relay 3). */
|
||||||
|
export interface LanePresence {
|
||||||
|
entry: boolean; // true = a radar/presence input on an entry barrier is active
|
||||||
|
exit: boolean; // true = … on an exit barrier
|
||||||
|
}
|
||||||
|
|
||||||
/** Cap the in-memory live feed so a long-running booth session can't grow it
|
/** Cap the in-memory live feed so a long-running booth session can't grow it
|
||||||
* unbounded — the full history is always available via the /api/events query. */
|
* unbounded — the full history is always available via the /api/events query. */
|
||||||
const MAX_FEED = 200;
|
const MAX_FEED = 200;
|
||||||
@@ -31,6 +39,8 @@ interface LiveState {
|
|||||||
devices: Record<string, DeviceStatus>;
|
devices: Record<string, DeviceStatus>;
|
||||||
/** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */
|
/** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */
|
||||||
lanes: LaneStatus | null;
|
lanes: LaneStatus | null;
|
||||||
|
/** Per-lane radar presence (advisory blink). Null until the first WS hello. */
|
||||||
|
radar: LanePresence | null;
|
||||||
setStatus: (s: WsStatus) => void;
|
setStatus: (s: WsStatus) => void;
|
||||||
setOccupancy: (o: Occupancy) => void;
|
setOccupancy: (o: Occupancy) => void;
|
||||||
pushEvent: (e: LedgerEvent) => void;
|
pushEvent: (e: LedgerEvent) => void;
|
||||||
@@ -40,6 +50,8 @@ interface LiveState {
|
|||||||
upsertDevice: (d: DeviceStatus) => void;
|
upsertDevice: (d: DeviceStatus) => void;
|
||||||
/** Set lane busy/free (WS hello + each lane-status push). */
|
/** Set lane busy/free (WS hello + each lane-status push). */
|
||||||
setLanes: (l: LaneStatus) => void;
|
setLanes: (l: LaneStatus) => void;
|
||||||
|
/** Set lane radar presence (WS hello + each lane-presence push). */
|
||||||
|
setRadar: (r: LanePresence) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +68,7 @@ export const useLiveStore = create<LiveState>((set) => ({
|
|||||||
feed: [],
|
feed: [],
|
||||||
devices: {},
|
devices: {},
|
||||||
lanes: null,
|
lanes: null,
|
||||||
|
radar: null,
|
||||||
setStatus: (status) => set({ status }),
|
setStatus: (status) => set({ status }),
|
||||||
setOccupancy: (occupancy) => set({ occupancy }),
|
setOccupancy: (occupancy) => set({ occupancy }),
|
||||||
pushEvent: (e) =>
|
pushEvent: (e) =>
|
||||||
@@ -66,5 +79,6 @@ export const useLiveStore = create<LiveState>((set) => ({
|
|||||||
setDevices: (list) => set({ devices: byId(list) }),
|
setDevices: (list) => set({ devices: byId(list) }),
|
||||||
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
|
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
|
||||||
setLanes: (lanes) => set({ lanes }),
|
setLanes: (lanes) => set({ lanes }),
|
||||||
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null }),
|
setRadar: (radar) => set({ radar }),
|
||||||
|
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null, radar: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
||||||
import { qk } from "./query.js";
|
import { qk } from "./query.js";
|
||||||
import { useLiveStore, type LaneStatus } from "./live-store.js";
|
import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js";
|
||||||
import { wsUrl } from "./origin.js";
|
import { wsUrl } from "./origin.js";
|
||||||
|
|
||||||
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
||||||
@@ -14,16 +14,17 @@ import { wsUrl } from "./origin.js";
|
|||||||
|
|
||||||
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
||||||
type WsMessage =
|
type WsMessage =
|
||||||
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus }
|
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus; radar: LanePresence }
|
||||||
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
||||||
| { kind: "printer-status"; event: unknown }
|
| { kind: "printer-status"; event: unknown }
|
||||||
| { kind: "device-status"; event: DeviceStatus }
|
| { kind: "device-status"; event: DeviceStatus }
|
||||||
| { kind: "lane-status"; lanes: LaneStatus };
|
| { kind: "lane-status"; lanes: LaneStatus }
|
||||||
|
| { kind: "lane-presence"; radar: LanePresence };
|
||||||
|
|
||||||
|
|
||||||
export function useLiveFeed(): void {
|
export function useLiveFeed(): void {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes } = useLiveStore();
|
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar } = useLiveStore();
|
||||||
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
||||||
// double-invoke and unmount.
|
// double-invoke and unmount.
|
||||||
const sockRef = useRef<WebSocket | null>(null);
|
const sockRef = useRef<WebSocket | null>(null);
|
||||||
@@ -56,10 +57,13 @@ export function useLiveFeed(): void {
|
|||||||
// Initial device-status snapshot for the footer.
|
// Initial device-status snapshot for the footer.
|
||||||
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
||||||
if (msg.lanes) setLanes(msg.lanes);
|
if (msg.lanes) setLanes(msg.lanes);
|
||||||
|
if (msg.radar) setRadar(msg.radar);
|
||||||
} else if (msg.kind === "device-status") {
|
} else if (msg.kind === "device-status") {
|
||||||
upsertDevice(msg.event);
|
upsertDevice(msg.event);
|
||||||
} else if (msg.kind === "lane-status") {
|
} else if (msg.kind === "lane-status") {
|
||||||
setLanes(msg.lanes);
|
setLanes(msg.lanes);
|
||||||
|
} else if (msg.kind === "lane-presence") {
|
||||||
|
setRadar(msg.radar);
|
||||||
} else if (msg.kind === "ledger") {
|
} else if (msg.kind === "ledger") {
|
||||||
setOccupancy(msg.occupancy);
|
setOccupancy(msg.occupancy);
|
||||||
pushEvent(msg.event);
|
pushEvent(msg.event);
|
||||||
|
|||||||
@@ -78,6 +78,22 @@ aux-output** capability.
|
|||||||
the setup UI takes effect on the **next radar edge**, not after a server restart. (The first cut
|
the setup UI takes effect on the **next radar edge**, not after a server restart. (The first cut
|
||||||
loaded the map once at boot, so a just-saved lamp silently did nothing until restart.)
|
loaded the map once at boot, so a just-saved lamp silently did nothing until restart.)
|
||||||
|
|
||||||
|
## On-screen twin — the booth barrier lights
|
||||||
|
|
||||||
|
The booth's **Hyrje / Dalje (Entry / Exit) indicators** mirror the physical lamp with the SAME
|
||||||
|
3-state rule, per lane: radar-present + camera-free → **blink green↔red** (~1 Hz); camera-busy →
|
||||||
|
**solid red**; else **solid green**. So the operator sees the same "detected, not yet confirmed →
|
||||||
|
confirmed → clear" story on screen as the lamp shows on the post.
|
||||||
|
|
||||||
|
The radar half is sourced by a small server tracker, **`LanePresence` (`lane-presence.ts`)**, that
|
||||||
|
subscribes to `deviceEvents.onInput` and resolves each presence edge to its lane via
|
||||||
|
**`presenceLaneOf`** (`device-resolve.ts`) — direction-agnostic (entry **and** exit), unlike the
|
||||||
|
entry-gated `relayForPresence` the one-car-one-ticket gate uses. It emits a `lane-presence`
|
||||||
|
`{entry,exit}` bus event on change; the WS forwards it (hello snapshot + push) into the booth's
|
||||||
|
`live-store.radar`, and `BarrierLight` (`BoothScreen.tsx`) blinks via the `.lane-blink` keyframe
|
||||||
|
(`index.css`, holds solid-red under `prefers-reduced-motion`). The camera half is the existing
|
||||||
|
[[lpr-camera|lane status]]. Same inputs, same rule as the lamp, so screen and post never disagree.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Built 2026-06-24 for the first booth (button I1, radar I2, lamp on a spare relay); the serialized-send
|
Built 2026-06-24 for the first booth (button I1, radar I2, lamp on a spare relay); the serialized-send
|
||||||
|
|||||||
+16
@@ -1794,3 +1794,19 @@ resolution + legacy-fallback identical + exit-radar resolves to the exit relay),
|
|||||||
case in `button-light.test.ts`, `activeLowFrom` cases in the dingtian suite. Full workspace
|
case in `button-light.test.ts`, `activeLowFrom` cases in the dingtian suite. Full workspace
|
||||||
`build lint test` green. Updated [[entry-double-press]], [[button-light-indicator]], [[dingtian-relay]],
|
`build lint test` green. Updated [[entry-double-press]], [[button-light-indicator]], [[dingtian-relay]],
|
||||||
memory `access-direction-is-per-relay`.
|
memory `access-direction-is-per-relay`.
|
||||||
|
|
||||||
|
## [2026-06-28] feat | Booth Entry/Exit lights blink on radar presence (mirror relay 3)
|
||||||
|
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. Added that signal end-to-end: a small server tracker
|
||||||
|
**`LanePresence`** (lane-presence.ts) subscribes to `deviceEvents.onInput`, resolves each presence
|
||||||
|
edge to its lane via a new **`presenceLaneOf`** (device-resolve.ts) — direction-agnostic (entry AND
|
||||||
|
exit), unlike the entry-gated `relayForPresence` — and emits a `lane-presence {entry,exit}` bus
|
||||||
|
event on change. The WS forwards it (hello snapshot + push) into `live-store.radar`; `BarrierLight`
|
||||||
|
(BoothScreen.tsx) became **3-state**, mirroring relay 3 exactly: radar+camera-free → BLINK green↔red
|
||||||
|
~1 Hz (`.lane-blink` keyframe in index.css, holds solid-red under prefers-reduced-motion);
|
||||||
|
camera-busy → SOLID red; else SOLID green. Same input + same rule as the lamp, so screen and post
|
||||||
|
never disagree. A test (lane-presence.test.ts) caught a real bug: the first cut reused
|
||||||
|
`relayForPresence`, so the EXIT lane never resolved (entry-gated) and never blinked —
|
||||||
|
`presenceLaneOf` fixes it. Full workspace build/lint/test green (185 server tests). Updated
|
||||||
|
[[button-light-indicator]] (new "On-screen twin" section).
|
||||||
|
|||||||
Reference in New Issue
Block a user