Files
parking_solution/apps/server/src/routes/hikvision-alarm.ts
T
julian 65328b8c11
CI / check (push) Failing after 15s
feat(anpr): subscriber-entry bridge + admin disable toggle
Wire the lane camera's vehicle event into the gated subscription flow: on a
vehicle/active push from an opt-in (config.anpr) camera, AnprBridge pulls a fresh
snapshot, runs ANPR, applies a stricter entry confidence floor, debounces, and —
matching the plate to a subscription BEFORE emitting — emits a kind:"plate" read.
The existing ReadDispatcher -> SubscriptionFlow then signs the entry/exit and opens
the barrier. A plate is never the sole authority: it routes through the same gate
(active/window/blocklist/car-count) as any credential. Fail-soft, fire-and-forget,
subscriber-only by construction. Field-verified end to end (plate AA504LX opened the
entry barrier and appended a signed vehicle_entry).

Add an admin master switch (site_config.anpr_entry_enabled, default ON) in Site
Settings that disables ONLY the barrier-driving bridge; advisory snapshot-ANPR and
lane busy/free are unaffected. Read live per event, so toggling takes effect with no
restart. Migration 0013 (additive ALTER ADD COLUMN, default 1).

- New: apps/server/src/anpr-entry.ts (AnprBridge) + tests (9)
- hikvision-alarm.ts hands vehicle detections to the bridge (fire-and-forget) + wiring tests (3)
- server.ts reorders the read flows above the hik-alarm registration
- snapshot.ts exports buildCamera for reuse
- env: VISION_ENTRY_MIN_CONFIDENCE (0.85), ANPR_DEBOUNCE_MS (12000)
- site route + SiteSettings checkbox + i18n (sq/en parity)
- wiki: lane-presence-and-anpr-entry / lpr-camera / index / log -> BUILT

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 19:49:18 +02:00

281 lines
14 KiB
TypeScript

import { randomUUID } from "node:crypto";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { desc, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
import { deviceEvents } from "../device-events.js";
import { requirePermission } from "../auth.js";
import { verifyDigest } from "../digest-auth.js";
import type { LaneStatus } from "../lane-status.js";
import type { AnprBridge } from "../anpr-entry.js";
// Hikvision "Alarm Server" event PUSH ingress. The newer-firmware cameras (Event →
// Smart/VCA with "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm
// Settings → Alarm Server) HTTP-POST an EventNotificationAlert to a URL we host every
// time the chosen target is detected. This is the same machine-call pattern as the
// Dingtian Input Link push (routes/devices.ts): source-IP guarded, NOT behind the SPA
// cookie/CSRF.
//
// DISCOVERY-FIRST. Hik's push format varies by model/firmware (event XML, or multipart
// with an attached JPEG, or — on some ANPR units — an <ANPR>/<plateNumber> block). So
// this endpoint is deliberately PERMISSIVE: it accepts ANY content-type as raw bytes,
// records the verbatim body as a `kind:"alarm"` device_event, and best-effort extracts a
// summary (eventType / target / plate). The goal of this first cut is to SEE exactly what
// a given camera sends — inspect via GET /api/events or the logs — before we wire it into
// the read bus / a snapshot trigger. It never opens a barrier (a plate read is advisory,
// never the sole reason; see wiki/concepts/append-only-event-chain.md).
//
// See wiki/entities/lpr-camera.md, wiki/concepts/device-input-flow.md.
interface HikDeviceConfig {
host?: string;
alarmPushEnabled?: boolean | string | number;
pushUser?: string;
pushPassword?: string;
/** Skip the source-IP guard for this device's pushes. The source IP is the primary
* LAN guard, but it's UNRELIABLE in some environments — notably WSL mirrored mode,
* which rewrites an inbound packet's source to the host's OWN address, so the camera's
* real IP never survives and a strict check rejects every push. When pushUser/
* pushPassword (Digest) are set, that auth is the real guard and source-IP adds little;
* this flag lets a deployment opt out. The signed ledger remains the anti-fraud truth. */
skipSourceIpCheck?: boolean | string | number;
}
/** Coerce a device-config flag to a boolean. The config is loosely-typed JSON from the
* setup form, which has historically stored a checkbox as the STRING "true" (a form-
* serialization quirk) — so accept true / "true" / 1 / "1" / "yes" / "on", reject the
* rest. Being lenient here means a stray "true" never silently disables a real feature. */
function isOn(v: unknown): boolean {
if (v === true) return true;
if (typeof v === "number") return v === 1;
if (typeof v === "string") return /^(1|true|yes|on)$/i.test(v.trim());
return false;
}
/** A best-effort summary pulled out of the raw push body (XML or JSON), for the device
* event detail + the log line. Absent fields just mean "not found in this firmware's
* payload" — the raw body is always stored so nothing is lost. */
interface AlarmSummary {
eventType?: string;
/** `active` (target entered the region) | `inactive` (target left). The edge that
* drives lane busy/free — see [[lpr-camera]] / hikvision-alarm.ts. */
eventState?: string;
target?: string;
plate?: string;
dateTime?: string;
channelId?: string;
}
function clientIp(req: FastifyRequest): string {
return req.ip.replace(/^::ffff:/, "");
}
/** First capture group of `re` in `s`, trimmed, or undefined. */
function pick(s: string, re: RegExp): string | undefined {
const m = re.exec(s);
return m?.[1]?.trim() || undefined;
}
/**
* Best-effort summary extraction. Hikvision event XML uses tags like <eventType>,
* <dateTime>, <channelID>; smart/ANPR events add target/plate tags whose exact names
* vary by firmware (<detectionTarget>, <targetType>, <plateNumber>, <licensePlate>).
* We probe several spellings; whatever doesn't match is simply absent. JSON bodies are
* scanned for the same keys.
*/
function summarize(body: string): AlarmSummary {
return {
eventType: pick(body, /<eventType>([^<]+)<\/eventType>/i) ?? pick(body, /"eventType"\s*:\s*"([^"]+)"/i),
eventState: pick(body, /<eventState>([^<]+)<\/eventState>/i) ?? pick(body, /"eventState"\s*:\s*"([^"]+)"/i),
target:
pick(body, /<(?:detectionTarget|targetType|objectType)>([^<]+)<\//i) ??
pick(body, /"(?:detectionTarget|targetType|objectType)"\s*:\s*"([^"]+)"/i),
plate:
pick(body, /<(?:plateNumber|licensePlate|plateNo)>([^<]+)<\//i) ??
pick(body, /"(?:plateNumber|licensePlate|plateNo)"\s*:\s*"([^"]+)"/i),
dateTime: pick(body, /<dateTime>([^<]+)<\/dateTime>/i),
channelId: pick(body, /<channelID>([^<]+)<\/channelID>/i) ?? pick(body, /<channelId>([^<]+)<\/channelId>/i),
};
}
export async function hikvisionAlarmRoutes(
app: FastifyInstance,
db: Db,
laneStatus?: LaneStatus,
anprBridge?: AnprBridge,
): Promise<void> {
// Accept ANY content-type as a raw Buffer (the camera may POST application/xml,
// multipart/form-data with a JPEG, or text). Fastify's default JSON parser would 415
// or empty these — we want the bytes verbatim. Scoped to THIS app instance via a
// wildcard parser; a 10 MB cap covers an event + an attached frame.
app.addContentTypeParser("*", { parseAs: "buffer", bodyLimit: 10 * 1024 * 1024 }, (_req, body, done) => {
done(null, body);
});
/** Record EVERY push (accepted or rejected) as a device_event so the read endpoint /
* DB always shows that SOMETHING arrived — the key fix: a rejected push used to log a
* warning and vanish, so "no event" was ambiguous (never sent? or sent + rejected?). */
function record(args: {
deviceId: string;
method: string;
accepted: boolean;
reason?: string;
ip: string;
contentType: string;
raw: Buffer;
summary: AlarmSummary;
}): void {
try {
db.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId: args.deviceId,
category: "camera",
kind: args.accepted ? "alarm" : "alarm-rejected",
detail: {
source: "hikvision-alarm-server",
accepted: args.accepted,
method: args.method,
...(args.reason ? { reason: args.reason } : {}),
ip: args.ip,
contentType: args.contentType,
bytes: args.raw.length,
...args.summary,
// Readable head verbatim (the XML part); truncated to keep the row small.
rawHead: args.raw.toString("utf8").slice(0, 8000),
},
occurredAt: new Date().toISOString(),
})
.run();
} catch (err) {
app.log.error(`hik-alarm device-event insert failed: ${(err as Error).message}`);
}
}
const handle = async (req: FastifyRequest<{ Params: { deviceId: string } }>, reply: FastifyReply) => {
const { deviceId } = req.params;
const method = req.method;
const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get();
const cfg = row?.config as HikDeviceConfig | undefined;
const ip = clientIp(req);
const contentType = String(req.headers["content-type"] ?? "");
const raw: Buffer = Buffer.isBuffer(req.body) ? (req.body as Buffer) : Buffer.from("");
const summary = summarize(raw.toString("utf8"));
// Log EVERY hit immediately (method + ip + size), before any guard — so even a probe
// that gets rejected is visible in the dev log the instant it arrives.
app.log.info(`[hik-alarm:${deviceId}] HIT ${method} from ${ip} (${contentType || "no-ct"} ${raw.length}B)`);
// Guard: must be a known hikvision device with alarm-push enabled, posting from its
// configured host IP. Source-IP is the primary guard on the LAN (like the Dingtian).
// On rejection we STILL record it (with the precise reason) so a push that reached us
// never silently disappears — that's what makes "is it coming?" answerable.
// The source-IP check is skipped when the device opts out (skipSourceIpCheck) — needed
// where the network rewrites the inbound source IP (e.g. WSL mirrored mode rewrites it
// to the host's own address), so a strict match can never pass. Digest auth (when set)
// and the signed ledger remain the real guards. See HikDeviceConfig.skipSourceIpCheck.
const skipIp = isOn(cfg?.skipSourceIpCheck);
let reason: string | null = null;
if (!row || !cfg) reason = "unknown device id";
else if (row.driverId !== "hikvision") reason = `device is ${row.driverId}, not hikvision`;
else if (!isOn(cfg.alarmPushEnabled)) reason = "alarm push not enabled on this device (tick it in Setup)";
else if (!cfg.host) reason = "device has no host IP configured";
else if (!skipIp && ip !== cfg.host) reason = `source IP ${ip} != device host ${cfg.host} (set skipSourceIpCheck if the network rewrites it, e.g. WSL)`;
if (reason) {
app.log.warn(`[hik-alarm:${deviceId}] REJECTED ${method} from ${ip} (${contentType} ${raw.length}B): ${reason}`);
record({ deviceId, method, accepted: false, reason, ip, contentType, raw, summary });
return reply.code(404).send({ error: "not found", reason });
}
// Optional Digest auth — only when the admin configured push creds (some firmware
// can't authenticate the Alarm Server call; then we rely on source-IP alone).
if (cfg!.pushUser && cfg!.pushPassword) {
if (!verifyDigest(req, reply, { user: cfg!.pushUser, password: cfg!.pushPassword })) {
record({ deviceId, method, accepted: false, reason: "digest auth failed/challenge", ip, contentType, raw, summary });
return; // 401 challenge already sent
}
}
// Loud log so the operator can SEE the payload during testing.
app.log.info(
`[hik-alarm:${deviceId}] ACCEPTED ${method} ${ip} ${contentType} ${raw.length}B ` +
`event=${summary.eventType ?? "?"}/${summary.eventState ?? "?"} target=${summary.target ?? "?"} plate=${summary.plate ?? "-"}`,
);
record({ deviceId, method, accepted: true, ip, contentType, raw, summary });
// Lane busy/free: a VEHICLE detection marks the camera's bound lane busy (advisory,
// for the booth barrier lights). Only on a vehicle target that's `active` — an
// `inactive` (leave) isn't sent by this camera class, so the lane auto-clears on a
// timeout in LaneStatus. We filter to vehicle per the booth's "vehicle only" intent.
const isVehicleActive =
(summary.target ?? "").toLowerCase() === "vehicle" &&
(summary.eventState ?? "active").toLowerCase() !== "inactive";
if (laneStatus && isVehicleActive) {
laneStatus.vehicleDetected(deviceId);
}
// ANPR BRIDGE: on a vehicle detection, if this camera opts into ANPR (config.anpr),
// pull a snapshot → read the plate → if it matches a SUBSCRIBER, emit a plate read
// onto the bus, which the existing gated SubscriptionFlow turns into an entry/exit +
// barrier open. Fire-and-forget — NEVER awaited on the 200 path (the camera must get
// a prompt ack or it retry-storms), and fail-soft inside the bridge. See anpr-entry.ts.
if (anprBridge && isVehicleActive) {
void anprBridge.onVehicleDetected(deviceId);
}
// Surface on the in-process bus as a generic breadcrumb so a live listener can show
// "camera saw a vehicle". NOT a DeviceReadEvent yet — that (plate identity driving
// entry/exit) is the deliberate next step once we know the real payload.
deviceEvents.emitInput({ driverId: "hikvision", deviceId, input: 0, edge: "on", at: new Date().toISOString(), source: "push" });
// 200 so the camera considers the alarm delivered and doesn't retry-storm.
return reply.code(200).send({ ok: true });
};
// Listen for EVERY method on the event path. The camera (and its "Test" button) may
// probe with GET/HEAD/OPTIONS/PUT, not just POST — and a method we don't register gets
// Fastify's generic 404, which the camera reads as "service available" while our
// handler never runs (so nothing is recorded). Registering all methods means ANYTHING
// that hits this URL reaches `handle` and is captured (the method is logged + stored),
// so we can finally SEE exactly what the camera sends. See wiki/entities/lpr-camera.md.
// (HEAD is auto-added by Fastify alongside GET — don't register it explicitly.)
for (const method of ["POST", "GET", "PUT", "PATCH", "DELETE", "OPTIONS"] as const) {
app.route({ method, url: "/api/devices/hikvision/:deviceId/event", handler: handle });
}
// Read endpoint: the recent alarm pushes (accepted AND rejected), newest first — so you
// can SEE in the browser whether events are arriving and why any were refused, instead
// of grepping the dev log or querying SQLite. Gated device:read (admin device view).
app.get<{ Querystring: { limit?: string } }>(
"/api/devices/hikvision/alarms",
{ preHandler: requirePermission("device:read") },
async (req) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500);
const rows = db
.select()
.from(deviceEventsTable)
.where(inArray(deviceEventsTable.kind, ["alarm", "alarm-rejected"]))
.orderBy(desc(deviceEventsTable.occurredAt))
.limit(limit)
.all();
const alarms = rows.map((r) => {
const d = (r.detail ?? {}) as Record<string, unknown>;
return {
at: r.occurredAt,
deviceId: r.deviceId,
accepted: d.accepted === true,
method: (d.method as string) ?? null,
reason: (d.reason as string) ?? null,
ip: (d.ip as string) ?? null,
contentType: (d.contentType as string) ?? null,
bytes: (d.bytes as number) ?? 0,
eventType: (d.eventType as string) ?? null,
eventState: (d.eventState as string) ?? null,
target: (d.target as string) ?? null,
plate: (d.plate as string) ?? null,
rawHead: (d.rawHead as string) ?? null,
};
});
return { count: alarms.length, alarms };
},
);
}