The reader on hand is a GEE-QR-ER80 QR/DataMatrix/1D barcode access reader
(not an EM4100 prox-card reader as first guessed). Interfaces: Wiegand 26/34,
RS-232, RS-485, USB, TCP/IP; 4-15 VDC; Linux-supported. Variant on hand: -Q-W
(QR scanner, Wiegand/RS-232/485).
This is the QR-ticket scanner the design already needed: a host-side reader
whose scans become read-bus events consumed by the (already-built) exit flow
and QR-permit path. Prefer RS-232/485 over Wiegand (Wiegand can't carry a
variable-length QR string; autonomy is moot with the no-ACL Dingtian).
New source + entity pages; updated ticket-encoding, entry-exit-readers, index.
Open (blocks the adapter): the RS-232/485 frame + baud (ASCII CR/LF expected).
Occupancy is a fold over the signed ledger (entries minus exits per identity);
getOccupancy returns {count, capacity, free, full}. Capacity is a single-row
site_config table (admin-set; null = uncapped; migration 0001, additive).
FULL gate lives in the transient entry flow: when full, refuse (no ticket, no
vehicle_entry, no open) and sign an anomaly. Permit entry is NOT gated --
subscribers are admitted past transient-full (their own maxConcurrent still
applies), so occupancy can read over capacity by design (reserve-for-permits).
Routes: GET /api/occupancy + GET /api/site-config (any role), PUT
/api/site-config (admin; non-negative int or null). Web SiteSettings: live
occupancy + FULL badge (everyone), capacity editor (admin).
Verified: fill to cap -> 3rd transient refused; permit admitted past full; exit
frees a slot; RBAC (operator can't set, -5 -> 400); verifyChain ok. Physical
FULL-sign relay output deferred.
A shift is two signed ledger events, no mutable table: new shift_open event
type + existing shift_z_report. The operator is the logged-in user (carried in
event identity); a shift is open iff their latest shift event is a shift_open.
ShiftService: close sums payment events in [start,end] by tender (cash/card, by
payment time), appends the signed shift_z_report (totals/counts/window), and
prints via a new generic PrinterDevice.printReport(title, lines) (Rongta ESC/POS
text) to a booth-receipt printer. Print is best-effort — a failed print does not
undo the signed close.
Routes (cashier/operator/admin): GET /api/shift/current, POST /api/shift/open
(409 if open), POST /api/shift/close (409 if none). Web ShiftControl in the
shell (non-readonly): Start/End + Z-report totals.
Verified: open -> double-open 409 -> payments (cash+card; one outside the window
excluded) -> close totals correct + signed + printed -> close-again 409 ->
re-open ok; readonly 403; verifyChain ok.
A credential read now routes by what the credential IS: matches a permit
(card/QR credential or a bound plate) -> permit flow; else -> transient exit
flow. Lane resolved once (readerLaneWithAccess); ExitFlow.onRead -> handleAt so
the dispatcher owns lane resolution.
Permit direction is inferred from session state for that car (the read value is
the per-car session key): no open session -> ENTRY (enforce maxConcurrent, sign
vehicle_entry, open); open -> EXIT (sign vehicle_exit, open, close). Fleet
permit = one session per car; anti-passback falls out naturally.
maxConcurrent enforced as a fold over the signed ledger (null = unbound).
Validity window + status + plate-OR-card identity as designed. No ticket/fee;
every use is a signed event carrying permitId. Refusals (revoked / out-of-window
/ at-capacity) are signed anomalies, barrier stays closed.
Verified against stubs: card entry -> inferred exit; fleet cap 2 (F3 rejected
at 2/2, then admitted after F1 exits); plate-bound opens; revoked rejects;
unknown credential falls through to exit reject; verifyChain ok.
validateTariffStructure (shared): non-negative ints, ascending block bounds,
only the last block open-ended — a malformed card can't be published.
Routes: GET /api/tariff (active + history, any signed-in role), POST
/api/tariff/versions (publish an immutable, effective-dated version; admin
only). The single site tariff row is created lazily. Editing = publish a new
version; past sessions keep their pricing.
Web: TariffComposer in the admin shell — edit currency, grace windows,
increment, daily cap, lost-ticket fee, and add/remove rate blocks (major-unit
input -> minor on submit); shows active version + history.
Verified via inject: empty -> active null; invalid blocks -> 400 with problem;
valid -> 201; readonly publish -> 403; after publishing, the pay station quote
returns 404 (no session) instead of 409 (no tariff) -- it now prices against the
active card.
computeFee() in @parking/shared: pure integer fee over a TariffStructure
(stepped blocks, rolling-24h cap). Two edges fixed under test: grace uses RAW
duration (not rounded-up minutes), and the block ladder resets each 24h day.
PayStation + routes (GET /api/pay/quote, POST /api/pay): look up the open
session, resolve the active tariff version (latest effectiveFrom <= entry),
computeFee, append a signed payment event (amount/currency/tender/
tariffVersionId/graceExitMin). overrideMinor handles lost-ticket/dispute. PCI
stays out of the app: tender only records cash/card.
Verified end to end: entry -> quote (300 for 90min) -> pay -> exit opens and
closes the session, verifyChain ok.
A credential read at an exit lane validates the session, then opens. Adds a
'read' channel to the device bus (DeviceReadEvent: ticket/plate/qr/card);
entry stays button-driven so reads are exit/identity events.
Flow: read -> fold the SIGNED ledger for that identity -> validate open + PAID
+ within gracePeriodExitMin -> signed vehicle_exit -> pulseOpen -> close the
session cache. Unpaid / grace-expired / unknown -> signed anomaly, barrier
stays closed (a deliberate business reject, not a fail-state; 'exit fails open'
is about host/power loss). Validation reads the ledger (authoritative), not the
cache.
No payment events exist until the pay station is built, so every transient exit
currently rejects -- the correct end-state, not yet passable. Verified against
stubs: unpaid->anomaly+no-open; paid+grace->exit+open+closed; expired->anomaly;
unknown->anomaly; verifyChain ok across entry->pay->exit.
Flagged: lane_devices has no entry/exit direction model (exit door hardcoded to
1); needs a lane-direction/role model before multi-reader lanes.
Capture that refusing at capacity is the default, not absolute: an operator
may opt into valet over-capacity (customer hands over keys, operator stacks
the car into custody). Manned-only, new custody/session shape. Deferred;
not built into the entry flow. Made capacity-occupancy's FULL gate a soft
policy knob.
Correction before schema work: the events table conflated the anti-fraud
business ledger with device telemetry. Decision: ledger_events (signed,
chained, reconciled) holds only business facts; device_events (unsigned,
prunable) holds relay/printer/camera/reader/input telemetry. A raw button
press is telemetry; the entry flow mints a signed vehicle_entry. Drops
input_received-as-signed-event.
New: decisions/event-streams-split, concepts/device-events; updated
append-only-event-chain, index, log.
Mirrored mode re-clones the Windows NIC's addresses each boot, so the kernel
keeps picking the wrong source for stacked device subnets (10.0.10.x sourced
from 192.168.1.123) — ARP resolves but ping/TCP dies, and every runtime
ip-route fix is wiped by wsl --shutdown.
deploy/wsl-fix-route-source.sh pins each scope-link route's src to this
host's own address in that subnet (no hardcoded IPs, idempotent, preserves
metric, non-fatal per route, waits for the route at boot). deploy/parking-net
.service reapplies it on every boot.
Dev-box only; the appliance is bare-metal Linux with static networkd config.
Verified: camera pings with no -I flag; driver pulls a snapshot with no
localAddress set.
Replace the hardcoded lane: 0 on input_received events with a real
device->lane lookup. A new LaneMap caches lane_devices.id -> lane,
built at startup and refreshed by the setup routes on assign/unassign.
An unmapped device logs lane: -1 + a warning (0 is a real lane) and is
still recorded faithfully (append-only chain).
source stays null for raw inputs by design: it's an IdentitySource
(how a vehicle was identified), not a device field; device provenance
remains in identity. Documented both in the wiki.
The string protocol (UDP 60001) has no password field but can fire relays
("11" = relay 1 on), bypassing relay_pw entirely. Proven on hardware: an
unauthenticated packet opened a relay. harden() had left it enabled "for
status reads".
- #status() now reads via the authenticated binary command (relay cmd 0x00)
instead of the string protocol, so the string protocol is no longer needed.
- harden() disables the string protocol (udp2.p=255). BEST-EFFORT: firmware
V3.6J's config API silently refuses to disable udp2 (the device web UI can),
so it's not part of the blocking verify -- harden() re-checks and returns a
warning instead of throwing. After a web-UI disable, the attack is dead and
binary control/status still work (verified on hardware).
- HardenResult gains an optional `warnings[]`; the assign route surfaces them
to the admin and logs them.
- Corrected the false comment claiming relay_pw stops an attacker (it is
defence-in-depth on plaintext UDP, not a boundary).
- Thread localAddress through the driver's UDP/HTTP calls so a multi-homed
host sources device traffic from the device-facing NIC.
- Device web login (webUser/webPassword) is no longer redacted from setup
state -- it's an operational credential for the admin-only device area;
pushPassword/relayPassword stay machine-only.
Wiki: document the vuln + fix, the firmware caveat, and the out-of-band
actuation gap (the log captures host actions only; reconciliation vs. an
independent witness is the real control and is not yet built).
The data model was already multi-instance (lane_devices = one row per
instance; assign always inserts) -- the limitation was UI-only. Make the
whole flow support more than one of every category:
- Backend: add DELETE /api/setup/assign/:id (unassign by id). /state now
redacts secrets (pushPassword/webPassword/relayPassword) via a shared
redactSecrets() also used by /assign -- it was returning raw config rows.
- Web: SetupWizard reworked from one fixed slot per category into a list of
assigned instances (driver/role/host + Remove) plus an "Add another" form.
select-type config fields (e.g. printer role) now render as dropdowns.
- api.ts: add fetchState(), unassignDevice(), Assignment/SetupState types.
Verified via Fastify inject: two printers assigned to one lane both list,
no secret leak, delete -> 204, delete unknown -> 404, count drops to 1.
Full repo typechecks.
Wiki: first-run-setup documents multi-instance + delete + redaction.
Add the rongta PrinterDevice driver (ESC/POS over raw TCP 9100) and the
device-agnostic pieces around it:
- Roles + failover: each printer declares a role (entry-dispenser/booth-
receipt) and failoverRank; printer-routing.ts picks the best healthy printer
and falls back outside->booth for entry tickets (never the reverse).
- Live status: MonitorableDevice.readStatus()/PrinterStatus capability. The
Rongta driver scrapes the device's own /prn_stat.htm (Cover/Cutter/Paper
End/Near End/Off-Line) rather than hand-decoding DLE EOT, whose reply bytes
on this clone don't match the canonical ESC/POS bit layout (verified on
hardware) -- avoids a false-healthy. Maps to ready/degraded/offline, fail
safe on an unreachable or unexpected page.
- Server PrinterMonitor polls enabled printers (PRINTER_POLL_MS, default 5s),
caches latest, emits "printer-status" on change. Exposed via
GET /api/printers/status and an SSE stream for the booth UI.
Verified against 10.0.10.6: ready when healthy, offline when unreachable
(no throw), bus emits on change and suppresses unchanged reads.
Wiki: new rongta-printer entity, printer-roles-failover and
printer-status-monitoring concepts; BOM/index/log updated.
Lock down the relay device for the flat (no-VLAN) network.
Relay control:
- pulseOpen/setRelay now use the Dingtian BINARY protocol (:60000) with a
relay password — the only relay option with auth (string :60001 has none, and
is kept only for the read-only status query). Frame verified on hardware.
HardenableDevice capability (driver harden()):
- set a random relay_pw (1-9999); disable unused channels (rs485/can/tcp x2/mqtt
-> p:255), keeping UDP1 binary (control) + UDP2 string (status).
- write-verified (device reboots on apply).
Assign/Save flow now does: fix preconditions -> harden -> set up input push;
the relay password is stored in lane_devices so the runtime device can command
the relay.
DELIBERATELY NOT touching the device's HTTP CGI session check (session_en):
enabling it on this firmware breaks the config-READ API (ECONNRESET) and locked
the backend out — required a factory reset to recover. The open CGI API is
accepted as flat-network reality; the signed event log is the real guarantee.
Verified end to end on hardware: assign hardens + configures the device, config
API stays reachable, pulseOpen with the stored password fires the relay, without
it is rejected. wiki: device-input-flow + dingtian-relay updated.
Two-step device setup so the admin verifies before committing — and never touches
the device's own web UI.
- POST /api/setup/test (admin-only): healthCheck + checkPreconditions, no save and
no device change. Returns device health + precondition issues.
- assign (Save) now also runs fixPreconditions (e.g. disables input_link_relay so
a button press doesn't auto-fire its relay) before configuring the input push.
Closes a gap where an assigned device could still auto-open. Fails the save with
no DB row if device configuration fails (no orphan/half-configured rows).
- SetupWizard: wires config fields -> Test connection (health badge + precondition
warnings) -> Save & configure; editing config resets prior test/save status.
Verified in-browser against the real device: Test -> ● ready + preconditions OK;
Save -> row persisted AND the device's Input Link URL written (push path matches
the saved device id). wiki/first-run-setup updated.
Secure the device→backend input push, and configure it automatically when the
admin assigns the device (no manual URL/secret entry).
Auth — HTTP Digest (chosen by hardware testing: the device can't push to a
self-signed HTTPS backend, but does Digest correctly; a URL token is sniffable/
logged):
- digest-auth.ts: MD5 qop=auth challenge/verify, single-use nonces (replay
resistance). Password never crosses the wire.
- push route: Digest + source-IP allowlist; per-device pushUser/pushPassword from
lane_devices. Still not behind the SPA cookie/CSRF auth (machine call). The
signed event log remains the real anti-fraud guarantee.
Auto-config on assign:
- setup assign: for push-capable devices, generate Digest creds, call
configureInputPush to write them + the push URLs to the device, store the creds
(password not echoed back). net.ts derives the backend IP on the device's
subnet (BACKEND_HOST_IP override).
- driver configureInputPush sets auth=2 + creds; PushConfig carries the creds.
- removed the earlier URL-token approach.
Two hard-won device-write bugs fixed in the driver:
- configApi now sets an explicit Content-Length — the device silently ignores
chunked request bodies (Node's default without Content-Length), so every config
write looked successful ({"status":0}) but did nothing. This was the root cause
of the session's "writes don't apply" mystery.
- #writeConfig polls until the change is verified, retrying (the device reboots on
apply; back-to-back writes were lost). The `pass` field caps at 31 chars, so the
generated password is 24 hex chars.
Verified on hardware: assign auto-configures the device; all 4 inputs then push
with Digest auth, zero failures. wiki/device-input-flow updated.
The device pushes button events to the backend via its Input Link URL feature;
the backend decides. No polling — the chosen entry architecture.
packages/devices:
- dingtian driver: configureInputPush() writes the device's input_link_url
config (per-input server/port/path, en=1, active-LOW, plain HTTP) so each
input HTTP-GETs the backend on press/release. Extracted #readConfig/#writeConfig
(with the required command:setconfig injection + post-write reset tolerance).
apps/server:
- routes/devices.ts: public GET/POST
/api/devices/dingtian/:deviceId/input/:n/{on,off} — translates a device push
into an internal device event. Not behind cookie/CSRF (machine call from the
device); trust comes from the signed event log, not this request.
- device-events.ts: internal EventEmitter bus so the entry flow subscribes to
input events without coupling to HTTP. Wired into the server.
Verified on hardware: configured the device, then real presses on all 4 inputs
pushed to the backend (input N on+off, source = device IP). No polling.
wiki: device-input-flow concept (path + trust model for the flat/no-VLAN
network); dingtian-relay updated; index + log.
Neither UHPPOTE nor ZKTeco is used — the Dingtian relay controller was chosen
and verified. Remove their code and re-scope the wiki.
Code:
- delete access-uhppote.ts, uhppoted.d.ts, access.ts (zkteco/esp32-relay stubs),
and the three uhppote-*.mjs hardware test scripts.
- remove the `uhppoted` npm dependency from @parking/devices and @parking/server.
- unregister uhppote/zkteco/esp32-relay from the driver registry; drop their
exports. Catalog access drivers = dingtian only. Build green (5/5).
- refresh now-stale example comments (registry/interfaces/setup/api) to use
current examples; keep the two "UHPPOTE blocker" references that explain why
the precondition capability exists.
Wiki (kept pages, re-scoped):
- uhppote-controller, zkteco-controller -> rejected/historical with callouts;
uhppote-vs-esp32 -> historical (detection-vs-prevention lens still useful).
- re-point all "current device" framing (standing-decisions, bom, overview,
open-questions, device-registry, device-discovery, index) to dingtian-relay.
- transferable concepts (network-isolation, event-log-ingestion, barrier-not-a-
door, threat-model) untouched. Raw source immutable. Links lint clean.
- dingtian-relay: relay+input controller (4ch on hand). Inputs are decoupled
from relays (configurable via input_link_relay) — solves the
access-controller-button-flow blocker the UHPPOTE couldn't. Full protocol from
the SDK (UDP string control :60001, `00` status parse, input_link_url push,
multicast discovery). Driver + hardware test still to build.
- dingtian-vs-mqtt: use direct HTTP/UDP now; MQTT skipped (broker = extra infra
+ failure mode + overkill at one-host/few-devices scale) but kept for later
multi-lane scale.
- autonomous-direction: record the roadmap to fully unmanned (no booth) and how
it reshapes the threat model (operator-fraud -> unattended-machine threats),
makes host-in-the-loop entry mandatory, and raises fail-state stakes.
- threat-model: note the unmanned shift. index + log.
gitignore the vendor SDK (dingtian/, 71MB of binaries/examples) — reference
only, protocol captured in the wiki.
Capture hard-won dev knowledge that was only in commit messages:
- wsl-dev-networking: WSL2 NAT blocks UDP broadcast (device discovery can't
reach the LAN); fix is mirrored networking (.wslconfig, Win11 22H2+), plus the
gotchas that remained after — multiple interfaces, subnet-directed broadcast,
localhost->IPv6 stall. Alternatives for non-mirrored setups.
- local-dev-workflow: first-time setup, pnpm dev, and the gotchas (the
strip-types dev-server hang -> tsx, the 127.0.0.1 proxy fix, .env loading,
seeding into the right DB).
- device-discovery: corrected the old "broadcast permission (EACCES)" note — the
real cause was the lib not enabling SO_BROADCAST for global 255.255.255.255;
documented the three verified broadcast gotchas + I/O serialization.
- schema: add a `reference` page type; new "Dev environment" index section; log.
Links lint clean; both new pages well-connected.
UHPPOTE controllers self-announce via UDP broadcast, but the frontend had no way
to find them — the admin had to type the serial blind. Add a generic discovery
capability and surface it in the setup wizard.
packages/devices:
- DiscoverableDriver capability + DiscoveredDevice type + isDiscoverable() guard
on the registry (optional, so any driver can opt in).
- uhppote driver implements discover() via uhppoted getDevices (UDP broadcast),
mapping each controller's serial/IP/firmware into a DiscoveredDevice; extract
shared buildCtx().
apps/server:
- GET /api/setup/discover/:driverId (admin-only): runs discover() and
health-checks each found device so reachability shows before assigning.
- catalog now returns a `discoverable` driver-id list.
apps/web:
- SetupWizard "Scan for controllers" button for discoverable drivers; lists found
devices with health badges; selecting one auto-fills serial + host. api client
gains discoverDevices().
wiki: new device-discovery concept; cross-link from registry/setup/uhppote;
note the broadcast-permission (EACCES) deployment caveat; index + log.
Verified: catalog flags uhppote discoverable; discover runs and fails gracefully
without hardware; non-discoverable driver -> 400; missing token -> 401.
Make the device-adapter pattern selectable so the admin chooses hardware at
install — per lane, from a catalog of supported drivers. Adding a device =
registering one more driver; no business-logic change.
packages/devices:
- interfaces.ts: AccessControlDevice / ReaderDevice / CameraDevice / PrinterDevice
(adds CameraDevice for entry/exit snapshot-on-event; access relay stays
intent-only per "a barrier is not a door").
- registry.ts: driver catalog with per-driver config fields + factory, config
validation, and a catalog payload for the setup UI.
- drivers/: stub adapters — access (zkteco, esp32-relay), reader (wiegand,
tcp-ip), camera (hikvision, dahua). Real vendor protocols TBD.
packages/db:
- lane_devices + setup_state tables (migration 0001); re-export query helpers.
apps/server:
- routes/setup.ts: GET /api/setup/catalog (public schema), and admin-only
/assign, /state, /complete with registry validation before persisting.
- extract auth.ts (requireJwtSecret, requireRole, JWT type aug).
apps/web:
- SetupWizard scaffold + api client: pick a driver per category for a lane,
render its config fields.
wiki: device-registry + first-run-setup concept pages; cross-link from
device-adapter-pattern; index + log updated.
Verified: full turbo build (5/5); catalog lists all drivers; admin assign
persists; missing-config and no-token requests are rejected.
Turborepo (pnpm workspaces) with all dependencies pinned to latest
mutually-compatible versions: turbo 2.9, TypeScript 6, Fastify 5,
React 19, Vite 8, better-sqlite3 12 + Drizzle ORM 0.45.
Layout:
- apps/server Fastify backend (local JWT auth + role guard, /health)
- apps/web React 19 + Vite 8 operator SPA
- packages/db Drizzle schema on SQLite/WAL; append-only events + users
- packages/devices reader/printer/relay adapter interfaces (intent-only relay)
- packages/shared shared domain types
Architecture constraints from the design wiki are encoded in the scaffold:
append-only hash-chained + signed event log, device-agnostic adapters,
"a barrier is not a door" (relay expresses intent only), fully-local
offline-first auth.
wiki/ is an LLM-maintained Obsidian knowledge base (28 pages) ingested
from the architecture & design notes, with its own maintenance schema.
Verified: pnpm install, full turbo build (5/5), server boots and serves
/health, drizzle-kit generates the initial migration.