From 23919164ee13f1cb9e02ca36a59d6d70e26223e3 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 14 Jun 2026 15:10:50 +0200 Subject: [PATCH] Dingtian input HTTP-push to backend (no polling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/server/src/device-events.ts | 28 +++++++++ apps/server/src/routes/devices.ts | 54 ++++++++++++++++ apps/server/src/server.ts | 6 +- .../devices/src/drivers/access-dingtian.ts | 61 ++++++++++++++++--- wiki/concepts/device-input-flow.md | 53 ++++++++++++++++ wiki/entities/dingtian-relay.md | 26 ++++++-- wiki/index.md | 1 + wiki/log.md | 14 +++++ 8 files changed, 228 insertions(+), 15 deletions(-) create mode 100644 apps/server/src/device-events.ts create mode 100644 apps/server/src/routes/devices.ts create mode 100644 wiki/concepts/device-input-flow.md diff --git a/apps/server/src/device-events.ts b/apps/server/src/device-events.ts new file mode 100644 index 0000000..3949306 --- /dev/null +++ b/apps/server/src/device-events.ts @@ -0,0 +1,28 @@ +import { EventEmitter } from "node:events"; + +// Internal event bus for device-originated events (button presses, etc.). +// Hardware drivers / inbound device pushes emit here; business logic (entry +// flow, event-log) subscribes — keeping the HTTP/transport layer thin and the +// app device-agnostic. See wiki/entities/fastify.md. + +export interface DeviceInputEvent { + readonly driverId: string; // e.g. "dingtian" + readonly deviceId: string; // which configured device (lane_devices id) + readonly input: number; // 1-based input/channel + readonly edge: "on" | "off"; // active / inactive + readonly at: string; // ISO-8601 (server receive time) + readonly source: "push" | "poll"; +} + +class DeviceEventBus extends EventEmitter { + emitInput(event: DeviceInputEvent): void { + this.emit("input", event); + } + onInput(cb: (event: DeviceInputEvent) => void): () => void { + this.on("input", cb); + return () => this.off("input", cb); + } +} + +/** Process-wide device event bus. */ +export const deviceEvents = new DeviceEventBus(); diff --git a/apps/server/src/routes/devices.ts b/apps/server/src/routes/devices.ts new file mode 100644 index 0000000..e725864 --- /dev/null +++ b/apps/server/src/routes/devices.ts @@ -0,0 +1,54 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; +import { deviceEvents } from "../device-events.js"; + +// Inbound device push endpoints. The Dingtian board's "Input Link URL" feature +// HTTP-calls us when an input (button) fires — no polling. We translate the +// push into an internal device event; the entry flow decides what to do +// (print a ticket, then command the relay). See wiki/entities/dingtian-relay.md. +// +// AUTH: these are machine-to-machine calls FROM the device, which can't do the +// SPA's cookie/CSRF auth. They are intentionally NOT behind requireRole. Trust +// does NOT come from this request — every barrier open is a host decision +// recorded as a signed event, so an out-of-band/forged open has no matching +// signed event and shows up as an anomaly (see wiki/concepts/append-only-event-chain +// and threat-model). A shared-secret check can be layered on later as +// defence-in-depth; on a flat network it isn't the security boundary. + +interface InputParams { + deviceId: string; + n: string; +} + +export async function deviceRoutes(app: FastifyInstance): Promise { + // Dingtian input ON-edge push (button pressed). The device is configured + // (via input_link_url) to call this path for each input. GET or POST both + // accepted — the device's method is configurable; the URL carries the input. + const handler = (edge: "on" | "off") => + async (req: FastifyRequest<{ Params: InputParams }>) => { + const { deviceId, n } = req.params; + const input = Number(n); + app.log.info(`[dingtian:${deviceId}] input ${input} ${edge} (push)`); + deviceEvents.emitInput({ + driverId: "dingtian", + deviceId, + input, + edge, + at: new Date().toISOString(), + source: "push", + }); + return { ok: true }; + }; + + for (const method of ["GET", "POST"] as const) { + app.route({ + method, + url: "/api/devices/dingtian/:deviceId/input/:n/on", + handler: handler("on"), + }); + app.route({ + method, + url: "/api/devices/dingtian/:deviceId/input/:n/off", + handler: handler("off"), + }); + } +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1afb4a0..1e0cb69 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -4,6 +4,7 @@ import Fastify, { type FastifyInstance } from "fastify"; import { createDb, type Db } from "@parking/db"; import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js"; import { authRoutes } from "./routes/auth.js"; +import { deviceRoutes } from "./routes/devices.js"; import { setupRoutes } from "./routes/setup.js"; // The backend is Fastify (Node). Hardware drivers live as isolated Fastify @@ -43,7 +44,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise { let cfg: Record; 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 { - const raw = await configApi(this.#host, this.#httpPort, "/api/v2/config.cgi", "GET", null, this.#timeout); - const cfg = JSON.parse(raw) as Record; + 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}//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//input" + }): Promise { + const cfg = await this.#readConfig(); + const ilu = cfg.input_link_url as Record; + 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> { + const raw = await configApi(this.#host, this.#httpPort, "/api/v2/config.cgi", "GET", null, this.#timeout); + return JSON.parse(raw) as Record; + } + + /** Write full config back. Injects the required `command:setconfig` and + * tolerates the device resetting on apply. */ + async #writeConfig(cfg: Record): Promise { // The set endpoint requires `"command":"setconfig"` injected after `status` // (the GET payload omits it). Rebuild preserving node order, command second. const out: Record = {}; @@ -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): boolean { diff --git a/wiki/concepts/device-input-flow.md b/wiki/concepts/device-input-flow.md new file mode 100644 index 0000000..806f18d --- /dev/null +++ b/wiki/concepts/device-input-flow.md @@ -0,0 +1,53 @@ +--- +type: concept +tags: [parking, architecture, devices, entry-flow] +sources: [] +updated: 2026-06-15 +--- + +# Device Input Flow (button → backend → relay) + +How a physical button press drives the entry lane. The **backend is the source of truth**: the +device only *reports* the press; the host decides and commands the relay. This is the host-in-the- +loop flow the [[dingtian-relay]] makes possible (and the [[uhppote-controller]] could not). + +## The path (no polling) + +``` +car arrives → driver presses button (input I_N, dry contact to GND) + → device HTTP-pushes GET …/api/devices/dingtian//input//on + → backend: emit internal device event (device-events bus) + → backend entry flow: create + sign an entry event, print the ticket + → backend: pulseOpen(N) over UDP → barrier opens + → (on release) device pushes …/input//off +``` + +- **Push, not poll.** The device's `input_link_url` feature is configured (by the driver's + `configureInputPush()`) to call the backend on each input edge — see [[dingtian-relay]]. The + driver's poll path remains only as a dev/fallback aid. +- **Per-input path** carries the input number in the URL (`…/input/3/on`), so routing needs no + body parsing. Both edges (`on`/`off`) are sent. +- **Internal event bus** (`device-events.ts`, a Node `EventEmitter`) decouples the HTTP/transport + layer from business logic — drivers/pushes emit; the entry flow subscribes. Keeps the app + [[device-adapter-pattern|device-agnostic]]. + +## Trust model (important — flat network, no VLAN) + +The relay-control direction (host → device) is **unauthenticated UDP**, and the site is a **flat +network with no VLAN** ([[network-isolation]] is not yet enforceable here). So we do **not** trust +the device or the network. Instead: + +- **Every barrier open is a host decision, recorded as a signed event BEFORE the relay fires** + ([[append-only-event-chain]]). If anyone opens the relay out-of-band (which the flat network + allows), there is **no matching signed event → a detectable anomaly**. The anti-fraud guarantee + is the **signed log**, not device/network auth. +- The inbound push endpoint is intentionally **not behind the SPA's cookie/CSRF auth** (it's a + machine call from the device). A **shared-secret / Basic-auth** on the push is available as + defence-in-depth (the device supports it) — worth adding, but it is *not* the security boundary. +- This sharpens under the [[autonomous-direction|unmanned]] roadmap: with no operator, tamper + detection via the signed log matters more than perimeter auth. + +## Status + +Input push **verified on hardware** (all 4 inputs, real presses reaching the backend). The entry +flow itself (signed event + ticket print + `pulseOpen`) is the next build — see [[dingtian-relay]]. diff --git a/wiki/entities/dingtian-relay.md b/wiki/entities/dingtian-relay.md index 50719d0..6235065 100644 --- a/wiki/entities/dingtian-relay.md +++ b/wiki/entities/dingtian-relay.md @@ -61,11 +61,27 @@ settings our flow depends on. > 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. +## Input push (no polling) — the chosen architecture + +The device **pushes** button events to the backend; the backend decides. **No polling.** The +driver's `configureInputPush()` writes the device's `input_link_url` config to point each input at +the backend: input N HTTP-GETs `…/api/devices/dingtian//input//on` (and `/off`) on +press/release. The backend ([[fastify]] route `routes/devices.ts`) translates each push into an +internal device event ([[device-input-flow]]); the entry flow then prints a ticket and commands +the relay via UDP. See [[device-input-flow]] for the full path + trust model. + +> The input-poll path in the driver (`onInput`) remains as a dev/fallback aid, but **push is the +> real path** — lower latency, and it can be authenticated (the device supports Basic/Digest + +> HTTPS on the push), unlike the open UDP control direction. + ## 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. +- ✅ **`input_link_relay` disabled via the driver** → pressing an input reports the event and + **fires NO relay** (`0000` after presses). The [[access-controller-button-flow]] blocker is + **solved**. +- ✅ **Input HTTP-push end to end** — configured the device via `configureInputPush()`, then real + button presses (all 4 inputs) **pushed to the backend** (`/input/N/on` + `/off` per press, + source = the device IP). No polling. Host-in-the-loop entry (`button → backend → ticket → + backend opens relay`) is real. +- ⬜ Next: wire the actual entry flow (input event → signed event + print ticket → `pulseOpen`). diff --git a/wiki/index.md b/wiki/index.md index edd6f93..acdb0ba 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -54,6 +54,7 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records. - [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter. - [[device-registry]] — catalog of selectable drivers per category (admin-configurable). - [[first-run-setup]] — admin assigns devices per lane from the catalog at install. +- [[device-input-flow]] — button → device push → backend decides → relay; backend is source of truth. - [[device-discovery]] — optional driver capability to scan the LAN (no current driver uses it; UHPPOTE was the example). - [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware. - [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable. diff --git a/wiki/log.md b/wiki/log.md index e7f3d32..2a83faa 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -131,3 +131,17 @@ device" framing (standing-decisions, bom, overview, open-questions) to [[dingtian-relay]]; noted no current driver uses [[device-discovery]]. Transferable concepts (network-isolation, event-log-ingestion, barrier-not-a-door, threat-model) kept as-is. Links lint clean; raw source untouched (immutable). + +## [2026-06-15] feature | Dingtian input HTTP-push → backend (no polling) +Wired the device's "Input Link URL" feature so it HTTP-pushes button events to +our backend — no polling. Driver `configureInputPush()` writes input_link_url +(per-input server/port/path, en=1, active-LOW, plain HTTP) via the config API +(reusing the #writeConfig + command:setconfig helper). New backend route +`routes/devices.ts`: public `GET/POST /api/devices/dingtian/:deviceId/input/:n/{on,off}` +→ emits onto an internal device-events bus (device-events.ts, EventEmitter) for +the entry flow to consume. VERIFIED on hardware: configured device, real presses +on all 4 inputs pushed to the backend (input N on+off, source = device IP). Trust +model recorded in [[device-input-flow]]: flat network / no VLAN → backend is source +of truth, every open is a signed event (out-of-band open = anomaly); push endpoint +not behind cookie auth (machine call), shared-secret available as defence-in-depth. +Next: wire signed event + ticket print + pulseOpen.