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
+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, …). */