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/101 dev-DB rows); GET /api/snapshots/:id re-emitted
it verbatim, so every legacy snapshot rendered blank in the booth modal.
Capture was already hardened (encodeForStorage re-encodes to a clean
image/jpeg, fail-soft via cleanType), but the serve route trusted the stored
value. Export cleanType and apply it when setting the response header, so a
bare image/jpeg is sent regardless of what was stored — un-breaks all legacy
rows with no data migration. A stored value from an untrusted device is itself
input; normalize on capture AND on serve. Adds cleanType unit tests.
Verified: a previously-unrenderable 2560x1440 row now decodes in-browser.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Two lessons from the first park-buzi staging deploy, both in backup-recovery.md:
- A new server env var (BACKUP_KEY) must be added to docker-compose.yml's
server.environment: allowlist, not just the Komodo secret/Stack env — otherwise
the container never receives it (inspect shows it absent, not empty).
- The backup target must be a host path bind-mounted into the container; a desktop-
automounted USB (/run/media/...) is invisible inside the container, so Test target
reports 'does not exist'. Destinations are admin-provisioned (fstab + compose bind-
mount), not operator-pluggable — partly a threat-model feature. Acknowledged as a
flexibility limitation; USB-automount-to-container flow deferred.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The server's compose environment: block is an allowlist — it only forwards the vars
it names. BACKUP_KEY was never added when the backup feature landed, so even though
Komodo wrote BACKUP_KEY into the Stack .env, compose dropped it and the container
came up without it (docker inspect showed JWT/SIGN present, BACKUP_KEY absent — not
empty, absent). The Backup screen correctly reported 'BACKUP_KEY missing'.
Add BACKUP_KEY: ${BACKUP_KEY:-} next to EVENT_SIGNING_KEY (optional, empty default —
backups stay off until it's set). The prod overlay only merges VISION_URL, so the
base addition flows through to prod.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The first :stage image is built and in the registry (stage-39c778f). Pin it in the
IaC so git matches Core's Stack env and a ResourceSync won't revert TAG to the
placeholder. Bump this on each promotion.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
A push only builds if it touches a path in the filter. The first stage commit was
komodo-only, so no :stage image was ever built. Add komodo/** so IaC/Stack changes
(and a komodo-only push to stage) also build+check — a deploy-config change gets the
same sanity pass before it reaches a booth. This commit itself touches the workflow
file (already filtered), so it triggers the build that produces the first :stage image.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
A committed stage-<sha> can never match the commit that introduces it (the pin
commit changes HEAD), so a hardcoded sha here is always stale by one. Make it an
explicit placeholder (stage-REPLACE_WITH_BUILT_SHA); the real immutable sha is set
when you deploy from Komodo Core after CI builds :stage-<sha>. No moving tag on a
booth still holds.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Model the staging-vs-production split that fleet-deployment-komodo flagged as open.
Three tiers: dev (working, no booth) -> stage (staging booth park-buzi, real-world
test) -> main (production, manual + pinned).
- build-images.yml: trigger on [dev, stage, main]. The tag computation is already
branch-derived, so :stage / :stage-<sha> build with no other change.
- komodo/resources.toml: park-buzi now branch=stage + TAG=stage-<sha> (pinned;
no webhook even on staging). BACKUP_KEY already wired as a per-booth secret.
- komodo/README.md: a Promotion (dev->stage->main) section; per-booth secret list
now includes backup_key; hard-rule #1 generalised to pinned <branch>-<sha>.
- wiki: fleet-deployment-komodo open-item resolved + a Promotion-tiers table;
deploy-trigger choice generalised; container-deployment tag list gains :stage.
Promotion is a merge: when dev is ready, merge dev->stage, CI builds the image,
bump TAG=stage-<sha> in resources.toml, deploy from Core. stage is branched from
dev HEAD so the first real-world test carries the full current app. Per-booth
secrets must pre-exist in Core; migrations run at boot so a promotion auto-migrates
the staging ledger (where a bad migration is caught before production).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Retention (keep-last / keep-daily-days) is operational policy the on-site admin
should tune, not a server env var requiring a redeploy -- same reasoning that moved
the target directory to the UI.
- Migration 0017: site_config.backup_keep_last + backup_keep_daily_days (nullable;
null = code default 7 / 30 per field).
- BackupService reads retention fresh each run; status() exposes keepLast +
keepDailyDays. DEFAULT_BACKUP_RETENTION is now a pure code default (env reads gone).
- PUT /api/backup/config accepts keepLast / keepDailyDays (non-negative int, or null
to reset to default; 400 on negative).
- UI: two retention fields on the Backup config card; one Save covers target +
retention. i18n sq + en.
BACKUP_KEY wired into Komodo:
- komodo/resources.toml: BACKUP_KEY=[[park_buzi_backup_key]] (per-booth secret,
alongside JWT / signing keys).
- komodo/.env.komodo.example: documents it as the ONLY backup env var -- escrow it
offsite alongside EVENT_SIGNING_KEY (recovery needs both); target + retention are
admin-chosen in the UI / DB, not env. Server .env.example trimmed to just BACKUP_KEY.
Also carries the small in-progress setup-intro i18n copy trim.
Tests: 218 server tests green, incl. retention persist / reset-to-default / reject-
negative and the updated status shape. Migration applies cleanly (needed a
statement-breakpoint between the two ALTERs). Wiki backup-recovery updated.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The backup destination is now chosen by the on-site admin in the UI (Setup ->
Backup), not a server env var. An env-pinned target defeats the purpose: the admin
can't point backups at a freshly-plugged USB or a NAS mount without editing .env
and restarting. The encryption key stays a server secret.
Target storage:
- New site_config.backup_target_dir (migration 0016, nullable; null = not
configured). BackupService reads it fresh each run, so a UI change takes effect
with no restart. Only BACKUP_KEY stays env -- a key must never live in the DB it
backs up.
Routes:
- PUT /api/backup/config -- set/clear the target (backup:update; upserts id=1).
- POST /api/backup/test -- probe a candidate path server-side (exists / is a
directory / writable) so the admin gets feedback before relying on it.
- status() now exposes targetDir + keyPresent, so the UI distinguishes
'no target set' from 'BACKUP_KEY missing'.
UI (apps/web/src/BackupSettings.tsx):
- A Setup -> Backup tab (gated backup:read): an editable target-path field with a
Test-target probe (localized ok/missing/not-a-dir/not-writable), Save, the status
panel (config state, last-run size/pruned/error, a distinct amber missing-key
warning), a Back up now button, and the restore-is-out-of-band note. Full i18n
(sq + en); nav.backup.
- API client: fetchBackupStatus / setBackupTarget / testBackupTarget / runBackup.
Also includes a small in-progress copy trim to the setup-intro i18n strings.
Verified live with Playwright: typed a path -> Test reported writable -> Save
persisted it -> status reflected it and showed the key-missing warning. Whole
monorepo build/lint/test green. Wiki backup-recovery + open-question #5 updated.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The SQLite DB is the signed append-only ledger, so a disk failure / stolen or
destroyed PC means total revenue-history loss (open-question #5). This is the first
slice of the backup-recovery design: the engine + a local/mounted target + a daily
timer + a manual route.
Engine (apps/server/src/backup.ts):
- Consistent online copy of the live WAL DB via better-sqlite3's native .backup()
(not a raw file copy, which can capture a torn WAL) — the restored copy is a
byte-identical, queryable DB.
- AES-256-GCM with a scrypt-derived key from BACKUP_KEY; self-describing header
(magic|version|salt|iv|...|authTag) so a restore tool needs only the key + file.
Zero new dependencies (Node crypto).
- The plaintext intermediate is kept in scratch (not the removable/network target)
and wiped in a finally, success or fail.
- Retention: keep-last-N + one-per-day within N days.
Wiring:
- BackupService (env config, single in-flight guard, last-success/last-error).
- routes/backup.ts: GET /api/backup/status (backup:read), POST /api/backup/run
(backup:create), 409 when unconfigured. No restore route — restore is an
out-of-band runbook action on a fresh appliance, not a console call.
- New permission resource in @parking/shared.
- server.ts: an unref'd daily timer, a no-op until BACKUP_TARGET_DIR + BACKUP_KEY
are set, deliberately not run at startup (a just-power-cut booth shouldn't write
to a possibly-unmounted disk).
- openRawDb() added to @parking/db/testing (open a file without migrating, for
restore-verification tests).
BACKUP_KEY is deliberately SEPARATE from EVENT_SIGNING_KEY (independent rotation;
backups travel, the signing key shouldn't). SMB/NFS work as mount paths; SFTP +
admin UI + restore runbook are deferred slices. Tests: round-trip byte-identical,
GCM tamper/wrong-key fail, short-key rejected, scratch cleaned, route auth/RBAC +
409. build/lint/test green (212 server tests). Wiki + open-question #5 updated.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
New concept page backup-recovery.md resolving the design half of open-question #5.
Driving scenario: a stolen/destroyed PC whose LUKS+TPM disk is unrecoverable by
design — recovery stands up a NEW PC, restores a backup, and keeps signing the
SAME chain.
Settled: admin-driven encrypted full-DB backup (SQLite online-backup/VACUUM INTO,
snapshots included) to local/USB, SMB/NFS, or SFTP targets; manual button + an
in-process daily timer; keep-last-N + dailies retention; restore is admin-only /
out-of-band (operator-adversary surface). A restored copy must still verifyChain.
Key custody (the load-bearing decision, bears on #6): three independent keys —
EVENT_SIGNING_KEY kept an extractable, escrowed software key DECOUPLED from the
TPM so the ledger survives total hardware loss (the conscious trade: a TPM-sealed
signing key would be unforgeable but permanently unverifiable after the machine
dies); a NEW dedicated park_buzi_backup_key in Komodo for backup encryption,
separate from the signing key; the LUKS/TPM disk key, appliance-only and
deliberately non-recoverable. Keys are never inside the backup they unlock.
Updated open-questions #5 (design SETTLED) + #10 note; disk-os-hardening deploy
runbook (why the signing key is not sealed + park_buzi_backup_key); index catalog
+ concept count. Design only — not yet built.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Bring three queryable pages current with the booth-UX commit (cce99aa) whose
breadth hadn't propagated:
- booth-console: Active Sessions as a real table, dropped status column/filter,
inline live-feed rows, removed TARGE via-badge + redundant Direction filter,
plate now searchable + backfilled via plate-recognized WS push, per-user font scale.
- shift: Z-report display simplified (shitje dropped, opening cash added) while
the signed payload is untouched.
- i18n: users.font_scale recorded alongside language/theme as the matching
per-user server-stored pref (migration 0014).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
An early wrong assumption named the QR/RFID access reader "GEE" /
"GEE/Fondvision" / "GEE-QR-ER80" (and summarized a raw GEE PDF as its
datasheet). There is no GEE device — it's the Dingtian DT-008
(dingtian-tech.com/en_us/qr_code_reader.html), the same vendor as the relay
board, which is why it integrates the identical HTTP-GET-push way.
Code:
- Driver symbol geeQrReaderDriver → dingtianQrReaderDriver; label →
"Dingtian DT-008 QR/RFID reader (HTTP push)"; comments/description rewritten
to the real DT-008 facts (Wiegand 26/34, TCP/IP, USB, RS485 — not RS-232;
QR/barcode + ID/IC/NFC — not DataMatrix/1D).
- Persisted driverId "gee-qr-reader" → "dingtian-qr-reader" (the registry
lookup key + the row created on assign in qr-reader.ts).
- Migration 0015 rewrites existing devices.driver_id rows so configured readers
keep resolving (applied to the dev DB — 2 rows; the booth applies it on boot).
Behaviour is unchanged: naming + the persisted id only.
Wiki + memory:
- Renamed entities/gee-qr-er80.md → dingtian-dt008-reader.md and
sources/gee-qr-er80.md → dingtian-dt008.md; rewrote both to the real DT-008
product-page specs while KEEPING all the verified-on-hardware protocol facts
(cjihao serial, .jsp path, Connection: close). Fixed every cross-reference +
"GEE" mention in 6 other pages. Memory gee-reader-serial-binding →
dingtian-reader-serial-binding. The only surviving "GEE" mentions are
deliberate naming-correction notes, the raw PDF filename, and the
append-only log history.
Full workspace build/lint/test green; dev DB readers verified resolving to the
registered dingtian-qr-reader driver.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Camera snapshots were stored RAW — the camera's full-res JPEG straight into
the BLOB, no resize/recompress. Measured on the dev DB: 300 snapshots = 81.7 MB
= ~72% of the 114 MB SQLite file (the big ones 2688×1520 / ~600 KB, Hikvision
main stream). They dominated the appliance's single backed-up DB file.
Re-encode on capture (snapshot.ts):
- Downscale each frame to SNAPSHOT_MAX_EDGE (1280px long edge) + recompress at
SNAPSHOT_JPEG_QUALITY (80) via sharp (libvips, Apache-2.0) before storage —
~6-10× smaller (verified 2688×1520 → 1280×724, ~8×), plate still readable,
clean image/jpeg (drops the camera's charset cruft). STORAGE-ONLY: recognition
keeps the ORIGINAL full-res bytes (downscaling hurts OCR). Fail-soft — a
re-encode error stores the original, never drops the snapshot or blocks the
(already-open) path. sharp lives in apps/server (owns the capture path), where
bcrypt already establishes the native-dep pattern.
Disk-pressure retention (snapshot-retention.ts) — a SAFETY VALVE, not the daily
mechanism (the re-encode does that). Daily check reads the DB filesystem used%
(statfs on db.$client.name); no-op unless ≥ SNAPSHOT_DISK_HIGH_PCT (70). Over the
mark: delete the OLDEST until an estimated SNAPSHOT_DISK_FREE_TARGET_PCT (10%) of
disk is freed — never below SNAPSHOT_MIN_KEEP (500) — then VACUUM once to return
space to the OS. A DELETE only frees SQLite pages (disk doesn't drop until VACUUM),
so the loop is driven by estimated freed bytes (SUM(length(bytes))), not a live
disk re-read; the prune owns the DB-locking VACUUM, run daily off-peak. diskUsage
is injectable for tests. None of this touches the signed ledger — snapshots are
unsigned/advisory, referenced only by id.
Tests: encodeForStorage (downscale / clean-type / no-enlarge / fail-soft) +
pruneSnapshots (no-op below mark / delete-oldest-to-target + VACUUM / MIN_KEEP
floor / skip-VACUUM-when-empty). All four snapshot env knobs documented in the
komodo env reference. Full workspace build/lint/test green; the prune smoke-verified
on a scratch DB copy (file shrank after VACUUM).
Existing ~81.7 MB of raw snapshots are unchanged (a one-off re-encode backfill is
a separate optional follow-up). Updated entry-exit-points + technology-stack wiki.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
A round of operator-facing fixes on the booth screen, shift views, and the
font-scale control. (Follows the font-scale feature in f706726, which used CSS
`zoom` — reverted here for the rem approach below.)
Font scaling (the A−/A+ control now actually works without breaking layout):
- The control scaled via CSS `zoom`, which also scaled viewport-locked containers
(h-screen frame, max-h-[90vh] modals) so at 130% modal headers/footers were
pushed off-screen. Reworked to scale TEXT only: converted every `text-[Npx]`
font utility to rem across the web app (~230 sites in 25 files + the
.label/.hint/.btn component classes + body in index.css; 16px root, so 100% is
visually identical), and applyFontScale now sets the ROOT font-size. vh/h-screen
layout stays put, so chrome never clips; tall content scrolls its own container.
Verified at 130%: text 12px→15.6px while the frame stayed viewport-height.
Live feed (event rows):
- Plate, badges and reason now flow inline after the identity and wrap only when
the row runs out of width — no more forced second line when there's empty space.
- Dropped the redundant TARGË via-badge (the plate chip already conveys it).
- Removed the Direction filter group (Hyrje/Dalje) — it duplicated the entry/exit
options already in the Type filter.
Active sessions:
- Rebuilt as a real table (Ticket/subscriber · Plate · Entry · Elapsed) so columns
align and long values (subscriber names, ticket ids) no longer truncate.
- Dropped the status column (an unpaid transient is normal; a subscriber shows ★ +
name; overstay keeps a row tint). Removed the now-redundant status filter; only
the Transient/Subscriber filter remains. Plate is now searchable (uses s.plate).
Shift report (close-shift modal + Shift History + printed Z-report slip):
- Removed the confusing `shitje` (subscription-sales) sub-line — Abonime is the
total; only the out-of-window part is broken out. subscriptionSalesMinor stays in
the signed payload (audit data), just not displayed/printed.
- Show the inherited opening cash ("Arka fillestare") above the expected drawer, so
opening + cash-taken = expected reads clearly. Money values no longer line-wrap.
Subscription edit modal:
- Fixed the 2-col grid alignment: a lone "only one version" cell was shifting every
following row by one column — it now emits a full label+value pair.
Removed orphaned i18n keys (fStatus*, fDir*, srcSubSales) from sq+en (parity kept).
Full workspace build/lint/test green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
A header A−/value/A+ control scales the whole UI, persisted per user and
restored on login from any booth — cloning the theme-pref pattern end to end.
- DB: users.font_scale (migration 0014; percent, 100 = base, NOT NULL default).
- Server: PUT /api/auth/font-scale (auth-guarded; clamps to 80–160, snaps to a
10-step); fontScale flows through sessionView → login + /me.
- Client: setFontScalePref + applyFontScale; applied in App alongside theme;
FontScaleToggle in the header; i18n sq+en.
Scaling uses CSS `zoom` on the root, NOT root font-size: the app's type is pinned
in px (text-[12px] etc., ~230 spots), which a font-size change would not scale —
so the dense Active-sessions / Live-feed logs stayed tiny. `zoom` scales
everything uniformly (text, spacing, icons) like the browser's Ctrl+/−, which is
the readability win for operators who need larger text.
Tests: 4 font-scale auth-route cases (persist + /me, clamp/snap, 400, default-100).
Full workspace build/lint/test green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Two booth feed fixes:
- Plate not showing until refresh. Plate recognition is async/advisory
(snapshot.ts recognizePlate → a kind:"read" device_event keyed by the session
identity), so it lands AFTER the entry/exit event already shipped over the WS
without a plate; a refresh re-fetched via the bulk enrich path and showed it.
Added a `plate-recognized` bus event (device-events.ts) emitted when the read
is written; ws.ts forwards it; the client patchPlate(identity, plate)
(live-store) backfills the already-rendered feed row in place and invalidates
the Query-owned active-sessions list. No refresh.
- Plate search didn't filter. Both the live-feed (BoothScreen) and active-sessions
(ActiveSessions) search haystacks matched the wrong field — the displayed plate
is the ENRICHED top-level e.plate/s.plate (set by enrichEvent), not payload.plate
(the plate is unsigned, never in the signed payload). Switched the haystacks to
the displayed field.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The on-screen Hyrje/Dalje barrier lights were 2-state (green=free / red=busy)
off the camera lane-status only — they couldn't show the radar-only "detected,
not yet confirmed" state that makes the physical button lamp (relay 3) blink.
Now they mirror the lamp's 3-state rule per lane:
radar present + camera not busy → BLINK green↔red (~1 Hz)
camera busy → SOLID red
otherwise → SOLID green
End-to-end:
- LanePresence (lane-presence.ts): subscribes to deviceEvents.onInput, resolves
each presence edge to its lane via the new direction-agnostic presenceLaneOf()
(device-resolve.ts) — entry AND exit, unlike the entry-gated relayForPresence
the one-car-one-ticket gate uses — and emits a lane-presence {entry,exit} bus
event on change. Wired in server.ts (start + onClose).
- WS forwards it (hello snapshot + push) into live-store.radar.
- BarrierLight (BoothScreen.tsx) is now 3-state; blinks via the .lane-blink
keyframe (index.css), which holds solid-red under prefers-reduced-motion.
Same input + same rule as the lamp, so the screen and the post never disagree.
A new test (lane-presence.test.ts) caught a real bug: the first cut reused
relayForPresence, so the EXIT lane never resolved (it's entry-gated) and never
blinked — presenceLaneOf fixes it. Covers entry/exit independence, de-dupe
across several radars on one lane, and ignoring non-presence inputs.
Full workspace build/lint/test green (185 server tests). Updated the
button-light-indicator wiki page ("On-screen twin").
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The controller new/edit modal hardcoded both its outputs and its inputs, so an
operator could neither add a generic event-driven relay nor a free-standing input
(e.g. a second radar at the exit). This unifies both into symmetric, first-class
lists. Behaviour for existing booths is unchanged (back-compat, no DB migration).
Outputs — one event→action relays[] list:
- A relay is "when EVENT X happens, do its action": entry/exit/both pulse a
barrier; a new `radarAlert` event drives a non-barrier alert lamp (blink while
its trigger input is active, SOLID once the camera confirms a car).
- Dropped the separate config.buttonLight block — the lamp is just a relays[] row
with direction:"radarAlert" (triggerInput + blink cadence). `alertRelaysOf()`
replaces `buttonLightOf()`; ButtonLightController keeps its proven 3-state
machine (serialized UDP, fail-OFF, hot-reload), now keyed per controllerId:relay
so several alert lamps on one controller run independently. Every barrier
resolver skips radarAlert rows (no auto-open; barrier-not-a-door intact).
Inputs — one first-class config.inputs[] list (the twin of relays[]):
- Each row is { input, role, relay?, kind?, activeLow?, cooldownSec? } with a
"+ Add input" button. role ∈ button | presence | alertTrigger; button/presence
name the relay they serve. An exit radar is just another presence row.
- Keystone `inputsOf(row)`: returns config.inputs[] or SYNTHESIZES it from the
legacy relays[].button/presenceInput/... fields, so relayForButton /
relayForPresence resolve identically from either shape — zero-downtime, no
migration. entry-flow.ts is unchanged (resolves through the same functions).
- Fixed a latent bug this exposed: the alert lamp's camera lock was hardcoded to
the ENTRY camera. Added relays[].lockLane ("entry"|"exit", default entry); the
lamp now locks on its own lane's camera, so an exit radar's lamp tracks the exit
camera. button-light tracks both #entryBusy/#exitBusy.
- Driver: extracted activeLowFrom(config) — merges inputs[] activeLow, legacy
relays[].presenceActiveLow, and the inputActiveLow escape hatch.
UI: the relay dropdown gained a "Radar alert" option (reveals trigger/lock/blink
inputs); InputEditor is rewritten to a generic list (role select folds loop/radar);
i18n sq+en kept at type-parity.
Tests: new device-resolve.test.ts (inputs[] resolution + legacy fallback identical
+ exit-radar resolves to the exit relay); button-light gains a two-independent-
alert-relays case and an exit-lamp lockLane case; access-dingtian gains
activeLowFrom cases. Full workspace build/lint/test green (i18n parity included).
Wiki + memory updated (button-light-indicator, entry-double-press, dingtian-relay).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
A shared entry/exit lane has both an entry and an exit camera on ONE lane: a
subscriber driving IN is admitted by the entry cam, but the exit cam sees the same
car leaving its frame and phantom-EXITs the occurrence just opened (its back plate).
Separate RECOGNITION from AUTO-OPEN per camera:
- config.anpr (unchanged) = run snapshots through the recognizer, record the plate
(evidence), BOTH directions — stays on.
- config.anprAutoTrigger (new, absent ⇒ on when anpr is on) = may THIS camera
auto-open the barrier. Set false on the shared-lane exit cam: it still recognises
plates but never auto-triggers. The bridge gates on it (anpr-entry.ts), before the
poll loop.
UI: a "Auto open/close on subscriber plate" checkbox under ANPR in the camera setup
(shown when anpr is on); persisted true/false so a park can explicitly disable it.
i18n sq+en (also corrected the now-stale anprHint "never opens a barrier" wording —
it does, via the bridge). +1 server test (anprAutoTrigger=false → no snapshot, no
read); 172 green. Documented the two toggle levels (site-wide + per-camera) in
lane-presence-and-anpr-entry.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
A loop started by a far/early car would (a) give up before the REAL car settled at
the barrier, and (b) swallow the real car's pushes (the #polling guard dropped them).
So a confident-but-wrong far-car plate could win, or the intended car get debounced
out after the loop ended — wrong car acted on, right car blocked.
Fix: a push that JOINS a running loop now EXTENDS the deadline (lastPush +
ANPR_POLL_WINDOW_MS) instead of being dropped, capped at start + ANPR_POLL_MAX_MS
(30s) so a continuously-busy lane can't slide forever. Each tick still pulls a FRESH
frame, so the loop tracks whoever is at the barrier NOW, not the car that started it.
Per-camera sliding deadline in #pollDeadline (cleared with #polling in finally).
+1 test (push mid-poll keeps the loop alive past the initial deadline); 171 server
tests green. New knob ANPR_POLL_MAX_MS documented in the komodo env reference + the
two concurrency guards written up in lane-presence-and-anpr-entry.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The Stack example only listed the required set; expand it to the FULL reference so
an operator can see (and tweak) every env without digging through code. Grouped:
- IMAGE SELECTION (REGISTRY, TAG)
- REQUIRED (JWT_SECRET, EVENT_SIGNING_KEY, COOKIE_SECURE — no safe default)
- COMMONLY SET (VISION_ENABLED, WS_ALLOWED_ORIGINS)
- SET BY COMPOSE — don't put in the Stack (VISION_URL, DATABASE_URL, VISION_RECOGNIZER)
- OPTIONAL TUNABLES with code defaults: ports, logging/retention, device+printer poll
intervals, lane/capture TTLs, and the ANPR knobs incl. this session's new
ANPR_POLL_MS=1000 / ANPR_POLL_WINDOW_MS=8000 (raise the window for a slow barrier)
- VISION CONTAINER env (the Python service's own VISION_* vars)
All defaults pulled from the code (process.env.X ?? default). Documentation only.
apps/web/.env.production hardcoded VITE_API_BASE=http://127.0.0.1:3000 — a
desktop-only value that's WRONG for the booth, which serves the SPA same-origin
(Fastify dist/ via Caddy :80) and needs a RELATIVE /api base. An absolute origin
baked at build would point the browser at localhost. origin.ts treats empty as
relative (API_BASE=""), matching the deploy (the 77b2acb fix / container-deployment
"Web access").
The desktop (Tauri) build DOES need an absolute origin, but that app is a deferred
separate task (currently hardcoded localhost); it must set VITE_API_BASE for its own
build when resumed, not here. Comment updated to say so.
The poll-until-confident loop (prev commit) opened a race: during its ~8s window a
subscriber could scan their card/QR at the reader and exit immediately — but the ANPR
loop kept polling and would ALSO emit a confident read a moment later, exiting the
NEXT open occurrence (a phantom double-exit, worst for a fleet sub with several open).
Guard it with the subscriber's open-occurrence count: the bridge identifies the
subscription as soon as a frame reads the bound plate (identity needs no confidence),
baselines openOccurrenceCount, then each tick AND before emit checks if it moved. If a
credential closed/opened an occurrence mid-poll, the subscriber already transacted →
abort, don't emit. New public SubscriptionFlow.openOccurrenceCount(). Bounded loop is
unchanged (ANPR_POLL_WINDOW_MS=8000 cap; never infinite).
+1 test (credential transacts mid-poll → no double-act); 170 server tests green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
A tiny stdlib HTTP server that logs every request (source IP, method, path, full
body, JPEG part stripped) to verify whether a Hikvision camera actually POSTs its
Alarm Server events — independent of our app's parsing/acceptance. It cracked the
2026-06-27 "auto-exit" investigation: proved the exit camera was sending NOTHING
(corrupt config DB), then later that it sent plain VMD without targetType=vehicle.
python3 test-post-camera-events.py [port] # default 8099
Point a camera's Alarm Server at this host:port; drive a car. A line from the
camera IP = it sends (debug downstream); silence = the camera isn't POSTing.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The ANPR bridge took ONE snapshot at the camera's vehicle-alarm instant — but the
alarm fires as the car APPROACHES, so that frame's plate is small/blurry/half-in-
frame and ANPR returns a low-confidence misread ('111'@0.20). The manual test reads
the SAME car at ~100% because by then it's STOPPED at the barrier, well-framed. So
subscriber auto-exit silently never fired (read below the 0.85 floor → ignored).
Fix (the car-stops-at-the-barrier insight): the bridge now PULLS A FRESH FRAME every
ANPR_POLL_MS (1000) and re-runs ANPR until one clears VISION_ENTRY_MIN_CONFIDENCE, or
ANPR_POLL_WINDOW_MS (8000) elapses (drove off / non-subscriber → give up cleanly).
- One loop per camera (#polling set) — the camera's ~1Hz alarm re-fires JOIN the
running loop instead of spawning N concurrent loops.
- Fresh camera.captureSnapshot each tick, NOT captureSnapshotShared (its 1.5s TTL
would re-serve the same bad approach frame).
- Camera-level debounce stamp moved to AFTER a successful emit (suppresses re-fires
for ANPR_DEBOUNCE_MS once we've acted), not before the loop.
VERIFIED on hardware (DS-2CD1047G3H-LIU exit lane): 7 garbage approach frames →
AA890XX@0.999 at the barrier → signed vehicle_exit. Still advisory + fail-soft; a
barrier never opens on a low-confidence read. anpr-entry.test.ts +1 (poll
escalation low→low→high); 169 server tests green. Documented in
lane-presence-and-anpr-entry + the lpr-camera camera-fault writeup.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The admin needs the device web password (to reach a controller/camera's own web
UI), and it's already stored + sent to this admin-only view (redactSecrets strips
only the machine secrets relay/push pw, NOT webPassword — by design, per the
SECRET_CONFIG_KEYS comment). But the form rendered every `secret` field as a masked
password input with no way to unmask it, so the value was present yet unreadable.
Add a per-field show/hide eye toggle on `secret` inputs. No new exposure: the field
is already admin-gated and the value already reaches the client; this just makes the
intended-visible credential readable/copyable. Machine secrets are redacted
server-side and never arrive, so there's nothing there to reveal. i18n sq+en.
healthCheck only opens the transport (TCP connect / USB open) — it proves the
printer is REACHABLE, not that paper feeds and the head fires. Add a "Print test
slip" action so the admin can physically confirm a printer is live (the new
host-net USB /dev/usb/lpN path, or a network printer).
- server: POST /api/setup/test-print — printer-only, re-merges stored secrets like
/test (so an edited network printer authenticates), creates the device, and pushes
a short slip via the device-agnostic printReport(). Fail-soft: a print error
(paper out, head fault, transport drop) is reported, never a 500. Mirrors the
test-anpr pattern.
- web: testPrint() client + PrintTestResult; a button in the device modal shown for
category=printer, with ok/fail rendering. i18n keys in sq + en (parity holds).
Server 168 tests pass; web + server typecheck clean.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
USB passthrough (1ea1aa4) made /dev/usb/lp1 visible in the container, but the node
is `crw-rw---- root:lp` (660) and the server runs as the non-root `app` user, not in
`lp` — so open(O_WRONLY) → EACCES → printer still "offline". Add the host's `lp` GID
(7 on this Ubuntu booth, verified `getent group lp` → lp:x:7:) via group_add, so the
app process gains the supplementary group that owns the node. Least-privilege: no
world-writable device, no root, no image rebuild. (If a future booth's lp GID differs,
update the number.)
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The Hikvision driver's create() had a leftover `console.log(c)` that dumped the
ENTIRE camera config — including the plaintext `password` — to stdout every time
the adapter was built, on every request that resolves a camera. That puts a device
credential in the logs (which get shipped/cached/read — the booth operator is the
adversary). Removed. Swept the rest of the shipped source: no other console.* leaks.
The USB ESC/POS printer is the host's /dev/usb/lpN (usblp char device, major 180),
but the container has its own /dev — `docker exec server ls /dev/usb` → "No such
file or directory", so probeUsb's open() ENOENTs and the printer is always offline
regardless of the path set in setup. Containerization isolates host hardware (same
root cause as the network fix); USB needs explicit passthrough:
- volumes: /dev/usb:/dev/usb → the lpN NODES appear inside the container
- device_cgroup_rules: 'c 180:* rmw' → permit the usblp char major (180), and the
`:*` minor wildcard survives lp0/lp1/lp2 renumbering across replug/boot-order.
Binding the /dev/usb DIR (not a single `devices:` node) is what survives renumber.
Merge verified: parking-data ledger volume preserved (lists append), host net intact,
config valid. Booth prereq: `usblp` loaded at boot + printer attached before start,
else /dev/usb is absent.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
network_mode: host + sysctls: net.ipv4.ping_group_range fails at container create:
"sysctl not allowed in host network namespace" — runc refuses a per-netns sysctl
when there's no separate netns. Remove it; under host net the server uses the HOST's
ping_group_range (set on the booth via /etc/sysctl.d). Fixes the park-buzi-server-1
start failure introduced by c87dcb2.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
In prod the containerized server sat on the Docker bridge (172.18.0.x) and could
only see eth0 — so the setup backend-IP picker (net.ts networkInterfaces) showed
just the Docker IP, the server couldn't reach the relay or fetch Hikvision ISAPI
snapshots, and push devices (readers/cameras) couldn't reach it. The server is the
ONLY container doing device I/O, so put it on the HOST network namespace.
- docker-compose.prod.yml: server + proxy → network_mode: host (server detaches the
base `parking` network via `networks: !reset []`). server VISION_URL=127.0.0.1:8089.
vision stays BRIDGED (it never touches a device — the server hands it JPEG bytes)
but publishes 8089 on 127.0.0.1 only, so the host-net server reaches it over
loopback while the ANPR service stays off the LAN.
- docker-compose.yml: VISION_URL is now ${VISION_URL:-http://vision:8089} so dev keeps
compose-DNS service-name routing; prod overrides to loopback.
- Caddyfile: reverse_proxy 127.0.0.1:3000 (was server:3000 — service DNS doesn't
resolve on host net). Dev doesn't use Caddy, so unaffected.
Merge validated for both envs (booth.sh config, exit 0). Host-net side effect: the
container ping_group_range sysctl is a no-op — the HOST must set it for reader ICMP
liveness (see appliance-provisioning).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Adopt Komodo Periphery (over the NetBird mesh) as the booth fleet control plane,
superseding SSH-and-booth.sh. The booth runs the SAME compose files; Komodo Core
drives them remotely. booth.sh is demoted to a break-glass local fallback.
- komodo/resources.toml mirrors the working park-buzi Stack (built by hand in the
Core UI, then exported to TOML — field names match the running v2.2). Stack-only:
servers are created by the agent onboarding OUTBOUND (one-time onboarding key →
Periphery self-registers, auto-rotating keys, booth opens no inbound port), so
there is no [[server]] block. Per-booth secrets via [[...]] refs to Core's store.
- komodo/README.md + .env.komodo.example document the flow and the hard rules
(no webhook; onboarding/outbound/mesh-only; per-booth unique secrets; never
down -v the ledger volume).
- wiki/decisions/fleet-deployment-komodo.md records the decision + threat-model
analysis (Periphery is a root agent → mesh-bound; EVENT_SIGNING_KEY-in-Core is a
fraud-root blast radius until ATECC608 signs; Core is now Tier-0; GPL-3.0 is fine
as external ops tooling). container-deployment reframed (booth.sh = fallback);
index + log updated.
Verified end-to-end against a real booth (park-buzi): onboarded OK, Stack deployed,
all containers green, admin seeded.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The booth deploys the compose files FLAT (e.g. /opt/parking_systems/) with
booth.sh next to them, but the script assumed it lived in <repo>/scripts/ and
blindly did `cd ..` — so REPO_DIR resolved to the parent, where there are no
compose files, and every subcommand operated on the wrong dir. `usage()` then
sed-read a relative $0 that no longer existed after the cd ("can't read
booth.sh"). Discover the compose files instead: check the script's own dir,
then ../, then $PWD, and cd to whichever has docker-compose.yml. usage() reads
an absolute $SELF so it survives the cd.
Also: .env.example defaulted TAG=main, but the registry only has dev-* tags
(no main build yet), so `compose pull` 404s. Default to TAG=dev and document
the moving-vs-immutable (dev / dev-<sha>) tag scheme.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
- lpr-camera.md: "503 Device Busy" can be PERSISTENT (main-stream saturation on
the G3H) — the real fix is sub-stream selection, not just retry.
- device-status-monitoring.md: QR reader health was false-healthy (hardcoded
"ready") until the ICMP-ping fix; document the push-device monitoring model.
- log entries for both 2026-06-26 sessions.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Two genuinely-offline QR readers showed GREEN: the adapter's healthCheck was
hardcoded to { ready, "stub" } and never probed. These are PUSH devices (scan →
GET our backend, resolve by serial) with NO TCP port, so a connect probe has
nothing to hit — the stub "solved" that by lying. False-healthy is the worst
failure for a status bar.
- Optional reader IP field (monitor-ONLY; scans still resolve by serial,
operation unchanged).
- Unprivileged ICMP ping (drivers/icmp.ts): shells /bin/ping -c1, exit-0 = reply.
No native dep, no CAP_NET_RAW. docker-compose.prod.yml sets
net.ipv4.ping_group_range so it works for the non-root container user.
- healthCheck: replies → ready, no reply → offline, NO IP → degraded
("set IP to monitor") — never a false green.
Verified on hardware: readers (10.0.10.7/.8) answer ICMP on the device VLAN;
UI Test connection → "● ready — ping 10.0.10.7". Tests: reader.test.ts (4).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
When a camera has Alarm Server push enabled, the setup form now shows the
camera's Alarm Settings (Destination IP / URL / Protocol / Port) ready to copy,
so the operator never hunts the deviceId or memorises the endpoint.
CRUCIAL: host/port come from the BACKEND address on the camera's subnet
(backendIpForDevice + the server's listen port — the same probe the push-IP
picker uses), NOT window.location.origin (the SPA's dev/proxy origin, which
would wrongly say localhost:5173). Verified live: matches the on-camera config
field-for-field (10.0.10.203 / …/event / HTTP / 3000). Shows a "save first"
(needs a deviceId) then "test first" (needs the resolved backend IP) hint.
i18n keys added to sq + en (parity enforced).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
A Hikvision DS-2CD1047G3H-LIU returned HTTP 503 (statusCode 2 / deviceBusy)
on EVERY main-stream snapshot — its main encoder is persistently saturated.
Probed on hardware: channels/101/picture → 503 on 5 consecutive tries, while
channels/102/picture (sub stream) → 200 clean JPEG every time. A retry loop
can't fix a persistent busy; the real fix is stream selection.
- Add a `stream` config field to the Hikvision driver (1=main, default for
back-compat; 2=sub). ISAPI channel id is <channel><stream> (101 main, 102 sub).
Verified live: setting the G3H to Sub flips its status degraded→ready (14.7KB
JPEG in ~87ms).
- captureSnapshot also retries the TRANSIENT case (503/500, linear backoff
250/500/750ms ×4) then fails naming it "(device busy)"; does NOT retry 401/404
(config errors won't self-heal). Complements captureSnapshotShared (concurrent
de-dup). healthCheck still reports a live 503 as degraded (surfaces a saturated
main stream rather than hiding it).
Tests: camera.test.ts (10) — retry behaviour + main/sub path selection.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The dev box runs vision as bare `uv run uvicorn`, and a plain uv run/uv sync
re-resolves the venv to the lockfile DEFAULTS, stripping fast-alpr/onnxruntime.
So after any `pnpm dev` real ANPR silently degraded to "snapshot, no plate"
(diagnosed 2026-06-25: real reads through 06-22, venv frozen lean since 06-19,
no other env with fast_alpr). The BOOTH was never affected — it runs the Docker
image, which bakes `uv sync --frozen --extra alpr` at build (immutable, weights
pre-warmed); a booth ModuleNotFoundError is a STALE image (fix: booth.sh update).
Vision package.json dev/start/recognize now run `uv sync --extra alpr &&` first
so pnpm dev is self-healing; added a dev:stub escape hatch for a lean run.
Documented in wiki/decisions/vision-service-packaging.md ("Two runtimes, one
fragile") + a log entry.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
On a vehicle entry, two paths captured the SAME Hikvision camera within ~1s —
the ANPR bridge (barrier-driving) and the advisory snapshotAsync (evidence/
telemetry) — each from a separate adapter instance. Hikvision serves snapshots
single-threaded, so the second concurrent GET returned HTTP 503; the bridge
then fail-softed and burned its 12s debounce, producing a ~74s "slow" subscriber
entry (observed 2026-06-25, Qazim Mulleti / AB816NN — plate read was instant at
conf 1.000; the delay was the 503/debounce churn, not recognition).
Add captureSnapshotShared() in snapshot.ts: a module-level, deviceId-keyed cache
that both paths call. It coalesces in-flight captures (the 2nd caller awaits the
1st's pull → no concurrent 503), serves a brief freshness window (1500ms) so the
bridge→advisory sequence for one vehicle reuses one frame, never caches a failure
(next caller retries), and keys by deviceId (no cross-camera/stale-vehicle reuse).
Wired into anpr-entry.ts (bridge) and snapshot.ts (advisory).
Tests: snapshot.test.ts (concurrent coalescing, TTL reuse, TTL-lapse re-pull,
failure-not-cached, per-camera keying); anpr-entry.test.ts mock updated. 168
server tests green.
NOTE: this removes the latency (the 503 collision). The separate double-entry
(two signed vehicle_entry for one car) — debounce-too-short / stamp-before-
success — is still open; less likely now but not eliminated.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The booth PC (Ubuntu) needs one command instead of the long
`docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env …`
over the three compose files.
scripts/booth.sh — prod by default (ENV=dev for the dev override):
up/down/restart/status/logs/pull/config/exec, plus the requested `update` =
pull the moving branch tag → up -d --remove-orphans (recreates only
digest-changed services; named volumes / the SQLite ledger are preserved) →
docker image prune. Prod refuses to run without .env (no safe JWT_SECRET
default); dev with no .env injects the documented benign local secret (the
base file makes JWT_SECRET shell-required via ${JWT_SECRET:?}). down never
passes -v (would wipe the signed-ledger volume); help/unknown-command
short-circuit before any Docker/.env requirement.
.env.example — the vars the compose files consume (REGISTRY, TAG, JWT_SECRET,
EVENT_SIGNING_KEY, COOKIE_SECURE=0, WS_ALLOWED_ORIGINS). .env stays gitignored.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
New concepts/printer-usb-transport.md (the seam, usblp char device,
reachability-only status, threat model). open-questions #14: confirm the
on-site printer is USB and bake the usblp + udev write-access rule into the
appliance image (provisioning, not app code; unverified on hardware). Updated
rongta-printer.md (USB transport note), index.md, log.md.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The wizard rendered every configField in a flat loop, so the USB device path
showed under a Network printer (and host/port would show under USB) — the
form could mislead. Add a transport-aware filter (mirroring the existing
pulseMs/inputRestingHigh skip): when Connection=USB hide host/port/httpPort,
otherwise hide devicePath. Verified live (Playwright): each transport shows
only its own fields and toggling swaps them.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The ESC/POS printer drivers were TCP-only — every path went through
sendRaw/probe to a raw socket on port 9100. Add a USB transport behind
the existing render layer without touching a single render*() function.
- printer-escpos.ts: sendRawUsb/probeUsb write the same ESC/POS bytes to a
kernel usblp char device (/dev/usb/lp0) via a plain fs write — no
libusb/CUPS/native dep (keeps MIT-only + minimal-deps appliance). A
discriminated Transport + transportFromConfig/sendTo/probeTo dispatch the
wire; anything not transport:"usb" is TCP, so existing host-only configs
need no migration. Shared transportField/devicePathField config fields.
- cashino + rongta resolve a Transport once; both are reachability-only over
USB, and the Rongta's HTTP status page degrades to the open-the-node probe
over USB (no guessed paper/cover — the standing honesty rule). host/port
made not-required so a USB printer needs neither.
- Tests: printer-escpos.test.ts (USB writes the exact rendered bytes; probe
present/absent; transportFromConfig TCP back-compat) + printer-cashino.test.ts
(USB-configured driver prints to the node, ready/offline).
USB itself is unverified on hardware (the on-site printers are networked);
the appliance-side usblp + udev provisioning is tracked as open-questions #14.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V