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:
@@ -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,
|
||||
* from camera detection). Advisory only; it gates nothing. */
|
||||
function BarrierLight({ label, busy }: { label: string; busy: boolean }) {
|
||||
/** One barrier light — a 3-state indicator mirroring the physical button lamp (relay 3):
|
||||
* - radar present + camera NOT busy → BLINK green↔red (~1 Hz): "detected, not yet confirmed"
|
||||
* - 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 (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${
|
||||
busy ? "border-term-red bg-term-red/10" : "border-term-green bg-term-green/10"
|
||||
}`}
|
||||
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${blinking ? "lane-blink" : solid}`}
|
||||
title={label}
|
||||
>
|
||||
{/* Barrier glyph: a post + an arm. Colour carries the 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">
|
||||
{/* Barrier glyph: a post + an arm. `currentColor` follows the (possibly blinking) state. */}
|
||||
<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="10" x2="21" y2="6" />
|
||||
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
<div className="leading-tight">
|
||||
<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"}`}>
|
||||
{busy ? "●" : "○"}
|
||||
</div>
|
||||
<div className="text-xs font-bold">{busy ? "●" : blinking ? "◐" : "○"}</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() {
|
||||
const { t } = useTranslation();
|
||||
const lanes = useLiveStore((s) => s.lanes);
|
||||
const radar = useLiveStore((s) => s.radar);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} />
|
||||
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} />
|
||||
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} radar={radar?.entry ?? false} />
|
||||
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} radar={radar?.exit ?? false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -428,3 +428,30 @@ html.theme-light .btn:hover:not(:disabled) {
|
||||
html.theme-light .btn-primary {
|
||||
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
|
||||
}
|
||||
|
||||
/** 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
|
||||
* unbounded — the full history is always available via the /api/events query. */
|
||||
const MAX_FEED = 200;
|
||||
@@ -31,6 +39,8 @@ interface LiveState {
|
||||
devices: Record<string, DeviceStatus>;
|
||||
/** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */
|
||||
lanes: LaneStatus | null;
|
||||
/** Per-lane radar presence (advisory blink). Null until the first WS hello. */
|
||||
radar: LanePresence | null;
|
||||
setStatus: (s: WsStatus) => void;
|
||||
setOccupancy: (o: Occupancy) => void;
|
||||
pushEvent: (e: LedgerEvent) => void;
|
||||
@@ -40,6 +50,8 @@ interface LiveState {
|
||||
upsertDevice: (d: DeviceStatus) => void;
|
||||
/** Set lane busy/free (WS hello + each lane-status push). */
|
||||
setLanes: (l: LaneStatus) => void;
|
||||
/** Set lane radar presence (WS hello + each lane-presence push). */
|
||||
setRadar: (r: LanePresence) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -56,6 +68,7 @@ export const useLiveStore = create<LiveState>((set) => ({
|
||||
feed: [],
|
||||
devices: {},
|
||||
lanes: null,
|
||||
radar: null,
|
||||
setStatus: (status) => set({ status }),
|
||||
setOccupancy: (occupancy) => set({ occupancy }),
|
||||
pushEvent: (e) =>
|
||||
@@ -66,5 +79,6 @@ export const useLiveStore = create<LiveState>((set) => ({
|
||||
setDevices: (list) => set({ devices: byId(list) }),
|
||||
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
|
||||
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 type { DeviceStatus, LedgerEvent, Occupancy } from "../api.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";
|
||||
|
||||
// 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). */
|
||||
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: "printer-status"; event: unknown }
|
||||
| { kind: "device-status"; event: DeviceStatus }
|
||||
| { kind: "lane-status"; lanes: LaneStatus };
|
||||
| { kind: "lane-status"; lanes: LaneStatus }
|
||||
| { kind: "lane-presence"; radar: LanePresence };
|
||||
|
||||
|
||||
export function useLiveFeed(): void {
|
||||
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
|
||||
// double-invoke and unmount.
|
||||
const sockRef = useRef<WebSocket | null>(null);
|
||||
@@ -56,10 +57,13 @@ export function useLiveFeed(): void {
|
||||
// Initial device-status snapshot for the footer.
|
||||
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
||||
if (msg.lanes) setLanes(msg.lanes);
|
||||
if (msg.radar) setRadar(msg.radar);
|
||||
} else if (msg.kind === "device-status") {
|
||||
upsertDevice(msg.event);
|
||||
} else if (msg.kind === "lane-status") {
|
||||
setLanes(msg.lanes);
|
||||
} else if (msg.kind === "lane-presence") {
|
||||
setRadar(msg.radar);
|
||||
} else if (msg.kind === "ledger") {
|
||||
setOccupancy(msg.occupancy);
|
||||
pushEvent(msg.event);
|
||||
|
||||
Reference in New Issue
Block a user