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.
This commit is contained in:
@@ -89,16 +89,13 @@ The [[parking-session]] domain folds over these **signed ledger** events:
|
||||
A session is a **projection** over this chain, never a mutable table — the same anti-fraud reason
|
||||
the chain exists. See [[parking-session]].
|
||||
|
||||
### ⚠️ As-built vs. the table split (pending)
|
||||
### As-built (table split done)
|
||||
|
||||
The current code records Dingtian **input (button) pushes** as `input_received` rows **in the
|
||||
signed chain** (with `lane` resolved via the `LaneMap`, `source` null, device provenance in
|
||||
`identity`). Per the 2026-06-15 split (above), a raw button press is **device telemetry** and
|
||||
belongs in **`device_events`**, *not* the signed ledger — only the business `vehicle_entry` it
|
||||
drives gets signed. So `input_received`-in-the-ledger is **transitional**; the pending refactor
|
||||
moves raw inputs to `device_events` and renames the chain table to `ledger_events`. (`LaneMap`
|
||||
lane-resolution and the "never stamp `lane: 0` for an unmapped device" rule carry over to whichever
|
||||
stream records the event.)
|
||||
The split above is implemented: raw Dingtian **input (button) pushes** are **device telemetry** in
|
||||
**`device_events`** (unsigned, prunable), keyed to the firing `devices` instance. Only the business
|
||||
`vehicle_entry` the press drives is signed into **`ledger_events`**. The signed events carry **no
|
||||
`lane`** — the pool-of-spaces model has none (dropped 2026-06-16; see [[entry-exit-points]]), and
|
||||
the canonical form bumped `sw-hmac-v1` → `sw-hmac-v2` accordingly.
|
||||
|
||||
### ⚠️ Limitation: the log captures HOST-ORIGINATED actions only
|
||||
|
||||
|
||||
@@ -33,6 +33,6 @@ principle. The choice of *which* adapter to trust is the [[trust-boundary]] deci
|
||||
|
||||
> **In practice** the adapters are made *selectable*: a [[device-registry]] catalogs the
|
||||
> supported drivers (ZKTeco / ESP32 relay, Wiegand / TCP-IP readers, Hikvision / Dahua cameras),
|
||||
> and the admin assigns one per lane during [[first-run-setup]]. Adding hardware support = one
|
||||
> and the admin assigns instances during [[first-run-setup]]. Adding hardware support = one
|
||||
> more registered driver, no business-logic change. (The implemented interfaces add a
|
||||
> `CameraDevice` for entry/exit snapshots alongside reader/relay/printer.)
|
||||
|
||||
@@ -60,7 +60,7 @@ replies), so the driver **serializes** all controller I/O. Override the broadcas
|
||||
2. Admin clicks **Scan** → `GET /api/setup/discover/:driverId` (admin-only).
|
||||
3. The server runs `discover()` and **health-checks each found device** so the admin sees
|
||||
reachability before assigning.
|
||||
4. Selecting a result **auto-fills serial + host**; the admin then assigns it to a lane.
|
||||
4. Selecting a result **auto-fills serial + host**; the admin then assigns + binds it.
|
||||
|
||||
## Deployment notes
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ diagnostics, and live booth status — **not** anti-fraud.
|
||||
ledger's integrity machinery.
|
||||
- **Disposable** — high-volume and churny; **may rotate/prune** on a retention policy (the ledger
|
||||
never does).
|
||||
- **Device-keyed** — references the `lane_devices` instance; `lane` resolved via the same `LaneMap`
|
||||
as before. Stores raw device provenance.
|
||||
- **Device-keyed** — references the `devices` instance (raw device provenance). No `lane`
|
||||
(pool-of-spaces model — see [[entry-exit-points]]).
|
||||
|
||||
## The boundary that matters
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ 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 `lane_devices` by the harden step (below).
|
||||
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
|
||||
@@ -55,7 +55,7 @@ fix preconditions (disable `input_link_relay`) → **harden** → set up input p
|
||||
capability ([[device-registry|HardenableDevice]]):
|
||||
|
||||
- **Sets a random `relay_pw`** (1–9999) so binary relay commands need it; stores it in
|
||||
`lane_devices` so the backend can keep commanding the relay.
|
||||
`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.
|
||||
|
||||
@@ -81,7 +81,7 @@ the clear. We **empirically tested the device** to pick the strongest achievable
|
||||
→ **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 `lane_devices` — the admin never types a URL or secret. HTTPS would be stronger but
|
||||
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`.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ updated: 2026-06-15
|
||||
How the system goes from "device-agnostic in principle" ([[device-adapter-pattern]]) to
|
||||
"**admin picks the device at setup**" in practice. A **registry** holds a catalog of supported
|
||||
**drivers**, grouped by category; the [[first-run-setup]] UI reads it so an
|
||||
operator can choose a device per lane and fill in its connection config.
|
||||
operator can choose a device and fill in its connection config.
|
||||
|
||||
> Implementation-derived (from `packages/devices`), not the source doc.
|
||||
|
||||
@@ -33,10 +33,11 @@ driver; **no business-logic change** — this is the [[device-adapter-pattern]]
|
||||
|
||||
## Why a registry (not hard-coded wiring)
|
||||
|
||||
- The admin chooses between **multiple devices per category** at install time, per lane
|
||||
(mirrors the "mixable per lane" principle — see [[trust-boundary]], [[entry-exit-readers]]).
|
||||
- The admin chooses between **multiple devices per category** at install time
|
||||
(a controller's relays mix entry/exit; readers bind to them — see [[entry-exit-points]],
|
||||
[[trust-boundary]], [[entry-exit-readers]]).
|
||||
- Config is **validated against the driver's declared fields** before persisting.
|
||||
- Selections persist in the `lane_devices` table and drive runtime adapter construction.
|
||||
- Selections persist in the `devices` table and drive runtime adapter construction.
|
||||
- Drivers may optionally implement **[[device-discovery]]** (`discover()`), so the admin can scan
|
||||
the LAN instead of typing connection details — no current driver uses it (the UHPPOTE did,
|
||||
before removal; the [[dingtian-relay]] uses a fixed IP).
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, architecture, devices, setup]
|
||||
sources: []
|
||||
updated: 2026-06-16
|
||||
---
|
||||
|
||||
# Entry / Exit Points (pool-of-spaces model)
|
||||
|
||||
A parking lot is **one pool of spaces** with a flexible set of **entry points** and **exit
|
||||
points** — any number of each, in any combination (1 in + 1 out, 1 in + 2 out, 2 in + 1 out, …).
|
||||
There is **no "lane"** concept anywhere in the system (dropped 2026-06-16 — see below).
|
||||
|
||||
## Direction lives on the relay, not the controller
|
||||
|
||||
An access controller (e.g. a [[dingtian-relay]] board) has **several relays** — each relay opens
|
||||
one barrier. Direction is a property of **each relay**, declared in the controller's config:
|
||||
|
||||
```jsonc
|
||||
// access `devices` row — one Dingtian board
|
||||
config: {
|
||||
host: "192.168.1.100",
|
||||
relays: [
|
||||
{ relay: 1, direction: "entry", button: 1 }, // entry barrier; entry button on input 1
|
||||
{ relay: 2, direction: "exit" } // exit barrier; opened by a reader, no button
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `direction`: `entry` | `exit` | `both` (`both` = one barrier/relay serving in and out).
|
||||
- `button`: the **input terminal** the transient **entry button** is wired to. Only entry/both
|
||||
relays have one. Absent = no button at that barrier (subscriber/reader-driven only).
|
||||
|
||||
The four real layouts all fall out of this:
|
||||
|
||||
| Layout | Controllers | Relays |
|
||||
| --- | --- | --- |
|
||||
| 1 barrier, both directions | 1 | `{relay:1, both, button:1}` |
|
||||
| 2 barriers, 1 board | 1 | `{relay:1, entry, button:1}`, `{relay:2, exit}` |
|
||||
| 2 barriers far apart | 2 | board A `{relay:1, entry}`, board B `{relay:1, exit}` |
|
||||
| 1 entry + 2 exit | 3 | A entry; B, C each exit |
|
||||
|
||||
## Readers / cameras BIND to a relay
|
||||
|
||||
A reader or camera points at the barrier it physically sits at, via its config:
|
||||
|
||||
```jsonc
|
||||
config: { ...readerConfig, controllerId: "<access devices.id>", relay: 2 }
|
||||
```
|
||||
|
||||
Its **direction is inherited** from that relay. So an exit read opens **exactly that relay** —
|
||||
no ambiguity even with multiple exit barriers ("the relay at that reader", decided 2026-06-16).
|
||||
Binding is optional: an unbound device falls back to a `config.direction` + the first relay
|
||||
site-wide of that direction (keeps the single-barrier case trivial). LPR is a snapshot sink —
|
||||
an ANPR service ([[opencv-anpr-service]]) POSTs the plate as a `plate` read to the reader
|
||||
endpoint, flowing through the same dispatcher.
|
||||
|
||||
## Resolution (one module: `apps/server/src/device-resolve.ts`)
|
||||
|
||||
- **Button press** → `relayForButton(controllerId, terminal)` → the entry relay whose `button`
|
||||
matches → entry flow → `pulseOpen(relay)`.
|
||||
- **Reader/permit/LPR read** → `relayForDevice(reader)` → the bound relay → `pulseOpen(relay)`;
|
||||
direction inherited.
|
||||
- **Snapshots** → `devicesByDirection("camera", dir)` → every camera serving that direction.
|
||||
|
||||
A directional barrier that contradicts the car's open-session state (an exit barrier scanned by a
|
||||
car not inside, or an entry barrier by a car already in) is a wrong-barrier / [[anti-passback]]
|
||||
refusal. A `both` relay defers to session state.
|
||||
|
||||
## The flows
|
||||
|
||||
| Flow | Trigger | Opens |
|
||||
| --- | --- | --- |
|
||||
| Transient entry | entry **button** press | the entry relay (button-mapped) → ticket prints |
|
||||
| Transient exit | voucher scan at exit reader | the exit relay (reader-bound), if paid+grace |
|
||||
| Subscriber entry | QR/RFID/plate at entry reader | the entry relay (reader-bound), if permit valid |
|
||||
| Subscriber exit | QR/RFID/plate at exit reader | the exit relay (reader-bound), if permit valid |
|
||||
|
||||
Every open also fires a [[camera snapshot|append-only-event-chain]] (async, never blocks the open).
|
||||
|
||||
## Why no lane
|
||||
|
||||
"Lane" was a leftover from a rows-of-gates mental model. It added nothing here:
|
||||
|
||||
- **Occupancy** is a site-wide fold over the ledger (entries − exits); it never grouped by lane.
|
||||
- **Device grouping** is now done by the reader→relay binding, far more precisely than a lane key.
|
||||
- **Anti-fraud** doesn't use it — the signed chain, the "open must match a signed event" check,
|
||||
and [[reconciliation]] all work on *what happened*, not *which gate*. The relay's direction
|
||||
already catches an exit firing an entry barrier, better than a lane number would.
|
||||
|
||||
Dropping it removed `lane` from `ledger_events`, `device_events`, `sessions`, and the device
|
||||
table (renamed `lane_devices` → `devices`). Because `lane` was part of the **signed canonical
|
||||
form**, this is a versioned change: the canonical array no longer includes lane, and the signer
|
||||
keyId bumped `sw-hmac-v1` → `sw-hmac-v2`. v1 events won't verify under v2 — intentional, gated by
|
||||
each event's stored `keyId` (done pre-deployment, on throwaway data, so zero real cost). See
|
||||
[[append-only-event-chain]].
|
||||
|
||||
## Camera snapshots (evidence, not a gate)
|
||||
|
||||
Captured **after** the barrier opens, **never awaited** — a camera failure can't delay or block an
|
||||
open (the signed ledger is the decision). Stored as a **BLOB in the `snapshots` table** (single
|
||||
backed-up DB, nothing scattered on disk), in its own table so hot telemetry scans don't drag image
|
||||
bytes and images prune independently. Linked to the signed `vehicle_entry/exit` by `identity`.
|
||||
Served read-only via `GET /api/snapshots/:id`. **Retention is unresolved** — see [[open-questions]].
|
||||
|
||||
## Related
|
||||
|
||||
[[entry-exit-readers]] · [[device-events]] · [[parking-session]] · [[anti-passback]] ·
|
||||
[[append-only-event-chain]] · [[barrier-not-a-door]] · [[opencv-anpr-service]] ·
|
||||
[[dingtian-relay]] · [[first-run-setup]]
|
||||
@@ -38,6 +38,10 @@ There are **two populations** of users, and they map to **two integration paths*
|
||||
keeps autonomy + native event log.
|
||||
- **Both models can share one relay** (valid Wiegand read **or** host `open` in "controlled"
|
||||
mode), so one lane serves permit + casual.
|
||||
- **Each reader BINDS to a controller relay** (`config.controllerId` + `relay`) — the barrier it
|
||||
sits at — and inherits that relay's direction (entry/exit/both). An exit read opens exactly that
|
||||
relay; an entry read the entry relay. This is how separate in/out readers are disambiguated, with
|
||||
no "lane". See [[entry-exit-points]].
|
||||
- **Host-in-the-loop is good for fraud detection** — two independent records (host's signed
|
||||
[[append-only-event-chain]] entry + the UHPPOTE remote-open event) should reconcile 1:1; any
|
||||
mismatch is an anomaly.
|
||||
|
||||
@@ -8,8 +8,10 @@ updated: 2026-06-15
|
||||
# First-Run Setup (device selection)
|
||||
|
||||
The admin install flow that makes the system **device-agnostic in practice**: on first run, an
|
||||
admin assigns devices **per lane** by choosing from the [[device-registry]] catalog and entering
|
||||
each device's connection config.
|
||||
admin adds **controllers** (each declaring its relays — entry/exit/both — and the entry-button
|
||||
terminal) and then **readers/cameras/printers** bound to a controller relay, choosing from the
|
||||
[[device-registry]] catalog and entering each device's connection config. There is **no lane** —
|
||||
the pool-of-spaces model; see [[entry-exit-points]].
|
||||
|
||||
> Implementation-derived (from `apps/server` + `apps/web`), not the source doc.
|
||||
|
||||
@@ -26,7 +28,7 @@ each device's connection config.
|
||||
device**: fixes preconditions (e.g. disables `input_link_relay`) and sets up the Digest-
|
||||
authenticated input push ([[device-input-flow]]) — the admin never touches the device's own web
|
||||
UI. **Fails the save** (no DB row) if the device can't be configured, so there are no
|
||||
orphan/half-configured rows. On success persists to `lane_devices`.
|
||||
orphan/half-configured rows. On success persists to `devices`.
|
||||
4. **Remove** — `DELETE /api/setup/assign/:id` (admin-only) drops one instance's row. Only our
|
||||
row is removed; the device itself is not un-hardened/un-configured (a stale push from an
|
||||
unknown device id is already rejected, and re-assigning reconfigures it).
|
||||
@@ -34,25 +36,25 @@ each device's connection config.
|
||||
|
||||
## Config granularity — multi-instance per category
|
||||
|
||||
The data model is **multi-instance**: `lane_devices` holds **one row per instance**, keyed by a
|
||||
generated `id`, with no one-per-(lane, category) constraint. So a lane can have **more than one of
|
||||
every category** — e.g. two printers (an entry dispenser + a booth printer; see
|
||||
[[printer-roles-failover]]), multiple readers, multiple cameras. `assign` always inserts a new row
|
||||
(never an upsert), and `state` returns the full list.
|
||||
The data model is **multi-instance**: `devices` holds **one row per instance**, keyed by a
|
||||
generated `id`. So the site can have **more than one of every category** — multiple controllers,
|
||||
readers, cameras, and printers (e.g. an entry dispenser + a booth printer; see
|
||||
[[printer-roles-failover]]). `assign` always inserts a new row (never an upsert), and `state`
|
||||
returns the full list.
|
||||
|
||||
The `SetupWizard` reflects this: each category shows the **list of assigned instances** for the
|
||||
current lane (with **Remove**) plus an **Add another** form — not a single fixed slot. `select`-type
|
||||
config fields (e.g. a printer's role) render as dropdowns.
|
||||
The `SetupWizard` reflects this: each category shows the **list of assigned instances** (with
|
||||
**Remove**) plus an **Add another** form — not a single fixed slot. `select`-type config fields
|
||||
(e.g. a printer's role) render as dropdowns.
|
||||
|
||||
Organized **per lane** — each lane gets its access controller(s), reader(s), camera(s), and
|
||||
printer(s), each with its own connection settings. Matches the architecture's "mixable per lane"
|
||||
reality (a lane can serve permit holders via [[wiegand]] and casual via host-side reads on one
|
||||
relay — see [[entry-exit-readers]]).
|
||||
There is **no lane**. Direction lives on each access **relay**; readers/cameras **bind** to a
|
||||
controller relay (`config.controllerId` + `relay`) — the barrier they serve — and inherit its
|
||||
direction. The wizard adds controllers first, then binds the other devices to a relay. See
|
||||
[[entry-exit-points]], [[entry-exit-readers]].
|
||||
|
||||
## Security notes
|
||||
|
||||
- The assign/state/delete/complete endpoints require the **admin** role ([[local-jwt-auth]]).
|
||||
- Device **credentials are stored in `lane_devices.config`** — protect at rest
|
||||
- Device **credentials are stored in `devices.config`** — protect at rest
|
||||
([[disk-os-hardening]]); device hosts belong on the isolated VLAN ([[network-isolation]]).
|
||||
- **Secrets are stripped on the way out**: `assign` and `state` both redact `pushPassword`,
|
||||
`webPassword`, and `relayPassword` from the returned config (the UI lists devices; it never
|
||||
|
||||
@@ -28,8 +28,8 @@ adversary is the insider who can edit the database) and the [[append-only-event-
|
||||
- A **session** is a **read-model folded from those events** — open when an entry has no matching
|
||||
exit, paid when a `payment` event references it, closed when an exit lands. It MAY be cached in
|
||||
a table for query speed (dashboards, "cars currently in"), but that cache is **always rebuildable
|
||||
from the chain and never authoritative**. Same pattern as the `LaneMap`
|
||||
([[append-only-event-chain]]), scaled to the business domain.
|
||||
from the chain and never authoritative** ([[append-only-event-chain]]), scaled to the business
|
||||
domain.
|
||||
- **Why this matters:** a mutable `sessions` row that stored "amount owed / paid" would reopen
|
||||
exactly the fraud hole the whole system exists to close (operator marks a session paid, pockets
|
||||
the cash). With sessions as a projection, "paid" is a **signed `payment` event** an operator
|
||||
@@ -123,7 +123,7 @@ follow this page and [[tariff]]; the decision is recorded in [[session-model]].
|
||||
- **The full transient loop now passes end to end** (verified): entry → quote → pay → exit opens,
|
||||
session closed, `verifyChain` ok.
|
||||
|
||||
> **Design gap (flagged):** `lane_devices` has **no entry/exit direction** model. Entry is
|
||||
> button-driven and exit is read-driven, so they don't currently collide — but a lane with both an
|
||||
> entry reader and an exit reader can't yet be distinguished. A lane-direction/role model is needed
|
||||
> before multi-reader lanes (relates to [[open-questions]] #1 topology).
|
||||
> **Resolved (2026-06-16):** the earlier "no entry/exit direction" gap is closed by the
|
||||
> [[entry-exit-points]] model. Direction lives on each access **relay**; readers/cameras bind to a
|
||||
> relay and inherit it. The "lane" concept was dropped entirely (pool-of-spaces) — separate in/out
|
||||
> readers are distinguished by their relay binding, not a lane.
|
||||
|
||||
@@ -13,7 +13,7 @@ still print when the outside dispenser jams or drops off the network.
|
||||
|
||||
## Roles
|
||||
|
||||
Each printer instance (a `lane_devices` row, category `printer`) declares a **role** in its
|
||||
Each printer instance (a `devices` row, category `printer`) declares a **role** in its
|
||||
config:
|
||||
|
||||
- **`entry-dispenser`** — outside, at the lane. Prints the entry ticket the driver takes.
|
||||
|
||||
@@ -45,7 +45,7 @@ interface.
|
||||
|
||||
`PrinterMonitor` (`apps/server/src/printer-monitor.ts`):
|
||||
|
||||
- reloads the monitored set from `lane_devices` each tick (so a newly-assigned printer is picked
|
||||
- reloads the monitored set from `devices` each tick (so a newly-assigned printer is picked
|
||||
up without a restart), keeping only enabled, monitorable printers;
|
||||
- polls every `PRINTER_POLL_MS` (default 5000ms), never overlapping ticks;
|
||||
- caches the latest status per device id;
|
||||
|
||||
Reference in New Issue
Block a user