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
9.9 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | |||||
|---|---|---|---|---|---|---|---|---|---|
| concept |
|
2026-07-04 | open |
One car = one ticket (entry anti-double-press)
A transient parking-session is a button press → print a ticket → sign a vehicle_entry
→ open the barrier. Nothing stopped a driver pressing the button repeatedly and minting a fresh
ticket each time — a real flaw found 2026-06-19. The damage is threefold:
- Ticket spam. One car walks away with a fistful of tickets.
- Occupancy corruption. Each press signs a
vehicle_entry, so capacity-occupancy (a fold over open sessions) counts one car as many — the lot reads "full" with empty spaces. - Ticket-shopping at exit. With several open sessions for the same car, the driver pays the cheapest and exits on it; the rest linger. A direct threat-model abuse.
The old #inFlight guard only blocked overlapping presses (it released in finally), so
press → print → press again issued a second ticket immediately. That is not enough.
The fix is PER-RELAY CONFIG, chosen by available barrier feedback
The guard lives on the entry relay's spec (config.relays[] — see entry-exit-points), because
whether real one-car-one-ticket is possible depends on the hardware at that lane. Two modes:
PRESENCE mode (preferred — when a vehicle-presence sensor is wired)
Inputs are a first-class config.inputs[] list (the twin of relays[]): each row is a terminal +
a role (button / presence / alertTrigger) + the relay it serves. A presence row =
the 1-based input terminal of a vehicle-presence sensor serving an entry/both relay on the same
dingtian-relay (the Dingtian's inputs are decoupled from its relays). The sensor may
be an induction loop OR a hikvision-radar (inputs[].kind: "loop"|"radar" — a
label; the gate behaviour is identical). A radar wired to idle opposite the button needs
inputs[].activeLow: true so its edge reads correctly. Multiple radars (entry + exit) are just
multiple presence rows — adding an exit radar is adding a row. (Pre-2026-06-28 configs wired this
on relays[].presenceInput/presenceKind/presenceActiveLow; the resolvers still read those as
back-compat, synthesizing inputs[] from them.) The rule makes one-car-one-ticket physical:
- A press prints only while a car is present on the loop.
- After a ticket prints, the relay is disarmed — no second ticket — until the loop CLEARS (the car drove through = it entered) and a new car re-occupies it.
So mashing the button while sitting on the loop does nothing; a new car must physically arrive
before another ticket can issue. The flow observes the loop's input edges (both directions) to track
present + armed per relay. This is how real lanes behave.
COOLDOWN mode (fallback — no barrier feedback)
When no loop is wired, relays[].entryCooldownSec suppresses repeat presses on that relay for N
seconds after a ticket (default unset = no guard; a sensible value is ~10–12 s — long enough for the
car to pull through, short enough not to block the next legitimate car). It is a timer, 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 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 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
A blocked/repeat press is recorded as unsigned device-events (a device_events
kind:"input" row with entrySuppressed:true + the reason), not a signed ledger anomaly. It
isn't fraud — it's the system correctly refusing to double-issue — so it stays out of the immutable
chain and off the red activity-log feed. (Operator's call, 2026-06-19.) The press is still auditable
in telemetry if ever needed.
Invariants preserved
- Fail-closed entry is untouched. A suppressed press simply does nothing; the printer-down HOLD path (append-only-event-chain) and the fail-state-safety rules are unchanged.
- The barrier is still intent-only. No timed close; presence is only a gate on ticketing, not a barrier-close trigger (barrier-not-a-door).
- State is in-memory + rebuildable. The per-relay
armed/presentmap is runtime state on the host (single-writer); it is derived from live input edges, never the source of truth. A restart starts armed (the first press after a restart works), which is the safe default.
As-built (2026-06-19; inputs[] 2026-06-28)
- Inputs live in
config.inputs[] = [{ input, role, relay?, kind?, activeLow?, cooldownSec? }](device-resolve.ts).inputsOf(row)returns them, or synthesizes the list from the legacyrelays[].button/presenceInput/...fields when a controller predates inputs[] (one back-compat shim; the UI no longer writes the legacy fields).relayForButton/relayForPresenceresolve throughinputsOf, carrypresenceInput/entryCooldownSeconto theResolvedRelay, and only ever gate entry/both relays. An exit radar = apresencerow on the exit relay. EntryFlow(entry-flow.ts) keeps a#guardmap keyedcontrollerId:relay:#onPresenceEdgetracks the loop,#suppressReasondecides camera → presence → cooldown (in that order),#recordSuppressedPresswrites the telemetry. The guard disarms + stamps the cooldown on print success (not on open). The camera state is a liveLaneStatusmirror (onLaneStatus, wired inserver.ts); "an entry camera is configured" is read per press so adding/removing one needs no restart. The duplicate-plate check isflagDuplicateEntryPlateinsnapshot.ts, called from the ANPR ride-along when the entry flow passes itsEventLog.- first-run-setup 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 yetCovered 2026-07-04:entry-press-gate.test.tspins 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.tspins the post-hoc plate anomaly. Hardware verification on park-buzi still worthwhile.- Pass-through sensor (closing loop / photocell past the barrier, a
passedInputrole): 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.
- Loop as a safety/anti-tailgate signal is a larger future use of the same input (free-exit detection is noted in bom); this change uses it only to gate ticketing.