Files
julian 1efa77bf56 devices: pool-of-spaces model — drop lane, per-relay direction
A parking lot is one pool of spaces with a flexible set of entry/exit
points — no "lane". Direction is a property of each RELAY inside an access
controller; readers/cameras bind to a controller relay and inherit it.

Schema:
- drop `lane` from ledger_events, device_events, sessions
- rename lane_devices -> devices (no lane/direction columns)
- access config.relays=[{relay,direction,button?}]; reader/camera
  config.controllerId+relay binding
- fresh 0000_baseline migration (history reset; dev data was throwaway)

Signed ledger:
- remove `lane` from canonicalize(); bump signer keyId sw-hmac-v1 -> v2
  (v1 events won't verify under v2 — intentional, gated per-event by keyId)

Server:
- new device-resolve.ts (replaces lane-map.ts): relayForButton,
  relayForDevice, firstRelayByDirection, devicesByDirection
- entry-flow: button terminal -> its relay; exit/permit: reader's bound
  relay; dispatcher resolves the bound relay + inherited direction
- camera snapshots fire by direction site-wide, async, never block open
- DeviceConfig widened to nested JSON for relays[]

Web:
- wizard: no lane selector; add controllers (relay map + entry-button
  terminal) first, then bind readers/cameras/printers to a controller relay

Wiki: new entry-exit-points.md (replaces lane-direction); reworked
entry-exit-readers, parking-session, first-run-setup, device-registry,
append-only-event-chain, device-events; removed stale lane/LaneMap mentions.
2026-06-16 20:29:38 +02:00

107 lines
5.8 KiB
Markdown
Raw Permalink 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: concept
tags: [parking, architecture, devices, entry-flow]
sources: []
updated: 2026-06-15
---
# Device Input Flow (button → backend → relay)
How a physical button press drives the entry lane. The **backend is the source of truth**: the
device only *reports* the press; the host decides and commands the relay. This is the host-in-the-
loop flow the [[dingtian-relay]] makes possible (and the [[uhppote-controller]] could not).
## The path (no polling)
```
car arrives → driver presses button (input I_N, dry contact to GND)
→ device HTTP-pushes GET …/api/devices/dingtian/<deviceId>/input/<N>/on
→ backend: emit internal device event (device-events bus)
→ backend entry flow: create + sign an entry event, print the ticket
→ backend: pulseOpen(N) over UDP → barrier opens
→ (on release) device pushes …/input/<N>/off
```
- **Push, not poll.** The device's `input_link_url` feature is configured (by the driver's
`configureInputPush()`) to call the backend on each input edge — see [[dingtian-relay]]. The
driver's poll path remains only as a dev/fallback aid.
- **Per-input path** carries the input number in the URL (`…/input/3/on`), so routing needs no
body parsing. Both edges (`on`/`off`) are sent.
- **Internal event bus** (`device-events.ts`, a Node `EventEmitter`) decouples the HTTP/transport
layer from business logic — drivers/pushes emit; the entry flow subscribes. Keeps the app
[[device-adapter-pattern|device-agnostic]].
## Trust model (important — flat network, no VLAN)
The site is a **flat network with no VLAN** ([[network-isolation]] is not yet enforceable here),
so we do **not** trust the device or the network. Both directions now have defence-in-depth, but
neither is the real boundary:
- **Relay control (host → device)** — UDP, now via the Dingtian **binary protocol on :60000 with a
`relay_pw`** (the only authenticated relay option; the string protocol has none). Set on the
device + stored in `devices` by the harden step (below).
- **Input push (device → host)** — guarded by **HTTP Digest auth** + a **source-IP allowlist**.
- **The real guarantee is the signed log:** every barrier open is a host decision, recorded as a
signed event BEFORE the relay fires ([[append-only-event-chain]]). An out-of-band open (which a
flat network allows) has **no matching signed event → a detectable anomaly**. Device/network
auth is just speed bumps; both are plaintext over a sniffable network.
- This sharpens under the [[autonomous-direction|unmanned]] roadmap: with no operator, tamper
detection via the signed log matters more than perimeter auth.
## Device hardening (on assign)
The assign/Save step configures the device end-to-end (admin never touches the device web UI):
fix preconditions (disable `input_link_relay`) → **harden** → set up input push. The `harden`
capability ([[device-registry|HardenableDevice]]):
- **Sets a random `relay_pw`** (1–9999) so binary relay commands need it; stores it in
`devices` so the backend can keep commanding the relay.
- **Disables unused protocol channels** (rs485, can, tcp×2, mqtt → `p:255`), keeping only UDP1
binary (relay control) + UDP2 string (status read) — fewer open doors.
> **⚠️ Lesson (the hard way):** do **NOT** enable the device's HTTP CGI session check
> (`session_en`). On this firmware (DT-R004) it makes the config-**read** API drop connections
> (`ECONNRESET`), locking the backend out of the very API it depends on — it required a **factory
> reset** to recover. The harden step deliberately leaves `session_en` off. The CGI config API
> being open is accepted as part of the flat-network reality (the signed log is the guarantee);
> the proper fix is network isolation, not this fragile device feature.
## Push authentication — Digest (decided by hardware testing)
The secret must not be in the URL (sniffable, logged) and the password must not cross the wire in
the clear. We **empirically tested the device** to pick the strongest achievable option:
| Option | Device result |
| --- | --- |
| HTTPS (self-signed) | ❌ device won't push to a self-signed cert |
| **Digest auth** (`auth=2`) | ✅ **works** — full 401-nonce challenge/response |
| Basic auth | ✅ works (but password base64 on the wire) |
| URL token | rejected by design (visible in URL/logs) |
→ **HTTP Digest** (MD5, qop=auth). The password is never sent (only a nonce-keyed hash); nonces
are **single-use** (replay resistance). Per-device credentials (`pushUser`/`pushPassword`) are
generated by the backend on **device assign**, written to the device's `input_link_url` config,
and stored in `devices` — the admin never types a URL or secret. HTTPS would be stronger but
the device can't do it here; Digest + the signed log is the practical answer on a flat network.
See `apps/server/src/digest-auth.ts`.
## Dingtian config-write gotchas (cost a lot of debugging)
Writing the device's config API (`/api/v2/config_set.cgi`) has two non-obvious traps — both now
handled in the driver:
1. **Content-Length is mandatory.** The device's embedded HTTP server does **not** accept chunked
request bodies. Node uses chunked encoding when `Content-Length` is absent, so the device
silently ignores the body and returns `{"status":0}` anyway — the write looks successful but
nothing changes. Always set `Content-Length`.
2. **The `pass` field caps at 31 chars** (longer is silently truncated → Digest mismatch). The
generated push password is 24 hex chars (96 bits).
3. (Also: the device reboots on apply, so the driver writes then **polls until the change is
verified**, retrying — back-to-back writes onto a rebooting device are lost.)
## Status
Input push **verified on hardware** with Digest auth (all 4 inputs, real presses authenticated, no
failures). The entry
flow itself (signed event + ticket print + `pulseOpen`) is the next build — see [[dingtian-relay]].