Files
parking_solution/wiki/concepts/entry-exit-points.md
T
julian 266e9b0027
Build desktop / desktop (push) Successful in 4m34s
Build & push images / images (push) Successful in 3m1s
CI / check (push) Successful in 40s
docs(wiki): record session findings — snapshot fix, booth rework, db reset
- entry-exit-points.md: the snapshot content-type bug + serve-side cleanType
  fix (Hikvision image/jpeg; charset="UTF-8" broke every legacy render).
- booth-exit-flow.md: the Active-Sessions/modal rework — inline barrier button
  removed -> modal; closed-within-grace view; live grace countdown; actual paid
  amount; read-only snapshot review in the closed-session view.
- local-dev-workflow.md: the gated `pnpm db:reset` training tool + flag table +
  the booth (docker exec, no pnpm) note.
- appliance-provisioning.md: new §7d — reset on the booth via docker exec into
  the server container (script ships in the deploy bundle; DATABASE_URL=
  /data/parking.sqlite), ledger-truncation warning + the two safety gates.
- index.md catalog line; log.md entries.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-30 17:58:43 +02:00

161 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: concept
tags: [parking, architecture, devices, setup]
sources: []
updated: 2026-06-30
---
# 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).
- `presenceInput` / `entryCooldownSec`: the **one-car-one-ticket** guard for the entry button —
`presenceInput` is the input terminal of a vehicle-presence loop (physical guard), or
`entryCooldownSec` a fallback timer when there's no barrier feedback. See [[entry-double-press]].
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`.
**Re-encoded for storage (2026-06-28).** Cameras serve full-res JPEGs (a Hikvision main stream is
2688×1520 / ~600 KB); stored raw, snapshots dominated the appliance DB (measured ~72%). Each frame
is now **downscaled (long edge ≤ `SNAPSHOT_MAX_EDGE`=1280) + recompressed (`SNAPSHOT_JPEG_QUALITY`
=80)** before storage via [[technology-stack|sharp]] (~6–10× smaller, plate still readable). The
re-encode is **storage-only** — ANPR recognition runs on the **original full-res** bytes
(downscaling hurts OCR). Fail-soft: a re-encode error stores the original, never drops the snapshot
(`snapshot.ts` `encodeForStorage`).
> **Content-type bug — every legacy snapshot rendered blank (fixed 2026-06-30).** Symptom: *no*
> snapshot showed in the booth modal. Root cause: some cameras (Hikvision) return
> `Content-Type: image/jpeg; charset="UTF-8"` — a charset param on a binary body is **malformed**, and
> browsers refuse to decode an `<img>` declared that way. Old capture code persisted that raw header
> into `snapshots.content_type` (100 of 101 rows in the dev DB), and the serve route
> (`GET /api/snapshots/:id`) re-emitted it **verbatim** → broken render for every legacy row. The
> capture path was *already* hardened (`encodeForStorage` re-encodes to a clean `image/jpeg`; its
> fail-soft branch calls `cleanType` to strip `; charset=…`), so NEW rows were fine — but the serve
> route trusted the stored value. Fix: the route now also runs `cleanType(row.contentType)` on the way
> out (a bare `image/jpeg`), which un-breaks all legacy rows with **no data migration**. Verified: a
> previously-unrenderable 2560×1440 row now decodes in-browser. Lesson: **normalize a camera-supplied
> content-type both on capture AND on serve** — a stored value from an untrusted device is itself input.
> The stored `content_type` column could be backfilled to `image/jpeg` for cleanliness, but serving
> normalizes so it isn't required.
**Retention (2026-06-28, resolves the old open question) — DISK-PRESSURE safety valve.** Snapshots
are unsigned/advisory, so they prune freely. The day-to-day shrink is the re-encode above; pruning is
a backstop that only fires under real disk pressure. A **daily** check (`snapshot-retention.ts`
`pruneSnapshots`, wired in `server.ts`) reads the DB filesystem's used%; if it's **≥
`SNAPSHOT_DISK_HIGH_PCT`=70%** it deletes the **OLDEST** snapshots until an estimated
`SNAPSHOT_DISK_FREE_TARGET_PCT`=10% of the disk is freed — never below the **`SNAPSHOT_MIN_KEEP`=500**
floor — then **`VACUUM`s once** to return the space to the OS (a row delete only frees SQLite pages;
the file doesn't shrink until VACUUM, which this prune now OWNS — daily, off-peak). Because a delete
doesn't move disk-used% until the VACUUM, the loop is driven by **estimated freed bytes**
(`SUM(length(bytes))` of deleted rows), not a live disk re-read. On a roomy booth disk this is a
near-permanent no-op. (Replaced the first cut's age/row-cap model the same day.)
### Refused entry/exit ALSO snapshots (2026-06-19)
A snapshot is evidence of **who was at the barrier** — which matters *most* when the barrier is
**refused** (a turned-away car is a fraud/dispute signal: "lot full" denial, an unpaid exit attempt,
a no-session ticket, an out-of-window subscription). Originally only the OPEN paths captured; now
**every refusal/hold anomaly fires the directional camera too**, keyed to the same `identity` the
anomaly carries so the [[booth-console|activity-log]] evidence strip finds it. Coverage: entry
refused-full / held-no-ticket (a refused entry has no ticket id → mint a synthetic `REFUSED-…` ref
to key the anomaly + photo together), exit refused closed/no-session/unpaid/grace-expired (booth
*and* reader paths), and a refused [[subscription]] (the lane the reader sits at picks the camera).
Same fire-and-forget contract — a refusal is never delayed by a camera. Failed captures still surface
as "⚠ camera unreachable" tiles (see [[booth-console]]).
## 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]]