--- 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.