Files
parking_solution/wiki/entities/dingtian-relay.md
T
julian 2915d141aa feat(devices): radar presence input + button-light output on the controller
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
2026-06-24 11:45:22 +02:00

174 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 (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|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|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.
## 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`).