Dingtian input HTTP-push to backend (no polling)

The device pushes button events to the backend via its Input Link URL feature;
the backend decides. No polling — the chosen entry architecture.

packages/devices:
- dingtian driver: configureInputPush() writes the device's input_link_url
  config (per-input server/port/path, en=1, active-LOW, plain HTTP) so each
  input HTTP-GETs the backend on press/release. Extracted #readConfig/#writeConfig
  (with the required command:setconfig injection + post-write reset tolerance).

apps/server:
- routes/devices.ts: public GET/POST
  /api/devices/dingtian/:deviceId/input/:n/{on,off} — translates a device push
  into an internal device event. Not behind cookie/CSRF (machine call from the
  device); trust comes from the signed event log, not this request.
- device-events.ts: internal EventEmitter bus so the entry flow subscribes to
  input events without coupling to HTTP. Wired into the server.

Verified on hardware: configured the device, then real presses on all 4 inputs
pushed to the backend (input N on+off, source = device IP). No polling.

wiki: device-input-flow concept (path + trust model for the flat/no-VLAN
network); dingtian-relay updated; index + log.
This commit is contained in:
2026-06-14 15:10:50 +02:00
parent 355026dcf7
commit 23919164ee
8 changed files with 228 additions and 15 deletions
@@ -202,9 +202,7 @@ class DingtianController
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),
);
cfg = await this.#readConfig();
} catch (err) {
return {
ok: false,
@@ -221,8 +219,7 @@ class DingtianController
}
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>;
const cfg = await this.#readConfig();
if (this.#linkDisabled(cfg)) return { ok: true, issues: [] };
// Disable the master flag AND clear the per-input action maps.
@@ -232,6 +229,54 @@ class DingtianController
ilr.on_action_on = (ilr.on_action_on as unknown[]).map(() => []);
}
await this.#writeConfig(cfg);
return this.checkPreconditions();
}
/**
* Configure the device to HTTP-push input (button) events to our backend —
* the "Input Link URL" feature. Each input N calls `${pathBase}/<N>/on` (and
* `/off`) on the given host:port via GET. Enables the feature and disables TLS
* (plain HTTP to the local backend). Replaces polling.
*/
async configureInputPush(opts: {
host: string;
port: number;
pathBase: string; // e.g. "/api/devices/dingtian/<deviceId>/input"
}): Promise<void> {
const cfg = await this.#readConfig();
const ilu = cfg.input_link_url as Record<string, unknown>;
const n = Number((ilu.cnt as number) ?? this.#channels);
const fill = (v: unknown) => Array.from({ length: n }, () => v);
ilu.en = 1;
ilu.active_level = fill(0); // active-LOW (matches this board's wiring)
ilu.tls = fill(0);
ilu.auth = fill(0);
ilu.server = fill(opts.host);
ilu.port = fill(opts.port);
ilu.user = fill("");
ilu.pass = fill("");
ilu.on_method = fill(0); // GET
ilu.off_method = fill(0);
ilu.on_path = Array.from({ length: n }, (_, i) => `${opts.pathBase}/${i + 1}/on`);
ilu.off_path = Array.from({ length: n }, (_, i) => `${opts.pathBase}/${i + 1}/off`);
ilu.on_body = fill("");
ilu.off_body = fill("");
await this.#writeConfig(cfg);
}
// --- config api internals ----------------------------------------------
async #readConfig(): Promise<Record<string, unknown>> {
const raw = await configApi(this.#host, this.#httpPort, "/api/v2/config.cgi", "GET", null, this.#timeout);
return JSON.parse(raw) as Record<string, unknown>;
}
/** Write full config back. Injects the required `command:setconfig` and
* tolerates the device resetting on apply. */
async #writeConfig(cfg: Record<string, unknown>): Promise<void> {
// 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> = {};
@@ -242,7 +287,7 @@ class DingtianController
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.
// success, not failure. Swallow the post-write reset.
try {
await configApi(
this.#host,
@@ -253,11 +298,9 @@ class DingtianController
this.#timeout,
);
} catch {
// device likely reset on apply — ignore and verify below
// device likely reset on apply
}
// 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 {