feat(booth): blink the Entry/Exit lights on radar presence (mirror relay 3)
Build desktop / desktop (push) Successful in 4m14s
Build & push images / images (push) Successful in 2m42s
CI / check (push) Successful in 37s

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:
2026-06-28 11:48:12 +02:00
parent 4418594af0
commit 38481f105f
12 changed files with 378 additions and 24 deletions
+19 -14
View File
@@ -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>
);
}