Files
parking_solution/wiki/entities/dingtian-relay.md
T
julian 5a5fedf4f4 docs(wiki): booth bring-up fixes — relay password, secret re-merge, lamp concurrency
- dingtian-relay: the "offline despite ping" gotcha (relay_pw in every binary frame,
  missing form field → Test connection sent 0 → timeout) + the identity-gated secret
  re-merge that stops a redirected probe exfiltrating the password.
- button-light-indicator: serialized desired-state worker (UDP is unordered → the lamp
  stuck on/off) and hot-reload of the lamp config (no restart).
- log entry for the three fixes (commits 420542c / fd15988 / 830993b).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 19:11:49 +02:00

13 KiB
Raw Blame History

type, tags, sources, updated
type tags sources updated
entity
parking
hardware
access-control
relay
2026-06-14

Dingtian Relay Controller

A network relay + input board (the unit on hand is the 4-channel variant: 4 relays + 4 inputs). Chosen to drive the entry/exit lane because — unlike the uhppote-controller — its inputs are independent of its relays, which solves the access-controller-button-flow blocker (a button on an input does not auto-open a relay; the host decides).

SDK: dingtian/4ch/sdk_v2_0_0/ (programming manual, examples). MIT-compatible use; no vendor runtime needed.

By default the device links each input to auto-fire its matching relay (input_link_relay: 1, on_action_on: [[0],[1],…] in the config) — i.e. the same auto-open problem as the UHPPOTE. The difference: it is configurable. Set input_link_relay: 0 (or clear the action mappings) so an input only reports and the host commands the relay. This config step is mandatory for the ticket-first entry flow. See autonomous-direction.

Protocol (Dingtian string — what we use)

Transport options: UDP/TCP string, UDP binary, HTTP CGI, Modbus, MQTT. We use HTTP + UDP — see dingtian-vs-mqtt.

  • Relay control — UDP binary, port 60000 (authenticated): the driver's pulseOpen sends a binary "write relay with jogging" frame carrying the relay_pw (the only relay option with a password). Frame (verified on hardware): FF AA <session> 03 <pwLo> <pwHi> <relayByte> <jogLo> <jogHi> — relayByte bit0=on, bits1-7= channel-1; jog is 100 ms units, LSB-first; password 16-bit LSB-first (0 = none). The relay jogs ON then auto-releases, so we never time a close (barrier-not-a-door). (The simpler string protocol — 1+ch on, 2+ch off, 11* jog — works too but has no auth; we use it only for the read-only status query.)
  • Status / inputs — send 00 → 「relays」:「inputs」:「count」, e.g. 0000:1111:4 (4ch: relays off, inputs high). 0 = OFF/Low, 1 = ON/High. Poll-based.
  • Input push — input_link_url: device HTTP POSTs to a host URL on input change — the push path for button events without a broker.
  • Discovery: UDP multicast 224.0.2.11:60000, send \x05\xAA (devices reply). Defaults: IP 192.168.1.100, UDP 60000 (binary) / 60001 (string).
  • Binary protocol (port 60000) adds optional password + multicast; bitmask relay/input maps.

Driver & config API

The dingtian driver (device-registry) implements: AccessControlDevice (relay pulse/latch over UDP), AuxOutputDevice (latch a NON-barrier output — see below), InputDevice (read inputs + poll-based 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 (default 80), distinct from the UDP control port 60001.

Spare relays + aux outputs (setAux)

A 4-input board typically has spare relays once the entry/exit barriers are wired. These drive non-barrier indicators — e.g. the entry button's 12 V lamp (see button-light-indicator). Business logic drives them through the device-agnostic AuxOutputDevice.setAux(channel, on) (a latch), never the barrier pulseOpen. The barrier-not-a-door rule doesn't apply to an aux output (it never gates a vehicle), so holding/blinking it is fine.

Per-input active level (presenceActiveLow / inputActiveLow)

Inputs are normalised against ONE board-wide resting level (inputRestingHigh). When a sensor (e.g. a hikvision-radar) idles opposite the button, list its terminal as active-LOW — sourced from each relay's presenceActiveLow, merged into the driver's inputActiveLow set — so that one input is read inverted while the button keeps the board default. (inputActive() is the pure helper; push-mode uses the device's own ilu.active_level instead.)

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 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.

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/<deviceId>/input/<N>/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.

What it pushes vs. doesn't (logging)

  • Inputs (buttons): YES, pushed. Input changes are HTTP-pushed via input_link_url and now land in the host's signed append-only-event-chain as input_received events (bus → EventLog). That is the audit trail for "a button fired."
  • Relay / barrier opens: NO push, no log. The device has no event log of its own and does not report when a relay fires — relay control is one-way UDP that the host initiates. So "the barrier opened" is not something to scrape from the device. The host records what it commanded (a future barrier_open_command event); a relay open with no matching signed host event is itself the anomaly to alarm on (threat-model). Do not treat the Dingtian as a log source — it is a dumb relay+input board; the host is the source of truth.

Hardening (harden()) — and why HTTP auth is not a boundary here

On assign the driver runs harden() (the device-registry capability):

  1. relay_pw — set a random relay password so binary relay commands (UDP 60000) need it.
  2. Disable EVERY other channel — set p:255 on the string protocol (udp2), rs485, can, tcp×2, mqtt; keep only UDP1 binary, which carries relay_pw for both control AND status.
  3. Rotate the admin/admin web login — GET /userset.cgi?<old_u>&<old_p>&<new_u>&<new_p>& (response &0&…& = success, verified on hardware). The new password is stored back in config (webUser/webPassword) so a re-run can rotate again (the device checks the old creds). This step is best-effort — a failure logs and does not fail the assign.

⚠️ The string protocol (udp2) is a password-less relay-fire path — the original harden() left it ENABLED "for status reads", which was a real hole. The Dingtian string protocol has NO password field and can fire relays ("11" = relay 1 on, "21" = off, "11*" = jog). Proven on hardware: sending "11" to UDP 60001 with no credentials opened relay 1, completely bypassing relay_pw. Fixes: (a) status reads moved to the authenticated binary read (relay command 0x00) so the string protocol is no longer needed; (b) harden() now sets udp2.p=255 to disable it. Firmware caveat (V3.6J): the CONFIG API silently refuses to disable udp2 — it accepts the write, reboots, and clamps it back — even though the device's web UI can disable it. So the udp2 disable is best-effort + warns (it is NOT part of the blocking verify); if it doesn't stick, harden() returns a warning telling the admin to flip UDP2 off in the device web UI. Verified: after the web-UI disable, the "11" attack gets no reply and the relay stays off, while authenticated binary control/status still work.

🔑 Web-login model (bug fixed). The login set has TWO distinct config keys: webPassword = the password the admin WANTS (blank → harden generates a random one), and webPasswordCurrent = the device's EXISTING password (the old cred userset.cgi checks; defaults to admin). The original code conflated them — an admin typing a desired password made harden send it as the old cred, the rotation failed, yet the DB still saved the typed value: the DB claimed a password the device never accepted (login stayed admin/admin). Fix: harden now rotates current → desired, verifies by re-authenticating with the new password, and only then returns secrets.webPassword; assign strips the typed inputs and persists only the verified value (else a warning, no save). Verified on hardware: device rejects admin/admin (&2&) and accepts the chosen password (&0&) after harden.

⚠️ The device CGI API is UNAUTHENTICATED. Verified on hardware: GET /api/v2/config.cgi, /, and even /userset.cgi all return 200 with no credentials. The admin/admin login gates only the interactive browser UI — the CGI control plane (read/write full config, fire relays, change the password) bypasses it entirely. The http config block has no setting to require Basic/Digest on inbound requests; the only inbound gate is session_en, which bricks the config-read API on this firmware (the factory-reset incident — do not enable it). So rotating the login is cosmetic (stops a casual browser reaching settings); it is not a boundary. On this flat, no-VLAN network the device control plane is effectively open — the signed event log is the real anti-fraud guarantee. See device-input-flow.

⚠️ session_en must stay OFF. Enabling the HTTP CGI session check makes the config-read API drop connections (ECONNRESET), locking out the API the driver depends on — recoverable only by factory reset. harden() deliberately never touches it.

relayPassword field + the "offline despite ping" gotcha (2026-06-24)

relay_pw is in every binary frame — control AND the status read healthCheck() uses. With a wrong/missing value the device silently drops the packet (no NAK), so the probe times out → the controller shows "offline" even though it pings (ping is ICMP and never touches the binary protocol). This bit a real bring-up: the driver read config.relayPassword but there was no form field for it, so Test connection sent 0 → timeout → "offline", while relay_pw was actually a non-zero value the harden flow had set. Diagnostic: a raw UDP status frame (FF AA <s> 00 <pwLo> <pwHi>) replies only with the right password — pw=N → ffaa…, pw=0 → timeout — and binding the WSL socket to the device-facing NIC (localAddress) also broke the reply (leave it unbound on WSL). Fix: a "Relay control password" config field (a secret; blank = keep the stored value).

🔒 Secret re-merge is identity-gated (don't let a redirected probe exfiltrate it). Because relayPassword/pushPassword are redacted from the client (first-run-setup), the edit form can't resend them, so /api/setup/test re-merges the stored secret by device id — but ONLY when the submitted config addresses the same device: matching driverId and every connection-identity field it sets (host/port/binaryPort/httpPort/serial). A redirected host/port or mismatched driver returns NO secret, so an authenticated admin can't point a test at an attacker host and have the password sent there (the booth operator is the threat-model adversary). Save already merged from the stored row; this closes the same gap on test.

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 → 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.
  • ✅ Web-login rotation — userset.cgi rotates admin/admin (response &0&/&; wrong old password → &2&/&). Confirmed the device validates the old creds. Also confirmed the CGI API needs NO auth (config dump + userset.cgi return 200 unauthenticated) → rotation is cosmetic.
  • ⬜ Next: wire the actual entry flow (input event → signed event + print ticket → pulseOpen).