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:
@@ -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,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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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, …). */
|
||||
|
||||
Reference in New Issue
Block a user