import { randomBytes } from "node:crypto"; import { createSocket } from "node:dgram"; import { request as httpRequest } from "node:http"; import type { AccessControlDevice, DeviceHealth, HardenableDevice, HardenResult, InputDevice, InputEvent, PreconditionDevice, PreconditionResult, PushConfig, PushConfigurableDevice, } from "../interfaces.js"; import type { AccessDriver, DeviceConfig } from "../registry.js"; import { hostField, portField, stubLog } from "./common.js"; // Dingtian relay+input controller driver. Backed by the "Dingtian string" // protocol over UDP. Implements AccessControlDevice (relay/barrier) AND the // optional InputDevice capability (host-readable buttons, decoupled from relays) // — which is what makes host-in-the-loop entry possible. See // wiki/entities/dingtian-relay.md and access-controller-button-flow.md. // // SAFETY: pulseOpen expresses INTENT only. It uses the device's jog/pulse // (momentary) so the relay self-releases; we never time a close against a // vehicle — anti-crush/auto-reverse is the barrier operator's firmware. // See wiki/concepts/barrier-not-a-door.md. // // SECURITY: unauthenticated UDP — the board must sit on an isolated VLAN // reachable only by the host. See wiki/concepts/network-isolation.md. // // NOTE: by default Dingtian links each input to auto-fire its relay // (input_link_relay). That must be DISABLED on the device for ticket-first // entry, else the button opens the barrier before the host can act. // (The string-protocol UDP helper was removed: harden() now disables the // password-less string protocol entirely, and status reads use the // authenticated binary read — see #status() / readStatusFrame.) /** * Send a Dingtian *binary* protocol frame (UDP, default port 60000) and await * the reply. Used for ALL relay traffic — control AND status read — because, * unlike the string protocol, the binary protocol carries a password (`relay_pw`). * harden() disables the string protocol precisely because it has NO password and * can fire relays (an unauthenticated `"11"` opens relay 1). With the string path * closed, relay_pw actually gates control. Frame verified on hardware: * * FF AA * * FF = command "set relay" * AA = result xor (0x00 ^ 0xAA, pc→device) * session = echoed back * relayCmd = 0 read status, 1 write, 3 jogging, … * pwLo,pwHi = relay password, 16-bit LSB-first (0 = none) * data = command-specific * * NOTE: relay_pw + plaintext UDP is defence-in-depth, NOT a boundary. An attacker * who sniffs the VLAN can replay the password. The real guarantee is the signed * event log (relay open with no signed command = fraud) + VLAN isolation. */ function binaryUdp( host: string, port: number, frame: Buffer, timeoutMs: number, localAddress?: string, ): Promise { return new Promise((resolve, reject) => { const sock = createSocket("udp4"); let settled = false; const done = (err: Error | null, val: Buffer | null) => { if (settled) return; settled = true; clearTimeout(timer); sock.close(); err ? reject(err) : resolve(val!); }; const timer = setTimeout(() => done(new Error("timeout"), null), timeoutMs); sock.on("error", (e) => done(e, null)); sock.on("message", (m) => done(null, m)); // Bind to a specific local address (the device-facing NIC) on multi-homed // hosts, so the device replies to the right source IP. See net.ts. const onBound = () => { sock.send(frame, port, host, (e) => { if (e) done(e, null); }); }; if (localAddress) sock.bind({ address: localAddress }, onBound); else sock.bind(onBound); }); } let binarySession = 0; /** * Build a binary "read relay status" frame (relay command 0x00). The device * replies `FF AA 00 ` (status widths scale * with channel count). This is the *authenticated* status read — unlike the * string protocol's `00`, it carries the relay password, so we can disable the * password-less string protocol entirely. Frame: `FF AA 00 `. * Verified on hardware (4ch): reply `ff aa 00 00 01 0f` = relay1 on, inputs 1111. */ function readStatusFrame(password: number): Buffer { const session = binarySession++ & 0xff; return Buffer.from([0xff, 0xaa, session, 0x00, password & 0xff, (password >> 8) & 0xff]); } /** Build a binary "write relay with jogging" frame (relay on, auto-off). */ function jogFrame(channel: number, password: number, jogMs: number): Buffer { const session = binarySession++ & 0xff; // relay index + on/off: bit0 = on, bits1..7 = (channel-1) const relayByte = (((channel - 1) & 0x7f) << 1) | 0x01; const units = Math.max(1, Math.round(jogMs / 100)); // 100ms units return Buffer.from([ 0xff, 0xaa, session, 0x03, // jogging password & 0xff, (password >> 8) & 0xff, relayByte, units & 0xff, (units >> 8) & 0xff, ]); } /** Build a binary "write relay" frame (latch on/off via mask+set). */ function writeRelayFrame(channel: number, on: boolean, password: number, channels: number): Buffer { const session = binarySession++ & 0xff; const bit = 1 << (channel - 1); const mask = bit; // only this channel updates const set = on ? bit : 0; // 4ch: mask + set are 1 byte each (bit0→relay1). const widthBytes = channels <= 8 ? 1 : channels <= 16 ? 2 : channels <= 24 ? 3 : 4; const maskBuf = Buffer.alloc(widthBytes); const setBuf = Buffer.alloc(widthBytes); maskBuf.writeUIntLE(mask, 0, widthBytes); setBuf.writeUIntLE(set, 0, widthBytes); return Buffer.concat([ Buffer.from([0xff, 0xaa, session, 0x01, password & 0xff, (password >> 8) & 0xff]), maskBuf, setBuf, ]); } const rand16 = () => randomBytes(2).readUInt16BE(0); /** GET a CGI path on the device's HTTP server and return the raw response text. */ function cgiGet(host: string, httpPort: number, path: string, timeoutMs: number, localAddress?: string): Promise { return new Promise((resolve, reject) => { const req = httpRequest({ host, port: httpPort, path, method: "GET", timeout: timeoutMs, localAddress }, (res) => { let data = ""; res.on("data", (c) => (data += c)); res.on("end", () => resolve(data)); }); req.on("error", reject); req.on("timeout", () => req.destroy(new Error("cgi timeout"))); req.end(); }); } interface DingtianStatus { relays: boolean[]; // true = on inputs: boolean[]; // true = active (after resting-level normalisation) channels: number; } const INPUT_LINK_ISSUE = { key: "input_link_relay", message: "input_link_relay is ENABLED — a button press will auto-fire its relay (opening the barrier before the host can act). Disable it for ticket-first entry.", fixable: true, } as const; /** GET/POST the device's JSON config API (HTTP; port is configurable). */ function configApi( host: string, httpPort: number, path: string, method: "GET" | "POST", body: string | null, timeoutMs: number, sessionId?: number, // device session check: sent as Cookie: session= localAddress?: string, // bind outbound to the device-facing NIC (multi-homed hosts) ): Promise { return new Promise((resolve, reject) => { // The device's embedded HTTP server does NOT support chunked request bodies. // Node uses chunked encoding when Content-Length is absent, so the device // silently ignores the body (POST returns {"status":0} but nothing changes). // Always set Content-Length explicitly. const headers: Record = {}; if (body) { headers["content-type"] = "application/json"; headers["content-length"] = Buffer.byteLength(body); } // When the device's HTTP session check is enabled, the CGI API requires a // matching session cookie (a numeric magic id). See programming manual §3.8. if (sessionId) headers["cookie"] = `session=${sessionId}`; const req = httpRequest( { host, port: httpPort, path, method, timeout: timeoutMs, localAddress, headers: Object.keys(headers).length ? headers : undefined, }, (res) => { let data = ""; res.on("data", (c) => (data += c)); res.on("end", () => resolve(data)); }, ); req.on("error", reject); req.on("timeout", () => req.destroy(new Error("config api timeout"))); if (body) req.write(body); req.end(); }); } class DingtianController implements AccessControlDevice, InputDevice, PreconditionDevice, PushConfigurableDevice, HardenableDevice { readonly driverId = "dingtian"; readonly #host: string; readonly #port: number; // legacy string-protocol port (60001) — protocol now disabled by harden(); kept for config compat readonly #binaryPort: number; // binary protocol (relay control) — UDP 60000 readonly #relayPassword: number; // relay_pw (0 = none) readonly #sessionId: number; // device CGI session id (0 = session check off) readonly #httpPort: number; readonly #timeout: number; // Local IP to source outbound device traffic from (the device-facing NIC on a // multi-homed host). undefined = let the OS choose. See net.ts / device-facing-ip. readonly #localAddress: string | undefined; readonly #channels: number; /** Input level at rest; an input is "active" when it differs from this. */ readonly #restingHigh: boolean; readonly #pulseMs: number; /** Device web-UI login user (gates the browser UI only, not the CGI API). */ readonly #webUser: string; /** The password the admin WANTS the device to have (the rotation target). If * blank, harden() generates a random one. */ readonly #webPassword: string | undefined; /** The device's CURRENT password, used as the OLD cred for userset.cgi. Defaults * to "admin" (factory). Distinct from #webPassword (the desired new value) so an * admin typing a desired password doesn't break rotation. */ readonly #webPasswordCurrent: string; #poll: ReturnType | null = null; #last: boolean[] | null = null; #subs = new Set<(e: InputEvent) => void>(); constructor(config: DeviceConfig) { this.#host = String(config.host); this.#port = config.port ? Number(config.port) : 60001; this.#binaryPort = config.binaryPort ? Number(config.binaryPort) : 60000; this.#relayPassword = config.relayPassword ? Number(config.relayPassword) : 0; this.#sessionId = config.sessionId ? Number(config.sessionId) : 0; this.#httpPort = config.httpPort ? Number(config.httpPort) : 80; this.#localAddress = config.localAddress ? String(config.localAddress) : undefined; this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 2000; this.#channels = config.channels ? Number(config.channels) : 4; // This unit idles with inputs HIGH (status "1111"); a press pulls LOW. this.#restingHigh = config.inputRestingHigh !== false; this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500; this.#webUser = config.webUser ? String(config.webUser) : "admin"; // webPassword = the DESIRED login (admin's choice; blank → harden generates). this.#webPassword = config.webPassword ? String(config.webPassword) : undefined; // webPasswordCurrent = the device's EXISTING password (the old cred userset.cgi // checks). Defaults to admin (factory). After a successful rotation, assign // stores the new value back here so a re-run can rotate again. this.#webPasswordCurrent = config.webPasswordCurrent ? String(config.webPasswordCurrent) : "admin"; } async connect(): Promise { await this.healthCheck(); } async disconnect(): Promise { this.#stopPolling(); stubLog(this.driverId, "disconnect"); } async healthCheck(): Promise { try { await this.#status(); return { status: "ready" }; } catch (err) { return { status: "offline", detail: (err as Error).message }; } } // --- relay / barrier ---------------------------------------------------- /** * Pulse a relay open (momentary). Channel is 1-based. Intent only — the device * jogs the relay ON then auto-releases after pulseMs, so we never time a close * against a vehicle. Uses the binary protocol + relay password (authenticated). */ async pulseOpen(doorId: number): Promise { this.#assertChannel(doorId); const frame = jogFrame(doorId, this.#relayPassword, this.#pulseMs); await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress); } /** Latch a relay on/off (e.g. for a held-open mode). Channel is 1-based. */ async setRelay(doorId: number, on: boolean): Promise { this.#assertChannel(doorId); const frame = writeRelayFrame(doorId, on, this.#relayPassword, this.#channels); await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress); } async getDoorStatus(doorId: number): Promise<"open" | "closed"> { this.#assertChannel(doorId); const { relays } = await this.#status(); // "open" here = relay energised. Physical door state needs a sensor input. return relays[doorId - 1] ? "open" : "closed"; } // --- inputs (buttons) --------------------------------------------------- async readInputs(): Promise { return (await this.#status()).inputs; } onInput(cb: (event: InputEvent) => void): () => void { this.#subs.add(cb); this.#startPolling(); return () => { this.#subs.delete(cb); if (this.#subs.size === 0) this.#stopPolling(); }; } // --- preconditions ------------------------------------------------------ /** * Parking requires `input_link_relay` DISABLED: otherwise a button press * auto-fires its relay, opening the barrier before the host can act (print a * ticket / decide). This is the configurable version of the UHPPOTE blocker. */ async checkPreconditions(): Promise { let cfg: Record; try { cfg = await this.#readConfig(); } catch (err) { return { ok: false, issues: [ { key: "config_unreachable", message: `could not read device config: ${(err as Error).message}`, fixable: false, }, ], }; } return { ok: this.#linkDisabled(cfg), issues: this.#linkDisabled(cfg) ? [] : [INPUT_LINK_ISSUE] }; } async fixPreconditions(): Promise { const cfg = await this.#readConfig(); if (this.#linkDisabled(cfg)) return { ok: true, issues: [] }; // Disable the master flag AND clear the per-input action maps. const ilr = cfg.input_link_relay as Record; ilr.input_link_relay = 0; if (Array.isArray(ilr.on_action_on)) { ilr.on_action_on = (ilr.on_action_on as unknown[]).map(() => []); } await this.#writeConfig(cfg, (after) => this.#linkDisabled(after)); return this.checkPreconditions(); } /** * Configure the device to HTTP-push input (button) events to our backend — * the "Input Link URL" feature. Each input N calls `${pathBase}//on` (and * `/off`) on host:port via GET, authenticated with **HTTP Digest** (the device * does Digest but not HTTPS-to-self-signed; both verified on hardware). The * password is never sent on the wire and the secret is not in the URL. * Enables the feature, plain HTTP, active-LOW. Replaces polling. */ async configureInputPush(opts: PushConfig): Promise { const cfg = await this.#readConfig(); const ilu = cfg.input_link_url as Record; const n = Number((ilu.cnt as number) ?? this.#channels); const fill = (v: unknown) => Array.from({ length: n }, () => v); ilu.en = 1; ilu.active_level = fill(0); // active-LOW (matches this board's wiring) ilu.tls = fill(0); // plain HTTP (device can't do HTTPS to self-signed) ilu.auth = fill(2); // 2 = Digest ilu.server = fill(opts.host); ilu.port = fill(opts.port); ilu.user = fill(opts.auth.user); ilu.pass = fill(opts.auth.password); ilu.on_method = fill(0); // GET ilu.off_method = fill(0); ilu.on_path = Array.from({ length: n }, (_, i) => `${opts.pathBase}/${i + 1}/on`); ilu.off_path = Array.from({ length: n }, (_, i) => `${opts.pathBase}/${i + 1}/off`); ilu.on_body = fill(""); ilu.off_body = fill(""); const wantPath = `${opts.pathBase}/1/on`; await this.#writeConfig(cfg, (after) => { const a = after.input_link_url as Record | undefined; const paths = a?.on_path as string[] | undefined; const pass = a?.pass as string[] | undefined; // Verify both the path and the (secret) password landed — the password is // what the backend's Digest check depends on. return ( a?.en === 1 && Array.isArray(paths) && paths[0] === wantPath && Array.isArray(pass) && pass[0] === opts.auth.password ); }); } // --- hardening ---------------------------------------------------------- /** * Lock the device down for a flat (no-VLAN) network: * - set a random relay password (`relay_pw`) so binary relay commands need it, * - keep ONLY UDP1 binary (password-protected relay control + status read), * - disable every other protocol channel: string, rs485, can, tcp×2, mqtt. * Returns the relay password for the backend to persist (required to keep * commanding the device afterwards). * * SECURITY — why the string protocol (UDP2) is now DISABLED (was a real hole): * the Dingtian string protocol has NO password field and can *fire* relays * (`"11"` = relay 1 on, `"21"` = off, `"11*"` = jog). Leaving it enabled — even * "just for status reads" — let anyone on the network open any barrier with one * unauthenticated UDP packet, completely bypassing relay_pw. Confirmed by * sending `"11"` to port 60001 with no credentials and watching relay 1 close. * So harden() sets udp2.p=255 and status reads move to the authenticated binary * read (relay command 0x00 — see #status()). * * NOTE: deliberately does NOT touch the device's HTTP CGI session check * (`session_en`). On this firmware enabling it makes the config-read API drop * connections, locking us out of the very API we depend on (verified the hard * way — required a factory reset). So we leave the config API as-is and rely on * relay_pw + fewer open channels + the signed event log. * * Even with the string hole closed, all of this is plaintext over UDP/HTTP → * defence-in-depth, NOT a boundary. The real guarantee is the signed event log * (a relay open with no matching signed command is the fraud signal) plus VLAN * isolation. See device-input-flow / network-isolation. */ async harden(): Promise { const cfg = await this.#readConfig(); const rc = cfg.relay_connect as Record; const relayPassword = 1 + (rand16() % 9999); // 1..9999 (0 = none) rc.relay_pw = relayPassword; // Keep ONLY UDP1=Binary (p:1) — it carries relay_pw for both control AND the // status read. Disable everything else (p:255 = None), INCLUDING the string // protocol (udp2), which is password-less and can fire relays. (rc.udp1 as Record).p = 1; (rc.udp2 as Record).p = 255; (rc.rs485 as Record).p = 255; (rc.can as Record).p = 255; (rc.tcpc as Record).p = 255; (rc.tcps as Record).p = 255; (rc.mqtt as Record).p = 255; // NOTE: udp2 (string protocol) is set to 255 here, but it is NOT part of the // blocking verify. On some firmware (e.g. V3.6J) the CONFIG API silently // refuses to disable udp2 — it accepts the write, reboots, and clamps it back // to enabled — even though every other channel applies and the device's own // web UI CAN disable it. We don't want assign to hard-fail over a firmware // quirk, so we attempt it, then re-check below and warn if it didn't stick. const afterCfg = await this.#writeConfig(cfg, (after) => { const a = after.relay_connect as Record | undefined; return ( a?.relay_pw === relayPassword && (a?.rs485 as Record | undefined)?.p === 255 && (a?.mqtt as Record | undefined)?.p === 255 ); }); const applied = [ "set relay password", "disabled rs485/can/tcp/mqtt channels (kept password-protected UDP binary)", ]; const warnings: string[] = []; const stringDisabled = ((afterCfg.relay_connect as Record)?.udp2 as Record | undefined)?.p === 255; if (stringDisabled) { applied.push("disabled the password-less string protocol (udp2)"); } else { warnings.push( "could not disable the string protocol (udp2) via the config API — this firmware ignores it. " + "An unauthenticated UDP packet to the string port can still fire relays. " + "Disable UDP2 in the device web UI, and rely on VLAN isolation + the signed event log. See dingtian-relay.md.", ); } const secrets: Record = { relayPassword }; // Set the device web login to the admin's chosen password (or a random one). // NOTE: cosmetic for the control plane — the CGI API needs NO auth (config // read/write + relay fire all work unauthenticated), so the login only gates // the interactive browser UI. We set it anyway (defence-in-depth) but it is // NOT a boundary; the signed event log is. See dingtian-relay.md. // // CRITICAL: only persist webPassword if the rotation VERIFIABLY took effect. // Otherwise the DB would claim a password the device doesn't have (the bug: // admin types a new pw, rotation fails on the wrong old-cred, DB still saves // the typed value, login stays admin/admin). On failure we warn instead. try { const newPassword = await this.#rotateWebLogin(); secrets.webUser = this.#webUser; secrets.webPassword = newPassword; // The new password is now the device's CURRENT one — store it so a future // re-harden uses the right old cred. secrets.webPasswordCurrent = newPassword; applied.push("set the device web-UI login (verified on the device)"); } catch (err) { warnings.push( `could not set the device web-UI login: ${(err as Error).message} ` + `The device login is UNCHANGED (still its previous password). The saved web password was NOT updated.`, ); } return { secrets, applied, warnings: warnings.length ? warnings : undefined }; } /** * Set the device web-UI login to the DESIRED password (the admin's choice, or a * random one if none was given) via * `userset.cgi?&&&&`. The device validates the * OLD credentials, so we send #webPasswordCurrent (admin on a fresh device). * Response `&&…&`, code 0 = success. * * After the rotation we VERIFY by attempting a no-op rotate using the NEW * password as the old cred — if that succeeds, the device really has the new * password (this is what catches the "DB says X but device is still admin/admin" * bug: a wrong old-cred makes the first call fail, and we never claim success). * Returns the password now live on the device. */ async #rotateWebLogin(): Promise { const newPassword = this.#webPassword ?? randomBytes(12).toString("hex"); const u = encodeURIComponent(this.#webUser); const setPath = (oldP: string, newP: string) => `/userset.cgi?${u}&${encodeURIComponent(oldP)}&${u}&${encodeURIComponent(newP)}&`; const res = await cgiGet(this.#host, this.#httpPort, setPath(this.#webPasswordCurrent, newPassword), this.#timeout, this.#localAddress); const code = res.split("&")[1]; if (code !== "0") { throw new Error( `userset.cgi rejected (response "${res.trim()}") — the device's current password is probably not "${this.#webPasswordCurrent}". ` + `Set the correct current password, or factory-reset the device.`, ); } // VERIFY: a no-op rotate (new → new) only succeeds if the device truly has it. const verify = await cgiGet(this.#host, this.#httpPort, setPath(newPassword, newPassword), this.#timeout, this.#localAddress); if (verify.split("&")[1] !== "0") { throw new Error(`web-login change did not take effect (verify response "${verify.trim()}")`); } return newPassword; } // --- config api internals ---------------------------------------------- async #readConfig(): Promise> { const raw = await configApi(this.#host, this.#httpPort, "/api/v2/config.cgi", "GET", null, this.#timeout, this.#sessionId, this.#localAddress); return JSON.parse(raw) as Record; } /** * Write full config back, then WAIT for the device to apply it. The device * reboots on apply (~10s) and back-to-back writes onto a rebooting device are * silently lost — so we poll until the device is reachable again AND `verify` * confirms the change landed, retrying the write if needed. * * @param verify predicate over the re-read config; should return true once the * intended change is present. */ async #writeConfig( cfg: Record, verify: (after: Record) => boolean, ): Promise> { // The set endpoint requires `"command":"setconfig"` injected after `status` // (the GET payload omits it). Rebuild preserving node order, command second. const out: Record = {}; for (const [k, v] of Object.entries(cfg)) { out[k] = v; if (k === "status") out.command = "setconfig"; } if (!("command" in out)) out.command = "setconfig"; const payload = JSON.stringify(out); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); for (let attempt = 1; attempt <= 3; attempt++) { // POST. The device resets on apply, so the connection may drop — that's // expected, not failure. try { await configApi(this.#host, this.#httpPort, "/api/v2/config_set.cgi", "POST", payload, this.#timeout, this.#sessionId, this.#localAddress); } catch { // device likely reset on apply } // Poll for the device to come back and the change to be present. for (let i = 0; i < 12; i++) { await sleep(2000); try { const after = await this.#readConfig(); if (verify(after)) return after; // applied — return the landed config } catch { // still rebooting / unreachable — keep polling } } // Not applied within the window — likely the POST hit a rebooting device. // Loop and re-POST (now that it's reachable again). } throw new Error("dingtian: config write did not apply after retries"); } #linkDisabled(cfg: Record): boolean { const ilr = cfg.input_link_relay as Record | undefined; if (!ilr) return true; // no such block → nothing to link const flagOff = ilr.input_link_relay === 0; const mapsEmpty = !Array.isArray(ilr.on_action_on) || (ilr.on_action_on as unknown[]).every((a) => Array.isArray(a) && a.length === 0); return flagOff || mapsEmpty; } // --- internals ---------------------------------------------------------- #assertChannel(ch: number): void { if (!Number.isInteger(ch) || ch < 1 || ch > this.#channels) { throw new Error(`dingtian: channel ${ch} out of range (1..${this.#channels})`); } } /** * Read relay + input status via the AUTHENTICATED binary protocol (relay * command 0x00). Reply: `FF AA 00 `, * each field `ceil(channels/8)` bytes, LSB-first (bit0 → relay/input 1). * * SECURITY: deliberately NOT the string protocol's `00` — that query has no * password field AND the string protocol can also *fire* relays, so leaving it * enabled defeats relay_pw entirely (an attacker sends `"11"` to open relay 1 * with no auth). harden() disables the string protocol; status reads come here. */ async #status(): Promise { const frame = readStatusFrame(this.#relayPassword); const reply = await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress); const width = Math.max(1, Math.ceil(this.#channels / 8)); // header: FF AA session 00 (4 bytes) + relay field + input field if (reply.length < 4 + width * 2) { throw new Error(`dingtian: short binary status reply (${reply.length} bytes)`); } const relayVal = reply.readUIntLE(4, width); const inputVal = reply.readUIntLE(4 + width, width); const relays: boolean[] = []; const inputs: boolean[] = []; for (let i = 0; i < this.#channels; i++) { const high = (inputVal & (1 << i)) !== 0; relays.push((relayVal & (1 << i)) !== 0); // active = differs from the resting level (a press pulls the line). inputs.push(high !== this.#restingHigh); } return { relays, inputs, channels: this.#channels }; } #startPolling(): void { if (this.#poll) return; const tick = async () => { let inputs: boolean[]; try { inputs = await this.readInputs(); } catch { return; // transient; try again next tick } const prev = this.#last; this.#last = inputs; if (!prev) return; // first sample establishes a baseline, no events const at = new Date().toISOString(); for (let i = 0; i < inputs.length; i++) { if (inputs[i] === prev[i]) continue; const event: InputEvent = { input: i + 1, edge: inputs[i] ? "pressed" : "released", at, }; for (const cb of this.#subs) cb(event); } }; // ~50ms poll: a button press is held well longer than this. this.#poll = setInterval(() => void tick(), 50); } #stopPolling(): void { if (this.#poll) { clearInterval(this.#poll); this.#poll = null; this.#last = null; } } } export const dingtianDriver: AccessDriver = { id: "dingtian", category: "access", label: "Dingtian relay controller", description: "Dingtian network relay+input board (UDP). Inputs are decoupled from relays — enables host-in-the-loop entry. Unauthenticated UDP: isolate the VLAN.", transports: ["udp"], pushesToBackend: true, // HTTP-pushes input/button events to the backend (Input Link URL) configFields: [ hostField, { ...portField(60001), required: false, help: "Dingtian string protocol UDP port — status read (default 60001)." }, { key: "binaryPort", label: "Binary protocol port", type: "port", required: false, default: 60000, help: "Dingtian binary protocol UDP port — authenticated relay control (default 60000)." }, { key: "httpPort", label: "HTTP config port", type: "port", required: false, default: 80, help: "Device web/config-API port (default 80)." }, { key: "channels", label: "Channels (relays/inputs)", type: "number", required: true, default: 4 }, { key: "pulseMs", label: "Pulse open (ms)", type: "number", required: false, default: 500, help: "Momentary relay pulse; the barrier operator owns the close.", }, { key: "inputRestingHigh", label: "Inputs idle HIGH", type: "boolean", required: false, default: true, help: "This board idles inputs HIGH (status 1111); a press pulls LOW.", }, { key: "timeoutMs", label: "Timeout (ms)", type: "number", required: false, default: 2000 }, // Device web-UI login. webPassword = the password you WANT (blank → a random // one is generated). webPasswordCurrent = the device's EXISTING password, used // as the old credential to change it (defaults to "admin" on a fresh device). // On a verified change, the new password is stored as both the saved login and // the current one. (Gates only the browser UI — CGI control plane is open.) { key: "webUser", label: "Device web username", type: "string", required: false, default: "admin", help: "Device web-UI login user (default admin)." }, { key: "webPassword", label: "New device web password", type: "secret", required: false, help: "The password to SET on the device web UI. Leave blank to auto-generate. Applied + verified on save." }, { key: "webPasswordCurrent", label: "Current device web password", type: "secret", required: false, help: "The device's existing web password (default admin on a fresh device). Needed to change it." }, ], create: (c) => new DingtianController(c), };