diff --git a/apps/server/scripts/dingtian-test.mjs b/apps/server/scripts/dingtian-test.mjs new file mode 100644 index 0000000..efde7bc --- /dev/null +++ b/apps/server/scripts/dingtian-test.mjs @@ -0,0 +1,73 @@ +// Dingtian relay+input hardware test. +// +// node apps/server/scripts/dingtian-test.mjs # status only (safe) +// node apps/server/scripts/dingtian-test.mjs watch # live input/button monitor +// node apps/server/scripts/dingtian-test.mjs pulse 1 # pulse relay 1 (prompts) +// +// Env: DINGTIAN_HOST (default 10.0.10.172), DINGTIAN_PORT (60001). +// +// SAFETY: `pulse` fires a relay → the barrier may move. It prompts first unless +// YES=1. pulseOpen is momentary (the device self-releases). + +import { createInterface } from "node:readline/promises"; +import { stdin, stdout } from "node:process"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { dingtianDriver } = require("@parking/devices"); + +const host = process.env.DINGTIAN_HOST ?? "10.0.10.172"; +const port = process.env.DINGTIAN_PORT ? Number(process.env.DINGTIAN_PORT) : 60001; +const dev = dingtianDriver.create({ host, port, channels: 4 }); + +const mode = process.argv[2] ?? "status"; +console.log(`dingtian @ ${host}:${port}\n`); + +async function showStatus() { + const health = await dev.healthCheck(); + console.log("health:", JSON.stringify(health)); + const inputs = await dev.readInputs(); + console.log("inputs (active=pressed):", inputs.map((v, i) => `in${i + 1}=${v ? "ON" : "off"}`).join(" ")); + for (let ch = 1; ch <= 4; ch++) { + console.log(`relay ${ch}:`, await dev.getDoorStatus(ch)); + } +} + +if (mode === "status") { + await showStatus(); + process.exit(0); +} + +if (mode === "watch") { + console.log("── press the buttons on the inputs — Ctrl-C to stop ──\n"); + const unsub = dev.onInput((e) => { + console.log(`[${e.at}] input ${e.input} ${e.edge.toUpperCase()}`); + }); + process.on("SIGINT", () => { + unsub(); + console.log("\nstopped."); + process.exit(0); + }); + // keep alive + await new Promise(() => {}); +} + +if (mode === "pulse") { + const ch = Number(process.argv[3] ?? 1); + if (process.env.YES !== "1") { + const rl = createInterface({ input: stdin, output: stdout }); + const ans = (await rl.question(`Pulse relay ${ch}? (barrier may move) [y/N] `)).trim(); + rl.close(); + if (ans.toLowerCase() !== "y") { + console.log("aborted."); + process.exit(0); + } + } + await dev.pulseOpen(ch); + console.log(`pulsed relay ${ch}.`); + // show the relay state right after (likely back off — pulse is momentary) + setTimeout(async () => { + console.log(`relay ${ch} now:`, await dev.getDoorStatus(ch)); + process.exit(0); + }, 300); +} diff --git a/packages/devices/src/drivers/access-dingtian.ts b/packages/devices/src/drivers/access-dingtian.ts new file mode 100644 index 0000000..bc2d337 --- /dev/null +++ b/packages/devices/src/drivers/access-dingtian.ts @@ -0,0 +1,365 @@ +import { createSocket } from "node:dgram"; +import { request as httpRequest } from "node:http"; +import type { + AccessControlDevice, + DeviceHealth, + InputDevice, + InputEvent, + PreconditionDevice, + PreconditionResult, +} 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. + +/** Send one UDP datagram and (optionally) await a single reply. */ +function udpRequest( + host: string, + port: number, + payload: string, + timeoutMs: number, + expectReply: boolean, +): Promise { + return new Promise((resolve, reject) => { + const sock = createSocket("udp4"); + let settled = false; + const done = (err: Error | null, val: string | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + sock.close(); + err ? reject(err) : resolve(val); + }; + const timer = setTimeout( + () => done(expectReply ? new Error("timeout") : null, null), + timeoutMs, + ); + sock.on("error", (e) => done(e, null)); + sock.on("message", (m) => done(null, m.toString())); + sock.bind(() => { + sock.send(Buffer.from(payload), port, host, (e) => { + if (e) done(e, null); + else if (!expectReply) done(null, null); + }); + }); + }); +} + +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, +): Promise { + return new Promise((resolve, reject) => { + const req = httpRequest( + { + host, + port: httpPort, + path, + method, + timeout: timeoutMs, + headers: body ? { "content-type": "application/json" } : 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 +{ + readonly driverId = "dingtian"; + readonly #host: string; + readonly #port: number; + readonly #httpPort: number; + readonly #timeout: number; + readonly #channels: number; + /** Input level at rest; an input is "active" when it differs from this. */ + readonly #restingHigh: boolean; + readonly #pulseMs: number; + + #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.#httpPort = config.httpPort ? Number(config.httpPort) : 80; + 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; + } + + 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. */ + async pulseOpen(doorId: number): Promise { + this.#assertChannel(doorId); + // Jog/pulse: "{1}{ch}*{units}" — ON then auto-OFF after pulseMs. + // units are 100ms each (5 = 500ms). Device self-releases the relay. + const units = Math.max(1, Math.round(this.#pulseMs / 100)); + await udpRequest(this.#host, this.#port, `1${doorId}*${units}`, this.#timeout, false); + } + + /** 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); + await udpRequest(this.#host, this.#port, `${on ? 1 : 2}${doorId}`, this.#timeout, false); + } + + 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 = JSON.parse( + await configApi(this.#host, this.#httpPort, "/api/v2/config.cgi", "GET", null, this.#timeout), + ); + } 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 raw = await configApi(this.#host, this.#httpPort, "/api/v2/config.cgi", "GET", null, this.#timeout); + const cfg = JSON.parse(raw) as Record; + 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(() => []); + } + + // 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"; + + // Device resets/applies after a write, so the connection may drop — that's + // success, not failure. Swallow the post-write reset and verify by re-reading. + try { + await configApi( + this.#host, + this.#httpPort, + "/api/v2/config_set.cgi", + "POST", + JSON.stringify(out), + this.#timeout, + ); + } catch { + // device likely reset on apply — ignore and verify below + } + // Give the device a moment to apply, then re-read to confirm. + await new Promise((r) => setTimeout(r, 4000)); + return this.checkPreconditions(); + } + + #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})`); + } + } + + /** Query "00" → parse "0000:1111:4" into relays/inputs/channels. */ + async #status(): Promise { + const reply = await udpRequest(this.#host, this.#port, "00", this.#timeout, true); + if (!reply) throw new Error("dingtian: empty status reply"); + const [relayStr, inputStr, countStr] = reply.trim().split(":"); + if (relayStr === undefined || inputStr === undefined) { + throw new Error(`dingtian: bad status reply "${reply}"`); + } + const bit = (c: string) => c === "1"; + return { + relays: [...relayStr].map(bit), + // active = differs from the resting level (press pulls the line). + inputs: [...inputStr].map((c) => bit(c) !== this.#restingHigh), + channels: countStr ? Number(countStr) : 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"], + configFields: [ + hostField, + { ...portField(60001), required: false, help: "Dingtian string protocol UDP port (default 60001)." }, + { 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 }, + ], + create: (c) => new DingtianController(c), +}; diff --git a/packages/devices/src/drivers/index.ts b/packages/devices/src/drivers/index.ts index c13ce3e..3ba33d8 100644 --- a/packages/devices/src/drivers/index.ts +++ b/packages/devices/src/drivers/index.ts @@ -3,6 +3,7 @@ import { registry } from "../registry.js"; import { esp32RelayDriver, zktecoDriver } from "./access.js"; +import { dingtianDriver } from "./access-dingtian.js"; import { uhppoteDriver } from "./access-uhppote.js"; import { dahuaDriver, hikvisionDriver } from "./camera.js"; import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js"; @@ -14,6 +15,7 @@ export function registerBuiltinDrivers(): void { if (registered) return; registered = true; registry.register(uhppoteDriver); + registry.register(dingtianDriver); registry.register(zktecoDriver); registry.register(esp32RelayDriver); registry.register(wiegandReaderDriver); @@ -24,6 +26,7 @@ export function registerBuiltinDrivers(): void { export { uhppoteDriver, + dingtianDriver, zktecoDriver, esp32RelayDriver, wiegandReaderDriver, diff --git a/packages/devices/src/index.ts b/packages/devices/src/index.ts index 7110729..a905561 100644 --- a/packages/devices/src/index.ts +++ b/packages/devices/src/index.ts @@ -5,5 +5,17 @@ export * from "./interfaces.js"; export * from "./registry.js"; -export { registerBuiltinDrivers } from "./drivers/index.js"; export { setDeviceLogSink, type DeviceLogSink } from "./drivers/common.js"; +// Built-in drivers: the registrar plus the individual driver objects (used by +// hardware test scripts and any direct/programmatic device access). +export { + registerBuiltinDrivers, + uhppoteDriver, + dingtianDriver, + zktecoDriver, + esp32RelayDriver, + wiegandReaderDriver, + tcpipReaderDriver, + hikvisionDriver, + dahuaDriver, +} from "./drivers/index.js"; diff --git a/packages/devices/src/interfaces.ts b/packages/devices/src/interfaces.ts index 229e587..363eb2b 100644 --- a/packages/devices/src/interfaces.ts +++ b/packages/devices/src/interfaces.ts @@ -35,6 +35,70 @@ export interface AccessControlDevice extends Device { getDoorStatus(doorId: number): Promise<"open" | "closed">; } +// --- Inputs (buttons / dry contacts) ------------------------------------- +// Optional capability for controllers that expose host-readable inputs SEPARATE +// from their relays — e.g. the Dingtian board. This is what enables host-in-the- +// loop entry: a button press is reported to the host, which decides (print a +// ticket) before commanding the relay — instead of the input auto-firing the +// relay. See wiki/decisions/access-controller-button-flow.md. +export interface InputDevice { + /** Read the current state of all inputs (true = active/pressed). */ + readInputs(): Promise; + /** + * Subscribe to input edges. Returns an unsubscribe fn. Implementations may + * back this with hardware push or polling — the consumer doesn't care. + */ + onInput(cb: (event: InputEvent) => void): () => void; +} + +export interface InputEvent { + /** 1-based input/channel index. */ + readonly input: number; + /** Edge: pressed = went active, released = went inactive. */ + readonly edge: "pressed" | "released"; + readonly at: string; // ISO-8601 +} + +/** Type guard: does this device expose host-readable inputs? */ +export function hasInputs(device: Device): device is Device & InputDevice { + return ( + typeof (device as Partial).readInputs === "function" && + typeof (device as Partial).onInput === "function" + ); +} + +// --- Preconditions (device must be configured a certain way) ------------- +// Optional capability: a device that depends on specific on-device configuration +// to work correctly for parking can report it. Example: the Dingtian board must +// have `input_link_relay` DISABLED, else a button press auto-fires the relay and +// defeats host-in-the-loop entry (the same trap as the UHPPOTE, but fixable here). +// The app does not own full device config (that's the vendor's web UI) — it only +// checks the few preconditions our flow depends on, and optionally fixes them. +// See wiki/decisions/access-controller-button-flow.md. +export interface PreconditionDevice { + checkPreconditions(): Promise; + /** Apply automatic fixes for fixable issues; returns the re-checked result. */ + fixPreconditions(): Promise; +} + +export interface PreconditionResult { + readonly ok: boolean; + readonly issues: PreconditionIssue[]; +} + +export interface PreconditionIssue { + readonly key: string; + readonly message: string; + /** True if fixPreconditions() can correct this automatically. */ + readonly fixable: boolean; +} + +export function hasPreconditions( + device: Device, +): device is Device & PreconditionDevice { + return typeof (device as Partial).checkPreconditions === "function"; +} + // --- Readers (RF / optical; TCP-IP or Wiegand) --------------------------- export interface ReaderDevice extends Device { /** Emits when a credential is read (card number, plate, QR payload, …). */ diff --git a/wiki/decisions/access-controller-button-flow.md b/wiki/decisions/access-controller-button-flow.md index 235e8c4..405c5c8 100644 --- a/wiki/decisions/access-controller-button-flow.md +++ b/wiki/decisions/access-controller-button-flow.md @@ -1,17 +1,22 @@ --- type: decision -tags: [parking, hardware, access-control, blocker, open] +tags: [parking, hardware, access-control, resolved] sources: [parking-system-architecture] updated: 2026-06-15 -status: open +status: settled --- -# Blocker: Push-Button → Auto-Open Defeats the Ticket-First Entry Flow +# Push-Button → Auto-Open: the Ticket-First Entry Blocker (RESOLVED) -> **Procurement-blocking finding (2026-06-15), from on-hardware testing.** The UHPPOTE and -> ZKTeco access controllers **on hand** cannot, as wired/configured, deliver the required entry -> flow. This blocks the entry lane and needs a hardware/wiring resolution before that lane ships. -> Work paused here to focus on the business side. See [[entry-exit-readers]], [[trust-boundary]]. +> **✅ RESOLVED (2026-06-15) by the [[dingtian-relay]] controller.** Its inputs are decoupled from +> its relays (`input_link_relay` configurable off — done & verified on hardware), so a button on an +> input reports to the host **without** firing a relay. Host-in-the-loop entry +> (`button → host → ticket → host opens relay`) now works. The original blocker (below) stands as +> the record of why the UHPPOTE/ZKTeco units couldn't do it. +> +> **Original procurement-blocking finding (2026-06-15), from on-hardware testing:** the UHPPOTE and +> ZKTeco controllers on hand could not, as wired/configured, deliver the required entry flow. +> See [[entry-exit-readers]], [[trust-boundary]]. ## The required flow diff --git a/wiki/entities/dingtian-relay.md b/wiki/entities/dingtian-relay.md index 81b65bb..50719d0 100644 --- a/wiki/entities/dingtian-relay.md +++ b/wiki/entities/dingtian-relay.md @@ -39,8 +39,33 @@ see [[dingtian-vs-mqtt]]. IP `192.168.1.100`, UDP `60000` (binary) / `60001` (string). - Binary protocol (port 60000) adds optional **password** + multicast; bitmask relay/input maps. -## Status +## Driver & config API -Protocol understood from the SDK; **driver + on-hardware test not built yet**. Next: a `dingtian` -relay driver in [[device-registry]] (UDP control + status parse) and the input-push endpoint, -with `input_link_relay` disabled on the device. Needs the device IP + LAN to test. +The `dingtian` driver ([[device-registry]]) implements three capabilities: +`AccessControlDevice` (relay pulse/latch over UDP), `InputDevice` (read inputs + poll-based +press/release events ~50 ms), and `PreconditionDevice` (below). Config fields include a separate +**`httpPort`** — the device's web/config API is on a configurable HTTP port (this unit: **8080**, +not the default 80), distinct from the UDP control port 60001. + +### Precondition: input_link_relay must be OFF + +The driver reads the device's JSON config (`GET /api/v2/config.cgi`) and **checks +`input_link_relay`**; if enabled it reports a fixable issue, and `fixPreconditions()` writes the +correction (`POST /api/v2/config_set.cgi`) — setting the flag to 0 and clearing `on_action_on`, +preserving everything else (network, etc.). This is the generic [[device-registry|precondition]] +capability: the app doesn't own full device config (that's the vendor web UI), only the few +settings our flow depends on. + +> **Write gotcha (cost real debugging):** the GET config payload **omits** a `"command"` field, but +> the set endpoint **requires `"command":"setconfig"`** injected right after `"status"`. Without it +> the POST returns/looks like success but silently does nothing (and the device may reset). With it, +> POST returns `{"status":0}` and the change sticks. JSON node order must be preserved. + +## Status — VERIFIED on hardware (DT-R004, sw V3.1.5461A, 10.0.10.172) + +- ✅ status read (`0000:1111:4`), relay pulse, input press/release events (active-LOW, idle HIGH). +- ✅ **`input_link_relay` disabled via the driver** → confirmed: pressing an input now reports the + event and **fires NO relay** (`0000` after presses). The [[access-controller-button-flow]] blocker + is **solved** — host-in-the-loop entry (`button → host → ticket → host opens relay`) works. +- ⬜ Next: input HTTP-push endpoint (device `input_link_url` → backend), and wiring the entry flow + (input event → print ticket → `pulseOpen`). Polling works today; push is the lower-latency path. diff --git a/wiki/index.md b/wiki/index.md index 454c827..51534be 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -74,6 +74,6 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records. ## Decisions - [[standing-decisions]] — settled decisions (stack, platform, integrity, access control, readers). - [[open-questions]] — 7 open items (6 procurement + JWT key choice); ESP32 device auth deferred. -- [[access-controller-button-flow]] — ⚠️ BLOCKER: UHPPOTE/ZKTeco on hand can't do ticket-first entry as wired. +- [[access-controller-button-flow]] — ✅ RESOLVED: Dingtian decoupled inputs enable ticket-first entry (was a UHPPOTE/ZKTeco blocker). - [[autonomous-direction]] — roadmap: toward fully unmanned (no booth); reshapes threat model + fail-state. - [[dingtian-vs-mqtt]] — transport choice: direct HTTP/UDP now, MQTT parked until multi-lane scale. diff --git a/wiki/log.md b/wiki/log.md index fa36717..f361f70 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -105,3 +105,16 @@ this scale) but kept for later multi-lane scale. Recorded the stated roadmap to **fully unmanned, no-booth** operation in [[autonomous-direction]] and its threat-model shift (operator-fraud → unattended-machine threats). New stub [[dingtian-relay]] with the full protocol from the SDK. Driver + on-hardware test still to build. + +## [2026-06-15] driver+test | Dingtian driver built; button blocker RESOLVED +Built the `dingtian` access driver (AccessControlDevice relay control + InputDevice +poll-based button events + new PreconditionDevice capability). Verified end to end on +real hardware (DT-R004 @ 10.0.10.172, HTTP config on :8080, UDP control :60001): +status read, relay pulse, input press/release. Disabled `input_link_relay` via the +driver's fixPreconditions (GET config → flag 0 + clear maps → POST config_set), then +confirmed: pressing inputs now fires NO relay (0000 status) — host-in-the-loop entry +works. The [[access-controller-button-flow]] blocker is RESOLVED. Gotcha recorded in +[[dingtian-relay]]: config_set requires injecting "command":"setconfig" after "status" +(GET omits it) or the write silently no-ops. Added httpPort config field (port 8080 ≠ +default 80). Test script apps/server/scripts/dingtian-test.mjs. Next: input HTTP-push +endpoint + wiring input→ticket→pulseOpen.