fix(setup): render boolean config fields as a checkbox (not a text box)
The generic config-field loop had no boolean branch, so a type:"boolean" field (e.g. the camera's alarmPushEnabled) fell through to a TEXT input and saved the STRING "true" instead of a real boolean. Downstream checks use === true, so the feature read as disabled even when the admin ticked it. - Web: render type:"boolean" config fields as a real checkbox; store/merge a true/false boolean (and persist false on edit so toggling off sticks); normalize a legacy string "true"/"false" on load. - Server: isOn() coerces the flag when reading config (accepts true/"true"/ 1/"yes"/"on") so an existing row saved as the string "true" still works without a re-save, and no other boolean field hits the same trap. Tests: hik-alarm accepts string "true" for alarmPushEnabled. server 112/112; web typecheck + build green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -95,6 +95,21 @@ describe("Hikvision Alarm Server push", () => {
|
|||||||
expect(String(d.rawHead)).toContain("EventNotificationAlert");
|
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 () => {
|
it("pulls a plate out of an ANPR-style payload when present", async () => {
|
||||||
seedHikCamera();
|
seedHikCamera();
|
||||||
const anpr = `<EventNotificationAlert><eventType>ANPR</eventType>
|
const anpr = `<EventNotificationAlert><eventType>ANPR</eventType>
|
||||||
|
|||||||
@@ -25,11 +25,22 @@ import { verifyDigest } from "../digest-auth.js";
|
|||||||
|
|
||||||
interface HikDeviceConfig {
|
interface HikDeviceConfig {
|
||||||
host?: string;
|
host?: string;
|
||||||
alarmPushEnabled?: boolean;
|
alarmPushEnabled?: boolean | string | number;
|
||||||
pushUser?: string;
|
pushUser?: string;
|
||||||
pushPassword?: 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
|
/** 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
|
* 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. */
|
* 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
|
// 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.
|
// never silently disappears — that's what makes "is it coming?" answerable.
|
||||||
let reason: string | null = null;
|
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 (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 (!cfg.host) reason = "device has no host IP configured";
|
||||||
else if (ip !== cfg.host) reason = `source IP ${ip} != device host ${cfg.host}`;
|
else if (ip !== cfg.host) reason = `source IP ${ip} != device host ${cfg.host}`;
|
||||||
|
|
||||||
|
|||||||
@@ -331,11 +331,13 @@ function DeviceForm({
|
|||||||
|
|
||||||
// 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.)
|
||||||
const [config, setConfig] = useState<Record<string, string | number>>(() => {
|
// 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<Record<string, string | number | boolean>>(() => {
|
||||||
if (!editCfg) return {};
|
if (!editCfg) return {};
|
||||||
const out: Record<string, string | number> = {};
|
const out: Record<string, string | number | boolean> = {};
|
||||||
for (const [k, v] of Object.entries(editCfg)) {
|
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;
|
return out;
|
||||||
});
|
});
|
||||||
@@ -415,9 +417,16 @@ function DeviceForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Scalar config the user entered, merged over driver defaults (for test/push-IP). */
|
/** Scalar config the user entered, merged over driver defaults (for test/push-IP). */
|
||||||
function mergedScalarConfig(): Record<string, string | number> {
|
function mergedScalarConfig(): Record<string, string | number | boolean> {
|
||||||
const out: Record<string, string | number> = {};
|
const out: Record<string, string | number | boolean> = {};
|
||||||
for (const f of selected?.configFields ?? []) {
|
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);
|
const v = config[f.key] ?? (f.default as string | number | undefined);
|
||||||
if (v !== undefined && v !== "") out[f.key] = v;
|
if (v !== undefined && v !== "") out[f.key] = v;
|
||||||
}
|
}
|
||||||
@@ -560,7 +569,27 @@ function DeviceForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{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.
|
||||||
|
<label key={f.key} className="my-2 flex max-w-sm 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={Boolean(config[f.key] ?? f.default ?? false)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.checked;
|
||||||
|
setConfig((c) => ({ ...c, [f.key]: v }));
|
||||||
|
resetStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold text-term-text">{f.label}</span>
|
||||||
|
{f.help && <span className="hint mt-0.5 block">{f.help}</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
<div key={f.key} className="field my-2 max-w-sm">
|
<div key={f.key} className="field my-2 max-w-sm">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
{f.label}
|
{f.label}
|
||||||
@@ -586,7 +615,7 @@ function DeviceForm({
|
|||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
||||||
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
|
value={(config[f.key] ?? (f.default as string | number | undefined) ?? "") as string | number}
|
||||||
placeholder={f.help}
|
placeholder={f.help}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
@@ -596,7 +625,8 @@ function DeviceForm({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
|
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
|
||||||
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
|
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
|
||||||
|
|||||||
Reference in New Issue
Block a user