4418594af0
The controller new/edit modal hardcoded both its outputs and its inputs, so an
operator could neither add a generic event-driven relay nor a free-standing input
(e.g. a second radar at the exit). This unifies both into symmetric, first-class
lists. Behaviour for existing booths is unchanged (back-compat, no DB migration).
Outputs — one event→action relays[] list:
- A relay is "when EVENT X happens, do its action": entry/exit/both pulse a
barrier; a new `radarAlert` event drives a non-barrier alert lamp (blink while
its trigger input is active, SOLID once the camera confirms a car).
- Dropped the separate config.buttonLight block — the lamp is just a relays[] row
with direction:"radarAlert" (triggerInput + blink cadence). `alertRelaysOf()`
replaces `buttonLightOf()`; ButtonLightController keeps its proven 3-state
machine (serialized UDP, fail-OFF, hot-reload), now keyed per controllerId:relay
so several alert lamps on one controller run independently. Every barrier
resolver skips radarAlert rows (no auto-open; barrier-not-a-door intact).
Inputs — one first-class config.inputs[] list (the twin of relays[]):
- Each row is { input, role, relay?, kind?, activeLow?, cooldownSec? } with a
"+ Add input" button. role ∈ button | presence | alertTrigger; button/presence
name the relay they serve. An exit radar is just another presence row.
- Keystone `inputsOf(row)`: returns config.inputs[] or SYNTHESIZES it from the
legacy relays[].button/presenceInput/... fields, so relayForButton /
relayForPresence resolve identically from either shape — zero-downtime, no
migration. entry-flow.ts is unchanged (resolves through the same functions).
- Fixed a latent bug this exposed: the alert lamp's camera lock was hardcoded to
the ENTRY camera. Added relays[].lockLane ("entry"|"exit", default entry); the
lamp now locks on its own lane's camera, so an exit radar's lamp tracks the exit
camera. button-light tracks both #entryBusy/#exitBusy.
- Driver: extracted activeLowFrom(config) — merges inputs[] activeLow, legacy
relays[].presenceActiveLow, and the inputActiveLow escape hatch.
UI: the relay dropdown gained a "Radar alert" option (reveals trigger/lock/blink
inputs); InputEditor is rewritten to a generic list (role select folds loop/radar);
i18n sq+en kept at type-parity.
Tests: new device-resolve.test.ts (inputs[] resolution + legacy fallback identical
+ exit-radar resolves to the exit relay); button-light gains a two-independent-
alert-relays case and an exit-lamp lockLane case; access-dingtian gains
activeLowFrom cases. Full workspace build/lint/test green (i18n parity included).
Wiki + memory updated (button-light-indicator, entry-double-press, dingtian-relay).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
209 lines
14 KiB
Markdown
209 lines
14 KiB
Markdown
---
|
||
type: entity
|
||
tags: [parking, hardware, access-control, relay]
|
||
sources: []
|
||
updated: 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 `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. Every relay is a `config.relays[]`
|
||
row carrying the **event** it reacts to (`direction`): the barrier events (`entry`/`exit`/`both`)
|
||
pulse, while a **`radarAlert`** row is an [[button-light-indicator|alert relay]] (blink + camera-lock).
|
||
Business logic drives alert relays through the device-agnostic `AuxOutputDevice.setAux(channel, on)`
|
||
(a latch), **never** the barrier `pulseOpen`; every barrier resolver skips `radarAlert` rows. The
|
||
[[barrier-not-a-door]] rule doesn't apply to an aux output (it never gates a vehicle), so
|
||
holding/blinking it is fine.
|
||
|
||
### Inputs are a first-class list (`config.inputs[]`)
|
||
|
||
Input wiring lives in `config.inputs[] = [{ input, role, relay?, kind?, activeLow?, cooldownSec? }]`
|
||
— the twin of `relays[]`. `role` ∈ `button` | `presence` | `alertTrigger`; a button/presence row
|
||
names the `relay` it serves; presence rows carry `kind` (loop/radar) + `activeLow`. Adding an exit
|
||
radar is adding a `presence` row. (Pre-2026-06-28 configs wired this on the relay itself —
|
||
`relays[].button/presenceInput/...`; `inputsOf()` synthesizes inputs[] from those for back-compat,
|
||
so old configs keep working until re-saved.)
|
||
|
||
### Per-input active level (`inputs[].activeLow` / `inputActiveLow`)
|
||
|
||
Inputs are normalised against ONE board-wide resting level (`inputRestingHigh`). When a sensor (e.g.
|
||
a [[hikvision-radar|radar]]) idles **opposite** the button, mark its terminal active-LOW — sourced
|
||
from `inputs[].activeLow` (and the legacy `relays[].presenceActiveLow`, plus an explicit top-level
|
||
`inputActiveLow[]` escape hatch), all merged by `activeLowFrom()` 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|precondition]]
|
||
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|HardenableDevice]] 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`).
|