diff --git a/apps/server/src/anpr-entry.test.ts b/apps/server/src/anpr-entry.test.ts index 21307b0..0c36640 100644 --- a/apps/server/src/anpr-entry.test.ts +++ b/apps/server/src/anpr-entry.test.ts @@ -33,9 +33,16 @@ beforeEach(() => { captureSnapshot.mockClear(); delete process.env.VISION_ENTRY_MIN_CONFIDENCE; delete process.env.ANPR_DEBOUNCE_MS; + // Poll-until-confident loop: keep the window + interval tiny so a below-floor / no-plate + // case gives up in ~one tick instead of the 8s production window (tests stay fast). Each + // bridge reads these in its constructor, so set them before `new AnprBridge`. + process.env.ANPR_POLL_MS = "1"; + process.env.ANPR_POLL_WINDOW_MS = "5"; }); afterEach(() => { vi.restoreAllMocks(); + delete process.env.ANPR_POLL_MS; + delete process.env.ANPR_POLL_WINDOW_MS; }); /** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */ @@ -128,6 +135,31 @@ describe("AnprBridge", () => { expect(reads).toEqual([]); }); + it("POLLS until confident: low-confidence approach frames, then a clean stop-at-barrier frame", async () => { + // The car APPROACHES (garbage reads) then STOPS at the barrier (clean read) — the bridge + // must re-pull until one frame clears the floor, not give up on the first bad frame. + const cam = seedCamera({ anpr: true }); + // analyze escalates: 0.20, 0.20, then 0.97 on the 3rd pull → that one emits. + const confs = [0.2, 0.2, 0.97]; + let i = 0; + const vision = { + enabled: true, + analyze: vi.fn(async () => ({ + plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] }, + plates: [], + lowConfidence: false, + modelVersion: "test", + tookMs: 1, + })), + } as unknown as VisionClient; + const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger()); + + const reads = await captureReads(() => bridge.onVehicleDetected(cam)); + expect(reads).toHaveLength(1); + expect(reads[0]).toMatchObject({ value: "AA111BB", kind: "plate" }); + expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames + }); + it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => { const cam = seedCamera({ anpr: true }); const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 }); diff --git a/apps/server/src/anpr-entry.ts b/apps/server/src/anpr-entry.ts index e57e57b..4aa20a1 100644 --- a/apps/server/src/anpr-entry.ts +++ b/apps/server/src/anpr-entry.ts @@ -3,7 +3,7 @@ import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db, ty import type { FastifyBaseLogger } from "fastify"; import { deviceEvents, type DeviceReadEvent } from "./device-events.js"; import { directionOf, type FlowDirection } from "./device-resolve.js"; -import { buildCamera, captureSnapshotShared } from "./snapshot.js"; +import { buildCamera } from "./snapshot.js"; import type { SubscriptionFlow } from "./subscription-flow.js"; import type { VisionClient } from "./vision-client.js"; @@ -49,6 +49,25 @@ function debounceMs(): number { return Number.isFinite(raw) && raw > 0 ? raw : 12_000; } +/** A single alarm fires the INSTANT motion starts — the car is still approaching, so the + * first frame often has a small/blurry/absent plate (a low-confidence misread). But the car + * then STOPS at the barrier (waiting for it to open) — the same stationary, well-framed + * moment the manual test reads at ~100%. So instead of one shot, we POLL fresh frames and + * re-run ANPR until one clears the confidence floor, or the window elapses. Poll interval: */ +function pollMs(): number { + const raw = Number(process.env.ANPR_POLL_MS ?? 1000); + return Number.isFinite(raw) && raw > 0 ? raw : 1000; +} + +/** Total time to keep polling for a confident read before giving up (the car drove off, or + * it's a non-subscriber). Bounded so a stray car can't loop forever. */ +function pollWindowMs(): number { + const raw = Number(process.env.ANPR_POLL_WINDOW_MS ?? 8000); + return Number.isFinite(raw) && raw > 0 ? raw : 8000; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + export class AnprBridge { readonly #db: Db; readonly #vision: VisionClient | null; @@ -56,9 +75,14 @@ export class AnprBridge { readonly #logger: FastifyBaseLogger; readonly #entryMinConfidence: number; readonly #debounceMs: number; + readonly #pollMs: number; + readonly #pollWindowMs: number; /** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by * `deviceId:plate` (post-match) — both gated against #debounceMs. */ readonly #lastFire = new Map(); + /** Cameras with a poll loop already in flight — a re-fired alarm (the camera pushes ~1Hz + * while the car sits) must NOT start a second concurrent loop on the same camera. */ + readonly #polling = new Set(); constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) { this.#db = db; @@ -67,6 +91,8 @@ export class AnprBridge { this.#logger = logger; this.#entryMinConfidence = entryMinConfidence(); this.#debounceMs = debounceMs(); + this.#pollMs = pollMs(); + this.#pollWindowMs = pollWindowMs(); } /** @@ -86,13 +112,18 @@ export class AnprBridge { if (!row || !row.enabled || row.category !== "camera") return; if ((row.config as CameraConfig)?.anpr !== true) return; // opt-in only - // Camera-level debounce (pre-snapshot): a car re-firing ~1Hz must not pull a - // snapshot + analyze every second. + // Post-success debounce: once we've emitted a read for this camera, ignore the + // ~1Hz re-fires for #debounceMs (set on success below). A fresh alarm AFTER the + // window is a new presentation and may start a new poll loop. if (this.#debounced(deviceId)) return; - this.#stamp(deviceId); + // One poll loop per camera: the camera pushes the SAME alarm ~1Hz while the car + // sits at the barrier — those re-fires must JOIN the running loop, not spawn N of them. + if (this.#polling.has(deviceId)) return; + this.#polling.add(deviceId); const camera = buildCamera(row); if (!camera) { + this.#polling.delete(deviceId); this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`); return; } @@ -100,18 +131,42 @@ export class AnprBridge { // "both" collapses to entry purely for the capture hint (it doesn't pick the lane — // the gated flow infers the verb from the camera's bound relay direction). const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry"; - // Shared capture (deviceId-keyed): coalesces with the advisory snapshotAsync for - // the SAME vehicle so the single-threaded camera isn't hit twice (→ HTTP 503). - const shot = await captureSnapshotShared(deviceId, camera, { direction }); - const result = await this.#vision.analyze(shot.bytes, shot.contentType); - if (!result || !result.plate) return; // nothing read - // Entry floor — stricter than the advisory floor (analyze() still returns the plate - // object with its confidence even when its own lowConfidence flag is set). - if (result.plate.confidence < this.#entryMinConfidence) { + // POLL-UNTIL-CONFIDENT. The alarm fires as the car APPROACHES (small/blurry/absent + // plate → low-confidence misread, e.g. '111'@0.20). But the car then STOPS at the + // barrier — the stationary, well-framed moment the manual test reads at ~100%. So we + // pull a FRESH frame every #pollMs and re-run ANPR until one clears the floor, or the + // #pollWindowMs window elapses (car drove off / non-subscriber). NB: a fresh pull each + // tick — NOT captureSnapshotShared, whose TTL would re-serve the same bad frame. + let result: Awaited> = null; + const deadline = Date.now() + this.#pollWindowMs; + let attempts = 0; + try { + while (Date.now() < deadline) { + attempts++; + const shot = await camera.captureSnapshot({ direction }); + const r = await this.#vision.analyze(shot.bytes, shot.contentType); + if (r?.plate && r.plate.confidence >= this.#entryMinConfidence) { + result = r; + break; + } + if (r?.plate) { + this.#logger.info( + `anpr-bridge: '${r.plate.text}' (${r.plate.confidence.toFixed(3)}) below floor ` + + `${this.#entryMinConfidence} — re-pulling (attempt ${attempts})`, + ); + } + if (Date.now() + this.#pollMs >= deadline) break; + await sleep(this.#pollMs); + } + } finally { + this.#polling.delete(deviceId); + } + + if (!result || !result.plate) { this.#logger.info( - `anpr-bridge: plate '${result.plate.text}' below entry floor ` + - `(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`, + `anpr-bridge: no confident plate from ${deviceId} after ${attempts} attempt(s) ` + + `in ${this.#pollWindowMs}ms — gave up`, ); return; } @@ -140,6 +195,9 @@ export class AnprBridge { const plateKey = `${deviceId}:${plate}`; if (this.#debounced(plateKey)) return; this.#stamp(plateKey); + // Camera-level debounce stamp — now that we've emitted, suppress the camera's ~1Hz + // re-fires (and any new poll loop) for #debounceMs. + this.#stamp(deviceId); this.#logger.info( `anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`, diff --git a/wiki/concepts/lane-presence-and-anpr-entry.md b/wiki/concepts/lane-presence-and-anpr-entry.md index c5a6745..10ec41d 100644 --- a/wiki/concepts/lane-presence-and-anpr-entry.md +++ b/wiki/concepts/lane-presence-and-anpr-entry.md @@ -62,7 +62,23 @@ then does the gated entry/exit + barrier open. Constructed in `server.ts` (the f 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` (7) + `hikvision-alarm.test.ts` wiring (3). +`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. 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 diff --git a/wiki/entities/lpr-camera.md b/wiki/entities/lpr-camera.md index 19e3d27..45ace53 100644 --- a/wiki/entities/lpr-camera.md +++ b/wiki/entities/lpr-camera.md @@ -2,7 +2,7 @@ type: entity tags: [parking, hardware, readers, offline-first] sources: [parking-system-architecture] -updated: 2026-06-26 +updated: 2026-06-27 --- # LPR Camera @@ -74,6 +74,21 @@ fixes — **don't assume it's a momentary blip**: status); the main encoder was simply never free. A retry loop **cannot** fix this — it just delays the failure. +> **Sharper finding (2026-06-27, same DS-2CD1047G3H-LIU).** Re-probed `10.0.10.13` directly after a +> camera reboot with every web/live-view connection closed. **Sub (102): 10/10 rapid back-to-back +> pulls → 200** (~50 ms, ~14.7 KB) — flawless even with NO delay, harder than the live ANPR cadence. +> **Main (101): 3/3 → 503 in ~20 ms** — an *instant* reject, not a timeout. So main isn't merely +> "saturated/busy" on this model — its snapshot endpoint is **structurally unavailable**; **sub (102) +> is mandatory**, not just preferable. SEPARATELY, the camera 503s on **any** stream when its +> **connection slots are exhausted** — a human holding the web UI / live-view, or parallel +> main-stream experiments, consume slots; a **reboot clears stuck slots**. This was the actual cause +> of the **2026-06-27 "subscribers auto-enter but don't auto-exit"** scare: manual main-stream +> testing held the exit camera's slots → the system's sub-stream snapshot pulls got "Device Busy" → +> the ANPR exit read never got a frame → no auto-exit. **NOT a code/flow bug** — the subscription +> exit logic, camera→relay binding, and `stream: "2"` config were all correct (auto-exit/entry pairs +> were clean before the test storm and after the reboot). (Also recorded as the LLM memory +> `g3h-main-stream-snapshot-503`.) + **The fix that actually works: snapshot from the SUB stream.** The Hikvision ISAPI channel id is `` (e.g. ch1 main = `101`, ch1 **sub = `102`**). The driver now has a **`stream` config field** (`1` = main, default for back-compat; `2` = sub). Set the G3H camera to **Sub (02)** in @@ -126,6 +141,58 @@ Center**, then **Alarm Settings → Alarm Server**, makes the camera **HTTP-POST use it directly; this `DS-2CD1043G2` does not, so the server pulls the frame and hands it to the [[opencv-anpr-service|vision service]]. +### "Subscribers auto-enter but don't auto-exit" — a 4-layer CAMERA fault, NOT our code (2026-06-27) + +A long debugging session on the **DS-2CD1047G3H-LIU** exit camera (`10.0.10.13`, exit-lane). The +symptom: subscribers (e.g. Caca) auto-entered via ANPR fine but **never auto-exited**. **Every +assumption about *our* code was wrong; all four real causes were camera-side.** Method that finally +cracked it: a **dumb HTTP sink** (`scratch-camera-sink.py`) the camera's Alarm Server was pointed +at, to see — verbatim — what the camera actually sends, independent of our app's parsing/acceptance. + +The wrong turns, and what was actually true: + +1. **Wrong assumption: "the exit camera 503s, so harden the snapshot retry / reduce load."** The 503 + storm in the data was mostly **manual main-stream testing**: on this G3H, `channels/101/picture` + (MAIN) **503s instantly every time** — structurally unavailable, not "busy" — while `102` (SUB) + serves 10/10 rapid pulls cleanly. AND the camera **503s on *any* stream when its connection slots + are exhausted** (a held web UI / live-view, parallel experiments); a **reboot clears stuck slots**. + So the snapshot retry/flow code was fine. See [[#HTTP 503 "Device Busy"]] + the + `g3h-main-stream-snapshot-503` memory. (The exit/subscription FLOW logic, camera→relay binding, + and `stream:"2"` config were all correct the whole time — verified: clean entry/exit pairs before + the test storm, and after the reboot.) + +2. **The actual blocker #1 — the exit camera never POSTed at all.** `alarmPushEnabled=true` in our + config, but `10.0.10.13` had sent **ZERO** alarms ever (entry cam `.12`: 1126). The sink received + nothing from `.13`; our `/event` endpoint logged no rejections either → the camera wasn't sending. + Cause found in the camera's own **Diagnose Information** dump: **`Main Db is broken` / + `db_restore failed` / `Going to reset cfg`** — the camera's internal config DB (`ipc_db`) was + **CORRUPT**, plus repeated reboots. A broken config DB means the event→linkage→push pipeline can't + reliably read its own config, so it silently never POSTs. **Fix: factory-reset the camera** (rebuilds + `ipc_db`), then reconfigure. (If corruption returns after a clean reset → failing flash → RMA.) + +3. **Wrong assumption: "missing gateway/DNS blocks the push."** A documented Hikvision note says a + gateway is needed even same-subnet — but here it was **inverted**: the WORKING cam `.12` has NO + gateway/DNS; the broken `.13` HAD both. Red herring. Gateway/DNS was not the cause. + +4. **The actual blocker #2 — after reset, the camera pushed PLAIN MOTION, not vehicle.** Post-reset + `.13` POSTed `VMD` with **no target tag**. The backend gates on + `target == "vehicle"` (`hikvision-alarm.ts` `isVehicleActive`, matches `` / + `` / ``), so a plain-motion push is **ignored** → bridge never fires. + The working `.12` sends `eventType=VMD` **with `target=vehicle`**. The difference is the AcuSense + **Detection-Target = Vehicle** filter ON the Motion event — defaulted OFF after factory reset. + **Fix: enable Vehicle target classification** on `.13`'s Motion Detection. Confirmed live: a real + drive-through then POSTed `VMD … vehicle` — exactly + what the backend needs. (So "VMD/Motion" IS the right event for this camera class; it's the *target + filter* that matters, not switching to a different event type.) + +**Takeaways:** (a) a camera that's silently not-pushing looks identical to "fine" in our logs — the +sink-to-prove-it-sends method is the fastest disambiguator; flagged as an observability gap (a +`alarmPushEnabled=true` camera with 0 pushes ever should be a surfaced condition, like the +[[device-status-monitoring|reader-liveness]] fix). (b) For ANPR the camera must send a **vehicle +`targetType`** — verify the push body, not just the UI toggles. (c) Hikvision config-DB corruption +is real; factory reset is the cure. None of this was a code bug. See +[[lane-presence-and-anpr-entry]] for the push→bridge→exit path. + ### Gotchas learned the hard way (2026-06-22 field session) Several traps surfaced trying to get a real camera to push. In order of how long each cost: diff --git a/wiki/log.md b/wiki/log.md index 6346fa0..eef4d98 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1717,3 +1717,39 @@ Gotchas that bit us (now in [[appliance-provisioning]] §7 + gotchas 7–11): `c (blank registry account → `no basic auth credentials`); user-mode + `/etc/komodo` root_directory → `Permission denied`; config key is **`core_address`** singular. [[appliance-provisioning]] §6 split: §6 = engine, §7 = Komodo deploy (PRIMARY) with §7c manual `booth.sh` break-glass. + +## [2026-06-27] query | "Subscribers auto-enter but don't auto-exit" → NOT a bug; G3H main-stream snapshot is structurally dead + +Investigated via a read-only VACUUM copy of the dev DB. Caca subscriber's ledger: clean +entry/exit pairs until ~16:38, then 6 entries + 0 exits. Traced to the **exit camera 10.0.10.13 +(DS-2CD1047G3H-LIU)** producing only 2 reads ever (vs 60 on the entry cam) — every exit-direction +snapshot after 16:38 was **HTTP 503 "device busy"**, so the ANPR exit read never got a frame → +no `emitRead` → no auto-exit. The subscription-flow exit logic, camera→relay binding (relay 2 = +exit, anpr on), and `stream: "2"` config were all **correct**. Direct hardware re-probe after a +camera reboot + closing web connections: **sub (102) 10/10 rapid → 200 (~50 ms)**, **main (101) +3/3 → 503 in ~20 ms (instant reject)**. So main-stream snapshots are **structurally unavailable** +on this model (sub mandatory), and the 503 storm was **connection-slot exhaustion from manual +main-stream testing** holding the camera's slots (reboot clears). Recorded in the +the `g3h-main-stream-snapshot-503` LLM memory + a 2026-06-27 sharper-finding note in [[lpr-camera]] +("503 Device Busy"). No code changed — diagnosis only. + +## [2026-06-27] query | "Auto-exit" RESOLVED — a 4-layer CAMERA fault on the G3H, never our code + +Continuation of the above. Drove the full diagnosis to ground using a **dumb HTTP sink** +(`scratch-camera-sink.py`) the exit camera's Alarm Server was pointed at, to capture the verbatim +push. Every assumption about *our* code was wrong; all real causes were camera-side on the +**DS-2CD1047G3H-LIU** (`10.0.10.13`): +(1) 503s were mostly **manual main-stream testing** (main 101 = instant 503 structurally; sub 102 = +perfect) + connection-slot exhaustion (reboot clears) — NOT a retry/flow bug. +(2) **Blocker #1:** the camera never POSTed at all (0 alarms ever vs 1126 on the entry cam); its +**Diagnose dump showed a CORRUPT config DB** (`Main Db is broken`/`db_restore failed`/`reset cfg`) — +factory-reset fixed it. +(3) Gateway/DNS was a **red herring** (working cam had none; broken cam had both). +(4) **Blocker #2:** post-reset it pushed **plain `VMD` with no target** → backend gates on +`target=="vehicle"` (`hikvision-alarm.ts` reads ``/``/``) +→ ignored. Enabling the AcuSense **Vehicle target filter** made the push carry +`vehicle` (confirmed live on a drive-through). "VMD/Motion" is the right +event for this class — it's the *target filter* that matters. +Flagged an **observability gap**: a camera with `alarmPushEnabled=true` and 0 pushes ever should be +a surfaced status (cf. the reader-liveness fix). Recorded in the `g3h-anpr-push-gotchas` memory + a +new troubleshooting section in [[lpr-camera]]. No code changed — diagnosis + camera reconfig only.