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,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,6 +3,7 @@
|
|||||||
|
|
||||||
import { registry } from "../registry.js";
|
import { registry } from "../registry.js";
|
||||||
import { esp32RelayDriver, zktecoDriver } from "./access.js";
|
import { esp32RelayDriver, zktecoDriver } from "./access.js";
|
||||||
|
import { dingtianDriver } from "./access-dingtian.js";
|
||||||
import { uhppoteDriver } from "./access-uhppote.js";
|
import { uhppoteDriver } from "./access-uhppote.js";
|
||||||
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
||||||
import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
||||||
@@ -14,6 +15,7 @@ export function registerBuiltinDrivers(): void {
|
|||||||
if (registered) return;
|
if (registered) return;
|
||||||
registered = true;
|
registered = true;
|
||||||
registry.register(uhppoteDriver);
|
registry.register(uhppoteDriver);
|
||||||
|
registry.register(dingtianDriver);
|
||||||
registry.register(zktecoDriver);
|
registry.register(zktecoDriver);
|
||||||
registry.register(esp32RelayDriver);
|
registry.register(esp32RelayDriver);
|
||||||
registry.register(wiegandReaderDriver);
|
registry.register(wiegandReaderDriver);
|
||||||
@@ -24,6 +26,7 @@ export function registerBuiltinDrivers(): void {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
uhppoteDriver,
|
uhppoteDriver,
|
||||||
|
dingtianDriver,
|
||||||
zktecoDriver,
|
zktecoDriver,
|
||||||
esp32RelayDriver,
|
esp32RelayDriver,
|
||||||
wiegandReaderDriver,
|
wiegandReaderDriver,
|
||||||
|
|||||||
@@ -5,5 +5,17 @@
|
|||||||
|
|
||||||
export * from "./interfaces.js";
|
export * from "./interfaces.js";
|
||||||
export * from "./registry.js";
|
export * from "./registry.js";
|
||||||
export { registerBuiltinDrivers } from "./drivers/index.js";
|
|
||||||
export { setDeviceLogSink, type DeviceLogSink } from "./drivers/common.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">;
|
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) ---------------------------
|
// --- Readers (RF / optical; TCP-IP or Wiegand) ---------------------------
|
||||||
export interface ReaderDevice extends Device {
|
export interface ReaderDevice extends Device {
|
||||||
/** Emits when a credential is read (card number, plate, QR payload, …). */
|
/** Emits when a credential is read (card number, plate, QR payload, …). */
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
---
|
---
|
||||||
type: decision
|
type: decision
|
||||||
tags: [parking, hardware, access-control, blocker, open]
|
tags: [parking, hardware, access-control, resolved]
|
||||||
sources: [parking-system-architecture]
|
sources: [parking-system-architecture]
|
||||||
updated: 2026-06-15
|
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
|
> **✅ RESOLVED (2026-06-15) by the [[dingtian-relay]] controller.** Its inputs are decoupled from
|
||||||
> ZKTeco access controllers **on hand** cannot, as wired/configured, deliver the required entry
|
> its relays (`input_link_relay` configurable off — done & verified on hardware), so a button on an
|
||||||
> flow. This blocks the entry lane and needs a hardware/wiring resolution before that lane ships.
|
> input reports to the host **without** firing a relay. Host-in-the-loop entry
|
||||||
> Work paused here to focus on the business side. See [[entry-exit-readers]], [[trust-boundary]].
|
> (`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
|
## The required flow
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,33 @@ see [[dingtian-vs-mqtt]].
|
|||||||
IP `192.168.1.100`, UDP `60000` (binary) / `60001` (string).
|
IP `192.168.1.100`, UDP `60000` (binary) / `60001` (string).
|
||||||
- Binary protocol (port 60000) adds optional **password** + multicast; bitmask relay/input maps.
|
- 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`
|
The `dingtian` driver ([[device-registry]]) implements three capabilities:
|
||||||
relay driver in [[device-registry]] (UDP control + status parse) and the input-push endpoint,
|
`AccessControlDevice` (relay pulse/latch over UDP), `InputDevice` (read inputs + poll-based
|
||||||
with `input_link_relay` disabled on the device. Needs the device IP + LAN to test.
|
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
@@ -74,6 +74,6 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records.
|
|||||||
## Decisions
|
## Decisions
|
||||||
- [[standing-decisions]] — settled decisions (stack, platform, integrity, access control, readers).
|
- [[standing-decisions]] — settled decisions (stack, platform, integrity, access control, readers).
|
||||||
- [[open-questions]] — 7 open items (6 procurement + JWT key choice); ESP32 device auth deferred.
|
- [[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.
|
- [[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.
|
- [[dingtian-vs-mqtt]] — transport choice: direct HTTP/UDP now, MQTT parked until multi-lane scale.
|
||||||
|
|||||||
+13
@@ -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
|
**fully unmanned, no-booth** operation in [[autonomous-direction]] and its threat-model
|
||||||
shift (operator-fraud → unattended-machine threats). New stub [[dingtian-relay]]
|
shift (operator-fraud → unattended-machine threats). New stub [[dingtian-relay]]
|
||||||
with the full protocol from the SDK. Driver + on-hardware test still to build.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user