ff3b011fe0
The reader sends Connection: keep-alive but only acts on the verdict (beep,
output) once the TCP socket closes. Fastify's default kept the connection alive,
so the reader waited out a ~10s keep-alive timeout before beeping — even though
the server replied in ~15ms. Every vendor demo replies Connection: close and
shuts the socket. Set reply.header('connection','close') on the QR endpoint.
Verified the header is now sent; symptom was correct accept/reject with a ~10s
lag before the beep.
703 lines
53 KiB
Markdown
703 lines
53 KiB
Markdown
# Wiki Log
|
||
|
||
Append-only chronological record. Each entry: `## [YYYY-MM-DD] <op> | <subject>`.
|
||
Query with `grep "^## \[" log.md | tail -5`.
|
||
|
||
## [2026-06-14] ingest | Parking System — Architecture & Design Notes
|
||
First source ingested. Bootstrapped wiki scaffolding (CLAUDE.md schema, index.md,
|
||
overview.md, log.md). Created source summary, 14 entity pages, 9 concept pages, and
|
||
decision records (settled decisions + 6 open questions). Source is a dense design
|
||
doc covering stack, threat model, device architecture, UHPPOTE access control, the
|
||
custom ESP32 controller alternative, readers, and a reference BOM.
|
||
|
||
## [2026-06-15] decision | JWT key choice + ESP32 deferred
|
||
From app work, not a new source. Added [[open-questions]] #7 (symmetric vs.
|
||
asymmetric JWT signing key — raised by the commit security review; prefer RS256/EdDSA
|
||
so verifying hosts hold only a public key, mirroring the ATECC608 / challenge-response
|
||
property). Marked [[esp32-custom-controller]] `status: deferred` per decision not to
|
||
implement device-level auth for now (access control stays on UHPPOTE + network
|
||
isolation); noted in [[open-questions]] #6. Updated [[local-jwt-auth]] (hardened secret
|
||
handling + 8h expiry, asymmetric-key pointer) and the index.
|
||
|
||
## [2026-06-15] decision | Device-agnostic registry + first-run setup
|
||
From app work. Made the [[device-adapter-pattern]] selectable: added a
|
||
[[device-registry]] (catalog of drivers per category) and a [[first-run-setup]]
|
||
flow so the admin picks a device per lane at install. Categories: access
|
||
(ZKTeco / ESP32 relay), reader (Wiegand / TCP-IP), camera (Hikvision / Dahua,
|
||
snapshot-on-event), printer. Added a `CameraDevice` interface; new `lane_devices`
|
||
+ `setup_state` tables (migration 0001); admin-only setup endpoints. Stub drivers
|
||
for now (no real vendor protocols yet). Verified catalog + assign + validation +
|
||
auth end to end.
|
||
|
||
## [2026-06-15] decision | UHPPOTE library chosen + real driver
|
||
Researched Node options for the UHPPOTE controller. Chose the official
|
||
**`uhppoted`** npm package (MIT, actively maintained, full API incl. openDoor,
|
||
get-event(s)/event-index, set-listener, restore-default — covers the whole
|
||
[[event-log-ingestion]] design). Rejected: raw-dgram DIY (reinvents the lib),
|
||
node-red-contrib-uhppoted (wrong model), Go REST sidecar (extra runtime). Added
|
||
it to @parking/devices and implemented a real `uhppote` [[uhppote-controller]]
|
||
access driver (pulseOpen→openDoor, healthCheck→getStatus), registered in the
|
||
catalog. CJS interop: default-import + destructure. Verified it builds, appears
|
||
in the catalog, and degrades to "offline" gracefully without hardware. Real
|
||
on-VLAN test still pending.
|
||
|
||
## [2026-06-15] feature | Device discovery (UHPPOTE scan in setup)
|
||
The frontend had no way to find a UHPPOTE — but the controllers self-announce via
|
||
UDP broadcast. Added a generic [[device-discovery]] capability: optional
|
||
`DiscoverableDriver.discover()` on the registry, implemented by the `uhppote`
|
||
driver via `getDevices`. New admin-only `GET /api/setup/discover/:driverId`
|
||
(health-checks each found device); catalog now returns a `discoverable` list.
|
||
SetupWizard gains a "Scan for controllers" button that lists found devices with
|
||
health badges and auto-fills serial + host on selection. Verified: catalog flags
|
||
uhppote; discover runs and fails gracefully without hardware (broadcast EACCES);
|
||
non-discoverable driver → 400; no token → 401. Modeled generically so cameras
|
||
(ONVIF) can add discovery later.
|
||
|
||
## [2026-06-15] test+blocker | UHPPOTE hardware bring-up + entry-flow blocker
|
||
Brought up the real UHPPOTE (serial 225088491, fw 09120) end to end. Fixed the
|
||
networking path: WSL2 mirrored mode, then driver bugs — subnet-directed broadcast
|
||
(the lib doesn't enable SO_BROADCAST for global 255.255.255.255), broadcast must
|
||
match the target's subnet for unicast reply routing (health-check timeout fix),
|
||
multi-subnet discovery, and serialized I/O (concurrent calls collided on :60001).
|
||
Added .env loading (Node --env-file), env-gated+fail-closed SETUP_AUTH_BYPASS, and
|
||
an authBypass flag so the wizard drops the token field. Test scripts in
|
||
apps/server/scripts/ (uhppote-listen, uhppote-relay).
|
||
|
||
VERIFIED on hardware: discovery; host-commanded openDoor doors 1&2 (physical +
|
||
reason="remote open door"); button presses live (reason="push button ok").
|
||
|
||
BLOCKER FOUND: the controller push-button input auto-opens the relay in firmware —
|
||
no command to report-without-opening — so ticket-first entry (button→print→open)
|
||
is impossible as wired. UHPPOTE can't do it on that input; ZKTeco *might* via a
|
||
programmable aux input + PULL SDK but that's unverified and needs a new driver.
|
||
Recorded in [[access-controller-button-flow]] + [[zkteco-controller]]. Entry-lane
|
||
hardware decision paused to focus on the business side.
|
||
|
||
## [2026-06-15] feature | Cookie-based auth/authz (login, CSRF)
|
||
Built real authentication: bcrypt login → JWT in an HttpOnly+SameSite=Strict
|
||
cookie, readable CSRF cookie + X-CSRF-Token header (double-submit) on mutations,
|
||
role-guarded routes. Routes: /api/auth/{login,logout,me}. First admin seeded via
|
||
`pnpm --filter @parking/server seed-admin`. Removed the SETUP_AUTH_BYPASS shim
|
||
and the wizard token field; the SPA gates on /api/auth/me and only shows setup to
|
||
admins. Same-origin via the Vite dev proxy and a new prod nginx config
|
||
(deploy/nginx.conf). Verified end to end (curl + browser): wrong pass→401,
|
||
login→cookies set, me→admin, assign without CSRF→403 / with→201, no cookie→401,
|
||
session persists across reload. Updated [[local-jwt-auth]].
|
||
|
||
## [2026-06-15] lint+docs | Dev-environment pages (WSL networking, workflow)
|
||
Captured hard-won dev knowledge that was only in commit messages: new
|
||
[[wsl-dev-networking]] (WSL2 NAT blocks UDP broadcast → mirrored mode + the
|
||
multi-interface / subnet-broadcast / IPv6-localhost gotchas that remained) and
|
||
[[local-dev-workflow]] (setup, seed:admin, the dev-server-hang from the broken
|
||
strip-types script → tsx, the 127.0.0.1 proxy fix, .env loading). Corrected the
|
||
earlier "broadcast permission (EACCES)" note in [[device-discovery]] — the real
|
||
cause was the lib not enabling SO_BROADCAST for the global 255.255.255.255;
|
||
documented the three verified broadcast gotchas + serialization. Added a `reference`
|
||
page type to the schema; new "Dev environment" index section.
|
||
|
||
## [2026-06-15] decision | Dingtian relay chosen; HTTP over MQTT; unmanned direction
|
||
New relay+input controller on hand (Dingtian 4ch). Its inputs are decoupled from
|
||
relays (configurable via input_link_relay) — solves the [[access-controller-button-flow]]
|
||
blocker the UHPPOTE couldn't. Transport decision [[dingtian-vs-mqtt]]: direct
|
||
HTTP/UDP now (UDP string for relay control on :60001; device input_link_url HTTP
|
||
push for button events), MQTT skipped (broker = infra + failure mode + overkill at
|
||
this scale) but kept for later multi-lane scale. Recorded the stated roadmap to
|
||
**fully unmanned, no-booth** operation in [[autonomous-direction]] and its threat-model
|
||
shift (operator-fraud → unattended-machine threats). New stub [[dingtian-relay]]
|
||
with the full protocol from the SDK. Driver + on-hardware test still to build.
|
||
|
||
## [2026-06-15] driver+test | Dingtian driver built; button blocker RESOLVED
|
||
Built the `dingtian` access driver (AccessControlDevice relay control + InputDevice
|
||
poll-based button events + new PreconditionDevice capability). Verified end to end on
|
||
real hardware (DT-R004 @ 10.0.10.172, HTTP config on :8080, UDP control :60001):
|
||
status read, relay pulse, input press/release. Disabled `input_link_relay` via the
|
||
driver's fixPreconditions (GET config → flag 0 + clear maps → POST config_set), then
|
||
confirmed: pressing inputs now fires NO relay (0000 status) — host-in-the-loop entry
|
||
works. The [[access-controller-button-flow]] blocker is RESOLVED. Gotcha recorded in
|
||
[[dingtian-relay]]: config_set requires injecting "command":"setconfig" after "status"
|
||
(GET omits it) or the write silently no-ops. Added httpPort config field (port 8080 ≠
|
||
default 80). Test script apps/server/scripts/dingtian-test.mjs. Next: input HTTP-push
|
||
endpoint + wiring input→ticket→pulseOpen.
|
||
|
||
## [2026-06-15] cleanup | Remove UHPPOTE/ZKTeco code; wiki → rejected/historical
|
||
Neither UHPPOTE nor ZKTeco is used (Dingtian chosen). Removed their code:
|
||
deleted access-uhppote.ts, uhppoted.d.ts, access.ts (zkteco/esp32 stubs), the
|
||
three uhppote-*.mjs scripts; dropped the `uhppoted` npm dep from both packages;
|
||
unregistered uhppote/zkteco/esp32-relay from the driver registry; updated example
|
||
comments. Catalog access drivers now = dingtian only. Build green.
|
||
Wiki: kept the pages but marked [[uhppote-controller]] + [[zkteco-controller]]
|
||
rejected/historical, [[uhppote-vs-esp32]] historical; re-pointed all "current
|
||
device" framing (standing-decisions, bom, overview, open-questions) to
|
||
[[dingtian-relay]]; noted no current driver uses [[device-discovery]]. Transferable
|
||
concepts (network-isolation, event-log-ingestion, barrier-not-a-door, threat-model)
|
||
kept as-is. Links lint clean; raw source untouched (immutable).
|
||
|
||
## [2026-06-15] feature | Dingtian input HTTP-push → backend (no polling)
|
||
Wired the device's "Input Link URL" feature so it HTTP-pushes button events to
|
||
our backend — no polling. Driver `configureInputPush()` writes input_link_url
|
||
(per-input server/port/path, en=1, active-LOW, plain HTTP) via the config API
|
||
(reusing the #writeConfig + command:setconfig helper). New backend route
|
||
`routes/devices.ts`: public `GET/POST /api/devices/dingtian/:deviceId/input/:n/{on,off}`
|
||
→ emits onto an internal device-events bus (device-events.ts, EventEmitter) for
|
||
the entry flow to consume. VERIFIED on hardware: configured device, real presses
|
||
on all 4 inputs pushed to the backend (input N on+off, source = device IP). Trust
|
||
model recorded in [[device-input-flow]]: flat network / no VLAN → backend is source
|
||
of truth, every open is a signed event (out-of-band open = anomaly); push endpoint
|
||
not behind cookie auth (machine call), shared-secret available as defence-in-depth.
|
||
Next: wire signed event + ticket print + pulseOpen.
|
||
|
||
## [2026-06-15] feature | Dingtian push auth via HTTP Digest (hardware-tested)
|
||
Secured the device→backend input push. Empirically tested auth options on the
|
||
device: HTTPS-to-self-signed FAILS, Basic works, **Digest works** → chose Digest
|
||
(MD5, qop=auth): password never on the wire, single-use nonces. Backend
|
||
digest-auth.ts (challenge/verify) + source-IP allowlist on the push route;
|
||
per-device pushUser/pushPassword generated on assign, written to the device and
|
||
stored in lane_devices (admin never types a URL/secret). Driver
|
||
configureInputPush now sets auth=2 + creds; the assign flow auto-configures the
|
||
device and persists the creds (net.ts derives the backend IP on the device's
|
||
subnet). Removed the earlier URL-token approach (token in URL is sniffable/logged).
|
||
TWO HARD-WON DEVICE BUGS fixed: (1) config_set requires an explicit Content-Length
|
||
— the device silently ignores chunked bodies (Node's default), which masqueraded
|
||
as "writes don't apply" all session; (2) the `pass` field caps at 31 chars →
|
||
use a 24-char password. Driver #writeConfig now polls-until-verified (device
|
||
reboots on apply). VERIFIED on hardware: assign auto-configures the device, then
|
||
all 4 inputs push with Digest auth, zero failures. Recorded in [[device-input-flow]].
|
||
|
||
## [2026-06-15] feature | Setup wizard: Test connection + Save & configure
|
||
Two-step device setup UX. New admin-only POST /api/setup/test (healthCheck +
|
||
checkPreconditions, no save / no device change). The assign (Save) step now also
|
||
fixes preconditions (disables input_link_relay) before configuring push — closing
|
||
a gap where assigned devices could still auto-fire relays; fails the save with no
|
||
DB row if device config fails (no orphan rows). SetupWizard wires the config
|
||
fields → Test button (health badge + precondition warnings) → Save & configure
|
||
button. Verified in-browser against the real device: Test shows ● ready +
|
||
preconditions OK; Save persists the row AND writes the device's Input Link URL
|
||
(push path matches the saved device id). Admin never logs into the device web UI.
|
||
Updated [[first-run-setup]].
|
||
|
||
## [2026-06-15] feature | Device hardening: binary relay + relay_pw + disable channels
|
||
Hardened the Dingtian relay control for the flat (no-VLAN) network. Switched
|
||
pulseOpen from the unauthenticated string protocol (:60001) to the **binary
|
||
protocol (:60000) with a relay password** — the only authenticated relay option
|
||
(frame verified on hardware: FF AA <sess> 03 <pwLE> <relayByte> <jogLE>). New
|
||
HardenableDevice capability: harden() sets a random relay_pw + disables unused
|
||
channels (rs485/can/tcp×2/mqtt → p:255, keep UDP binary+string). Folded into the
|
||
assign/Save flow (preconditions → harden → push); relayPassword stored in
|
||
lane_devices. Verified end to end: assign configures + hardens the device, config
|
||
API stays reachable, pulseOpen with the stored password fires the relay, without
|
||
it is rejected.
|
||
|
||
⚠️ LESSON: enabling the device's HTTP CGI session check (session_en) on this
|
||
firmware breaks the config-READ API (ECONNRESET) — locked us out, needed a FACTORY
|
||
RESET to recover. harden() deliberately does NOT touch session_en. The open CGI
|
||
API is accepted as flat-network reality; the signed log is the real guarantee.
|
||
Recorded in [[device-input-flow]] + [[dingtian-relay]].
|
||
|
||
## [2026-06-14] query | Dingtian web-login rotation + CGI API is unauthenticated
|
||
While addressing "change the device's default admin/admin", traced the device web
|
||
UI JS (system.js) → the change-login endpoint is
|
||
`GET /userset.cgi?<old_u>&<old_p>&<new_u>&<new_p>&` (response `&0&/&` = success,
|
||
`&2&/&` = wrong old pw). Added a best-effort `setWebLogin`/`#rotateWebLogin` step
|
||
to `harden()` (new pw stored back as config `webPassword`, stripped from API
|
||
responses). KEY FINDING: the device CGI API needs NO authentication — config dump,
|
||
config write, relay fire, and userset.cgi itself all return 200 unauthenticated
|
||
(verified on 10.0.10.5). admin/admin gates only the browser UI; there is no
|
||
inbound-auth setting (only session_en, which bricks the read API). So rotating the
|
||
login is COSMETIC, not a boundary — the signed event log remains the real
|
||
guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
|
||
|
||
## [2026-06-14] ingest | Rongta 80mm printer driver + printer roles/failover
|
||
- Added `rongta` PrinterDevice driver (ESC/POS over raw TCP 9100); registered in registry.
|
||
- Decision: ≥2 printers per lane by role (entry-dispenser outside, booth-receipt inside);
|
||
entry ticket fails over outside→booth (asymmetric — receipts never print outside).
|
||
- Selection logic lives in packages/devices/printer-routing.ts (orderForRole, printWithFailover).
|
||
- One unit verified reachable at 10.0.10.6:9100 from host (TCP connect OK).
|
||
- New pages: [[rongta-printer]], [[printer-roles-failover]]. Updated [[bom]], [[index]].
|
||
- Open: all-printers-down policy belongs to the (not-yet-built) entry flow, not the printer layer.
|
||
|
||
## [2026-06-14] ingest | Live printer status monitoring
|
||
- Added MonitorableDevice.readStatus()/PrinterStatus capability in packages/devices.
|
||
- Rongta readStatus() scrapes the device's own /prn_stat.htm (Cover/Cutter/Paper End/Near End/
|
||
Off-Line) — chosen over hand-decoding DLE EOT because this clone's DLE EOT bytes don't match
|
||
the canonical ESC/POS bit layout (verified on hardware; risk of false-healthy).
|
||
- Server PrinterMonitor: polls enabled monitorable printers (PRINTER_POLL_MS, default 5s),
|
||
caches latest, emits "printer-status" on change. API: GET /api/printers/status + SSE stream.
|
||
- Verified live: 10.0.10.6 -> ready (all flags clear); unreachable host -> offline (no throw);
|
||
bus emits on change, suppresses unchanged. Full repo typechecks (8/8).
|
||
- New page: [[printer-status-monitoring]]. Updated [[rongta-printer]], [[index]].
|
||
- Open: capture the page's actual text for an ACTIVE fault (pull paper / open cover) to confirm
|
||
the Yes flip; wire degraded/offline into failover + entry-flow all-down policy.
|
||
|
||
## [2026-06-15] ingest | Multi-instance device setup (add/remove per category)
|
||
- Confirmed the data model was already multi-instance (lane_devices = one row per instance,
|
||
assign always inserts); the limitation was UI-only (one slot per category).
|
||
- Backend: added DELETE /api/setup/assign/:id (unassign); /state now redacts secrets
|
||
(pushPassword/webPassword/relayPassword) via a shared redactSecrets() also used by /assign.
|
||
- Web: SetupWizard reworked — each category lists assigned instances (with Remove) + "Add
|
||
another" form; select-type config fields now render as dropdowns (fixes printer role input).
|
||
- Verified via Fastify inject: 2 printers assigned to one lane -> both listed, no secret leak,
|
||
delete -> 204, delete unknown -> 404, count drops to 1. Full repo typechecks (8/8).
|
||
- Updated [[first-run-setup]].
|
||
|
||
## [2026-06-15] ingest | Append-only signed event log (Dingtian input pushes persist)
|
||
- Q: does the Dingtian push events? -> inputs YES (input_link_url), relay opens NO (device keeps
|
||
no log). Host is the source of truth; a relay open w/o matching signed event is the anomaly.
|
||
- Implemented EventLog (apps/server/event-log.ts): serialized append, monotonic index, prevHash
|
||
chain, signature; verifyChain() detects tamper/reorder/delete. Read: GET /api/events;
|
||
integrity: GET /api/events/verify (admin).
|
||
- Signer abstraction (packages/shared) over the ATECC608; SoftwareSigner (HMAC, EVENT_SIGNING_KEY)
|
||
shipped now since chip wiring is open-question #6. Caveat documented: software signer is
|
||
tamper-evident but NOT unforgeable-by-owner.
|
||
- Wired bus -> log: Dingtian input pushes become input_received events (lane mapping TODO).
|
||
- Added ParkingEventType 'input_received'.
|
||
- Verified via inject: push w/o digest -> 401; pushes -> 2 signed+chained events; verify -> ok;
|
||
direct DB tamper -> verifyChain catches at the right index; deleted row -> index gap. 5 concurrent
|
||
appends -> indices 1..5 intact. Full repo typechecks.
|
||
- Updated [[append-only-event-chain]], [[dingtian-relay]].
|
||
|
||
## [2026-06-15] ingest | Event log + Dingtian string-protocol security fix
|
||
- Append-only signed event log shipped (EventLog, Signer abstraction over ATECC608 w/ SoftwareSigner
|
||
HMAC; GET /api/events + /api/events/verify). Dingtian input pushes persist as input_received.
|
||
Verified on hardware: shorting I1-I4 -> 8 signed+chained events, verifyChain ok.
|
||
- SECURITY (verified on hardware): the password-less string protocol (udp2) can fire relays
|
||
("11" -> relay1 on) with NO auth, bypassing relay_pw. Fixes: status reads moved to authenticated
|
||
binary read (cmd 0x00); harden() disables udp2 BEST-EFFORT (firmware V3.6J config API refuses,
|
||
but web UI works) and returns a warning instead of throwing. After web-UI disable, the "11" attack
|
||
is dead and binary control/status still work.
|
||
- GAP (user-identified): event log captures host-originated actions only; out-of-band relay
|
||
actuation (sniffed relay_pw, string protocol, ip_watchdog) produces NO event — proven on hardware.
|
||
Real control is reconciliation vs. an independent witness; witness+reconciliation NOT yet built.
|
||
- Device web login (webUser/webPassword) now un-redacted in setup state (admin-only device area);
|
||
pushPassword/relayPassword stay machine-only.
|
||
- harden() warnings surfaced via the assign response.
|
||
- localAddress threaded through the Dingtian driver (device-facing-IP foundation; multi-homed hosts).
|
||
- INCIDENT: probing default.cgi factory-reset the bench device (now at 192.168.1.100, defaults).
|
||
Re-provisioning is the ADMIN's job via First-run setup (app must not hardcode site IPs).
|
||
- Updated [[append-only-event-chain]], [[dingtian-relay]].
|
||
|
||
## [2026-06-15] fix | Dingtian web-password: desired-vs-current split + verify + UI warnings
|
||
- BUG (found in real assign): admin typed a web password; harden used it as the OLD cred, rotation
|
||
failed silently, DB saved the typed value but device login stayed admin/admin. Also UDP2 warning
|
||
never reached the admin (frontend discarded the assign response).
|
||
- FIX: split config into webPassword (desired; blank→random) and webPasswordCurrent (existing old
|
||
cred, default admin). harden() rotates current→desired, VERIFIES by re-auth with the new pw, and
|
||
only returns secrets.webPassword on success (else warning, no save). assign strips typed
|
||
webPassword/webPasswordCurrent and persists only verified secrets.
|
||
- SetupWizard now shows assign-response warnings (amber banner, per category) — closes the
|
||
feedback loop for the UDP2-can't-disable case.
|
||
- Verified on hardware (192.168.1.100): harden set login to a chosen pw; device then rejects
|
||
admin/admin (&2&) and accepts the chosen pw (&0&). UDP2 warning surfaced as designed.
|
||
- Updated [[dingtian-relay]].
|
||
|
||
## [2026-06-15] update | input_received lane resolution + source semantics
|
||
- Wired device→lane resolution: `LaneMap` (`apps/server/src/lane-map.ts`) caches
|
||
`lane_devices.id → lane`, refreshed by setup routes on assign/unassign. `input_received`
|
||
events now carry the firing device's lane instead of a hardcoded `lane: 0`. Unmapped device →
|
||
`lane: -1` + warn (0 is a real lane; never mis-stamp).
|
||
- Documented that `source` stays null for raw inputs by design (it's an IdentitySource, not a
|
||
device field); device provenance is in `identity`.
|
||
- Updated [[append-only-event-chain]].
|
||
|
||
## [2026-06-15] test+lesson | Hikvision camera verified; multi-subnet source-address trap
|
||
- Pulled a real snapshot from a Hikvision camera on the bench: `GET
|
||
http://10.0.10.121/ISAPI/Streaming/channels/101/picture`, Digest auth, admin/admin123 → HTTP 200,
|
||
2688×1520 JPEG. Path + auth + creds confirmed. ISAPI is the right surface; the device's
|
||
"Enable Hikvision-CGI" toggle is a *different* legacy CGI API and is NOT needed.
|
||
- Caveat recorded: the camera driver is still a STUB — the wizard's "● ready — stub / ●
|
||
preconditions OK" contacts nothing; cameras have no preconditions (only [[dingtian-relay]]
|
||
implements checkPreconditions). Noted the cosmetic "Backend push IP" bug (camera pulls, doesn't
|
||
push; field should gate on a `pushesToBackend` capability).
|
||
- LESSON (cost an hour of "why can't we ping the subnet"): with two device subnets stacked on one
|
||
NIC (`192.168.1.123` + `10.0.10.203` on eth1), Linux picked the WRONG source address for
|
||
`10.0.10.x` → ARP shows REACHABLE but all ping/TCP times out. Fix: pin `src` on the connected
|
||
route (`ip route change <subnet>/24 dev <nic> proto kernel scope link src <host-ip>`), or force
|
||
source per-call (`ping -I` / `curl --interface`). Devices arrive on assorted static `/24`s; the
|
||
host carries one IP per subnet — this trap is the recurring cost of that.
|
||
- Decision context: production is a dedicated hardened **Linux appliance** (this WSL2 box is a dev
|
||
stand-in). Multi-subnet config + `src` pinning is an appliance deployment concern (made
|
||
persistent via networkd/netplan), riding on [[network-isolation]]; long-term answer is to re-IP
|
||
devices onto one planned parking subnet at install.
|
||
- Updated [[lpr-camera]] (snapshot driver + verified-on-hardware section), [[wsl-dev-networking]]
|
||
(multi-subnet source-address trap + appliance pattern).
|
||
|
||
## [2026-06-15] driver+fix | Real Hikvision/Dahua camera driver; push-IP field gated
|
||
- Replaced the camera STUB with a real `HttpCamera` (`packages/devices/src/drivers/camera.ts`):
|
||
Hikvision ISAPI (`/ISAPI/Streaming/channels/<ch>01/picture`) + Dahua CGI (0-based channel), both
|
||
over client-side HTTP Digest (new `drivers/http-digest.ts`, two-shot 401→challenge→response,
|
||
qop=auth MD5 — the client counterpart to the server's digest-auth.ts). `healthCheck()` now
|
||
actually pulls a frame instead of returning `ready/stub`. Added `localAddress` + `timeoutMs` +
|
||
`channel` config; threads the device-facing NIC for the multi-subnet trap.
|
||
- Snapshot interface: `Snapshot` now carries `bytes: Buffer` (driver fetches); `imageRef` is
|
||
optional and set by the CALLER once stored — keeps the adapter free of storage deps. Nothing
|
||
consumed captureSnapshot yet, so no migration needed.
|
||
- Cosmetic bug fixed: "Backend push IP" showed for any reachable host. Added a `pushesToBackend`
|
||
flag to `DeviceDriver` (only [[dingtian-relay]] sets it), exposed as `pushCapable` in the catalog
|
||
(mirrors `discoverable`), and gated both the wizard's backend-IP fetch and the field on it.
|
||
Cameras/printers/readers no longer show it.
|
||
- VERIFIED on hardware: built clean (5/5 packages); ran the real driver against the Hikvision at
|
||
10.0.10.121 → healthCheck ready, captureSnapshot returned a valid 322 KB JPEG (correct magic).
|
||
- Updated [[lpr-camera]].
|
||
|
||
## [2026-06-15] fix | Permanent WSL2 source-address fix (systemd hook)
|
||
- The multi-subnet source-address trap kept recurring (every `wsl --shutdown` wipes the runtime
|
||
`ip route` pin — mirrored mode re-clones the Windows NIC's addresses fresh each boot, and NOTHING
|
||
inside Linux owns them: networkd/NM/netplan all inactive). Made it permanent on the dev box.
|
||
- `deploy/wsl-fix-route-source.sh`: walks each `proto kernel scope link` route on the NIC and pins
|
||
`src` to the host's own address in that same subnet — no hardcoded IPs (covers future device
|
||
subnets), idempotent, preserves route metric, non-fatal per route. `deploy/parking-net.service`:
|
||
oneshot, enabled, reapplies on every boot.
|
||
- BUGS hit + fixed while building it: (1) `ip route change` errors `RTNETLINK: No such file` when
|
||
the route isn't up yet at boot → use `replace`; (2) `set -e` made one failed `ip` abort the whole
|
||
unit → dropped it, per-route warnings instead; (3) `network.target` fires before mirrored-mode
|
||
addresses land → script waits up to 15s for a route.
|
||
- VERIFIED: service enabled+active, journal shows `pinned 10.0.10.0/24 -> src 10.0.10.203`, camera
|
||
pings with NO -I flag (0% loss), and the real Hikvision driver pulls a snapshot with NO
|
||
`localAddress` set. Root cause noted as Windows-side (stray 192.168.1.x); this is the
|
||
self-contained Linux answer.
|
||
- Updated [[wsl-dev-networking]].
|
||
|
||
## [2026-06-15] design | Business layer kickoff — parking session model
|
||
- Pivoted from the (hardware-verified) device/integrity layer to the business domain. Wiki-first.
|
||
- KEY DECISION: a [[parking-session]] is a PROJECTION over the signed [[append-only-event-chain]],
|
||
never a mutable table — a mutable sessions row with paid/owed would reopen the operator-fraud
|
||
hole the whole system closes. "Paid" = a signed `payment` event (unforgeable, undeletable).
|
||
- Scope (user): mixed site, TRANSIENT-FIRST; [[permit]] holders layered as a 2nd identity source
|
||
that short-circuits payment. Payment = PAY-ON-FOOT / pay station (decoupled from exit; exit lane
|
||
only validates paid + within walk-back grace). Matches [[autonomous-direction]].
|
||
- New signed event types designed (not yet built): `vehicle_entry`, `vehicle_exit`, `payment`,
|
||
`void` — extend `input_received`. Lifecycle OPEN→PAID→CLOSED (+VOIDED); overstay top-up is the
|
||
one genuinely stateful edge case.
|
||
- New pages: [[parking-session]], [[tariff]] (pure/data-driven fee fn; gracePeriodExit is a real
|
||
pay-on-foot revenue param), decision [[session-model]]. Updated [[append-only-event-chain]],
|
||
[[index]]. Closes the dangling entry-flow thread from [[device-input-flow]].
|
||
- [[permit]] drafted + RESOLVED from user input: credentials = RF tag/chip/card + QR (optical
|
||
reader). Car limits = two numbers: `registeredCars[]` whitelist + admin-set `maxConcurrent` (in
|
||
at once) — enforced as a fold over the permit's open sessions. Identity = card/QR OR matching
|
||
plate (either opens; card-sharing not prevented by design, caught by reconciliation). Autonomy =
|
||
host-in-the-loop for everything → Dingtian stays sufficient, no new controller; permit entry
|
||
fails closed if host down. Remaining open: reader hardware models; lapsed/revoked policy.
|
||
- NEXT: schema (`packages/db`: permits/tariffs + session projection) + the
|
||
input_received→vehicle_entry flow (closes the [[device-input-flow]] thread).
|
||
|
||
## [2026-06-15] design | Host-side vision service (ANPR + vehicle verification)
|
||
- User: optionally bind camera images to an OpenCV service we build. Resolved scope: ANPR (plate →
|
||
`IdentitySource='lpr'`); a **separate local Python/OpenCV microservice** on the appliance (Node →
|
||
localhost HTTP), offline; it **replaces the dedicated edge-AI [[lpr-camera]]** (recognition on
|
||
ordinary Hikvision/Dahua snapshots — reuses `Snapshot.bytes`).
|
||
- LICENSING: best ANPR/vehicle models are AGPL/commercial vs. the MIT/Apache/BSD standing rule.
|
||
Decision: **scoped AGPL exception** — allowed INSIDE the vision service only (separate process,
|
||
not linked); app stays permissive. Amended [[standing-decisions]].
|
||
- USER ANTI-FRAUD INSIGHT: a fraudster can print a registered plate and enter with a different car.
|
||
→ service also does **vehicle-attribute / fingerprint verification**, so the *car* reconciles, not
|
||
just the plate. This fills the independent-witness gap [[append-only-event-chain]] calls out:
|
||
plate-on-different-car = anomaly. Recognition is advisory (confidence + ticket fallback), evidence
|
||
(read + image) attaches to the signed event.
|
||
- New pages: [[opencv-anpr-service]], decision [[vision-service]]. Updated [[standing-decisions]],
|
||
[[lpr-camera]] (host-side supersedes edge-AI), [[permit]] (plate-spoof defence),
|
||
[[append-only-event-chain]] (vision as witness), [[index]].
|
||
- Open: recognizer/vehicle-model choice + accuracy; fingerprint method + anomaly threshold; appliance
|
||
compute (CPU vs GPU/NPU); per-camera opt-in; the still-unbuilt reconciliation logic.
|
||
|
||
## [2026-06-15] design | Transient pricing — composable, versioned tariff
|
||
- User: pricing is unknown + constantly changing → must be **admin-composable at runtime**, currency
|
||
selectable, FX later. Reframed [[tariff]] from "config we ship with numbers" to a first-class
|
||
editable entity.
|
||
- DECISIONS: (1) rate structure = **stepped duration blocks + rolling-24h daily cap** (flat rate is
|
||
one block; expresses first-hour/taper/cap with no special cases); (2) overstay top-up =
|
||
**reprice the difference** (recompute entry→now − alreadyPaid); (3) tariffs are **effective-dated
|
||
immutable versions** — edits publish a new version, sessions reprice against the version in force,
|
||
the `payment` event records `tariffVersionId` (reproducible + fixed in the signed chain); (4)
|
||
**one active tariff per site**, but modelled with id/scope so multi-tariff needs no migration;
|
||
(5) **currency selectable (ISO 4217)**, money = `{minorUnits, currency}`, payment reserves a null
|
||
`fxRate` → FX-ready, **FX engine deferred** (needs offline rate source — new [[open-questions]] #8).
|
||
- Ships with **no rate card**; owner must compose+publish one (blank = free or gated, operator
|
||
policy — open). Numbers in the page are illustrative, not defaults.
|
||
- Wrote the pure integer fee algorithm into [[tariff]] (data model: `tariffs` + immutable
|
||
`tariff_versions`). Updated [[open-questions]] (#8 FX), [[index]].
|
||
- NEXT: schema (`packages/db`) for tariffs/versions + permits + session projection, then the
|
||
composer UI + the input_received→vehicle_entry flow.
|
||
|
||
## [2026-06-15] design | Shifts (manned-only) + Z-report; drop time-based token
|
||
- Q: what happens at operator shift end? Resolved scope, deliberately small.
|
||
- Shifts exist ONLY in manned mode — a human accountability boundary. The fully-automated/unmanned
|
||
system has NO shifts; the pay-station cash-collection cycle + [[reconciliation]] replace it.
|
||
- Shift is NOT time-based: relief arrives late / no-shows / one operator forced into a double.
|
||
→ **drop the 8h token expiry**; login valid **until explicit logout** (updated [[local-jwt-auth]];
|
||
code change pending). Start/End Shift are **explicit, independent of login** — one login spans many
|
||
shifts; a double = End then Start again, no re-login.
|
||
- End Shift = sum signed `payment` events in the shift by tender → append a signed `shift_z_report`
|
||
(type already in packages/shared, chained to prior Z) → **PRINT cash total + POS total (if a POS
|
||
is configured)**. That's the whole human-side ask. No blind count / variance gate / manager
|
||
override. Fraud control stays in the signed chain + later [[reconciliation]] (catch a skim after
|
||
the fact, not at close). Blind-count documented as an explicit optional add-on, not built.
|
||
- New page [[shift]]; updated [[local-jwt-auth]], [[index]].
|
||
- Open: Z sums by payment-time (the operator who took the money) — confirm; X-report (read-only
|
||
mid-shift); per-operator vs per-booth vs per-site (ties to [[open-questions]] #1). `payment` event
|
||
needs a `tender` field (cash/card) — fold into the schema step.
|
||
|
||
## [2026-06-15] design | Scope sweep — capacity, validation, reporting, integrity gaps
|
||
- "What else can a PMS do?" — swept the full feature surface against the design; user picked the
|
||
in-scope gaps. New pages:
|
||
- [[capacity-occupancy]] — occupancy = fold over open sessions; refuse entry + drive a FULL sign
|
||
when full; **exit never blocked** ([[fail-state-safety]]); zone-ready; counting-drift = anomaly.
|
||
- [[validation-discounts]] — merchant validates a ticket → **signed discount event** applied at
|
||
fee time ([[tariff]]); over-validation visible to [[reconciliation]]; payment records gross/disc/net.
|
||
- [[reporting-analytics]] — revenue/occupancy/stay/permit/anomaly reports as projections over the
|
||
chain; **plate-search** (admin looks up a session by plate IF captured — honest "not captured").
|
||
- [[clock-integrity]] — fees depend on the host clock; offline box → backdating attack; monotonic
|
||
index catches reorder, clock-regression = `anomaly`, RTC + privileged-only time change.
|
||
- [[blocklist]] — barred plates/cards refused at **entry only**; signed + attributed.
|
||
- Folded into existing pages: **manual overrides** = signed reason-coded events (legitimate
|
||
counterpart to the out-of-band-open anomaly) + **lost-ticket admin-arbitrary amount** →
|
||
[[parking-session]] + [[tariff]]; **backup/restore** confirmed in-scope, expanded [[open-questions]]
|
||
#5 (restored copy must still verifyChain; doubles as the reconciliation export).
|
||
- NOT captured (flagged): **intercom/help-call** — user didn't select it, but it's the only human
|
||
fallback for an unmanned lane; revisit. Deferred roadmap: reservations, mobile app, EV, loyalty.
|
||
- Updated [[index]].
|
||
|
||
## [2026-06-15] design | Second sweep — ticket encoding + anti-passback; money corners deferred
|
||
- More gap-hunting. New pages:
|
||
- [[ticket-encoding]] — the transient session key: opaque/unguessable **ticket id printed as QR**
|
||
by [[rongta-printer]], **scanned at pay station + exit** (new ReaderDevice/imager behind the
|
||
adapter); plate-as-ticket ticketless alt coexists per lane. The physical backbone of the
|
||
transient flow (was only implied).
|
||
- [[anti-passback]] — one id can't enter while it already has an OPEN session (card/ticket-passing
|
||
over the fence); a fold over the chain, *under* permit `maxConcurrent`. Soft (flag `anomaly`) by
|
||
default vs. hard (refuse); honest dependence on reliable exit detection.
|
||
- DEFERRED (user): **receipts/VAT invoices** + **refunds/change/overpay** — depend on pay-station
|
||
hardware + manned/unmanned payment subsystem; recorded as [[open-questions]] #9, revisit at
|
||
procurement (may change what the `payment` event stores → flagged before schema).
|
||
- Still open & load-bearing: **lane topology** (#1) — not resolved; scopes sessions/occupancy/shifts.
|
||
- Updated [[open-questions]] (#9), [[index]].
|
||
|
||
## [2026-06-15] decision | Split signed business ledger from device telemetry
|
||
- User correction before schema: the `events` table conflated TWO things — the anti-fraud business
|
||
ledger AND device telemetry (button pushes as `input_received`). Split them.
|
||
- `ledger_events` (rename of `events`): signed, hash-chained, ATECC608-signed business facts only
|
||
(vehicle_entry/exit, payment, void, shift_z_report + witness barrier_open_command/observed,
|
||
anomaly). Reconciliation + session/tariff/occupancy projections run on this.
|
||
- `device_events` (new, [[device-events]]): UNSIGNED hardware telemetry (relay fired, paper-out,
|
||
camera offline, reader read, raw input edges); high-volume, may rotate/prune; never reconciled.
|
||
- A raw button press is telemetry → device_events; the entry flow then mints a SIGNED vehicle_entry.
|
||
So `input_received`-as-signed-event is dropped (was transitional). No prod chain data exists, so
|
||
the rename/restructure is safe now (no signatures to invalidate).
|
||
- New: decision [[event-streams-split]], concept [[device-events]]; updated [[append-only-event-chain]]
|
||
(two streams + as-built-vs-pending), [[index]].
|
||
- NEXT (schema): rename events→ledger_events; add device_events; split ParkingEventType in shared;
|
||
then tariffs/versions, permits, blocklist, sessions projection. EventLog/canonicalize/verifyChain
|
||
+ /api/events follow the rename (code refactor, separate from this wiki commit).
|
||
|
||
## [2026-06-15] design+build | Entry flow (start) + valet/over-capacity captured
|
||
- Building the entry flow: device input → signed `vehicle_entry` → print ticket → pulseOpen.
|
||
- DECISION (print failure): **hold** — if all printers are down, sign an `anomaly` (entry attempt,
|
||
ticket unprinted) and do NOT open (no unticketed transient — couldn't pay on exit; operator
|
||
handles the held car). The `vehicle_entry` is appended ONLY on the success path, right before
|
||
pulseOpen — preserving "signed before open" and never logging an entry for a car that didn't get in.
|
||
- DECISION (capacity): wire transient entry now; the FULL gate comes later (needs capacity config +
|
||
occupancy fold).
|
||
- VALET / OVER-CAPACITY (user): "full" is a **soft, operator-configurable** policy — operator may
|
||
valet-accept over capacity (customer hands over keys + leaves, operator stacks the car). Manned-only,
|
||
new custody/session shape. Captured as [[valet-overcapacity]] + made [[capacity-occupancy]] FULL a
|
||
soft policy; NOT built into the entry flow (clean seam left). Deferred.
|
||
- New page [[valet-overcapacity]]; updated [[capacity-occupancy]], [[index]].
|
||
|
||
## [2026-06-15] build | Exit flow (pay-on-foot validation)
|
||
- Built `apps/server/src/exit-flow.ts`. Added a `read` channel to the device bus (DeviceReadEvent:
|
||
ticket/plate/qr/card) — readers/LPR emit reads; entry stays button-driven, so reads are
|
||
unambiguously exit/identity events for now.
|
||
- 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.
|
||
- Pay station doesn't exist yet → no `payment` events → every transient exit currently REJECTS.
|
||
Correct end-state, not passable until pay-station lands (decided).
|
||
- VERIFIED against stubs: unpaid→anomaly+no-open; paid+grace→vehicle_exit+open+closed; grace-expired
|
||
→anomaly; unknown ticket→anomaly; verifyChain ok across entry→pay→exit.
|
||
- GAP flagged: lane_devices has no entry/exit DIRECTION model (door mapping hardcoded to 1 for exit);
|
||
fine while entry=button/exit=read, but multi-reader lanes need a lane-direction/role model (ties to
|
||
[[open-questions]] #1). Updated [[parking-session]] as-built + gap, [[index]].
|
||
|
||
## [2026-06-15] build | Pay station + fee calc; JWT 8h → until-logout
|
||
- JWT: dropped the 8h `expiresIn` (server.ts global + login). Token now valid **until explicit
|
||
logout**; cookie maxAge = 30 days so a browser restart doesn't log out an active operator
|
||
(auth.ts `COOKIE_MAX_AGE_SECONDS`). Closes the pending change from the shift decision; updated
|
||
[[local-jwt-auth]].
|
||
- `computeFee(enteredAt, asOf, structure)` in `packages/shared` — pure integer fee calc.
|
||
TWO BUGS caught by tests: (1) grace must use RAW duration, not the rounded-up minutes (a 10-min
|
||
stay was being charged a full hour); (2) the block ladder must RESET each rolling-24h day (decision:
|
||
day 2 restarts at first-block pricing → 25h = 1200 cap + 200). Both fixed; 9 cases pass.
|
||
- Pay station (`apps/server/src/pay-station.ts` + routes `GET /api/pay/quote`, `POST /api/pay`):
|
||
open session → active tariff version → computeFee → signed `payment` event (amount/currency/tender/
|
||
tariffVersionId/graceExitMin); `overrideMinor` for lost-ticket/dispute. Cashier/operator/admin guard.
|
||
- VERIFIED: full loop entry→quote(300 for 90min)→pay→exit opens+closes, verifyChain ok. (A
|
||
raw-SQL backdate in one test correctly broke the chain — the tamper-evidence working, not a flow bug.)
|
||
- Updated [[tariff]] (settled edges + as-built), [[parking-session]] (pay station as-built; full
|
||
loop passes).
|
||
|
||
## [2026-06-15] build | Tariff composer (makes the pay station operable)
|
||
- `validateTariffStructure` in `packages/shared` — non-negative ints, ascending block bounds, only
|
||
the last block open-ended; a malformed card can't be published.
|
||
- Routes (`apps/server/src/routes/tariffs.ts`): `GET /api/tariff` (active + history, any role) and
|
||
`POST /api/tariff/versions` (publish immutable version, ADMIN only). Single site `tariffs` row
|
||
created lazily. Editing = publish a new version (effective-dated, immutable).
|
||
- UI (`apps/web/src/TariffComposer.tsx`, admin shell next to SetupWizard): currency, grace windows,
|
||
increment, daily cap, lost-ticket, add/remove rate blocks; major-unit input → minor on submit;
|
||
shows active + history.
|
||
- VERIFIED via Fastify inject: GET empty→active null; invalid (out-of-order blocks)→400 w/ problem;
|
||
valid→201 createdBy=admin; readonly publish→403; after publish the pay station quote returns 404
|
||
(session) not 409 (no tariff) — i.e. it now sees the active card. Full build 5/5.
|
||
- Updated [[tariff]] (composer as-built).
|
||
|
||
## [2026-06-15] build | Permit entry/exit branch + read dispatcher
|
||
- `apps/server/src/permit-flow.ts` + `read-dispatch.ts`. 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`, shared in lane-map.ts). Refactored
|
||
ExitFlow.onRead → handleAt(lane,e) so the dispatcher owns lane resolution.
|
||
- Permit DIRECTION 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.
|
||
- maxConcurrent enforced as a fold over the signed ledger (count the permit's entries whose car has
|
||
no later exit); null = unbound. Validity window + status + plate-OR-card identity as designed.
|
||
No ticket/fee; every use is a signed event carrying permitId. Refusals = signed anomaly, no open.
|
||
- VERIFIED against stubs: card entry → inferred exit; fleet maxConcurrent=2 (F1,F2 in, F3 rejected,
|
||
F1 exits → F3 enters); plate-bound permit opens; revoked → reject; unknown credential falls through
|
||
to exit-flow reject (not mis-read as permit); verifyChain ok. Full build 5/5.
|
||
- Updated [[permit]] (as-built), [[parking-session]] (read dispatch).
|
||
|
||
## [2026-06-15] build | Permit admin CRUD (route + UI)
|
||
- `apps/server/src/routes/permits.ts`: a permit is an aggregate (row + credentials + bound plates);
|
||
create/update replace the child sets as one unit. GET (any role, for lookup), POST/PUT/DELETE +
|
||
POST /:id/revoke (admin only). Validation: maxConcurrent positive-int-or-null; must have ≥1
|
||
credential OR ≥1 plate. Revoke = soft (keeps history); DELETE = hard (past ledger events untouched).
|
||
- `apps/web/src/PermitManager.tsx` in the admin shell: list + add/edit (holder, car-bound toggle →
|
||
maxConcurrent or unbound, validity window, credentials add/remove, plates as a list), revoke, delete.
|
||
- Makes permits usable without hand-seeding (companion to the tariff composer).
|
||
- VERIFIED via inject: empty + maxConcurrent=0 → 400 w/ messages; valid → 201; operator LIST 200 but
|
||
create 403; update unbinds + REPLACES child rows (old cred gone); revoke→revoked; delete→204 then
|
||
404, children cleaned. Full build 5/5.
|
||
- Updated [[permit]] (CRUD as-built).
|
||
|
||
## [2026-06-16] build | Shifts: open/close + signed Z-report (manned mode)
|
||
- Shift = two signed ledger events, NO mutable table: new `shift_open` event type + existing
|
||
`shift_z_report`. Operator = logged-in user (in event `identity`); open iff their latest shift
|
||
event is a `shift_open`. `apps/server/src/shift-service.ts`.
|
||
- Close sums `payment` events in the window by tender (cash/card, by payment time) → signed
|
||
`shift_z_report` (totals/counts/window) → prints via the NEW generic
|
||
`PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt printer.
|
||
Print is best-effort — failure doesn't undo the signed close (`printed:false` returned).
|
||
- Routes (`routes/shift.ts`, cashier/operator/admin): GET /api/shift/current, POST open (409 if
|
||
open), POST close (409 if none). UI `ShiftControl` in the shell (non-readonly): Start/End + Z totals.
|
||
- Added `printReport` to the PrinterDevice interface + Rongta driver (reusable for receipts later).
|
||
- VERIFIED: open→double-open 409→payments (cash+card; one dated outside the window excluded)→close
|
||
totals (cash 500/card 250/3)→close-again 409→re-open ok; readonly 403; verifyChain ok. Full build 5/5.
|
||
- Updated [[shift]] (as-built).
|
||
|
||
## [2026-06-16] build | Capacity / FULL gate (occupancy fold + transient refuse)
|
||
- Occupancy = fold over the ledger (entries−exits per identity; `apps/server/src/occupancy.ts`),
|
||
`getOccupancy` → {count, capacity, free, full}. Capacity = single-row `site_config` table (admin,
|
||
null=uncapped); migration 0001 (additive, no prompt).
|
||
- FULL gate in the TRANSIENT entry flow: occupancy.full → refuse (no ticket/entry/open) + signed
|
||
anomaly. Permit entry NOT gated (subscribers admitted past transient-full; their maxConcurrent
|
||
still applies) — occupancy can read over-capacity by design.
|
||
- Routes (`routes/site.ts`): GET /api/occupancy + GET /api/site-config (any role), PUT
|
||
/api/site-config (admin; non-neg int or null). UI `SiteSettings`: live occupancy + FULL badge
|
||
(all), capacity editor (admin).
|
||
- VERIFIED: fill to cap=2 → 3rd transient refused (anomaly, no open); permit still admitted (occ 3/2,
|
||
free −1); exit frees a slot; routes RBAC (op can't set, −5→400, set/clear ok); verifyChain ok.
|
||
Full build 5/5. Physical FULL-sign relay output deferred.
|
||
- Updated [[capacity-occupancy]] (as-built).
|
||
|
||
## [2026-06-16] ingest | GEE-QR-ER80 QR access reader datasheet
|
||
- User has the reader; ingested `raw/GEE-QR-ER80 QR Code Access Control Reader.pdf`.
|
||
- CORRECTION: earlier guessed "ER80-EM" = a 125 kHz EM4100 prox-card reader. WRONG — the datasheet
|
||
shows **GEE-QR-ER80**, a **QR / DataMatrix / 1D barcode** optical access reader (optional ID/IC
|
||
card). It's the [[ticket-encoding|QR ticket]] scanner the design already needed, not a card reader.
|
||
- Specs: interfaces Wiegand 26/34 · RS-232 · RS-485 · USB · TCP/IP; 4–15 VDC <800 mA; 360°;
|
||
Windows + **Linux**; wiring VCC/GND/D0/D1/TX(R+)/RX(R-)/LED/BEEP. On hand: **`-Q-W`** (QR scanner;
|
||
Wiegand/RS-232/RS-485).
|
||
- Fit: host-side reader → a serial `ReaderDevice` adapter emitting `read` events → consumed by the
|
||
already-built exit flow + QR-permit path. Prefer RS-232/485 (serial) over Wiegand (Wiegand can't
|
||
carry variable-length QR; autonomy moot since [[dingtian-relay]] has no onboard ACL).
|
||
- New: source [[gee-qr-er80]] summary + entity [[gee-qr-er80]]. Updated [[ticket-encoding]],
|
||
[[entry-exit-readers]], [[index]].
|
||
- OPEN (blocks the adapter): the RS-232/485 **frame + baud** — is a QR scan an ASCII CR/LF string
|
||
(expected) or framed? Datasheet omits it; resolve via vendor docs or by observing the port.
|
||
|
||
## [2026-06-16] ingest+test | ER80 protocol = HTTP GET poll + JSON verdict (SDK)
|
||
- Hardware bring-up: configured the reader via the vendor Windows tool (server IP/port + "server
|
||
language"). Moved it to 10.0.10.7. It pings (source-pin must be 10.0.10.203 — trap recurs). No
|
||
beep on scans — initially looked like "not scanning."
|
||
- Found the QRCode SDK v1.6.5 (`QRCode_sdk - QRCode_v1_6_5/sdk/`). Protocol SETTLED, supersedes the
|
||
serial guess in [[gee-qr-er80]]: reader does **HTTP GET** `/qa/mcardsea.php?cardid&mjihao&cjihao&
|
||
status&time` on each scan; server replies **JSON** `{data:[{...,status,output}],code:0}`. Reply
|
||
`status` 1=valid(beep 2×)/0=invalid(beep 1×); `output` 0=Access/1=WG26/2=WG34; `time` syncs clock.
|
||
`status` low digit in the GET = direction (1=in/0=out).
|
||
- KEY: feedback/beep is decided by the SERVER REPLY, not locally → the "no beep" was my catch-all
|
||
replying plain "OK" not the JSON verdict, NOT a scan failure. Host-in-the-loop + SYNCHRONOUS.
|
||
- "Server language" (JSP/PHP/C#/ASP/CGI) only selects the URL PATH; transport is plain HTTP.
|
||
- New source [[qrcode-sdk]]; updated [[gee-qr-er80]] (protocol resolved, serial open-Qs dropped),
|
||
[[index]]. SDK kept in place (bulky+binaries), not copied to raw/.
|
||
- NEXT: backend route — parse GET, DECIDE (reuse permit/exit lookup), reply JSON verdict, emit on
|
||
read bus. Refactor read flows to RETURN an outcome so the reply can reflect accept/reject.
|
||
|
||
## [2026-06-16] build+fix | QR reader endpoint + ReadOutcome refactor; dev-DB migrate fix
|
||
- DB FIX: dev server crashed `no such table: lane_devices`. Cause: server `.env` DATABASE_URL points
|
||
at `apps/server/parking.sqlite` (the old dev DB I'd moved aside during the ledger split; new
|
||
migrations added since). Applied `drizzle-kit migrate` to that path → all 14 tables present. Fresh
|
||
DB → needs `seed-admin` + device re-assignment (empty, expected).
|
||
- REFACTOR: read flows now RETURN a `ReadOutcome {accepted,direction,reason}` (device-events.ts).
|
||
`ReadDispatcher.dispatch`, `ExitFlow.handleAt`, `PermitFlow.run` updated. A synchronous reader can
|
||
answer the device; fire-and-forget readers ignore it.
|
||
- ENDPOINT: `routes/qr-reader.ts` — `GET/POST /qa/mcardsea.php` (public; reader has no auth, on the
|
||
device subnet). Parses the SDK GET, dispatches the scan, replies the SDK verdict (status 1/0 →
|
||
beep 2×/1×, output 0, time-sync). Reader's lane keyed off device serial (cjihao) as lane_devices.id
|
||
for now.
|
||
- VERIFIED via inject: valid permit QR→status:1+open; re-scan→permit exit; unknown→status:0; reader
|
||
on barrier-less lane→status:0. Full build 5/5.
|
||
- Updated [[gee-qr-er80]] (endpoint as-built + hardware open items).
|
||
|
||
## [2026-06-16] test+fix | QR reader VERIFIED on hardware; path is .jsp not .php
|
||
- Ran a verbatim-vendor logger on :3000 (replies like mcardsea.php: status:0/output:2). Reader
|
||
**beeped** → it scans, sends, and acts on the reply. Earlier "no beep" = nothing was answering :3000.
|
||
- Real GET captured: `/qa/mcardsea.jsp?cardid=52020056&mjihao=1&cjihao=H05M2AFA&status=11&time=...`
|
||
from 10.0.10.7 (OEM = Fondvision, per referer).
|
||
- KEY FIX: the "server language" setting selects the URL EXTENSION — this unit is JSP → posts
|
||
**`.jsp`**, but our route was `.php` only (would 404 the reader). Route now registers
|
||
php/jsp/asp/aspx/cgi. Build green.
|
||
- Real serial **cjihao=H05M2AFA** = the lane key → assign reader as lane_devices.id="H05M2AFA".
|
||
Reader beeped on status:0 (invalid/1-beep); a matching permit/session → status:1 (2-beep accept).
|
||
- Updated [[gee-qr-er80]] (verified-on-hardware).
|
||
|
||
## [2026-06-16] feature | gee-qr-reader driver — assign by serial, resolve lane by config
|
||
- The QR reader is a push device; setup wizard always assigns a random-UUID id, so "id = serial"
|
||
isn't possible via the UI. Clean fix instead: new **`gee-qr-reader`** driver (reader category) with
|
||
a single `serial` config field. Admin assigns it in the wizard (UUID id) + types the serial.
|
||
- QR endpoint now resolves the lane by **matching `lane_devices.config.serial` to the scan's
|
||
`cjihao`** (was: row id == cjihao). `qrReaderRoutes(app, db, dispatcher)`. Unassigned serial →
|
||
no lane → status:0 (graceful).
|
||
- `tcpip-reader` flagged as the WRONG model for this device (host-connects-out stub).
|
||
- VERIFIED via inject through the real /api/setup/assign: assign {serial:"H05M2AFA"} → .jsp scan +
|
||
matching permit → status:1 + open; re-scan → exit; unknown card → status:0; unassigned serial →
|
||
status:0. Full build 5/5.
|
||
- Updated [[gee-qr-er80]] (assignment as-built).
|
||
|
||
## [2026-06-16] feature | stub-access driver (bench-test the flows without a relay)
|
||
- Live QR scan reached the real app (.jsp, serial resolved) but rejected: "reader not on an
|
||
access-equipped lane" — lane 1 had the reader but no access device. The dispatcher requires an
|
||
access device on the same lane.
|
||
- Added a no-op **`stub-access`** driver (access category, no config): `pulseOpen` just logs, no
|
||
device I/O — stands in on a lane to test QR→permit→accept (incl. the beep) without the
|
||
[[dingtian-relay]] connected. NOT for production. Registered in the catalog.
|
||
- To get a live accept: assign Stub barrier to the reader's lane + a permit whose QR = the scanned
|
||
code → status:1 (2-beep) + logged pulseOpen.
|
||
|
||
## [2026-06-16] fix | QR reader 10s beep delay — reply must Connection: close
|
||
- Live accept worked (status:1, pulseOpen, 2 beeps) but the beep came ~10 s LATE. Server responded
|
||
in 14.7 ms; user confirmed request is fast, only the beep lags → delay is the READER, not us.
|
||
- Cause: reader sends `Connection: keep-alive` but only ACTS on the verdict once the socket CLOSES;
|
||
Fastify kept it alive → reader waited out a ~10 s keep-alive timeout. Every vendor demo replies
|
||
`Connection: close` + shuts the socket.
|
||
- Fix: endpoint sets `reply.header("connection","close")`. Verified the header is now sent.
|
||
- Updated [[gee-qr-er80]] (⚠️ Connection: close requirement).
|