Model the entry button (I1) and a Hikvision radar (I2) as named children of the access controller, and drive the button's 12V lamp on a spare relay. - Radar = the existing relays[].presenceInput one-car-one-ticket gate, now labelled presenceKind: loop|radar. A radar may idle opposite the button, so add a per-input active-level override: relays[].presenceActiveLow -> driver inputActiveLow set, inverting just that terminal (pure helper inputActive()). The Dingtian has one board-wide resting level otherwise. - AuxOutputDevice.setAux(channel,on) capability on the device interface (Dingtian latch) so business logic drives a NON-barrier lamp through the interface. Barriers still only pulseOpen — barrier-not-a-door preserved. - ButtonLightController: subscribes to the radar input edge + the camera lane status and drives a 3-state lamp — radar+car=solid, radar-only=blink (~1Hz), else off. Fails OFF on host loss/error; de-duped. A radar detection never opens a barrier on its own (advisory; threat model). - SetupWizard: presence kind + active-low + a button-light relay picker; sq+en i18n. Tests: button-light.test.ts (truth table + blink + fail-OFF + de-dupe), access-dingtian.test.ts (active-level inversion). Workspace build+lint+test green (158 server tests). Wiki: hikvision-radar, button-light-indicator + updates. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
12 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:
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.)
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).