Dingtian relay driver — resolves the ticket-first entry blocker

The Dingtian board's inputs are independent of its relays (configurable), so a
button on an input can report to the host WITHOUT auto-firing a relay — solving
the access-controller-button-flow blocker the UHPPOTE/ZKTeco couldn't.

packages/devices:
- access-dingtian.ts: `dingtian` access driver implementing AccessControlDevice
  (relay pulse/latch via UDP string protocol :60001), InputDevice (read inputs +
  poll-based press/release events, active-LOW), and the new PreconditionDevice.
- PreconditionDevice capability on the interface: a device can report config it
  requires for parking and optionally fix it. Dingtian checks input_link_relay
  via the HTTP config API and can disable it.
- httpPort config field — the web/config API port is separate from UDP control
  (this unit uses 8080, not the default 80).
- Register dingtian; export driver objects from the package.

Verified on real hardware (DT-R004 @ 10.0.10.172): status read, relay pulse,
input events; disabled input_link_relay via the driver, then confirmed pressing
inputs fires NO relay (0000) — host-in-the-loop entry works.

Config-write gotcha recorded: config_set.cgi requires "command":"setconfig"
injected after "status" (GET omits it) or the POST silently no-ops.

apps/server/scripts/dingtian-test.mjs: status / watch / pulse hardware test.
wiki: dingtian-relay verified; button-flow marked RESOLVED; index + log.
This commit is contained in:
2026-06-14 14:15:00 +02:00
parent 4319fb86dc
commit 1b55e2034d
9 changed files with 573 additions and 13 deletions
+73
View File
@@ -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);
}
@@ -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<string | null> {
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<string> {
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<typeof setInterval> | 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<void> {
await this.healthCheck();
}
async disconnect(): Promise<void> {
this.#stopPolling();
stubLog(this.driverId, "disconnect");
}
async healthCheck(): Promise<DeviceHealth> {
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<void> {
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<void> {
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<boolean[]> {
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<PreconditionResult> {
let cfg: Record<string, unknown>;
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<PreconditionResult> {
const raw = await configApi(this.#host, this.#httpPort, "/api/v2/config.cgi", "GET", null, this.#timeout);
const cfg = JSON.parse(raw) as Record<string, unknown>;
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<string, unknown>;
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<string, unknown> = {};
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<string, unknown>): boolean {
const ilr = cfg.input_link_relay as Record<string, unknown> | 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<DingtianStatus> {
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),
};
+3
View File
@@ -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,
+13 -1
View File
@@ -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";
+64
View File
@@ -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<boolean[]>;
/**
* 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<InputDevice>).readInputs === "function" &&
typeof (device as Partial<InputDevice>).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<PreconditionResult>;
/** Apply automatic fixes for fixable issues; returns the re-checked result. */
fixPreconditions(): Promise<PreconditionResult>;
}
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<PreconditionDevice>).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, …). */
@@ -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
+29 -4
View File
@@ -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.
+1 -1
View File
@@ -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.
+13
View File
@@ -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.