Fix two bugs found running the real assign flow: the saved web password didn't match the device (login stayed admin/admin), and the UDP2 warning never reached the admin. Web password: - Split the conflated field into webPassword (the DESIRED login; blank -> auto-generate) and webPasswordCurrent (the device's EXISTING password used as the old cred, default admin). Before, an admin typing a desired password made harden send it as the old cred -> rotation failed -> but the DB still saved the typed value, so it claimed a password the device never accepted. - harden() now rotates current -> desired, VERIFIES by re-authenticating with the new password, and only returns secrets.webPassword on success (else a warning, nothing saved). Stores webPasswordCurrent for future re-runs. - assign strips the typed webPassword/webPasswordCurrent and persists only the verified secret -- the DB never claims an unapplied password. Warnings to the UI: - assignDevice returns warnings[]; SetupWizard shows them in an amber "saved, but action needed" banner per category. This is how the admin learns the firmware wouldn't disable UDP2 (finish in the device web UI). Verified on hardware: after harden the device rejects admin/admin and accepts the chosen password; the UDP2 warning surfaces.
10 KiB
type, tags, sources, updated
| type | tags | sources | updated | ||||
|---|---|---|---|---|---|---|---|
| entity |
|
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.
⚠️ The one gotcha: input_link_relay
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
pulseOpensends a binary "write relay with jogging" frame carrying therelay_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: IP192.168.1.100, UDP60000(binary) /60001(string). - Binary protocol (port 60000) adds optional password + multicast; bitmask relay/input maps.
Driver & config API
The dingtian driver (device-registry) implements three capabilities:
AccessControlDevice (relay pulse/latch over UDP), 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.
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
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_urland now land in the host's signed append-only-event-chain asinput_receivedevents (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_commandevent); 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):
relay_pw— set a random relay password so binary relay commands (UDP 60000) need it.- Disable EVERY other channel — set
p:255on the string protocol (udp2), rs485, can, tcp×2, mqtt; keep only UDP1 binary, which carriesrelay_pwfor both control AND status. - Rotate the
admin/adminweb 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 bypassingrelay_pw. Fixes: (a) status reads moved to the authenticated binary read (relay command0x00) so the string protocol is no longer needed; (b)harden()now setsudp2.p=255to 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), andwebPasswordCurrent= the device's EXISTING password (the old creduserset.cgichecks; defaults toadmin). 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 rotatescurrent → desired, verifies by re-authenticating with the new password, and only then returnssecrets.webPassword; assign strips the typed inputs and persists only the verified value (else a warning, no save). Verified on hardware: device rejectsadmin/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.cgiall return 200 with no credentials. Theadmin/adminlogin gates only the interactive browser UI — the CGI control plane (read/write full config, fire relays, change the password) bypasses it entirely. Thehttpconfig block has no setting to require Basic/Digest on inbound requests; the only inbound gate issession_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_enmust 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.
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_relaydisabled via the driver → pressing an input reports the event and fires NO relay (0000after 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+/offper press, source = the device IP). No polling. Host-in-the-loop entry (button → backend → ticket → backend opens relay) is real. - ✅ Web-login rotation —
userset.cgirotatesadmin/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.cgireturn 200 unauthenticated) → rotation is cosmetic. - ⬜ Next: wire the actual entry flow (input event → signed event + print ticket →
pulseOpen).