b4f1418858
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
146 lines
9.9 KiB
Markdown
146 lines
9.9 KiB
Markdown
---
|
||
type: concept
|
||
tags: [parking, entry, anti-fraud, safety, devices]
|
||
sources: []
|
||
updated: 2026-07-04
|
||
status: open
|
||
---
|
||
|
||
# One car = one ticket (entry anti-double-press)
|
||
|
||
A transient [[parking-session|entry]] 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|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|booth/customer]] 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|controller]] (the Dingtian's inputs are decoupled from its relays). The sensor may
|
||
be an **induction loop** OR a **[[hikvision-radar|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|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
|
||
|
||
A blocked/repeat press is recorded as **unsigned [[device-events|telemetry]]** (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/present` map 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 legacy
|
||
`relays[].button/presenceInput/...` fields when a controller predates inputs[] (one back-compat
|
||
shim; the UI no longer writes the legacy fields). `relayForButton`/`relayForPresence` resolve
|
||
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 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~~ **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.
|
||
- **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.
|