7 Commits

Author SHA1 Message Date
julian 25a72ff20a feat(anpr): per-camera auto-open toggle (anprAutoTrigger) for shared lanes
Build desktop / desktop (push) Successful in 4m32s
Build & push images / images (push) Successful in 2m43s
CI / check (push) Successful in 37s
A shared entry/exit lane has both an entry and an exit camera on ONE lane: a
subscriber driving IN is admitted by the entry cam, but the exit cam sees the same
car leaving its frame and phantom-EXITs the occurrence just opened (its back plate).

Separate RECOGNITION from AUTO-OPEN per camera:
- config.anpr (unchanged) = run snapshots through the recognizer, record the plate
  (evidence), BOTH directions — stays on.
- config.anprAutoTrigger (new, absent ⇒ on when anpr is on) = may THIS camera
  auto-open the barrier. Set false on the shared-lane exit cam: it still recognises
  plates but never auto-triggers. The bridge gates on it (anpr-entry.ts), before the
  poll loop.

UI: a "Auto open/close on subscriber plate" checkbox under ANPR in the camera setup
(shown when anpr is on); persisted true/false so a park can explicitly disable it.
i18n sq+en (also corrected the now-stale anprHint "never opens a barrier" wording —
it does, via the bridge). +1 server test (anprAutoTrigger=false → no snapshot, no
read); 172 green. Documented the two toggle levels (site-wide + per-camera) in
lane-presence-and-anpr-entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 23:52:16 +02:00
julian c2a861208f fix(anpr): sliding poll window so a car arriving mid-loop isn't lost
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
2026-06-27 23:30:41 +02:00
julian a888125eca docs(komodo): complete env reference — every server/vision var + defaults
The Stack example only listed the required set; expand it to the FULL reference so
an operator can see (and tweak) every env without digging through code. Grouped:
- IMAGE SELECTION (REGISTRY, TAG)
- REQUIRED (JWT_SECRET, EVENT_SIGNING_KEY, COOKIE_SECURE — no safe default)
- COMMONLY SET (VISION_ENABLED, WS_ALLOWED_ORIGINS)
- SET BY COMPOSE — don't put in the Stack (VISION_URL, DATABASE_URL, VISION_RECOGNIZER)
- OPTIONAL TUNABLES with code defaults: ports, logging/retention, device+printer poll
  intervals, lane/capture TTLs, and the ANPR knobs incl. this session's new
  ANPR_POLL_MS=1000 / ANPR_POLL_WINDOW_MS=8000 (raise the window for a slow barrier)
- VISION CONTAINER env (the Python service's own VISION_* vars)
All defaults pulled from the code (process.env.X ?? default). Documentation only.
2026-06-27 23:23:11 +02:00
julian 96fd97efa9 fix(web): VITE_API_BASE relative (empty) for the booth's same-origin SPA
apps/web/.env.production hardcoded VITE_API_BASE=http://127.0.0.1:3000 — a
desktop-only value that's WRONG for the booth, which serves the SPA same-origin
(Fastify dist/ via Caddy :80) and needs a RELATIVE /api base. An absolute origin
baked at build would point the browser at localhost. origin.ts treats empty as
relative (API_BASE=""), matching the deploy (the 77b2acb fix / container-deployment
"Web access").

The desktop (Tauri) build DOES need an absolute origin, but that app is a deferred
separate task (currently hardcoded localhost); it must set VITE_API_BASE for its own
build when resumed, not here. Comment updated to say so.
2026-06-27 23:16:19 +02:00
julian 2a13b95da6 fix(anpr): abort the poll loop if the subscriber transacts by card/QR mid-poll
The poll-until-confident loop (prev commit) opened a race: during its ~8s window a
subscriber could scan their card/QR at the reader and exit immediately — but the ANPR
loop kept polling and would ALSO emit a confident read a moment later, exiting the
NEXT open occurrence (a phantom double-exit, worst for a fleet sub with several open).

Guard it with the subscriber's open-occurrence count: the bridge identifies the
subscription as soon as a frame reads the bound plate (identity needs no confidence),
baselines openOccurrenceCount, then each tick AND before emit checks if it moved. If a
credential closed/opened an occurrence mid-poll, the subscriber already transacted →
abort, don't emit. New public SubscriptionFlow.openOccurrenceCount(). Bounded loop is
unchanged (ANPR_POLL_WINDOW_MS=8000 cap; never infinite).

+1 test (credential transacts mid-poll → no double-act); 170 server tests green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 23:05:31 +02:00
julian 513566c89e chore(debug): add test-post-camera-events.py — a dumb HTTP sink for camera pushes
A tiny stdlib HTTP server that logs every request (source IP, method, path, full
body, JPEG part stripped) to verify whether a Hikvision camera actually POSTs its
Alarm Server events — independent of our app's parsing/acceptance. It cracked the
2026-06-27 "auto-exit" investigation: proved the exit camera was sending NOTHING
(corrupt config DB), then later that it sent plain VMD without targetType=vehicle.

  python3 test-post-camera-events.py [port]   # default 8099

Point a camera's Alarm Server at this host:port; drive a car. A line from the
camera IP = it sends (debug downstream); silence = the camera isn't POSTing.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 22:55:02 +02:00
julian f77ed11782 feat(anpr): poll snapshots until a confident plate, so auto-exit works
The ANPR bridge took ONE snapshot at the camera's vehicle-alarm instant — but the
alarm fires as the car APPROACHES, so that frame's plate is small/blurry/half-in-
frame and ANPR returns a low-confidence misread ('111'@0.20). The manual test reads
the SAME car at ~100% because by then it's STOPPED at the barrier, well-framed. So
subscriber auto-exit silently never fired (read below the 0.85 floor → ignored).

Fix (the car-stops-at-the-barrier insight): the bridge now PULLS A FRESH FRAME every
ANPR_POLL_MS (1000) and re-runs ANPR until one clears VISION_ENTRY_MIN_CONFIDENCE, or
ANPR_POLL_WINDOW_MS (8000) elapses (drove off / non-subscriber → give up cleanly).
- One loop per camera (#polling set) — the camera's ~1Hz alarm re-fires JOIN the
  running loop instead of spawning N concurrent loops.
- Fresh camera.captureSnapshot each tick, NOT captureSnapshotShared (its 1.5s TTL
  would re-serve the same bad approach frame).
- Camera-level debounce stamp moved to AFTER a successful emit (suppresses re-fires
  for ANPR_DEBOUNCE_MS once we've acted), not before the loop.

VERIFIED on hardware (DS-2CD1047G3H-LIU exit lane): 7 garbage approach frames →
AA890XX@0.999 at the barrier → signed vehicle_exit. Still advisory + fail-soft; a
barrier never opens on a low-confidence read. anpr-entry.test.ts +1 (poll
escalation low→low→high); 169 server tests green. Documented in
lane-presence-and-anpr-entry + the lpr-camera camera-fault writeup.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 22:54:49 +02:00
12 changed files with 612 additions and 49 deletions
+120 -5
View File
@@ -33,13 +33,22 @@ beforeEach(() => {
captureSnapshot.mockClear(); captureSnapshot.mockClear();
delete process.env.VISION_ENTRY_MIN_CONFIDENCE; delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
delete process.env.ANPR_DEBOUNCE_MS; 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(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
delete process.env.ANPR_POLL_MS;
delete process.env.ANPR_POLL_WINDOW_MS;
delete process.env.ANPR_POLL_MAX_MS;
}); });
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */ /** A camera bound to an entry relay; `anpr` toggles recognition, `anprAutoTrigger` the
function seedCamera(opts: { anpr?: boolean } = {}): string { * per-camera auto-open gate (absent ⇒ defaults on). */
function seedCamera(opts: { anpr?: boolean; anprAutoTrigger?: boolean } = {}): string {
const controllerId = randomUUID(); const controllerId = randomUUID();
db.insert(devices).values({ db.insert(devices).values({
id: controllerId, id: controllerId,
@@ -53,7 +62,13 @@ function seedCamera(opts: { anpr?: boolean } = {}): string {
id: camId, id: camId,
category: "camera", category: "camera",
driverId: "hikvision", driverId: "hikvision",
config: { host: "10.0.0.9", controllerId, relay: 1, ...(opts.anpr ? { anpr: true } : {}) }, config: {
host: "10.0.0.9",
controllerId,
relay: 1,
...(opts.anpr ? { anpr: true } : {}),
...(opts.anprAutoTrigger === false ? { anprAutoTrigger: false } : {}),
},
enabled: true, enabled: true,
}).run(); }).run();
return camId; return camId;
@@ -79,8 +94,17 @@ function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: numb
} }
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */ /** A fake SubscriptionFlow: only `match()` is called by the bridge. */
function fakeSubFlow(match: SubscriptionMatch | null): SubscriptionFlow { function fakeSubFlow(
return { match: vi.fn(() => match) } as unknown as SubscriptionFlow; match: SubscriptionMatch | null,
// openOccurrenceCount: a constant, or a sequence consumed per call (to simulate a
// credential closing an occurrence mid-poll → count changes).
openCounts: number | number[] = 1,
): SubscriptionFlow {
const seq = Array.isArray(openCounts) ? [...openCounts] : null;
return {
match: vi.fn(() => match),
openOccurrenceCount: vi.fn(() => (seq ? (seq.length > 1 ? seq.shift()! : seq[0]) : (openCounts as number))),
} as unknown as SubscriptionFlow;
} }
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" }; const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
@@ -109,6 +133,18 @@ describe("AnprBridge", () => {
expect(captureSnapshot).not.toHaveBeenCalled(); expect(captureSnapshot).not.toHaveBeenCalled();
}); });
it("does NOT auto-trigger when anprAutoTrigger=false (recognition on, auto-open off)", async () => {
// Shared entry/exit lane: the exit cam keeps anpr (recognition) but auto-trigger off, so a
// car driving IN isn't phantom-EXITed by its back plate. The bridge bails before snapshot.
const cam = seedCamera({ anpr: true, anprAutoTrigger: false });
const vision = fakeVision({ plate: "AA111BB", confidence: 0.99 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]);
expect(captureSnapshot).not.toHaveBeenCalled(); // gated before the poll loop
});
it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => { it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
const cam = seedCamera({ anpr: true }); const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 }); const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
@@ -128,6 +164,85 @@ describe("AnprBridge", () => {
expect(reads).toEqual([]); 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;
// Generous window so all 3 escalation attempts run deterministically under suite load
// (the global beforeEach sets a tiny 5ms window for the give-up cases).
process.env.ANPR_POLL_MS = "1";
process.env.ANPR_POLL_WINDOW_MS = "2000";
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("SLIDES the window: a push mid-poll keeps the loop alive past the initial deadline", async () => {
// A loop started by an early/far car would expire — but a NEW push (another car arriving)
// extends the deadline, so the loop keeps polling and reads the car that settles at the
// barrier. Here: a SHORT base window, vision stays low until attempt 5; a second push at
// the start bumps the deadline so attempt 5's confident read still lands.
const cam = seedCamera({ anpr: true });
const confs = [0.2, 0.2, 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;
process.env.ANPR_POLL_MS = "5";
process.env.ANPR_POLL_WINDOW_MS = "12"; // tiny — would expire ~attempt 2 WITHOUT a slide
process.env.ANPR_POLL_MAX_MS = "5000"; // ceiling far above, so the slide is what matters
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(async () => {
const loop = bridge.onVehicleDetected(cam); // starts the loop
// Joining pushes keep sliding the deadline forward so the slow-to-confident read lands.
for (let k = 0; k < 5; k++) {
await new Promise((r) => setTimeout(r, 5));
void bridge.onVehicleDetected(cam); // each bumps the deadline (loop already running)
}
await loop;
});
expect(reads).toHaveLength(1);
expect(reads[0]).toMatchObject({ value: "AA111BB" });
});
it("ABORTS if the subscriber transacts by another credential mid-poll (no double-act)", async () => {
// The car's plate is read (identity known) but stays below the floor; meanwhile the
// subscriber scans their card → openOccurrenceCount drops. The bridge must abort and NOT
// emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. fleet).
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "AA111BB", confidence: 0.5 }); // never clears the floor
// openOccurrenceCount: 1 at baseline, then 0 (the card exit closed it) on the next check.
const sub = fakeSubFlow(SUB_MATCH, [1, 0]);
const bridge = new AnprBridge(db, vision, sub, silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]); // aborted — the credential already handled it
});
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => { it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
const cam = seedCamera({ anpr: true }); const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 }); const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
+152 -15
View File
@@ -3,7 +3,7 @@ import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db, ty
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import { deviceEvents, type DeviceReadEvent } from "./device-events.js"; import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
import { directionOf, type FlowDirection } from "./device-resolve.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 { SubscriptionFlow } from "./subscription-flow.js";
import type { VisionClient } from "./vision-client.js"; import type { VisionClient } from "./vision-client.js";
@@ -30,6 +30,10 @@ import type { VisionClient } from "./vision-client.js";
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */ /** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
interface CameraConfig { interface CameraConfig {
readonly anpr?: boolean; readonly anpr?: boolean;
/** Whether this camera may AUTO-OPEN the barrier (entry/exit). Absent ⇒ true (when anpr is
* on). Set false to keep recognition but suppress auto-trigger — e.g. the exit camera on a
* shared entry/exit lane. */
readonly anprAutoTrigger?: boolean;
readonly [k: string]: unknown; readonly [k: string]: unknown;
} }
@@ -49,6 +53,40 @@ function debounceMs(): number {
return Number.isFinite(raw) && raw > 0 ? raw : 12_000; 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;
}
/** How long to keep polling AFTER THE LAST vehicle push before giving up. SLIDING: each new
* push for the camera extends the deadline by this much from now — so a loop started by a
* far/early car keeps pulling fresh frames as the REAL car arrives and settles at the
* barrier (the loop tracks "whoever is here now", not the car that started it). */
function pollWindowMs(): number {
const raw = Number(process.env.ANPR_POLL_WINDOW_MS ?? 8000);
return Number.isFinite(raw) && raw > 0 ? raw : 8000;
}
/** Hard ceiling on a single loop from its START, so a continuously-busy lane (pushes never
* stop) can't slide the window forever. The loop ends at min(lastPush + window, start + max). */
function pollMaxMs(): number {
const raw = Number(process.env.ANPR_POLL_MAX_MS ?? 30_000);
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** A plate DeviceReadEvent skeleton (value filled by the caller) — for matching the
* subscriber by plate during the poll loop without re-building the whole event. */
function baseRead(row: { driverId: string }, deviceId: string): Omit<DeviceReadEvent, "value"> {
return { driverId: row.driverId, deviceId, kind: "plate", at: new Date().toISOString() };
}
export class AnprBridge { export class AnprBridge {
readonly #db: Db; readonly #db: Db;
readonly #vision: VisionClient | null; readonly #vision: VisionClient | null;
@@ -56,9 +94,19 @@ export class AnprBridge {
readonly #logger: FastifyBaseLogger; readonly #logger: FastifyBaseLogger;
readonly #entryMinConfidence: number; readonly #entryMinConfidence: number;
readonly #debounceMs: number; readonly #debounceMs: number;
readonly #pollMs: number;
readonly #pollWindowMs: number;
readonly #pollMaxMs: number;
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by /** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
* `deviceId:plate` (post-match) — both gated against #debounceMs. */ * `deviceId:plate` (post-match) — both gated against #debounceMs. */
readonly #lastFire = new Map<string, number>(); readonly #lastFire = new Map<string, number>();
/** 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<string>();
/** Per-camera SLIDING deadline for the running poll loop. A push that joins a running loop
* bumps this forward (lastPush + window, capped at start + max), so the loop keeps pulling
* fresh frames while cars keep arriving — tracking whoever settles at the barrier. */
readonly #pollDeadline = new Map<string, number>();
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) { constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
this.#db = db; this.#db = db;
@@ -67,6 +115,9 @@ export class AnprBridge {
this.#logger = logger; this.#logger = logger;
this.#entryMinConfidence = entryMinConfidence(); this.#entryMinConfidence = entryMinConfidence();
this.#debounceMs = debounceMs(); this.#debounceMs = debounceMs();
this.#pollMs = pollMs();
this.#pollWindowMs = pollWindowMs();
this.#pollMaxMs = pollMaxMs();
} }
/** /**
@@ -84,15 +135,35 @@ export class AnprBridge {
if (site && site.anprEntryEnabled === false) return; if (site && site.anprEntryEnabled === false) return;
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get(); const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
if (!row || !row.enabled || row.category !== "camera") return; if (!row || !row.enabled || row.category !== "camera") return;
if ((row.config as CameraConfig)?.anpr !== true) return; // opt-in only const cfg = row.config as CameraConfig;
if (cfg?.anpr !== true) return; // recognition opt-in (also gates the evidence/advisory path)
// Per-camera AUTO-TRIGGER gate. `anpr` keeps recognition (snapshots + plate record) on;
// this controls whether THIS camera may auto-open the barrier. A shared entry/exit lane
// sets it false on (e.g.) the exit camera so its back-plate read doesn't phantom-exit the
// car that just entered. Absent ⇒ true (back-compat: existing anpr cameras still trigger).
if (cfg.anprAutoTrigger === false) return;
// Camera-level debounce (pre-snapshot): a car re-firing ~1Hz must not pull a // Post-success debounce: once we've emitted a read for this camera, ignore the
// snapshot + analyze every second. // ~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; if (this.#debounced(deviceId)) return;
this.#stamp(deviceId); // One poll loop per camera. A push that arrives while a loop runs JOINs it — and
// SLIDES the deadline forward (a different car arriving mid-loop keeps the loop alive
// so it tracks whoever's at the barrier now, instead of giving up on the early car).
const now = Date.now();
if (this.#polling.has(deviceId)) {
const cur = this.#pollDeadline.get(deviceId) ?? now;
// Slide to lastPush + window, but never past the per-loop hard ceiling (set at start).
this.#pollDeadline.set(deviceId, Math.max(cur, now + this.#pollWindowMs));
return;
}
this.#polling.add(deviceId);
// Initial deadline; the hard ceiling (start + max) is enforced in the loop below.
this.#pollDeadline.set(deviceId, now + this.#pollWindowMs);
const camera = buildCamera(row); const camera = buildCamera(row);
if (!camera) { if (!camera) {
this.#polling.delete(deviceId);
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`); this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
return; return;
} }
@@ -100,18 +171,74 @@ export class AnprBridge {
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane — // "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). // the gated flow infers the verb from the camera's bound relay direction).
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry"; 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 // POLL-UNTIL-CONFIDENT. The alarm fires as the car APPROACHES (small/blurry/absent
// object with its confidence even when its own lowConfidence flag is set). // plate → low-confidence misread, e.g. '111'@0.20). But the car then STOPS at the
if (result.plate.confidence < this.#entryMinConfidence) { // 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.
// While polling, watch whether THIS subscriber transacts by another credential
// (card/QR at the reader). If their open-occurrence count drops mid-poll, the
// subscriber already exited/entered — the bridge must NOT also emit (it would act on
// the NEXT open occurrence: a phantom double-exit, worst for a fleet sub). We learn the
// subscription as soon as a frame reads the bound plate (identity needs no confidence),
// snapshot the count, then keep polling for a CONFIDENT read; abort if the count moved.
let result: Awaited<ReturnType<VisionClient["analyze"]>> = null;
let watchedSubId: string | null = null;
let baselineOpen = 0;
// Hard ceiling for THIS loop (start + max); the sliding deadline (bumped by joining
// pushes) is read from #pollDeadline each tick but never allowed past this cap.
const hardCap = Date.now() + this.#pollMaxMs;
let attempts = 0;
try {
while (Date.now() < Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap)) {
attempts++;
const shot = await camera.captureSnapshot({ direction });
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
// Identify the subscriber from ANY readable plate (even below the barrier floor),
// and baseline their open count once — so we can detect a credential beating us.
if (r?.plate?.text) {
const m0 = this.#subscription.match({ ...baseRead(row, deviceId), value: r.plate.text.trim().toUpperCase() });
if (m0 && watchedSubId == null) {
watchedSubId = m0.subscriptionId;
baselineOpen = this.#subscription.openOccurrenceCount(watchedSubId);
}
}
// A credential (card/QR) closed/opened an occurrence for this subscriber mid-poll →
// they already transacted; stop polling and do NOT emit.
if (watchedSubId && this.#subscription.openOccurrenceCount(watchedSubId) !== baselineOpen) {
this.#logger.info( this.#logger.info(
`anpr-bridge: plate '${result.plate.text}' below entry floor ` + `anpr-bridge: subscriber ${watchedSubId} transacted by another credential mid-poll — aborting ANPR`,
`(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`, );
return;
}
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})`,
);
}
// Stop if the next tick would land past the (possibly slid) deadline or the cap.
const effDeadline = Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap);
if (Date.now() + this.#pollMs >= effDeadline) break;
await sleep(this.#pollMs);
}
} finally {
this.#polling.delete(deviceId);
this.#pollDeadline.delete(deviceId);
}
if (!result || !result.plate) {
this.#logger.info(
`anpr-bridge: no confident plate from ${deviceId} after ${attempts} attempt(s) ` +
`in ${this.#pollWindowMs}ms — gave up`,
); );
return; return;
} }
@@ -135,11 +262,21 @@ export class AnprBridge {
return; return;
} }
// Final guard against the credential-mid-poll race: if the subscriber transacted between
// our baseline and now (e.g. a card scan in the last tick), don't double-act.
if (watchedSubId === match.subscriptionId && this.#subscription.openOccurrenceCount(match.subscriptionId) !== baselineOpen) {
this.#logger.info(`anpr-bridge: ${match.subscriptionId} already transacted — skipping ANPR emit`);
return;
}
// Plate-level debounce — belt-and-suspenders against a gap that slips the // Plate-level debounce — belt-and-suspenders against a gap that slips the
// camera-level gate re-emitting the SAME plate. // camera-level gate re-emitting the SAME plate.
const plateKey = `${deviceId}:${plate}`; const plateKey = `${deviceId}:${plate}`;
if (this.#debounced(plateKey)) return; if (this.#debounced(plateKey)) return;
this.#stamp(plateKey); 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( this.#logger.info(
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`, `anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
+9
View File
@@ -310,6 +310,15 @@ export class SubscriptionFlow {
* subscription, (b) pick which occurrence a read closes, and (c) enforce * subscription, (b) pick which occurrence a read closes, and (c) enforce
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that. * `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
*/ */
/** How many occurrences this subscription currently has OPEN (entries not yet exited).
* Public so the ANPR bridge can detect a credential (card/QR) exit landing mid-poll — if
* the count drops while it's polling, the subscriber already transacted and the bridge must
* NOT also emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. for
* a fleet sub). See anpr-entry.ts. */
openOccurrenceCount(subscriptionId: string): number {
return this.#openOccurrences(subscriptionId).length;
}
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] { #openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all(); const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
// Net entries−exits per occurrence identity, keeping the entry order (oldest first). // Net entries−exits per occurrence identity, keeping the entry order (oldest first).
+12 -9
View File
@@ -1,11 +1,14 @@
# Production build env for the SPA (auto-loaded by `vite build`, which the Tauri # Production build env for the SPA (auto-loaded by `vite build`). NOT loaded by `vite` dev.
# desktop bundle runs via beforeBuildCommand). NOT loaded by `vite` dev.
# #
# The desktop shell serves the bundled SPA from tauri://localhost (no proxy, not # RELATIVE /api base (empty value). The booth serves the SPA same-origin (Fastify serves
# same-origin), so the SPA must reach Fastify by absolute origin. This is the # dist/, reached via Caddy on :80), so requests must stay relative — baking an absolute
# appliance's local Fastify address. Not a secret — committed for reproducible # origin here would point the browser at the wrong host. This matches the deploy
# desktop builds. Override per-deployment if Fastify binds elsewhere. # (wiki/decisions/container-deployment.md "Web access"; the 77b2acb fix).
# #
# NOTE: a plain browser prod build (Fastify serving dist/ same-origin) does NOT # DESKTOP (Tauri) NOTE: the desktop shell serves the SPA from tauri://localhost (no proxy,
# want this set. If you build the SPA for that, override VITE_API_BASE="" . # not same-origin) and DOES need an absolute Fastify origin — but the desktop app is a
VITE_API_BASE=http://127.0.0.1:3000 # DEFERRED, separate task (it's currently hardcoded to localhost:3000; see apps/desktop +
# the desktop-app-hardcoded-localhost note). When that work resumes, set VITE_API_BASE to
# the appliance's Fastify origin for the desktop build only (e.g. via apps/desktop or an
# exported override), NOT here.
VITE_API_BASE=
+31 -2
View File
@@ -342,9 +342,16 @@ function DeviceForm({
const isController = category === "access"; const isController = category === "access";
const isCamera = category === "camera"; const isCamera = category === "camera";
const isPrinter = category === "printer"; const isPrinter = category === "printer";
// ANPR opt-in for a camera: when true, the VisionReader polls this camera for plates // ANPR opt-in for a camera: when true, this camera's snapshots are run through the
// (config.anpr). Off by default. See wiki/entities/opencv-anpr-service.md. // recognizer (plate recorded as evidence, both directions). (config.anpr). Off by default.
// See wiki/entities/opencv-anpr-service.md.
const [anpr, setAnpr] = useState<boolean>(editCfg?.anpr === true); const [anpr, setAnpr] = useState<boolean>(editCfg?.anpr === true);
// Auto-trigger: when true, THIS camera's vehicle detection may auto-open the barrier
// (subscriber entry/exit). Separate from `anpr` so a shared entry/exit lane can keep
// RECOGNITION on both cameras but disable auto-open on, e.g., the exit camera (whose
// back-plate read would otherwise phantom-exit the car that just entered). Defaults ON
// when anpr is on (back-compat). (config.anprAutoTrigger).
const [anprAuto, setAnprAuto] = useState<boolean>(editCfg?.anprAutoTrigger !== false);
// Pre-fill scalar config fields from the existing assignment when editing. // Pre-fill scalar config fields from the existing assignment when editing.
// (relays/controllerId/relay are model fields handled by their own state below.) // (relays/controllerId/relay are model fields handled by their own state below.)
@@ -503,6 +510,10 @@ function DeviceForm({
} }
// Camera ANPR opt-in (only persisted when on, to keep configs minimal). // Camera ANPR opt-in (only persisted when on, to keep configs minimal).
if (isCamera && anpr) out.anpr = true; if (isCamera && anpr) out.anpr = true;
// Auto-trigger flag — only meaningful when anpr is on. Persist it (true OR false) so a
// park can explicitly DISABLE auto-open on a camera (e.g. the exit cam of a shared lane)
// while keeping recognition. Absent ⇒ defaults ON (back-compat for existing cameras).
if (isCamera && anpr) out.anprAutoTrigger = anprAuto;
return out; return out;
} }
@@ -797,6 +808,24 @@ function DeviceForm({
</label> </label>
)} )}
{/* Auto-trigger is only meaningful with ANPR on. Off = this camera RECOGNISES plates
(evidence) but does NOT auto-open the barrier — for a shared entry/exit lane where
the exit cam's back-plate read would phantom-exit a car that just entered. */}
{isCamera && anpr && (
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
<input
type="checkbox"
className="mt-0.5"
checked={anprAuto}
onChange={(e) => setAnprAuto(e.target.checked)}
/>
<span>
<span className="font-semibold text-term-text">{t("setup.anprAuto")}</span>
<span className="hint mt-0.5 block">{t("setup.anprAutoHint")}</span>
</span>
</label>
)}
{/* CAMERA + Alarm Server push ON: show the camera's Alarm Server settings, {/* CAMERA + Alarm Server push ON: show the camera's Alarm Server settings,
ready to copy, so the operator never has to find the deviceId or memorise the ready to copy, so the operator never has to find the deviceId or memorise the
endpoint. The CAMERA reaches us over the device VLAN, NOT via the browser's endpoint. The CAMERA reaches us over the device VLAN, NOT via the browser's
+4 -1
View File
@@ -395,7 +395,10 @@ export const en: Catalog = {
addRelay: "+ Add relay", addRelay: "+ Add relay",
anpr: "Plate recognition (ANPR)", anpr: "Plate recognition (ANPR)",
anprHint: anprHint:
"Enable to scan plates on this camera: the vision service reads the plate from a snapshot and feeds it as a read (advisory only — it never opens a barrier on its own). Requires the vision service running.", "Read plates on this camera: the vision service reads the plate from each snapshot and records it (both entry and exit). Requires the vision service running.",
anprAuto: "Auto open/close on subscriber plate",
anprAutoHint:
"Let THIS camera auto-open the barrier when it recognises a subscriber's plate. Turn OFF on a shared entry/exit lane's exit camera, so a car driving IN isn't auto-EXITed by its back plate (recognition still runs — only the auto-trigger is off).",
testAnpr: "Test ANPR", testAnpr: "Test ANPR",
anprTesting: "Testing ANPR…", anprTesting: "Testing ANPR…",
testAnprHint: testAnprHint:
+4 -1
View File
@@ -405,7 +405,10 @@ export const sq = {
// Camera ANPR opt-in. // Camera ANPR opt-in.
anpr: "Njohja e targave (ANPR)", anpr: "Njohja e targave (ANPR)",
anprHint: anprHint:
"Aktivizo që ky aparat të skanojë targat: shërbimi i vizionit lexon targën nga pamja dhe e dërgon si lexim (vetëm këshillues — nuk hap vetë barrierën). Kërkon shërbimin e vizionit aktiv.", "Lexo targat në këtë aparat: shërbimi i vizionit lexon targën nga çdo pamje dhe e regjistron (hyrje dhe dalje). Kërkon shërbimin e vizionit aktiv.",
anprAuto: "Hapje/mbyllje automatike me targën e abonentit",
anprAutoHint:
"Lejo që KY aparat të hapë vetë barrierën kur njeh targën e një abonenti. ÇAKTIVIZOJE te aparati i daljes në një korsi të përbashkët hyrje/dalje, që një makinë që HYN të mos DALË automatikisht nga targa e pasme (njohja vazhdon — fiket vetëm hapja automatike).",
testAnpr: "Testo ANPR", testAnpr: "Testo ANPR",
anprTesting: "Duke testuar ANPR…", anprTesting: "Duke testuar ANPR…",
testAnprHint: testAnprHint:
+89 -14
View File
@@ -1,24 +1,99 @@
# Komodo Stack environment — reference of what a booth Stack needs and WHERE it comes # Komodo Stack environment — the COMPLETE reference of every env the booth stack reads:
# from. Under Komodo, plain env lives in the Stack definition (komodo/resources.toml); # required, image-selection, set-by-compose (don't override), and the optional tunables
# the two SECRETS come from Komodo Core's secret store, PER BOOTH and UNIQUE. This file # with their code defaults. Under Komodo, plain env lives in the Stack definition
# is documentation only — do NOT fill in real secrets here (it would be the same leak # (komodo/resources.toml); the two SECRETS come from Core's secret store, PER BOOTH and
# we're avoiding). See wiki/decisions/fleet-deployment-komodo.md. # UNIQUE. This file is DOCUMENTATION — never fill in real secrets here. See
# wiki/decisions/fleet-deployment-komodo.md.
#
# A minimal working Stack only needs: REGISTRY, TAG, the two secrets, COOKIE_SECURE=0,
# VISION_ENABLED=1, WS_ALLOWED_ORIGINS. Everything under "OPTIONAL TUNABLES" has a safe
# default in code — set one only to override it.
# --- plain Stack env (lives in resources.toml; safe in git) ------------------- # ════════════════════════════════════════════════════════════════════════════
# IMAGE SELECTION (picks which container image to pull — not server runtime env)
# ════════════════════════════════════════════════════════════════════════════
REGISTRY=git.infra.msai.al/mca/parking_solution REGISTRY=git.infra.msai.al/mca/parking_solution
# IMMUTABLE per-commit tag. Manual + pinned. Bump per deploy. Never the moving `dev` # IMMUTABLE per-commit tag. Manual + pinned. Bump per deploy. Never the moving `dev`
# on a production booth. # on a PRODUCTION booth (a staging booth may track `dev`).
TAG=dev-830993b TAG=dev-830993b
# ════════════════════════════════════════════════════════════════════════════
# REQUIRED (no safe default — the server refuses to boot / login breaks without)
# ════════════════════════════════════════════════════════════════════════════
# Login signing secret (>=32 chars, no change-me/insecure/dev-only). openssl rand -hex 32.
JWT_SECRET=[[booth_<name>_jwt_secret]]
# Ledger-signing HMAC key — the anti-fraud root. DISTINCT per booth; never reuse. If unset
# it falls back to JWT_SECRET (warned). openssl rand -hex 32.
EVENT_SIGNING_KEY=[[booth_<name>_event_signing_key]]
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT, so
# without =0 the auth cookie never sends and operators CANNOT log in. Set 1 only behind TLS.
COOKIE_SECURE=0 COOKIE_SECURE=0
# ════════════════════════════════════════════════════════════════════════════
# COMMONLY SET (have defaults, but you usually want these explicit on a booth)
# ════════════════════════════════════════════════════════════════════════════
# Turn the ANPR/vision call on. Default "" (off-ish). Set 1 to enable. (Prod compose also
# forces VISION_RECOGNIZER=fast_alpr on the vision container.)
VISION_ENABLED=1 VISION_ENABLED=1
# Extra origins the booth WebSocket (/api/ws) accepts beyond same-origin. Comma-separated,
# e.g. http://parksystems.msai.al. Blank = only the same-origin booth URL. Default "".
WS_ALLOWED_ORIGINS= WS_ALLOWED_ORIGINS=
# --- secrets (Core secret store, referenced by name in resources.toml) -------- # ════════════════════════════════════════════════════════════════════════════
# Generated PER BOOTH (openssl rand -hex 32), registered in Core under booth-scoped # SET BY COMPOSE — do NOT put these in the Stack (the compose files own them)
# names, never reused across sites. The user generates these; they are never typed on # ════════════════════════════════════════════════════════════════════════════
# a CLI or committed. # VISION_URL=http://127.0.0.1:8089 # prod override (host-net server → loopback vision)
# DATABASE_URL=/data/parking.sqlite # the mounted volume (the signed ledger)
# VISION_RECOGNIZER=fast_alpr # prod override on the vision container
# ════════════════════════════════════════════════════════════════════════════
# OPTIONAL TUNABLES — all have code defaults; set only to override. (defaults shown)
# ════════════════════════════════════════════════════════════════════════════
# --- networking / process ---
# PORT=3000 # server listen port
# HOST=0.0.0.0 # bind interface (127.0.0.1 = loopback only)
# BACKEND_HOST_IP= # override the auto-picked IP devices push back to
# # (multi-NIC hosts; usually auto-detected fine)
# WEB_DIST_DIR= # where the built SPA lives (the image sets it)
# --- logging ---
# LOG_LEVEL=info # debug|info|warn|error
# LOG_RETENTION_DAYS=30 # app_logs auto-purge age
# LOG_RETENTION_MAX_ROWS=50000 # app_logs row cap
# RECYCLE_BIN_RETENTION_DAYS=30 # soft-deleted items auto-purge age (0 = keep forever)
# --- device monitor / lane ---
# DEVICE_POLL_MS=8000 # device health poll interval
# PRINTER_POLL_MS=5000 # printer status poll interval
# LANE_BUSY_TTL_MS=30000 # how long a lane stays "busy" after a vehicle push
# CAPTURE_TTL_MS=30000 # snapshot evidence cache TTL
# --- ANPR / vision (server side) ---
# VISION_URL is compose-set (above). These are the knobs you may tweak per booth:
# VISION_TIMEOUT_MS=1500 # per /analyze call timeout
# VISION_MIN_CONFIDENCE=0.5 # advisory floor (telemetry/lane); below = low_confidence
# VISION_ENTRY_MIN_CONFIDENCE=0.85 # STRICT barrier-driving floor (auto entry/exit). A
# # read below this is ignored (falls back to card/QR).
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this = one presentation
# ANPR_POLL_MS=1000 # poll-until-confident: re-pull a fresh frame every N ms
# ANPR_POLL_WINDOW_MS=8000 # ...for this long AFTER THE LAST vehicle push (sliding:
# # a car arriving mid-loop extends it). RAISE if a slow
# # barrier means the car waits >8s before reading clean.
# ANPR_POLL_MAX_MS=30000 # hard ceiling on one loop from start (so a continuously
# # busy lane can't slide the window forever)
# ════════════════════════════════════════════════════════════════════════════
# VISION CONTAINER env (the Python ANPR service — its OWN process, prefix VISION_)
# Mostly compose-set; documented here for completeness. (defaults shown)
# ════════════════════════════════════════════════════════════════════════════
# VISION_RECOGNIZER=fast_alpr # stub | fast_alpr (prod compose forces fast_alpr)
# VISION_HOST=0.0.0.0 # bind (prod publishes 127.0.0.1 only — see compose)
# VISION_PORT=8089
# VISION_DETECTOR_MODEL=yolo-v9-t-384-license-plate-end2end
# VISION_OCR_MODEL=cct-xs-v2-global-model
# VISION_MIN_CONFIDENCE=0.5 # the Python service's own floor (keep ~in sync w/ server)
# ════════════════════════════════════════════════════════════════════════════
# SECRETS — referenced by name in resources.toml, stored in Core (never inline here)
# ════════════════════════════════════════════════════════════════════════════
# JWT_SECRET -> [[booth_<name>_jwt_secret]] (login) # JWT_SECRET -> [[booth_<name>_jwt_secret]] (login)
# EVENT_SIGNING_KEY -> [[booth_<name>_event_signing_key]] (ledger signing — fraud root) # EVENT_SIGNING_KEY -> [[booth_<name>_event_signing_key]] (ledger signing — fraud root)
# Periphery/registry auth also live in Core: # periphery passkey -> [[periphery_passkey_booth_<name>]] (agent onboarding)
# periphery passkey -> [[periphery_passkey_booth_<name>]] # registry account -> [[gitea_registry_account]] (image pull)
# registry account -> [[gitea_registry_account]]
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
import sys
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8099
class Sink(BaseHTTPRequestHandler):
def _log(self, method: str) -> None:
ts = datetime.now().strftime("%H:%M:%S")
src = self.client_address[0]
clen = int(self.headers.get("Content-Length", 0) or 0)
body = self.rfile.read(clen) if clen else b""
ctype = self.headers.get("Content-Type", "-")
print(f"\n=== {ts} {method} {self.path} from {src} ===", flush=True)
print(f" Content-Type: {ctype} ({clen} bytes)", flush=True)
text = body.decode("utf-8", "replace")
cut = text.find('Content-Type: image/jpeg')
if cut != -1:
text = text[:cut] + "\n [...JPEG image part omitted...]"
print(" body:\n" + text, flush=True)
self.send_response(200)
self.send_header("Content-Length", "2")
self.end_headers()
self.wfile.write(b"OK")
def do_POST(self):
self._log("POST")
def do_GET(self):
self._log("GET")
def do_PUT(self):
self._log("PUT")
def log_message(self, *args):
pass
if __name__ == "__main__":
print(f"camera-event post test listening on 0.0.0.0:{PORT}", flush=True)
print("Ctrl-C to stop.\n", flush=True)
ThreadingHTTPServer(("0.0.0.0", PORT), Sink).serve_forever()
+43 -1
View File
@@ -62,7 +62,49 @@ 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 — 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. 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 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.
**Toggles — two levels (the auto-open is optional).** ANPR auto entry/exit can be turned off without
losing plate recognition:
- **Site-wide:** `site_config.anprEntryEnabled` (a Site Settings switch) gates the WHOLE bridge
(both directions); off ⇒ no camera auto-opens, recognition/lane-status unaffected.
- **Per-camera** (2026-06-27): `config.anprAutoTrigger` (absent ⇒ on when `anpr` is on). This
separates **recognition** (`config.anpr` — snapshots run through the recognizer, plate recorded,
BOTH directions) from **auto-open** (`anprAutoTrigger` — may this camera fire the barrier). The
case it solves: a **shared entry/exit lane** where ONE physical lane has both an entry and an exit
camera. A subscriber driving IN is admitted by the entry cam — but the exit cam sees the SAME car
leaving its frame and would **phantom-exit** the occurrence just opened (its back plate). Set the
exit cam's `anprAutoTrigger = false`: it still recognises plates for the record, but never
auto-opens. (The Setup checkbox "Auto open/close on subscriber plate" appears under ANPR.)
> **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 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 camera, admits them through the same gated flow a QR/card scan uses.** Scope was cut to subscribers
+68 -1
View File
@@ -2,7 +2,7 @@
type: entity type: entity
tags: [parking, hardware, readers, offline-first] tags: [parking, hardware, readers, offline-first]
sources: [parking-system-architecture] sources: [parking-system-architecture]
updated: 2026-06-26 updated: 2026-06-27
--- ---
# LPR Camera # 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 status); the main encoder was simply never free. A retry loop **cannot** fix this — it just delays
the failure. 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 **The fix that actually works: snapshot from the SUB stream.** The Hikvision ISAPI channel id is
`<channel><stream>` (e.g. ch1 main = `101`, ch1 **sub = `102`**). The driver now has a **`stream` `<channel><stream>` (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 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` use it directly; this `DS-2CD1043G2`
does not, so the server pulls the frame and hands it to the [[opencv-anpr-service|vision service]]. 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 `<eventType>VMD</eventType>` with **no target tag**. The backend gates on
`target == "vehicle"` (`hikvision-alarm.ts` `isVehicleActive`, matches `<targetType>` /
`<detectionTarget>` / `<objectType>`), 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 `<eventType>VMD</eventType> … <targetType>vehicle</targetType>` — 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) ### 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: Several traps surfaced trying to get a real camera to push. In order of how long each cost:
+36
View File
@@ -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 → (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: `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. §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 `<targetType>`/`<detectionTarget>`/`<objectType>`)
→ ignored. Enabling the AcuSense **Vehicle target filter** made the push carry
`<targetType>vehicle</targetType>` (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.