c2a861208f
A loop started by a far/early car would (a) give up before the REAL car settled at the barrier, and (b) swallow the real car's pushes (the #polling guard dropped them). So a confident-but-wrong far-car plate could win, or the intended car get debounced out after the loop ended — wrong car acted on, right car blocked. Fix: a push that JOINS a running loop now EXTENDS the deadline (lastPush + ANPR_POLL_WINDOW_MS) instead of being dropped, capped at start + ANPR_POLL_MAX_MS (30s) so a continuously-busy lane can't slide forever. Each tick still pulls a FRESH frame, so the loop tracks whoever is at the barrier NOW, not the car that started it. Per-camera sliding deadline in #pollDeadline (cleared with #polling in finally). +1 test (push mid-poll keeps the loop alive past the initial deadline); 171 server tests green. New knob ANPR_POLL_MAX_MS documented in the komodo env reference + the two concurrency guards written up in lane-presence-and-anpr-entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
157 lines
11 KiB
Markdown
157 lines
11 KiB
Markdown
---
|
||
type: concept
|
||
tags: [parking, camera, anpr, subscription, lane, vision]
|
||
sources: []
|
||
updated: 2026-06-22
|
||
status: open
|
||
---
|
||
|
||
# Lane Presence & ANPR Subscriber Entry
|
||
|
||
Two related things a camera's vehicle detection feeds, worked out over a long field session
|
||
(2026-06-22, see [[lpr-camera]] for the camera-side saga + the corrected "it was the undrawn
|
||
detection area" conclusion):
|
||
|
||
1. **Lane busy/free** — BUILT. An advisory barrier light on the booth.
|
||
2. **ANPR subscriber entry** — BUILT (2026-06-22). A subscriber's plate, read from the lane camera,
|
||
drives their entry/exit through the EXISTING [[subscription]] flow. The "bridge" below.
|
||
|
||
The camera (Hik `DS-2CD1043G2-LIU`) only emits `VMD` events with `eventState=active` and a
|
||
`targetType` of `vehicle`/`human` — a coarse **presence** signal, never an identity. Everything
|
||
here is built on that, and on the camera's hard limits.
|
||
|
||
## What the camera actually gives us (measured)
|
||
|
||
- **Push only, no poll.** No ISAPI endpoint reports "is a car in the zone now"; the only live source
|
||
is the event push. We tried to force a steadier signal by flipping `notificationRecurrence`
|
||
`beginning → recurring` via ISAPI — the firmware **accepts the PUT but silently reverts** (locked to
|
||
`beginning` on this value line). So: notify-once-at-motion-start is all we get.
|
||
- **No leave signal.** The camera never sends an `inactive`/end event. Confirmed by config
|
||
(`notificationRecurrence: beginning`) AND a controlled in/out test.
|
||
- **Re-fire is MOVEMENT-driven, not steady.** Controlled test (call out enter/leave, correlate to the
|
||
event log): while a car MOVES, `active` repeats ~1–3 s apart; while it sits MOTIONLESS, gaps stretch
|
||
to ~15–25 s. Crucially the camera has **~no dwell lag** — it goes silent within ~1 s of the car
|
||
leaving (last event 16:15:17 vs car-left ~16:15:30).
|
||
|
||
## 1. Lane busy/free (BUILT)
|
||
|
||
`apps/server/src/lane-status.ts` (`LaneStatus`) + the hik-alarm handler + the booth WS. A `vehicle`
|
||
`active` event on a camera bound to entry/exit marks THAT lane busy and arms an auto-clear timer; the
|
||
booth shows two barrier lights beside the scan input (green=free, red=busy). **Advisory only — gates
|
||
nothing** (never blocks a ticket or opens a barrier; the standing rule).
|
||
|
||
- **"Free" is timeout-driven** (no leave signal). The TTL must exceed the still-car gap (~25 s) or a
|
||
parked car flickers free — so `LANE_BUSY_TTL_MS` default is **30 s** (started at a guessed 90 s,
|
||
briefly 5 s, then set to 30 s from the measured data). The camera's lack of dwell lag means 30 s
|
||
also clears promptly after departure.
|
||
- A `both`-direction camera marks both lanes. Pushed over the existing `/api/ws` (kind `lane-status`).
|
||
|
||
## 2. ANPR subscriber entry — THE BRIDGE (BUILT 2026-06-22)
|
||
|
||
> **"Bridge" = a HANDLER CLASS in `apps/server/src/anpr-entry.ts` (`AnprBridge`). NOT a new
|
||
> service / container / app.** It is in-process glue that calls things that ALREADY exist.
|
||
|
||
**As built:** `hikvision-alarm.ts`, on a `vehicle`/non-`inactive` push from an `anpr`-opted-in
|
||
camera, hands the deviceId to `AnprBridge.onVehicleDetected()` (fire-and-forget, never awaited on the
|
||
camera's 200). The bridge: debounce (camera-level, pre-snapshot) → `captureSnapshot` (fresh pull, via
|
||
the reused `snapshot.ts buildCamera`) → `vision.analyze` → entry confidence floor
|
||
(`VISION_ENTRY_MIN_CONFIDENCE`, 0.85) → normalize plate → **`subscriptionFlow.match()` (match BEFORE
|
||
emit)** → if a subscriber, `deviceEvents.emitRead({kind:"plate"})`; if not, record an advisory
|
||
`anpr-skip` device_event and stop. The existing `onRead → ReadDispatcher → SubscriptionFlow.run()`
|
||
then does the gated entry/exit + barrier open. Constructed in `server.ts` (the flows were reordered
|
||
above the hik-alarm registration so the bridge can take `subscriptionFlow`). Fail-soft throughout —
|
||
any snapshot/vision error degrades to the subscriber's card/QR, never throws into the push handler.
|
||
Two new env knobs: `VISION_ENTRY_MIN_CONFIDENCE` (0.85), `ANPR_DEBOUNCE_MS` (12_000). Covered by
|
||
`anpr-entry.test.ts` + `hikvision-alarm.test.ts` wiring.
|
||
|
||
> **POLL-until-confident (2026-06-27).** The single-shot capture above was upgraded to a **poll
|
||
> loop**. The camera fires its vehicle alarm the INSTANT motion starts — the car is still
|
||
> APPROACHING, so the first frame's plate is small/blurry/half-in-frame and ANPR returns a
|
||
> low-confidence misread (observed live on the exit lane: `'111'`@0.20, `'AE18671'`@0.19 …, while
|
||
> the manual `test-anpr` on the SAME stopped car read `AA890XX`@1.00). The car then STOPS at the
|
||
> barrier waiting for it to open — the stationary, well-framed moment the test reads at ~100%. So
|
||
> the bridge now **pulls a FRESH frame every `ANPR_POLL_MS` (1000) and re-runs ANPR until one clears
|
||
> the floor, or `ANPR_POLL_WINDOW_MS` (8000) elapses** (car drove off / non-subscriber → give up
|
||
> cleanly). One loop per camera (`#polling` set) so the ~1Hz alarm re-fires JOIN it, not spawn N;
|
||
> each tick is a fresh `camera.captureSnapshot` (NOT `captureSnapshotShared`, whose TTL would
|
||
> re-serve the same bad frame). VERIFIED on hardware: 7 garbage approach frames → `AA890XX`@0.999
|
||
> at the barrier → signed `vehicle_exit`. This is what made subscriber **auto-exit** actually work
|
||
> on the DS-2CD1047G3H-LIU (whose alarm fires on approach, not at the readable moment — see
|
||
> [[lpr-camera]] "auto-enter but don't auto-exit"). Still advisory + fail-soft; a barrier never
|
||
> opens on a low-confidence read.
|
||
>
|
||
> **Two concurrency guards on the loop** (the edges a single push-triggered loop creates):
|
||
> 1. **Credential-mid-poll abort.** If the subscriber scans their card/QR at the reader DURING the
|
||
> loop, they've already transacted — the bridge watches their `openOccurrenceCount` (baselined
|
||
> once a frame reads the bound plate) and **aborts without emitting** if it moves, so it never
|
||
> double-acts (which would exit the NEXT open occurrence — bad for a fleet sub).
|
||
> 2. **SLIDING window for a different car arriving mid-poll.** A loop started by a far/early car
|
||
> must not (a) give up before the REAL car settles, nor (b) swallow the real car's pushes. So a
|
||
> push that joins a running loop **extends the deadline** (`lastPush + ANPR_POLL_WINDOW_MS`),
|
||
> capped at `start + ANPR_POLL_MAX_MS` (30s) so a continuously-busy lane can't slide forever.
|
||
> Because each tick pulls a FRESH frame, the loop naturally tracks whoever is at the barrier
|
||
> *now*, not the car that started it. (Knobs: `ANPR_POLL_MS`, `ANPR_POLL_WINDOW_MS`,
|
||
> `ANPR_POLL_MAX_MS`.)
|
||
|
||
The goal (narrowed deliberately — see Rejected below): **a subscriber's plate, read by the lane
|
||
camera, admits them through the same gated flow a QR/card scan uses.** Scope was cut to subscribers
|
||
ONLY — no queue segmentation, no per-car tracking, no make/model, no ticket-button gating.
|
||
|
||
Almost everything already exists; the bridge is the one missing wire:
|
||
|
||
| Piece | Status |
|
||
| --- | --- |
|
||
| Camera vehicle event | ✅ [[lpr-camera]] (hik-alarm) |
|
||
| Pull a snapshot | ✅ `snapshot.ts` (`captureSnapshot`) |
|
||
| Read the plate | ✅ [[opencv-anpr-service]] `/analyze` (~50 ms on the DEV PC; appliance TBD) |
|
||
| Match a plate → subscriber | ✅ `subscription-flow.ts` `match()` + `subscription_plates` (`via:"plate"`) |
|
||
| Plate read → gated entry/exit | ✅ `read-dispatch.ts` + SubscriptionFlow (active/window/blocklist/car-count) |
|
||
| **Emit the plate onto the read bus** | ✅ `anpr-entry.ts` (`AnprBridge`) — on a vehicle push from an `anpr` camera it snapshots → analyzes → matches a subscriber → `emitRead({kind:"plate"})`. (Built 2026-06-22.) |
|
||
|
||
**The bridge logic:** on a camera `vehicle`/`active` event from an **opt-in** camera (`config.anpr`),
|
||
snapshot → `vision.analyze` → if a plate clears a **HIGH** confidence floor → **debounce** → emit
|
||
`DeviceReadEvent{kind:"plate", value, deviceId}`. The existing dispatcher + flow do the rest.
|
||
|
||
### Decisions settled with the user (2026-06-22)
|
||
|
||
- **Both directions.** Entry- and exit-bound cameras both work; the dispatcher infers the verb from
|
||
the camera's bound relay direction (an entry camera → entry, exit → exit). No per-read inference.
|
||
- **High confidence required.** A barrier-driving read needs a stricter bar than the advisory-record
|
||
floor — a NEW `VISION_ENTRY_MIN_CONFIDENCE` (≈0.85) distinct from `VISION_MIN_CONFIDENCE`. (The
|
||
subscriber still holds their card/QR, so a near-miss read just falls back to that.)
|
||
- **Opt-in** per camera (`config.anpr`), so a site that didn't ask for plate-entry is unaffected.
|
||
- **Debounce is REQUIRED — for correctness, NOT CPU.** The camera re-fires ~1 Hz while a car is
|
||
present; emitting a read every second would drive REPEAT entries (a fleet sub opens a 2nd
|
||
occurrence; a single-car sub spams "already inside") or, on an exit camera, repeat exits
|
||
(`sub.refused.noSession` after the first, and FIFO could phantom-close another occurrence). So the
|
||
same plate on the same camera within ~10–15 s = ONE credential presentation.
|
||
- NB: a direction-bound camera does NOT flip entry↔exit on repeat reads (the barrier's direction
|
||
fixes the verb), so the earlier "flip-flop" fear was wrong — but repeat-same-direction is still
|
||
bad. Debounce stands.
|
||
- **Threat-model rule preserved by construction.** A plate is trivially spoofable (print it on
|
||
paper); it must NEVER be the sole reason a barrier opens. Routing through the existing
|
||
SubscriptionFlow means the plate is just another credential through the same gate (active / window
|
||
/ blocklist / car-count) — not a bypass. See [[opencv-anpr-service]], [[append-only-event-chain]].
|
||
|
||
### To validate (booth-PC test day, ~2026-06-23)
|
||
|
||
The dev-PC ANPR is ~50 ms/frame, but that says little about the hardened **booth appliance** (likely a
|
||
low-power CPU, possibly 5–15× slower). Real-hardware latency is the open number. The user is bringing
|
||
the actual booth PC to test on.
|
||
|
||
## Rejected / out of scope (and why)
|
||
|
||
- **Vision monitors the RTSP livestream continuously** for presence — rejected: rebuilds (worse) what
|
||
the camera already does (presence), pegs the appliance CPU 24/7, and the leave-detection heuristic
|
||
is no cleaner than a 30 s timeout.
|
||
- **Vision polls every ~1 s to track THE car, segment a queue, count cars, read make/model** — a
|
||
genuinely harder need (bumper-to-bumper cars the camera can't tell apart). Set ASIDE, not dismissed:
|
||
it needs a general vehicle DETECTOR ([[opencv-anpr-service]] is plate-only today) + "same-car-vs-new"
|
||
logic + make/model (a third, weak, heavy model), and its viability is **gated on appliance
|
||
inference budget we cannot measure on the dev PC**. Revisit only if real traffic + hardware justify
|
||
it. The vision feasibility was checked: `fast_alpr` live + ready; plate-only; no vehicle/presence
|
||
detector exists yet.
|
||
- **Gate the entry ticket button on lane-busy** (don't print when no car) — deferred. The lane-busy
|
||
signal supports it, but it gates a PHYSICAL action so it must FAIL-OPEN (allow when presence is
|
||
unknown). Parked pending the user's real goal (anti-spam print vs one-ticket-per-car).
|