diff --git a/apps/server/src/routes/hikvision-alarm.test.ts b/apps/server/src/routes/hikvision-alarm.test.ts index 342d020..f9f92e1 100644 --- a/apps/server/src/routes/hikvision-alarm.test.ts +++ b/apps/server/src/routes/hikvision-alarm.test.ts @@ -95,6 +95,21 @@ describe("Hikvision Alarm Server push", () => { expect(String(d.rawHead)).toContain("EventNotificationAlert"); }); + it("accepts the legacy string \"true\" for alarmPushEnabled (setup form quirk)", async () => { + // The setup checkbox historically saved a STRING "true" instead of a boolean; the + // guard must coerce it, not silently reject a feature the admin enabled. + seedHikCamera({ alarmPushEnabled: "true" }); + const res = await app.inject({ + method: "POST", + url: `/api/devices/hikvision/${CAM_ID}/event`, + headers: { "content-type": "application/xml" }, + payload: VEHICLE_XML, + remoteAddress: CAM_IP, + }); + expect(res.statusCode).toBe(200); + expect(alarmEvents()).toHaveLength(1); + }); + it("pulls a plate out of an ANPR-style payload when present", async () => { seedHikCamera(); const anpr = `ANPR diff --git a/apps/server/src/routes/hikvision-alarm.ts b/apps/server/src/routes/hikvision-alarm.ts index 8b9c407..231d20e 100644 --- a/apps/server/src/routes/hikvision-alarm.ts +++ b/apps/server/src/routes/hikvision-alarm.ts @@ -25,11 +25,22 @@ import { verifyDigest } from "../digest-auth.js"; interface HikDeviceConfig { host?: string; - alarmPushEnabled?: boolean; + alarmPushEnabled?: boolean | string | number; pushUser?: string; pushPassword?: string; } +/** 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. */ @@ -133,9 +144,9 @@ export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promis // 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. let reason: string | null = null; - if (!row) reason = "unknown device id"; + if (!row || !cfg) reason = "unknown device id"; else if (row.driverId !== "hikvision") reason = `device is ${row.driverId}, not hikvision`; - else if (!cfg?.alarmPushEnabled) reason = "alarm push not enabled on this device (tick it in Setup)"; + 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 (ip !== cfg.host) reason = `source IP ${ip} != device host ${cfg.host}`; diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index a2537ce..6836c4b 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -331,11 +331,13 @@ function DeviceForm({ // Pre-fill scalar config fields from the existing assignment when editing. // (relays/controllerId/relay are model fields handled by their own state below.) - const [config, setConfig] = useState>(() => { + // Booleans are kept as real booleans (a checkbox field) — older saved configs may + // have stored a boolean as the string "true"/"false"; normalize those on load. + const [config, setConfig] = useState>(() => { if (!editCfg) return {}; - const out: Record = {}; + const out: Record = {}; for (const [k, v] of Object.entries(editCfg)) { - if (typeof v === "string" || typeof v === "number") out[k] = v; + if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") out[k] = v; } return out; }); @@ -415,9 +417,16 @@ function DeviceForm({ } /** Scalar config the user entered, merged over driver defaults (for test/push-IP). */ - function mergedScalarConfig(): Record { - const out: Record = {}; + function mergedScalarConfig(): Record { + const out: Record = {}; for (const f of selected?.configFields ?? []) { + // Boolean (checkbox) fields persist a REAL boolean — always (so toggling one OFF + // on an edit actually writes false), defaulting to the field default or false. + if (f.type === "boolean") { + const cur = config[f.key]; + out[f.key] = typeof cur === "boolean" ? cur : Boolean(cur ?? f.default ?? false); + continue; + } const v = config[f.key] ?? (f.default as string | number | undefined); if (v !== undefined && v !== "") out[f.key] = v; } @@ -560,7 +569,27 @@ function DeviceForm({ )} - {selected.configFields.map((f) => ( + {selected.configFields.map((f) => + f.type === "boolean" ? ( + // Boolean config field → a real checkbox (stores a true/false boolean, not + // the string "true"). The label sits beside the box, with the help below. + + ) : (
- ))} + ), + )} {/* CONTROLLER: the relay map — which relay opens which direction + entry button. */} {isController && }