fix(entry): enforce the camera press-gate + duplicate-ticket defenses

Field report (park-buzi): a BLINKING entry button still printed — the lamp
encoded blink-vs-solid (radar-only vs radar+camera) but #suppressReason only
checked the radar, so a radar false-positive (rain, pedestrian) minted a real
signed ticket. Three layered fixes:

1. CAMERA gate on the physical press: with an entry camera configured, a press
   is live only in the lamp's SOLID state (LaneStatus.entry busy, mirrored into
   EntryFlow via onLaneStatus). Suppress-only — the camera stays advisory (never
   opens, never traps). Camera-less sites keep the radar-only gate; a faulty
   camera is dropped via the existing bypassPresenceCamera admin toggle.

2. Cooldown as a REAL backstop behind presence: the presence branch returned
   early, so entryCooldownSec was dead wherever a loop was wired. Now it bounds
   the stationary-car double-ticket (a motion radar drops a motionless car →
   spurious loop-clear re-arms one-car-one-ticket → same car reprints).

3. Post-hoc duplicate-plate anomaly (entry-side twin of plateSwapSuspected):
   when entry ANPR recognizes a plate already OPEN under another session entered
   within ENTRY_DUP_PLATE_WINDOW_MIN (default 15 min), sign ONE
   entry.duplicatePlate anomaly naming both tickets for the operator to void.
   ANPR stays non-blocking (rides the post-open snapshot as before).

REJECTED: camera-vetoed re-arm (defer re-arm until the lane flips free). The
camera has no leave events — "free" is a ~30s silence timeout that never lapses
inside a queue, so every queued car after the first would be suppressed until
an operator intervened. Blocking legit entry at peak beats nothing; the proper
preventive fix is a pass-through sensor (passedInput) — recorded as open in
wiki/concepts/entry-double-press.md.

Also: setup.relayTest reason was missing from both web catalogs (parity is only
enforced sq<->en, so the build passed) — added.

Tests: entry-press-gate.test.ts (blink suppresses / solid prints / camera-less
unaffected / bypass honored / cooldown catches the dropout re-press / residual
risk documented / still-present re-press stays suppressed) +
entry-duplicate-plate.test.ts (flags open dup, ignores closed/stale/self/other
plates). Suite 258 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-07-04 18:41:06 +02:00
parent 094e963e5e
commit b4f1418858
10 changed files with 532 additions and 20 deletions
+52 -7
View File
@@ -2,7 +2,7 @@
type: concept
tags: [parking, entry, anti-fraud, safety, devices]
sources: []
updated: 2026-06-19
updated: 2026-07-04
status: open
---
@@ -53,7 +53,42 @@ car to pull through, short enough not to block the next legitimate car). It is a
mitigation, not a guarantee** — a determined abuser can wait it out. Use it only where presence
feedback isn't available; prefer wiring a loop.
The two can coexist (presence first, cooldown as a backstop), but presence is authoritative when set.
The two **coexist** (2026-07-04): presence is checked first, and the cooldown now genuinely runs as
a **backstop behind it** (before that date the presence branch returned early and the cooldown never
ran when a loop was wired). The backstop exists because of the **stationary-car radar dropout**: a
motion (doppler) radar loses a car that stops moving — the flow sees a spurious loop-clear, re-arms
one-car-one-ticket, and the *same* car's next press mints a second ticket. A configured cooldown
bounds how fast that can happen. Keep it short (~10–15 s): in presence mode it only fires on a
press the loop already approved, which includes the *next legitimate car in a queue*.
### CAMERA gate (2026-07-04 — when an entry camera is configured)
The [[button-light-indicator]] lamp always encoded the intended UX — **blink** = radar-only
(something in the zone, no confirmed car), **solid** = radar + camera agree — but the press handler
only ever checked the radar, so a blinking button still printed (radar false-positives: rain, a
pedestrian, reflections). Now the press gate enforces the lamp's rule: with an entry camera
configured, a press is live **only while the entry lane's camera confirms a vehicle**
([[lane-presence-and-anpr-entry|LaneStatus]] busy — the SOLID state). This keeps the camera
**advisory** in the safety sense: it only ever *suppresses a ticket*, never opens a barrier and
never traps a car. A camera-less site keeps the radar-only gate; a faulty camera is dropped via the
admin [[entry-presence-bypass]] (`bypassPresenceCamera`), same as the operator-issued path.
### REJECTED: camera-vetoed re-arm (the queue trap)
The obvious fix for the stationary-car dropout — *don't re-arm on loop-clear while the camera still
sees a car; re-arm when the lane flips free* — was designed and **rejected** (2026-07-04). The
entry camera sends no leave events; "free" is a ~30 s detection-silence timeout. In any queue the
next car occupies the zone before that timeout can lapse, so the lane never flips free between two
legitimate cars — every queued car after the first would be suppressed until an operator intervened.
Blocking legitimate entry at peak load is strictly worse than the occasional duplicate ticket. The
duplicate is handled **post-hoc** instead:
### Duplicate-plate reconciliation (post-hoc, entry-side twin of [[plate-reconciliation]])
ANPR already rides the entry snapshot (never blocking — the barrier is open before recognition
starts). When the recognized plate is **already OPEN under another session entered within the last
~15 min** (`ENTRY_DUP_PLATE_WINDOW_MIN`), the flow signs ONE `entry.duplicatePlate` **anomaly**
pointing at both tickets; the operator voids the duplicate. Window short on purpose — a closed
session or an old read is a legit re-visit, not a double press. The proper *preventive* fix is a
**pass-through sensor** (closing loop / photocell past the barrier) as an unambiguous "a car went
through" signal — noted below as open.
## A suppressed press is a NO-OP, not an anomaly
@@ -82,17 +117,27 @@ in telemetry if ever needed.
through `inputsOf`, carry `presenceInput`/`entryCooldownSec` onto the `ResolvedRelay`, and only ever
gate entry/both relays. An exit radar = a `presence` row on the exit relay.
- `EntryFlow` (`entry-flow.ts`) keeps a `#guard` map keyed `controllerId:relay`: `#onPresenceEdge`
tracks the loop, `#suppressReason` decides presence/cooldown, `#recordSuppressedPress` writes the
telemetry. The guard disarms + stamps the cooldown on **print success** (not on open).
tracks the loop, `#suppressReason` decides camera → presence → cooldown (in that order),
`#recordSuppressedPress` writes the telemetry. The guard disarms + stamps the cooldown on
**print success** (not on open). The camera state is a live `LaneStatus` mirror
(`onLaneStatus`, wired in `server.ts`); "an entry camera is configured" is read per press so
adding/removing one needs no restart. The duplicate-plate check is
`flagDuplicateEntryPlate` in `snapshot.ts`, called from the ANPR ride-along when the entry
flow passes its `EventLog`.
- [[first-run-setup|SetupWizard]] relay editor: entry/both relays expose a **Presence loop
(terminal)** field and, when no loop is set, a **Cooldown after ticket (s)** field (localized
sq+en — see [[i18n]]).
## Open
- **No automated test yet** (the standing harness gap) — verify on hardware: with a loop, a held
button issues one ticket; after the car clears the loop a new car gets a fresh one. Without a loop,
a cooldown blocks the repeat and the suppressed press lands in telemetry.
- ~~No automated test yet~~ **Covered 2026-07-04**: `entry-press-gate.test.ts` pins the camera
gate (blink suppresses / solid prints / camera-less unaffected / bypass honored), the cooldown
backstop behind presence, and one-car-one-ticket; `entry-duplicate-plate.test.ts` pins the
post-hoc plate anomaly. Hardware verification on park-buzi still worthwhile.
- **Pass-through sensor** (closing loop / photocell past the barrier, a `passedInput` role): the
only *unambiguous* "the car went through" signal. Would let re-arm key on actual passage instead
of loop-clear, killing the stationary-car dropout without the queue trap. Procurement + wiring
question for the lanes.
- **Exit side:** the symmetric concern (re-reading a ticket at exit) is already handled differently —
exit validates against an open session, so a second read finds the session closed (no double-exit).
No presence gate needed there today.