46 Commits

Author SHA1 Message Date
julian d5e41500a8 feat(backup): admin UI with admin-chosen target directory
Build desktop / desktop (push) Successful in 4m42s
Build & push images / images (push) Successful in 2m53s
CI / check (push) Successful in 39s
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
2026-06-29 12:21:26 +02:00
julian 0c218179c4 feat(backup): encrypted on-site DB backup engine + local target
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
2026-06-29 11:59:45 +02:00
julian 9e442586af docs(wiki): settle on-site encrypted backup + disaster-recovery design
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
2026-06-29 11:43:23 +02:00
julian 11567a417f docs(wiki): catch concept pages up to the booth-UX/shift/font-scale work
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
2026-06-29 11:43:12 +02:00
julian f6e35bbebf fix(reader): correct the QR reader's identity — Dingtian DT-008, not "GEE"
Build desktop / desktop (push) Successful in 4m16s
Build & push images / images (push) Successful in 2m46s
CI / check (push) Successful in 38s
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
2026-06-28 17:39:15 +02:00
julian 96acd6b662 feat(snapshot): re-encode captures + disk-pressure retention
Build desktop / desktop (push) Successful in 4m13s
Build & push images / images (push) Successful in 2m56s
CI / check (push) Successful in 38s
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
2026-06-28 17:15:15 +02:00
julian cce99aadfd fix(web): booth UI/UX pass — readable font scaling + booth layout/report clarity
Build desktop / desktop (push) Successful in 4m12s
Build & push images / images (push) Successful in 2m42s
CI / check (push) Successful in 37s
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
2026-06-28 15:15:09 +02:00
julian f706726eeb feat(prefs): per-user UI font scale (A−/A+), saved to the profile
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
2026-06-28 12:25:04 +02:00
julian 6734e9815e fix(booth): backfill the live-feed plate + make plate search work
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
2026-06-28 12:24:46 +02:00
julian 38481f105f feat(booth): blink the Entry/Exit lights on radar presence (mirror relay 3)
Build desktop / desktop (push) Successful in 4m14s
Build & push images / images (push) Successful in 2m42s
CI / check (push) Successful in 37s
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
2026-06-28 11:48:12 +02:00
julian 4418594af0 refactor(setup): unify controller I/O — event-driven relays[] + generic inputs[]
Build desktop / desktop (push) Successful in 4m16s
Build & push images / images (push) Successful in 2m43s
CI / check (push) Successful in 38s
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
2026-06-28 11:23:15 +02:00
julian 25a72ff20a feat(anpr): per-camera auto-open toggle (anprAutoTrigger) for shared lanes
Build desktop / desktop (push) Successful in 4m32s
Build & push images / images (push) Successful in 2m43s
CI / check (push) Successful in 37s
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
2026-06-27 23:52:16 +02:00
julian c2a861208f fix(anpr): sliding poll window so a car arriving mid-loop isn't lost
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
2026-06-27 23:30:41 +02:00
julian a888125eca docs(komodo): complete env reference — every server/vision var + defaults
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.
2026-06-27 23:23:11 +02:00
julian 96fd97efa9 fix(web): VITE_API_BASE relative (empty) for the booth's same-origin SPA
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.
2026-06-27 23:16:19 +02:00
julian 2a13b95da6 fix(anpr): abort the poll loop if the subscriber transacts by card/QR mid-poll
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
2026-06-27 23:05:31 +02:00
julian 513566c89e chore(debug): add test-post-camera-events.py — a dumb HTTP sink for camera pushes
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
2026-06-27 22:55:02 +02:00
julian f77ed11782 feat(anpr): poll snapshots until a confident plate, so auto-exit works
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
2026-06-27 22:54:49 +02:00
julian e4a17efd97 feat(setup): reveal toggle for secret fields (the device web password)
Build desktop / desktop (push) Successful in 4m37s
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 44s
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.
2026-06-27 17:51:32 +02:00
julian 6d32e0fc0f fix(i18n): correct translation for 'addAnother' in Albanian
Build desktop / desktop (push) Successful in 4m29s
Build & push images / images (push) Successful in 2m41s
CI / check (push) Successful in 42s
2026-06-27 14:37:02 +02:00
julian 3a60367232 feat(setup): print a real test slip from the printer "Test connection" modal
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
2026-06-27 14:36:39 +02:00
julian 045892bc94 fix(deploy): group_add lp (gid 7) so the server can write the USB printer node
Build & push images / images (push) Successful in 2m41s
CI / check (push) Successful in 42s
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
2026-06-27 14:23:02 +02:00
julian 916c147b4d fix(camera): drop debug console.log(c) leaking the camera password to logs
Build desktop / desktop (push) Successful in 4m39s
Build & push images / images (push) Successful in 2m49s
CI / check (push) Successful in 41s
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.
2026-06-27 14:13:45 +02:00
julian 1ea1aa4189 fix(deploy): pass the USB printer (usblp) into the host-net server container
Build & push images / images (push) Successful in 2m45s
CI / check (push) Successful in 39s
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
2026-06-27 14:11:51 +02:00
julian 9c20faf8de fix(deploy): drop server sysctls under host net (runc rejects per-netns sysctl)
Build & push images / images (push) Successful in 2m37s
CI / check (push) Successful in 35s
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
2026-06-27 12:59:10 +02:00
julian a68dc23393 fix(i18n): update placeholder text for park name in English and Albanian translations
Build desktop / desktop (push) Successful in 4m14s
Build & push images / images (push) Successful in 2m38s
CI / check (push) Successful in 37s
2026-06-27 12:48:51 +02:00
julian c87dcb2253 fix(deploy): server on host network so it sees the booth LAN / device VLAN
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
2026-06-27 12:48:27 +02:00
julian 7eadf71a0b docs(wiki): appliance-provisioning — Komodo deploy is now the primary flow
§6 split: §6 = Docker engine only; new §7 = the Komodo Periphery deploy (PRIMARY,
verified end-to-end on park-buzi 2026-06-27):
- 7a install Periphery (onboarding key, user-mode/outbound, runs as admin, no
  inbound port; core_address = Core's proxy URL)
- 7b deploy the Stack in Core (registry+git accounts, per-booth [[..]] secrets,
  env incl. COOKIE_SECURE=0; seed admin via Komodo's container terminal — no SSH)
- 7b-bis fleet-as-code via komodo/resources.toml + ResourceSync (empty diff =
  in sync)
- 7c break-glass: manual booth.sh when mesh/Core is down

Added Komodo deploy gotchas 7-11 (core_address is the proxy URL not :9120;
git-auth ≠ registry-auth; user-mode vs /etc/komodo root_directory; core_address
singular; empty-diff/disabled-Execute = success). §5b SSH TODO reframed (Komodo
removes SSH from routine ops). Header + date updated; log entry added. Fixed a
stale [[atecc608-secure-element]] alias in the prior log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 12:30:08 +02:00
julian 9918f278b2 feat(deploy): Komodo fleet deployment — resources.toml + decision
CI / check (push) Successful in 36s
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
2026-06-27 12:14:04 +02:00
julian 83298bc0c5 fix(deploy): booth.sh works in the flat /opt layout; .env TAG=dev default
Build desktop / desktop (push) Successful in 4m37s
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 37s
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
2026-06-27 08:42:11 +02:00
julian 898cf1953a docs(wiki): camera 503/stream, alarm URL helper, reader ICMP liveness
- 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
2026-06-26 16:47:19 +02:00
julian dd0f6e483a fix(reader): real ICMP liveness — QR reader status was a hardcoded "ready"
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
2026-06-26 16:47:12 +02:00
julian 40de8a7467 feat(setup): generate the camera's Alarm Server settings to paste
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
2026-06-26 16:47:02 +02:00
julian f0fd15bb88 fix(camera): selectable snapshot stream + retry transient 503 Device Busy
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
2026-06-26 16:46:46 +02:00
julian 40ffa90dac fix(vision): self-heal local real ANPR — dev scripts sync the alpr extra
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
2026-06-26 08:11:17 +02:00
julian b3cb67188e fix(anpr): share one camera snapshot across bridge + advisory paths
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
2026-06-26 08:10:56 +02:00
julian b1c4109045 docs(wiki): document scripts/booth.sh in container-deployment
Add a "Booth operator wrapper" section (commands, the update flow, env
handling, the volume/ledger safety notes) + a log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 20:38:44 +02:00
julian 50dd554b43 feat(deploy): booth.sh wrapper over the compose files + update flow
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
2026-06-24 20:38:44 +02:00
julian 6d7682ab4a docs(wiki): printer USB transport + open-question for the provisioning
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
2026-06-24 20:32:24 +02:00
julian 793b8d83ee fix(setup): hide transport-irrelevant printer fields (USB vs Network)
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
2026-06-24 20:32:17 +02:00
julian 7366ad19cb feat(printer): USB transport behind the ESC/POS render layer
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
2026-06-24 20:32:10 +02:00
julian 5a5fedf4f4 docs(wiki): booth bring-up fixes — relay password, secret re-merge, lamp concurrency
- dingtian-relay: the "offline despite ping" gotcha (relay_pw in every binary frame,
  missing form field → Test connection sent 0 → timeout) + the identity-gated secret
  re-merge that stops a redirected probe exfiltrating the password.
- button-light-indicator: serialized desired-state worker (UDP is unordered → the lamp
  stuck on/off) and hot-reload of the lamp config (no restart).
- log entry for the three fixes (commits 420542c / fd15988 / 830993b).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 19:11:49 +02:00
julian 830993bcb8 fix(button-light): serialize relay sends + hot-reload the lamp config
Build desktop / desktop (push) Successful in 4m20s
Build & push images / images (push) Successful in 2m45s
CI / check (push) Successful in 37s
Two bugs in the button-light controller:

1. Stuck relay (random on/off). The blink fired fire-and-forget setAux every 500ms over
   UNORDERED UDP with no serialization — concurrent on/off packets reordered/overlapped,
   so the relay latched on whichever packet the device processed last. Replace with a
   desired-state + serialized worker (#pump): the blink timer only flips desiredOn; a
   single in-flight send per lamp is guaranteed, and on completion it re-converges to the
   latest desired state — so the final state is always authoritative and a lost/stale
   packet self-corrects.

2. Lamp ignored until restart. The lamp map was built once at start(); a button light
   added/changed via the UI never took effect without a server restart. #reconcile now
   re-reads the device config (at start and before each event, like DeviceMonitor),
   adding/updating/dropping lamps live — so a just-saved lamp blinks on the next radar
   edge.

Tests assert confirmedOf() (the device's latched state); +1 reconcile-after-start case.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 19:04:18 +02:00
julian fd15988a73 refactor(setup): split the controller form into Outputs and Inputs sections
The controller editor mixed outputs and inputs in one flat "Relays" block — relay
direction, the entry-button terminal, and the presence/radar terminal all on the same
row, with the lamp orphaned below. Reorganize into two labelled sections:

- Outputs — relays (barriers + lamp): relay # + direction, the button-light relay, and
  "Pulse open (ms)" (a relay hold-time, NOT an input setting — answers a recurring
  confusion).
- Inputs — terminals (button, sensor): per entry relay, the button + presence/radar
  terminals (kind, active-low) and cooldown, each labelled "For relay N", plus the
  board-wide "Inputs idle HIGH".

UI-only: storage stays config.relays[] (+ config.buttonLight), so saved booth configs
keep working with no migration. pulseMs/inputRestingHigh are pulled out of the generic
field loop and rendered in their section. i18n parity (sq + en).

Also passes the device id to testDevice() so an edited device's stored relay password
re-merges on Test connection (pairs with the secure-merge server change).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 19:04:05 +02:00
julian 420542ce10 fix(setup): add Dingtian relay-password field + secure secret re-merge on test
The relay control password (relay_pw) was read by the driver but had NO form field,
so Test connection sent it as 0 → the device ignored the probe → a controller showed
"offline" even though it pinged. Add a "Relay control password" config field (secret;
blank keeps the stored value).

Because relayPassword is redacted from the client, the edit form can't resend it — so
the test endpoint now re-merges the stored secret by device id (mirroring save). It is
re-merged ONLY when the submitted config addresses the SAME device: matching driverId
and every connection-identity field it sets (host/port/binaryPort/httpPort/serial). A
redirected host/port or mismatched driver yields NO secret, so a probe can't exfiltrate
the password to an attacker host (the booth operator is the threat-model adversary).
testDevice() now passes the device id; setup-secrets.test.ts covers the identity guard.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 19:03:53 +02:00
julian 2915d141aa feat(devices): radar presence input + button-light output on the controller
Model the entry button (I1) and a Hikvision radar (I2) as named children of the
access controller, and drive the button's 12V lamp on a spare relay.

- Radar = the existing relays[].presenceInput one-car-one-ticket gate, now labelled
  presenceKind: loop|radar. A radar may idle opposite the button, so add a per-input
  active-level override: relays[].presenceActiveLow -> driver inputActiveLow set,
  inverting just that terminal (pure helper inputActive()). The Dingtian has one
  board-wide resting level otherwise.
- AuxOutputDevice.setAux(channel,on) capability on the device interface (Dingtian
  latch) so business logic drives a NON-barrier lamp through the interface. Barriers
  still only pulseOpen — barrier-not-a-door preserved.
- ButtonLightController: subscribes to the radar input edge + the camera lane status
  and drives a 3-state lamp — radar+car=solid, radar-only=blink (~1Hz), else off.
  Fails OFF on host loss/error; de-duped. A radar detection never opens a barrier on
  its own (advisory; threat model).
- SetupWizard: presence kind + active-low + a button-light relay picker; sq+en i18n.

Tests: button-light.test.ts (truth table + blink + fail-OFF + de-dupe),
access-dingtian.test.ts (active-level inversion). Workspace build+lint+test green
(158 server tests). Wiki: hikvision-radar, button-light-indicator + updates.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 11:45:22 +02:00
129 changed files with 8449 additions and 861 deletions
+37
View File
@@ -0,0 +1,37 @@
# Booth deploy env — copy to `.env` and fill in, then run ./scripts/booth.sh up
# (prod). Consumed by docker-compose.yml + the prod override via --env-file.
# See wiki/decisions/container-deployment.md. Do NOT commit the filled-in .env.
# --- image source (prod pulls from the house Gitea registry) ------------------
# The registry namespace; combined with the image name + TAG below.
REGISTRY=git.infra.msai.al/mca/parking_solution
# Image tag to deploy. CI publishes TWO tags per build: a MOVING branch tag
# (`dev`, and `main` once that branch is built) republished on every push, and an
# IMMUTABLE per-commit `dev-<sha>` (e.g. dev-830993b). Use the moving tag for a
# self-updating booth (`booth.sh update` pulls the latest); pin the `<branch>-<sha>`
# form for a reproducible, deterministic deploy. NOTE: `main` images only exist once
# something is built on main — until then deploy from `dev`.
TAG=dev
# --- secrets (NO safe defaults — the server refuses to boot without a real one) -
# JWT signing secret. Generate yourself, never share it: openssl rand -hex 32
# Must be 32+ chars and must NOT contain change-me / insecure / dev-only.
JWT_SECRET=
# Ledger-signing key for the append-only signed event chain. Set a DISTINCT value
# in prod (don't reuse JWT_SECRET). openssl rand -hex 32
EVENT_SIGNING_KEY=
# --- booth LAN specifics ------------------------------------------------------
# Auth cookie is HTTPS-only by default; the booth is plain HTTP behind Caddy on
# :80, so this MUST stay 0 or operators cannot log in. Set to 1 only behind TLS.
COOKIE_SECURE=0
# Remote origins the live WS feed must accept (same-origin always passes). Add any
# address admins hit the UI from beyond the booth itself, comma-separated, e.g.
# http://parksystems.msai.al (leave blank if only the local booth URL is used).
WS_ALLOWED_ORIGINS=
# Vision/ANPR. Prod override already forces the fast_alpr engine; leave VISION_ENABLED=1
# unless you are running without the camera. (Set 0 to disable the vision call entirely.)
VISION_ENABLED=1
+3 -1
View File
@@ -9,5 +9,7 @@
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`. # CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
:80 { :80 {
encode gzip encode gzip
reverse_proxy server:3000 # Host network (prod): the server runs on the host's net namespace (to reach the booth LAN /
# device VLAN), so reach it over loopback, not the compose service name `server`.
reverse_proxy 127.0.0.1:3000
} }
+15
View File
@@ -15,6 +15,21 @@ JWT_SECRET=
# them (keyId), so verifyChain still validates a chain that spans a key change. # them (keyId), so verifyChain still validates a chain that spans a key change.
EVENT_SIGNING_KEY= EVENT_SIGNING_KEY=
# On-site encrypted DB backup (durability for the signed ledger). A daily timer + an admin
# "back up now" button write a consistent, AES-256-GCM-encrypted copy to the target. The
# TARGET DIRECTORY is chosen by the admin in the UI (Setup → Backup) and stored in the DB —
# NOT here. Only the encryption KEY is an env secret. RESTORE is an out-of-band runbook action,
# not a console call. See wiki/concepts/backup-recovery.md.
#
# Dedicated backup-encryption key (>=16 chars), SEPARATE from EVENT_SIGNING_KEY so it can
# rotate without fracturing the signed chain. Generate with: openssl rand -hex 32
# Escrow it offsite (alongside EVENT_SIGNING_KEY) — recovery needs both, and neither is ever
# stored inside the backup it unlocks. Backups stay a no-op until BOTH this key and an in-UI
# target directory are set.
# BACKUP_KEY=
# BACKUP_KEEP_LAST=7 # keep this many newest backups always
# BACKUP_KEEP_DAILY_DAYS=30 # plus one-per-day within this window
# Optional ---------------------------------------------------------------- # Optional ----------------------------------------------------------------
# PORT=3000 # PORT=3000
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only. # HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
+2 -1
View File
@@ -23,7 +23,8 @@
"@parking/shared": "workspace:*", "@parking/shared": "workspace:*",
"bcrypt": "6.0.0", "bcrypt": "6.0.0",
"fastify": "5.8.5", "fastify": "5.8.5",
"fastify-plugin": "6.0.0" "fastify-plugin": "6.0.0",
"sharp": "^0.35.2"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "6.0.0", "@types/bcrypt": "6.0.0",
+125 -5
View File
@@ -15,8 +15,13 @@ import type { SubscriptionFlow, SubscriptionMatch } from "./subscription-flow.js
// Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub // Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub
// (no registry, no network). The factory returns a fresh shot each call. // (no registry, no network). The factory returns a fresh shot each call.
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" })); const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
// The bridge now goes through captureSnapshotShared (the dedup wrapper, exercised in
// snapshot.test.ts); here it just delegates to the fake camera's captureSnapshot so this
// suite stays focused on the bridge's own match/debounce/emit logic.
vi.mock("./snapshot.js", () => ({ vi.mock("./snapshot.js", () => ({
buildCamera: () => ({ captureSnapshot }), buildCamera: () => ({ captureSnapshot }),
captureSnapshotShared: (_id: string, camera: { captureSnapshot: typeof captureSnapshot }, ctx: unknown) =>
camera.captureSnapshot(ctx as never),
})); }));
// Import AFTER the mock is registered. // Import AFTER the mock is registered.
@@ -28,13 +33,22 @@ beforeEach(() => {
captureSnapshot.mockClear(); captureSnapshot.mockClear();
delete process.env.VISION_ENTRY_MIN_CONFIDENCE; delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
delete process.env.ANPR_DEBOUNCE_MS; delete process.env.ANPR_DEBOUNCE_MS;
// Poll-until-confident loop: keep the window + interval tiny so a below-floor / no-plate
// case gives up in ~one tick instead of the 8s production window (tests stay fast). Each
// bridge reads these in its constructor, so set them before `new AnprBridge`.
process.env.ANPR_POLL_MS = "1";
process.env.ANPR_POLL_WINDOW_MS = "5";
}); });
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
delete process.env.ANPR_POLL_MS;
delete process.env.ANPR_POLL_WINDOW_MS;
delete process.env.ANPR_POLL_MAX_MS;
}); });
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */ /** A camera bound to an entry relay; `anpr` toggles recognition, `anprAutoTrigger` the
function seedCamera(opts: { anpr?: boolean } = {}): string { * per-camera auto-open gate (absent ⇒ defaults on). */
function seedCamera(opts: { anpr?: boolean; anprAutoTrigger?: boolean } = {}): string {
const controllerId = randomUUID(); const controllerId = randomUUID();
db.insert(devices).values({ db.insert(devices).values({
id: controllerId, id: controllerId,
@@ -48,7 +62,13 @@ function seedCamera(opts: { anpr?: boolean } = {}): string {
id: camId, id: camId,
category: "camera", category: "camera",
driverId: "hikvision", driverId: "hikvision",
config: { host: "10.0.0.9", controllerId, relay: 1, ...(opts.anpr ? { anpr: true } : {}) }, config: {
host: "10.0.0.9",
controllerId,
relay: 1,
...(opts.anpr ? { anpr: true } : {}),
...(opts.anprAutoTrigger === false ? { anprAutoTrigger: false } : {}),
},
enabled: true, enabled: true,
}).run(); }).run();
return camId; return camId;
@@ -74,8 +94,17 @@ function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: numb
} }
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */ /** A fake SubscriptionFlow: only `match()` is called by the bridge. */
function fakeSubFlow(match: SubscriptionMatch | null): SubscriptionFlow { function fakeSubFlow(
return { match: vi.fn(() => match) } as unknown as SubscriptionFlow; match: SubscriptionMatch | null,
// openOccurrenceCount: a constant, or a sequence consumed per call (to simulate a
// credential closing an occurrence mid-poll → count changes).
openCounts: number | number[] = 1,
): SubscriptionFlow {
const seq = Array.isArray(openCounts) ? [...openCounts] : null;
return {
match: vi.fn(() => match),
openOccurrenceCount: vi.fn(() => (seq ? (seq.length > 1 ? seq.shift()! : seq[0]) : (openCounts as number))),
} as unknown as SubscriptionFlow;
} }
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" }; const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
@@ -104,6 +133,18 @@ describe("AnprBridge", () => {
expect(captureSnapshot).not.toHaveBeenCalled(); expect(captureSnapshot).not.toHaveBeenCalled();
}); });
it("does NOT auto-trigger when anprAutoTrigger=false (recognition on, auto-open off)", async () => {
// Shared entry/exit lane: the exit cam keeps anpr (recognition) but auto-trigger off, so a
// car driving IN isn't phantom-EXITed by its back plate. The bridge bails before snapshot.
const cam = seedCamera({ anpr: true, anprAutoTrigger: false });
const vision = fakeVision({ plate: "AA111BB", confidence: 0.99 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]);
expect(captureSnapshot).not.toHaveBeenCalled(); // gated before the poll loop
});
it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => { it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
const cam = seedCamera({ anpr: true }); const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 }); const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
@@ -123,6 +164,85 @@ describe("AnprBridge", () => {
expect(reads).toEqual([]); expect(reads).toEqual([]);
}); });
it("POLLS until confident: low-confidence approach frames, then a clean stop-at-barrier frame", async () => {
// The car APPROACHES (garbage reads) then STOPS at the barrier (clean read) — the bridge
// must re-pull until one frame clears the floor, not give up on the first bad frame.
const cam = seedCamera({ anpr: true });
// analyze escalates: 0.20, 0.20, then 0.97 on the 3rd pull → that one emits.
const confs = [0.2, 0.2, 0.97];
let i = 0;
const vision = {
enabled: true,
analyze: vi.fn(async () => ({
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
plates: [],
lowConfidence: false,
modelVersion: "test",
tookMs: 1,
})),
} as unknown as VisionClient;
// Generous window so all 3 escalation attempts run deterministically under suite load
// (the global beforeEach sets a tiny 5ms window for the give-up cases).
process.env.ANPR_POLL_MS = "1";
process.env.ANPR_POLL_WINDOW_MS = "2000";
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toHaveLength(1);
expect(reads[0]).toMatchObject({ value: "AA111BB", kind: "plate" });
expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames
});
it("SLIDES the window: a push mid-poll keeps the loop alive past the initial deadline", async () => {
// A loop started by an early/far car would expire — but a NEW push (another car arriving)
// extends the deadline, so the loop keeps polling and reads the car that settles at the
// barrier. Here: a SHORT base window, vision stays low until attempt 5; a second push at
// the start bumps the deadline so attempt 5's confident read still lands.
const cam = seedCamera({ anpr: true });
const confs = [0.2, 0.2, 0.2, 0.2, 0.97];
let i = 0;
const vision = {
enabled: true,
analyze: vi.fn(async () => ({
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
plates: [],
lowConfidence: false,
modelVersion: "test",
tookMs: 1,
})),
} as unknown as VisionClient;
process.env.ANPR_POLL_MS = "5";
process.env.ANPR_POLL_WINDOW_MS = "12"; // tiny — would expire ~attempt 2 WITHOUT a slide
process.env.ANPR_POLL_MAX_MS = "5000"; // ceiling far above, so the slide is what matters
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(async () => {
const loop = bridge.onVehicleDetected(cam); // starts the loop
// Joining pushes keep sliding the deadline forward so the slow-to-confident read lands.
for (let k = 0; k < 5; k++) {
await new Promise((r) => setTimeout(r, 5));
void bridge.onVehicleDetected(cam); // each bumps the deadline (loop already running)
}
await loop;
});
expect(reads).toHaveLength(1);
expect(reads[0]).toMatchObject({ value: "AA111BB" });
});
it("ABORTS if the subscriber transacts by another credential mid-poll (no double-act)", async () => {
// The car's plate is read (identity known) but stays below the floor; meanwhile the
// subscriber scans their card → openOccurrenceCount drops. The bridge must abort and NOT
// emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. fleet).
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "AA111BB", confidence: 0.5 }); // never clears the floor
// openOccurrenceCount: 1 at baseline, then 0 (the card exit closed it) on the next check.
const sub = fakeSubFlow(SUB_MATCH, [1, 0]);
const bridge = new AnprBridge(db, vision, sub, silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]); // aborted — the credential already handled it
});
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => { it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
const cam = seedCamera({ anpr: true }); const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 }); const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
+151 -12
View File
@@ -30,6 +30,10 @@ import type { VisionClient } from "./vision-client.js";
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */ /** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
interface CameraConfig { interface CameraConfig {
readonly anpr?: boolean; readonly anpr?: boolean;
/** Whether this camera may AUTO-OPEN the barrier (entry/exit). Absent ⇒ true (when anpr is
* on). Set false to keep recognition but suppress auto-trigger — e.g. the exit camera on a
* shared entry/exit lane. */
readonly anprAutoTrigger?: boolean;
readonly [k: string]: unknown; readonly [k: string]: unknown;
} }
@@ -49,6 +53,40 @@ function debounceMs(): number {
return Number.isFinite(raw) && raw > 0 ? raw : 12_000; return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
} }
/** A single alarm fires the INSTANT motion starts — the car is still approaching, so the
* first frame often has a small/blurry/absent plate (a low-confidence misread). But the car
* then STOPS at the barrier (waiting for it to open) — the same stationary, well-framed
* moment the manual test reads at ~100%. So instead of one shot, we POLL fresh frames and
* re-run ANPR until one clears the confidence floor, or the window elapses. Poll interval: */
function pollMs(): number {
const raw = Number(process.env.ANPR_POLL_MS ?? 1000);
return Number.isFinite(raw) && raw > 0 ? raw : 1000;
}
/** How long to keep polling AFTER THE LAST vehicle push before giving up. SLIDING: each new
* push for the camera extends the deadline by this much from now — so a loop started by a
* far/early car keeps pulling fresh frames as the REAL car arrives and settles at the
* barrier (the loop tracks "whoever is here now", not the car that started it). */
function pollWindowMs(): number {
const raw = Number(process.env.ANPR_POLL_WINDOW_MS ?? 8000);
return Number.isFinite(raw) && raw > 0 ? raw : 8000;
}
/** Hard ceiling on a single loop from its START, so a continuously-busy lane (pushes never
* stop) can't slide the window forever. The loop ends at min(lastPush + window, start + max). */
function pollMaxMs(): number {
const raw = Number(process.env.ANPR_POLL_MAX_MS ?? 30_000);
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** A plate DeviceReadEvent skeleton (value filled by the caller) — for matching the
* subscriber by plate during the poll loop without re-building the whole event. */
function baseRead(row: { driverId: string }, deviceId: string): Omit<DeviceReadEvent, "value"> {
return { driverId: row.driverId, deviceId, kind: "plate", at: new Date().toISOString() };
}
export class AnprBridge { export class AnprBridge {
readonly #db: Db; readonly #db: Db;
readonly #vision: VisionClient | null; readonly #vision: VisionClient | null;
@@ -56,9 +94,19 @@ export class AnprBridge {
readonly #logger: FastifyBaseLogger; readonly #logger: FastifyBaseLogger;
readonly #entryMinConfidence: number; readonly #entryMinConfidence: number;
readonly #debounceMs: number; readonly #debounceMs: number;
readonly #pollMs: number;
readonly #pollWindowMs: number;
readonly #pollMaxMs: number;
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by /** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
* `deviceId:plate` (post-match) — both gated against #debounceMs. */ * `deviceId:plate` (post-match) — both gated against #debounceMs. */
readonly #lastFire = new Map<string, number>(); readonly #lastFire = new Map<string, number>();
/** Cameras with a poll loop already in flight — a re-fired alarm (the camera pushes ~1Hz
* while the car sits) must NOT start a second concurrent loop on the same camera. */
readonly #polling = new Set<string>();
/** Per-camera SLIDING deadline for the running poll loop. A push that joins a running loop
* bumps this forward (lastPush + window, capped at start + max), so the loop keeps pulling
* fresh frames while cars keep arriving — tracking whoever settles at the barrier. */
readonly #pollDeadline = new Map<string, number>();
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) { constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
this.#db = db; this.#db = db;
@@ -67,6 +115,9 @@ export class AnprBridge {
this.#logger = logger; this.#logger = logger;
this.#entryMinConfidence = entryMinConfidence(); this.#entryMinConfidence = entryMinConfidence();
this.#debounceMs = debounceMs(); this.#debounceMs = debounceMs();
this.#pollMs = pollMs();
this.#pollWindowMs = pollWindowMs();
this.#pollMaxMs = pollMaxMs();
} }
/** /**
@@ -84,15 +135,35 @@ export class AnprBridge {
if (site && site.anprEntryEnabled === false) return; if (site && site.anprEntryEnabled === false) return;
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get(); const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
if (!row || !row.enabled || row.category !== "camera") return; if (!row || !row.enabled || row.category !== "camera") return;
if ((row.config as CameraConfig)?.anpr !== true) return; // opt-in only const cfg = row.config as CameraConfig;
if (cfg?.anpr !== true) return; // recognition opt-in (also gates the evidence/advisory path)
// Per-camera AUTO-TRIGGER gate. `anpr` keeps recognition (snapshots + plate record) on;
// this controls whether THIS camera may auto-open the barrier. A shared entry/exit lane
// sets it false on (e.g.) the exit camera so its back-plate read doesn't phantom-exit the
// car that just entered. Absent ⇒ true (back-compat: existing anpr cameras still trigger).
if (cfg.anprAutoTrigger === false) return;
// Camera-level debounce (pre-snapshot): a car re-firing ~1Hz must not pull a // Post-success debounce: once we've emitted a read for this camera, ignore the
// snapshot + analyze every second. // ~1Hz re-fires for #debounceMs (set on success below). A fresh alarm AFTER the
// window is a new presentation and may start a new poll loop.
if (this.#debounced(deviceId)) return; if (this.#debounced(deviceId)) return;
this.#stamp(deviceId); // One poll loop per camera. A push that arrives while a loop runs JOINs it — and
// SLIDES the deadline forward (a different car arriving mid-loop keeps the loop alive
// so it tracks whoever's at the barrier now, instead of giving up on the early car).
const now = Date.now();
if (this.#polling.has(deviceId)) {
const cur = this.#pollDeadline.get(deviceId) ?? now;
// Slide to lastPush + window, but never past the per-loop hard ceiling (set at start).
this.#pollDeadline.set(deviceId, Math.max(cur, now + this.#pollWindowMs));
return;
}
this.#polling.add(deviceId);
// Initial deadline; the hard ceiling (start + max) is enforced in the loop below.
this.#pollDeadline.set(deviceId, now + this.#pollWindowMs);
const camera = buildCamera(row); const camera = buildCamera(row);
if (!camera) { if (!camera) {
this.#polling.delete(deviceId);
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`); this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
return; return;
} }
@@ -100,16 +171,74 @@ export class AnprBridge {
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane — // "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
// the gated flow infers the verb from the camera's bound relay direction). // the gated flow infers the verb from the camera's bound relay direction).
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry"; const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
const shot = await camera.captureSnapshot({ direction });
const result = await this.#vision.analyze(shot.bytes, shot.contentType);
if (!result || !result.plate) return; // nothing read
// Entry floor — stricter than the advisory floor (analyze() still returns the plate // POLL-UNTIL-CONFIDENT. The alarm fires as the car APPROACHES (small/blurry/absent
// object with its confidence even when its own lowConfidence flag is set). // plate → low-confidence misread, e.g. '111'@0.20). But the car then STOPS at the
if (result.plate.confidence < this.#entryMinConfidence) { // barrier — the stationary, well-framed moment the manual test reads at ~100%. So we
// pull a FRESH frame every #pollMs and re-run ANPR until one clears the floor, or the
// #pollWindowMs window elapses (car drove off / non-subscriber). NB: a fresh pull each
// tick — NOT captureSnapshotShared, whose TTL would re-serve the same bad frame.
// While polling, watch whether THIS subscriber transacts by another credential
// (card/QR at the reader). If their open-occurrence count drops mid-poll, the
// subscriber already exited/entered — the bridge must NOT also emit (it would act on
// the NEXT open occurrence: a phantom double-exit, worst for a fleet sub). We learn the
// subscription as soon as a frame reads the bound plate (identity needs no confidence),
// snapshot the count, then keep polling for a CONFIDENT read; abort if the count moved.
let result: Awaited<ReturnType<VisionClient["analyze"]>> = null;
let watchedSubId: string | null = null;
let baselineOpen = 0;
// Hard ceiling for THIS loop (start + max); the sliding deadline (bumped by joining
// pushes) is read from #pollDeadline each tick but never allowed past this cap.
const hardCap = Date.now() + this.#pollMaxMs;
let attempts = 0;
try {
while (Date.now() < Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap)) {
attempts++;
const shot = await camera.captureSnapshot({ direction });
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
// Identify the subscriber from ANY readable plate (even below the barrier floor),
// and baseline their open count once — so we can detect a credential beating us.
if (r?.plate?.text) {
const m0 = this.#subscription.match({ ...baseRead(row, deviceId), value: r.plate.text.trim().toUpperCase() });
if (m0 && watchedSubId == null) {
watchedSubId = m0.subscriptionId;
baselineOpen = this.#subscription.openOccurrenceCount(watchedSubId);
}
}
// A credential (card/QR) closed/opened an occurrence for this subscriber mid-poll →
// they already transacted; stop polling and do NOT emit.
if (watchedSubId && this.#subscription.openOccurrenceCount(watchedSubId) !== baselineOpen) {
this.#logger.info( this.#logger.info(
`anpr-bridge: plate '${result.plate.text}' below entry floor ` + `anpr-bridge: subscriber ${watchedSubId} transacted by another credential mid-poll — aborting ANPR`,
`(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`, );
return;
}
if (r?.plate && r.plate.confidence >= this.#entryMinConfidence) {
result = r;
break;
}
if (r?.plate) {
this.#logger.info(
`anpr-bridge: '${r.plate.text}' (${r.plate.confidence.toFixed(3)}) below floor ` +
`${this.#entryMinConfidence} — re-pulling (attempt ${attempts})`,
);
}
// Stop if the next tick would land past the (possibly slid) deadline or the cap.
const effDeadline = Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap);
if (Date.now() + this.#pollMs >= effDeadline) break;
await sleep(this.#pollMs);
}
} finally {
this.#polling.delete(deviceId);
this.#pollDeadline.delete(deviceId);
}
if (!result || !result.plate) {
this.#logger.info(
`anpr-bridge: no confident plate from ${deviceId} after ${attempts} attempt(s) ` +
`in ${this.#pollWindowMs}ms — gave up`,
); );
return; return;
} }
@@ -133,11 +262,21 @@ export class AnprBridge {
return; return;
} }
// Final guard against the credential-mid-poll race: if the subscriber transacted between
// our baseline and now (e.g. a card scan in the last tick), don't double-act.
if (watchedSubId === match.subscriptionId && this.#subscription.openOccurrenceCount(match.subscriptionId) !== baselineOpen) {
this.#logger.info(`anpr-bridge: ${match.subscriptionId} already transacted — skipping ANPR emit`);
return;
}
// Plate-level debounce — belt-and-suspenders against a gap that slips the // Plate-level debounce — belt-and-suspenders against a gap that slips the
// camera-level gate re-emitting the SAME plate. // camera-level gate re-emitting the SAME plate.
const plateKey = `${deviceId}:${plate}`; const plateKey = `${deviceId}:${plate}`;
if (this.#debounced(plateKey)) return; if (this.#debounced(plateKey)) return;
this.#stamp(plateKey); this.#stamp(plateKey);
// Camera-level debounce stamp — now that we've emitted, suppress the camera's ~1Hz
// re-fires (and any new poll loop) for #debounceMs.
this.#stamp(deviceId);
this.#logger.info( this.#logger.info(
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`, `anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
+150
View File
@@ -0,0 +1,150 @@
import { constants } from "node:fs";
import { access, stat } from "node:fs/promises";
import { resolve } from "node:path";
import { eq, siteConfig, type Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult } from "./backup.js";
// Thin coordinator around the backup engine (backup.ts). The TARGET DIRECTORY is admin-chosen
// and stored in site_config.backup_target_dir (read fresh each run, so changing it in the UI
// takes effect with no restart). The ENCRYPTION KEY stays an env/Komodo secret (BACKUP_KEY) —
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
// wiki/concepts/backup-recovery.md.
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
export function backupKeyFromEnv(): string {
return process.env.BACKUP_KEY ?? "";
}
export interface TargetCheck {
readonly ok: boolean;
/** Machine-readable reason when !ok: "empty" | "missing" | "not_a_dir" | "not_writable". */
readonly reason?: string;
}
export interface BackupStatus {
/** True once a target dir is set AND a usable key is present (else backups are a no-op). */
readonly configured: boolean;
/** The admin-chosen target dir (null if unset) — surfaced so the UI can show/edit it. */
readonly targetDir: string | null;
/** Whether the env key is present + long enough (the UI flags a missing key distinctly). */
readonly keyPresent: boolean;
readonly running: boolean;
readonly lastSuccessAt: string | null;
readonly lastResult: { path: string; bytes: number; prunedFiles: number } | null;
readonly lastErrorAt: string | null;
readonly lastError: string | null;
}
/** Probe a candidate target path server-side: exists, is a directory, is writable. */
export async function checkTargetDir(dir: string): Promise<TargetCheck> {
const trimmed = dir.trim();
if (!trimmed) return { ok: false, reason: "empty" };
const path = resolve(trimmed);
let st: Awaited<ReturnType<typeof stat>>;
try {
st = await stat(path);
} catch {
return { ok: false, reason: "missing" };
}
if (!st.isDirectory()) return { ok: false, reason: "not_a_dir" };
try {
await access(path, constants.W_OK);
} catch {
return { ok: false, reason: "not_writable" };
}
return { ok: true };
}
export class BackupService {
readonly #db: Db;
readonly #logger?: FastifyBaseLogger;
#running = false;
#lastSuccessAt: string | null = null;
#lastResult: BackupResult | null = null;
#lastErrorAt: string | null = null;
#lastError: string | null = null;
constructor(db: Db, logger?: FastifyBaseLogger) {
this.#db = db;
this.#logger = logger;
}
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
targetDir(): string | null {
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const dir = row?.backupTargetDir?.trim();
return dir ? dir : null;
}
get keyPresent(): boolean {
return backupKeyFromEnv().length >= 16;
}
get configured(): boolean {
return this.targetDir() !== null && this.keyPresent;
}
status(): BackupStatus {
return {
configured: this.configured,
targetDir: this.targetDir(),
keyPresent: this.keyPresent,
running: this.#running,
lastSuccessAt: this.#lastSuccessAt,
lastResult: this.#lastResult
? { path: this.#lastResult.path, bytes: this.#lastResult.bytes, prunedFiles: this.#lastResult.prunedFiles }
: null,
lastErrorAt: this.#lastErrorAt,
lastError: this.#lastError,
};
}
/**
* Run one backup. `trigger` is just for the log line. Serialized: if one is already in
* flight, resolves to that same promise. Reads the target dir + key at run time. Records
* last-success/last-error. Re-throws on failure so a manual caller (the route) can surface
* it; the scheduled timer wraps + swallows.
*/
#inflight: Promise<BackupResult> | null = null;
async run(trigger: "manual" | "scheduled"): Promise<BackupResult> {
if (this.#inflight) return this.#inflight;
const targetDir = this.targetDir();
const key = backupKeyFromEnv();
if (!targetDir) throw new Error("backup: no target directory configured");
if (key.length < 16) throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)");
this.#running = true;
this.#inflight = (async () => {
try {
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
const res = await runBackup(this.#db, { targetDir, key, retention: DEFAULT_BACKUP_RETENTION }, this.#logger);
this.#lastResult = res;
this.#lastSuccessAt = new Date().toISOString();
this.#lastError = null;
return res;
} catch (err) {
this.#lastError = (err as Error).message;
this.#lastErrorAt = new Date().toISOString();
this.#logger?.error(`backup: failed (${trigger}): ${this.#lastError}`);
throw err;
} finally {
this.#running = false;
this.#inflight = null;
}
})();
return this.#inflight;
}
/** Scheduled-run wrapper: never throws (a timer must not crash the process). */
async runScheduled(): Promise<void> {
if (!this.configured) return; // silent no-op when backups aren't set up
try {
await this.run("scheduled");
} catch {
/* recorded in last-error; already logged */
}
}
}
+197
View File
@@ -0,0 +1,197 @@
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createTestDb, openRawDb } from "@parking/db/testing";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
DEFAULT_BACKUP_RETENTION,
parseBackupStamp,
pruneOldBackups,
runBackup,
} from "./backup.js";
// Mirror of the engine's header layout, so the test decrypts independently (a real restore
// tool would do exactly this) rather than trusting the engine to also decrypt.
const MAGIC = Buffer.from("PKBK", "ascii");
const SALT_LEN = 16;
const IV_LEN = 12;
const TAG_LEN = 16;
function decryptBackup(enc: Buffer, key: string): Buffer {
expect(enc.subarray(0, 4)).toEqual(MAGIC);
expect(enc[4]).toBe(1); // format version
let off = 5;
const salt = enc.subarray(off, (off += SALT_LEN));
const iv = enc.subarray(off, (off += IV_LEN));
const tag = enc.subarray(enc.length - TAG_LEN);
const ciphertext = enc.subarray(off, enc.length - TAG_LEN);
const derived = scryptSync(key, salt, 32);
const decipher = createDecipheriv("aes-256-gcm", derived, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
let workDir: string;
const KEY = "a-test-backup-key-that-is-long-enough";
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), "pk-backup-test-"));
});
afterEach(() => {
rmSync(workDir, { recursive: true, force: true });
});
describe("runBackup — round-trip", () => {
it("produces an encrypted backup that decrypts to a byte-identical, queryable DB", async () => {
// A real on-disk DB so the engine's better-sqlite3 .backup() runs for real.
const dbPath = join(workDir, "source.sqlite");
const t = createTestDb(dbPath);
// Put some recognizable data in.
t.sqlite.exec("CREATE TABLE marker (k TEXT PRIMARY KEY, v TEXT)");
t.sqlite.prepare("INSERT INTO marker (k, v) VALUES (?, ?)").run("hello", "world");
const targetDir = join(workDir, "target");
const res = await runBackup(t.db, { targetDir, key: KEY });
t.close();
expect(res.bytes).toBeGreaterThan(0);
expect(res.path).toMatch(/parking-backup-\d{8}T\d{6}Z\.sqlite\.enc$/);
// Decrypt independently and open the recovered DB raw (no migrations — verify as-written).
const plain = decryptBackup(readFileSync(res.path), KEY);
const restoredPath = join(workDir, "restored.sqlite");
writeFileSync(restoredPath, plain);
const restored = openRawDb(restoredPath);
const row = restored.prepare("SELECT v FROM marker WHERE k = ?").get("hello") as { v: string };
expect(row.v).toBe("world");
restored.close();
});
it("rejects a missing/short key before touching the filesystem", async () => {
const t = createTestDb();
await expect(runBackup(t.db, { targetDir: join(workDir, "t"), key: "short" })).rejects.toThrow(
/BACKUP_KEY/,
);
t.close();
});
it("removes the plaintext scratch copy after a successful run", async () => {
const scratchDir = join(workDir, "scratch");
const t = createTestDb();
await runBackup(t.db, {
targetDir: join(workDir, "target"),
key: KEY,
scratchDir,
// Stub the copy so we don't need a file-backed handle here.
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "PRAGMA;"),
});
t.close();
// The only thing left in scratch must NOT be a .sqlite plaintext.
const left = readdirSync(scratchDir).filter((n) => n.endsWith(".sqlite"));
expect(left).toEqual([]);
});
it("wipes the plaintext scratch copy even when the copy step fails", async () => {
const scratchDir = join(workDir, "scratch");
mkdirSync(scratchDir, { recursive: true });
const t = createTestDb();
// Force a failure: the copy step writes the plaintext, then throws (mid-pipeline). The
// finally{} must still remove the plaintext it left behind.
await expect(
runBackup(t.db, {
targetDir: join(workDir, "target"),
key: KEY,
scratchDir,
makeConsistentCopy: async (_db, dest) => {
writeFileSync(dest, "PRAGMA;"); // leave a plaintext intermediate…
throw new Error("simulated copy failure"); // …then fail
},
}),
).rejects.toThrow(/simulated copy failure/);
t.close();
const left = readdirSync(scratchDir).filter((n) => n.endsWith(".sqlite"));
expect(left).toEqual([]);
});
});
describe("backup encryption — tamper evidence (AES-256-GCM)", () => {
it("a flipped ciphertext byte fails authentication on decrypt", async () => {
const t = createTestDb();
const targetDir = join(workDir, "target");
const res = await runBackup(t.db, {
targetDir,
key: KEY,
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "the quick brown fox".repeat(100)),
});
t.close();
const enc = readFileSync(res.path);
// Flip a byte in the ciphertext region (after the header, before the tag).
enc[5 + SALT_LEN + IV_LEN + 3] ^= 0xff;
expect(() => decryptBackup(enc, KEY)).toThrow();
});
it("the wrong key fails authentication", async () => {
const t = createTestDb();
const res = await runBackup(t.db, {
targetDir: join(workDir, "target"),
key: KEY,
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "payload".repeat(50)),
});
t.close();
expect(() => decryptBackup(readFileSync(res.path), "a-different-but-also-long-key-xx")).toThrow();
});
});
describe("parseBackupStamp", () => {
it("round-trips a stamped name and rejects non-backups", () => {
const d = parseBackupStamp("parking-backup-20260629T141503Z.sqlite.enc");
expect(d?.toISOString()).toBe("2026-06-29T14:15:03.000Z");
expect(parseBackupStamp("random.txt")).toBeNull();
expect(parseBackupStamp("parking-backup-not-a-date.sqlite.enc")).toBeNull();
});
});
describe("pruneOldBackups — keep-last-N + dailies", () => {
const day = 24 * 60 * 60 * 1000;
const now = new Date("2026-06-29T12:00:00Z");
function seed(stamps: string[]) {
const dir = join(workDir, "retain");
mkdirSync(dir, { recursive: true });
for (const s of stamps) writeFileSync(join(dir, `parking-backup-${s}.sqlite.enc`), "x");
return dir;
}
const stamp = (ms: number) =>
new Date(ms).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
it("keeps the keepLast newest regardless of age", async () => {
// 5 backups within the last hour; keepLast=3 → 2 pruned, even though all are recent.
const t = now.getTime();
const dir = seed([0, 1, 2, 3, 4].map((i) => stamp(t - i * 60 * 1000)));
const pruned = await pruneOldBackups(dir, { keepLast: 3, keepDailyDays: 0 }, now);
expect(pruned).toBe(2);
expect(readdirSync(dir).length).toBe(3);
});
it("keeps one-per-day within the daily window and drops older", async () => {
const t = now.getTime();
// Two backups today, one 5 days ago, one 40 days ago. keepLast=1, keepDailyDays=30.
const dir = seed([
stamp(t), // today A (newest → kept by keepLast)
stamp(t - 60 * 1000), // today B (same day as the kept one → pruned)
stamp(t - 5 * day), // 5 days ago (kept: within window, unique day)
stamp(t - 40 * day), // 40 days ago (pruned: outside the window)
]);
const pruned = await pruneOldBackups(dir, { keepLast: 1, keepDailyDays: 30 }, now);
expect(pruned).toBe(2);
const left = readdirSync(dir);
expect(left.length).toBe(2);
});
it("is a no-op on a missing target dir", async () => {
const pruned = await pruneOldBackups(join(workDir, "does-not-exist"), DEFAULT_BACKUP_RETENTION, now);
expect(pruned).toBe(0);
});
});
+215
View File
@@ -0,0 +1,215 @@
import { createCipheriv, randomBytes, scryptSync } from "node:crypto";
import { createReadStream, createWriteStream } from "node:fs";
import { mkdir, readdir, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join, resolve } from "node:path";
import { pipeline } from "node:stream/promises";
import type { Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
// On-site encrypted DB backup — the durability half of the anti-fraud design. The SQLite
// DB *is* the signed append-only ledger, so a disk failure / stolen-or-destroyed PC means
// total revenue-history loss. This produces a consistent, encrypted, restore-to-a-fresh-
// appliance copy. See wiki/concepts/backup-recovery.md.
//
// Two load-bearing properties:
// 1. CONSISTENT copy of a LIVE WAL-mode DB — via better-sqlite3's online .backup() (NOT a
// raw file copy, which can capture a torn WAL). The result must still verifyChain.
// 2. Encrypted with a DEDICATED key (BACKUP_KEY / park_buzi_backup_key), SEPARATE from
// EVENT_SIGNING_KEY — so the backup key can rotate without fracturing the signed chain,
// and a backup target never exposes the signing key. The key is NEVER written into the
// backup it unlocks.
//
// This module is the engine (consistent copy → encrypt → retention). Targets beyond a local/
// mounted path (SMB/NFS are just mount paths; SFTP) and the manual button/route are layered on
// top. RESTORE is intentionally NOT here — it's an out-of-band runbook action on a fresh box.
/** AES-256-GCM with a scrypt-derived key. Self-describing header so a restore tool needs only
* the key + the file. Layout: magic | version | salt(16) | iv(12) | ciphertext… | authTag(16). */
const MAGIC = Buffer.from("PKBK", "ascii"); // ParKing BacKup
const FORMAT_VERSION = 1;
const SALT_LEN = 16;
const IV_LEN = 12;
const TAG_LEN = 16;
const SCRYPT_KEYLEN = 32; // AES-256
export interface BackupRetention {
/** Keep at least this many most-recent backups regardless of age. */
readonly keepLast: number;
/** Beyond keepLast, keep one backup per day for this many days; older ones are pruned. */
readonly keepDailyDays: number;
}
export const DEFAULT_BACKUP_RETENTION: BackupRetention = {
keepLast: Number(process.env.BACKUP_KEEP_LAST ?? 7),
keepDailyDays: Number(process.env.BACKUP_KEEP_DAILY_DAYS ?? 30),
};
export interface BackupOptions {
/** Directory the encrypted backup is written to (a mounted local/USB/SATA/SMB/NFS path). */
readonly targetDir: string;
/** Encryption key (BACKUP_KEY / park_buzi_backup_key). ≥16 chars enforced. */
readonly key: string;
readonly retention?: BackupRetention;
/** Override the consistent-copy step (tests inject a fake to avoid a real sqlite handle). */
readonly makeConsistentCopy?: (db: Db, destPath: string) => Promise<void>;
/** Override "now" for deterministic filenames/retention in tests. */
readonly now?: () => Date;
/** Scratch dir for the intermediate plaintext copy (default os.tmpdir()). */
readonly scratchDir?: string;
}
export interface BackupResult {
/** Absolute path of the encrypted backup written. */
readonly path: string;
/** Size of the encrypted file in bytes. */
readonly bytes: number;
/** Backups pruned by the retention policy this run. */
readonly prunedFiles: number;
}
/** Filename convention: parking-backup-YYYYMMDDTHHMMSSZ.sqlite.enc — sortable, UTC, parseable. */
const FILE_PREFIX = "parking-backup-";
const FILE_SUFFIX = ".sqlite.enc";
function stampFor(d: Date): string {
return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
}
/** Parse the UTC instant back out of a backup filename, or null if it doesn't match. */
export function parseBackupStamp(name: string): Date | null {
const base = basename(name);
if (!base.startsWith(FILE_PREFIX) || !base.endsWith(FILE_SUFFIX)) return null;
const stamp = base.slice(FILE_PREFIX.length, -FILE_SUFFIX.length);
// 20260629T141503Z → 2026-06-29T14:15:03Z
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(stamp);
if (!m) return null;
const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
const dt = new Date(iso);
return Number.isNaN(dt.getTime()) ? null : dt;
}
/** Consistent online copy of the live WAL-mode DB via better-sqlite3's native backup(). */
async function defaultConsistentCopy(db: Db, destPath: string): Promise<void> {
// db.$client is the raw better-sqlite3 Database; .backup() returns a promise and copies a
// transactionally-consistent snapshot even while the source is being written.
const client = db.$client as { backup: (dest: string) => Promise<unknown> };
await client.backup(destPath);
}
/** Encrypt `srcPath` → `destPath` streaming, with the self-describing header. */
async function encryptFile(srcPath: string, destPath: string, key: string): Promise<void> {
const salt = randomBytes(SALT_LEN);
const iv = randomBytes(IV_LEN);
const derived = scryptSync(key, salt, SCRYPT_KEYLEN);
const cipher = createCipheriv("aes-256-gcm", derived, iv);
const out = createWriteStream(destPath);
const header = Buffer.concat([MAGIC, Buffer.from([FORMAT_VERSION]), salt, iv]);
out.write(header);
await pipeline(createReadStream(srcPath), cipher, out, { end: false });
// GCM auth tag is available only after the cipher has flushed; append it, then close.
const tag = cipher.getAuthTag();
await new Promise<void>((res, rej) => {
out.end(tag, () => res());
out.on("error", rej);
});
}
/**
* Run one backup: consistent copy → encrypt → prune old backups by retention.
* Best-effort caller-facing: throws on real failure (so a manual run surfaces the error),
* but the scheduled timer wraps it and logs.
*/
export async function runBackup(
db: Db,
opts: BackupOptions,
logger?: FastifyBaseLogger,
): Promise<BackupResult> {
if (!opts.key || opts.key.length < 16) {
throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)");
}
const now = opts.now ?? (() => new Date());
const retention = opts.retention ?? DEFAULT_BACKUP_RETENTION;
const targetDir = resolve(opts.targetDir);
await mkdir(targetDir, { recursive: true });
const stamp = stampFor(now());
const finalPath = join(targetDir, `${FILE_PREFIX}${stamp}${FILE_SUFFIX}`);
// Intermediate plaintext copy in scratch (NOT the target dir — the target may be a network
// share / removable disk; keep the plaintext local and short-lived, then wipe it).
const scratch = opts.scratchDir ?? tmpdir();
await mkdir(scratch, { recursive: true });
const plainPath = join(scratch, `${FILE_PREFIX}${stamp}.sqlite`);
try {
const copy = opts.makeConsistentCopy ?? defaultConsistentCopy;
await copy(db, plainPath);
await encryptFile(plainPath, finalPath, opts.key);
} finally {
// Always wipe the plaintext intermediate, success or fail — it's the unencrypted ledger.
await rm(plainPath, { force: true }).catch((err) =>
logger?.warn(`backup: failed to remove plaintext scratch copy: ${(err as Error).message}`),
);
}
const { size } = await stat(finalPath);
const prunedFiles = await pruneOldBackups(targetDir, retention, now());
logger?.info(
`backup: wrote ${basename(finalPath)} (${(size / 1048576).toFixed(1)} MB)` +
(prunedFiles > 0 ? `, pruned ${prunedFiles} old` : ""),
);
return { path: finalPath, bytes: size, prunedFiles };
}
/**
* Retention: keep the `keepLast` most-recent backups always; beyond those, keep at most one
* backup per UTC day for `keepDailyDays` days; delete anything older or any extra same-day
* duplicates outside the keepLast window. Returns the count deleted.
*/
export async function pruneOldBackups(
targetDir: string,
retention: BackupRetention,
now: Date,
): Promise<number> {
let names: string[];
try {
names = await readdir(targetDir);
} catch {
return 0; // target gone/unmounted — nothing to prune (the write would have failed first)
}
const backups = names
.map((n) => ({ name: n, at: parseBackupStamp(n) }))
.filter((b): b is { name: string; at: Date } => b.at !== null)
.sort((a, b) => b.at.getTime() - a.at.getTime()); // newest first
const keep = new Set<string>();
// 1. Always keep the keepLast newest.
for (const b of backups.slice(0, Math.max(0, retention.keepLast))) keep.add(b.name);
// 2. Beyond that, keep the newest per UTC day within the keepDailyDays window.
const cutoff = now.getTime() - retention.keepDailyDays * 24 * 60 * 60 * 1000;
const seenDays = new Set<string>();
for (const b of backups) {
if (keep.has(b.name)) {
seenDays.add(b.at.toISOString().slice(0, 10));
continue;
}
if (b.at.getTime() < cutoff) continue; // too old → not kept
const day = b.at.toISOString().slice(0, 10);
if (seenDays.has(day)) continue; // already have a backup for this day → prune the extra
seenDays.add(day);
keep.add(b.name);
}
let pruned = 0;
for (const b of backups) {
if (keep.has(b.name)) continue;
await rm(join(targetDir, b.name), { force: true });
pruned += 1;
}
return pruned;
}
+357
View File
@@ -0,0 +1,357 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { eq, devices, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import type { AuxOutputDevice } from "@parking/devices";
import { ButtonLightController } from "./button-light.js";
import { deviceEvents } from "./device-events.js";
import { silentLogger } from "./test-helpers.js";
// ButtonLightController: alert (radarAlert) relays — the entry-button lamp on a spare
// relay, driven by the lamp's trigger input vs. the camera lane status. Truth table:
// trigger active + lane busy -> SOLID on
// trigger active + lane free -> BLINK (~1 Hz)
// otherwise -> OFF
// Lamp is a non-barrier aux output; fails OFF; de-dupes redundant writes. A controller may
// carry several alert relays (each its own row + trigger input), keyed independently.
let db: Db;
const CONTROLLER = "ctl-1";
const RADAR_INPUT = 2; // I2
const LAMP_RELAY = 3; // spare relay R3
/** A fake aux device recording setAux calls (channel,on). Optionally throws. */
function fakeAux(record: Array<{ ch: number; on: boolean }>, throwOnce = { v: false }): AuxOutputDevice {
return {
async setAux(channel: number, on: boolean): Promise<void> {
if (throwOnce.v) {
throwOnce.v = false;
throw new Error("UDP down");
}
record.push({ ch: channel, on });
},
};
}
beforeEach(() => {
({ db } = createTestDb());
vi.useFakeTimers();
// One controller: entry relay 1 with radar on I2; lamp on spare relay 3.
db.insert(devices).values({
id: CONTROLLER,
category: "access",
driverId: "dingtian",
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" },
{ relay: 2, direction: "exit" },
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
],
},
enabled: true,
}).run();
});
afterEach(() => {
vi.useRealTimers();
});
/** Emit a radar (presence input) edge for the controller. */
function radar(present: boolean): void {
deviceEvents.emitInput({
driverId: "dingtian",
deviceId: CONTROLLER,
input: RADAR_INPUT,
edge: present ? "on" : "off",
at: new Date().toISOString(),
source: "poll",
});
}
/** Emit a lane status (entry busy/free). */
function lane(entryBusy: boolean): void {
deviceEvents.emitLaneStatus({ entry: entryBusy, exit: false });
}
/** Flush the microtask queue so serialized setAux promises (and their re-pump on
* completion) settle. The lamp worker sends ONE UDP at a time and re-pumps on resolve;
* a few turns drain a burst. Needed because sends are now async (was synchronous). */
async function flush(): Promise<void> {
for (let i = 0; i < 6; i++) await Promise.resolve();
}
describe("ButtonLightController truth table", () => {
it("OFF at start (no radar, no car)", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
ctl.start();
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("off");
// confirmedOn starts null; OFF de-dupes (null !== false → one off write), so the
// device is confirmed OFF and at most one call was made.
expect(ctl.confirmedOf(CONTROLLER)).toBe(false);
ctl.stop();
});
it("radar present + lane busy -> SOLID on", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
lane(true);
radar(true);
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // device latched ON
// Solid = no blinking: advancing time produces no further sends.
const n = calls.length;
vi.advanceTimersByTime(2000);
await flush();
expect(calls.length).toBe(n);
ctl.stop();
});
it("radar present + lane free -> BLINK (toggles the device over time)", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
radar(true); // lane still free
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // on now
vi.advanceTimersByTime(500);
await flush();
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // toggled off
vi.advanceTimersByTime(500);
await flush();
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // toggled on
ctl.stop();
});
it("blink -> solid when the camera confirms a car (lane busy)", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
radar(true); // blink
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
lane(true); // camera confirms
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
// No more toggles (blink torn down) — the device stays ON over time.
vi.advanceTimersByTime(2000);
await flush();
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
ctl.stop();
});
it("radar clears -> OFF", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
lane(true);
radar(true); // solid
await flush();
radar(false); // car gone
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("off");
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // device latched OFF
ctl.stop();
});
it("de-dupes redundant writes (no spam on repeat events)", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
lane(true);
radar(true); // solid, on
await flush();
const n = calls.length;
radar(true); // same state — no new edge (present unchanged)
lane(true); // same lane — no change
await flush();
expect(calls.length).toBe(n);
ctl.stop();
});
it("fails OFF: a setAux error does not throw or escalate", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const throwOnce = { v: true };
const aux = fakeAux(calls, throwOnce);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
// First write (initial off) throws — must be swallowed.
expect(() => ctl.start()).not.toThrow();
await flush();
// Subsequent writes work; driving to solid still converges to ON.
lane(true);
radar(true);
await flush();
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
ctl.stop();
});
it("ignores controllers without an alert relay", () => {
// A second controller, no alert relay.
db.insert(devices).values({
id: "ctl-2",
category: "access",
driverId: "dingtian",
config: { host: "10.0.0.6", relays: [{ relay: 1, direction: "entry", presenceInput: 2 }] },
enabled: true,
}).run();
const calls: Array<{ ch: number; on: boolean }> = [];
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
ctl.start();
expect(ctl.stateOf("ctl-2")).toBeNull();
ctl.stop();
});
it("picks up an alert relay ADDED after start() (no restart needed)", async () => {
// Fresh controller with a radar input but NO alert relay yet.
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
// Replace the seeded controller with one that has the radar but no lamp.
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }],
},
})
.where(eq(devices.id, CONTROLLER))
.run();
ctl.start();
await flush();
// No lamp configured → an input does nothing.
radar(true);
await flush();
expect(ctl.stateOf(CONTROLLER)).toBeNull();
expect(calls.length).toBe(0);
radar(false);
await flush();
// Admin saves an alert relay (relay 3, trigger I2) — without restarting the server.
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" },
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
],
},
})
.where(eq(devices.id, CONTROLLER))
.run();
// The very next radar edge reconciles + blinks (lane still free).
radar(true);
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
ctl.stop();
});
it("drives two alert relays on one controller independently", async () => {
const R3 = 3;
const R4 = 4;
const I2 = 2;
const I3 = 3;
// Controller with two alert lamps, each on its own trigger input.
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry", presenceInput: I2, presenceKind: "radar" },
{ relay: R3, direction: "radarAlert", triggerInput: I2, blinkOnMs: 500, blinkOffMs: 500 },
{ relay: R4, direction: "radarAlert", triggerInput: I3, blinkOnMs: 500, blinkOffMs: 500 },
],
},
})
.where(eq(devices.id, CONTROLLER))
.run();
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
// I2 active → only R3 blinks; R4 stays off (different trigger).
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "on", at: new Date().toISOString(), source: "poll" });
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
// I3 active → R4 blinks too, independently.
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I3, edge: "on", at: new Date().toISOString(), source: "poll" });
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
// Camera confirms a car → BOTH lock solid (lane-busy is site-wide).
lane(true);
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("solid");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
// I2 clears → R3 off, R4 still solid (its trigger still active).
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "off", at: new Date().toISOString(), source: "poll" });
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
ctl.stop();
});
it("an EXIT alert lamp locks on the EXIT camera, not entry", async () => {
const R4 = 4;
const I5 = 5; // exit radar
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry" },
{ relay: 2, direction: "exit" },
// Exit alert lamp: triggers on the exit radar, locks on the EXIT camera.
{ relay: R4, direction: "radarAlert", triggerInput: I5, lockLane: "exit", blinkOnMs: 500, blinkOffMs: 500 },
],
},
})
.where(eq(devices.id, CONTROLLER))
.run();
const aux = fakeAux([]);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
// Exit radar active → blink.
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I5, edge: "on", at: new Date().toISOString(), source: "poll" });
await flush();
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
// ENTRY camera busy must NOT lock this exit lamp — it still blinks.
deviceEvents.emitLaneStatus({ entry: true, exit: false });
await flush();
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
// EXIT camera busy → SOLID.
deviceEvents.emitLaneStatus({ entry: true, exit: true });
await flush();
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
ctl.stop();
});
});
+317
View File
@@ -0,0 +1,317 @@
import { eq, devices, type Db, type DeviceRow } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { hasAuxOutput, registry, type AuxOutputDevice } from "@parking/devices";
import { deviceEvents, type DeviceInputEvent, type LaneStatusEvent } from "./device-events.js";
import { alertRelaysOf, relayForPresence, type RelaySpec } from "./device-resolve.js";
// Alert (radarAlert) relays — non-barrier indicator lamps, e.g. the entry button's 12 V
// light. Each lamp is a `relays[]` row with event `radarAlert`, driven by ITS trigger
// input vs. the camera "car in zone" signal (the advisory lane-status). A disagreement
// indicator:
// trigger active + lane busy (camera confirms a car) → SOLID on
// trigger active + lane free (radar sees something, no car) → BLINK (~1 Hz)
// otherwise → OFF
// The lamp is a NON-barrier aux output (setAux latch), so holding/blinking it is fine
// — barrier-not-a-door applies only to barriers, which still only pulseOpen. The lamp
// FAILS OFF: any error / shutdown leaves it off, so a dead lamp is "no hint", never a
// misleading solid "go". A controller may have several alert relays (each its own row +
// trigger input), keyed independently. See wiki/concepts/button-light-indicator.md.
type LightState = "off" | "solid" | "blink";
const DEFAULT_BLINK_MS = 500;
/** Per-lamp live state for the alert rule (one per radarAlert relay). */
interface LampState {
/** The controller this lamp lives on (its deviceId) — for resolving the aux adapter. */
readonly controllerId: string;
/** Alert relay row (relay #, triggerInput, blink ms). Mutable: #reconcile updates it in
* place when the admin changes the alert config without a restart. */
spec: RelaySpec;
/** Is the lamp's trigger input (the radar) currently active? */
present: boolean;
/** The high-level state we're rendering (to avoid restarting a running blink). */
rendered: LightState | null;
/** Active blink timer, if blinking. */
blink: ReturnType<typeof setInterval> | null;
/** Blink phase (true = currently on). */
blinkOn: boolean;
/** The output we WANT the relay to be in. The serialized worker drives the device
* toward this. The blink timer only flips this flag — it never sends directly. */
desiredOn: boolean;
/** The output we last CONFIRMED on the device (after a successful send). null = unknown. */
confirmedOn: boolean | null;
/** True while a send is in flight for this lamp — serializes UDP so on/off can't
* overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */
sending: boolean;
}
/** Resolves a controller's live aux-output adapter. The default goes through the
* driver registry; tests inject a spy. Returns null when the controller has no
* aux-output capability (or won't build). */
export type AuxResolver = (controllerId: string) => AuxOutputDevice | null;
export class ButtonLightController {
readonly #db: Db;
readonly #logger: FastifyBaseLogger;
readonly #resolveAux: AuxResolver;
/** Per-lamp state, keyed by `${controllerId}:${relay}` (a controller may have several). */
readonly #lamps = new Map<string, LampState>();
/** Latest lane status — a camera-confirmed car in the entry / exit zone. A lamp locks
* SOLID off its OWN lane's camera (`spec.lockLane`), so an exit radar's lamp tracks the
* exit camera, not the entry one. */
#entryBusy = false;
#exitBusy = false;
/** Controllers we've already warned lack the aux-output capability (warn once). */
readonly #warned = new Set<string>();
#unsubInput: (() => void) | null = null;
#unsubLane: (() => void) | null = null;
constructor(db: Db, logger: FastifyBaseLogger, resolveAux?: AuxResolver) {
this.#db = db;
this.#logger = logger;
this.#resolveAux = resolveAux ?? ((id) => this.#auxFromRegistry(id));
}
/** Subscribe to radar input edges + lane status, and initialise every lamp OFF. */
start(): void {
this.#reconcile();
// All lamps start OFF (known-safe baseline) regardless of prior device state.
for (const lamp of this.#lamps.values()) this.#apply(lamp);
this.#unsubInput = deviceEvents.onInput((e) => this.#onInput(e));
this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s));
}
/** Reconcile the lamp map with the CURRENT device config (the booth can add/change a
* button light without a server restart). Mirrors DeviceMonitor, which re-reads the
* device set each tick. Adds lamps for newly-configured controllers, updates the spec
* (relay #, blink ms) in place — preserving live `present`/blink state — and drops
* lamps whose controller lost its buttonLight or was disabled. Called at start() and
* before handling each event, so a just-saved lamp takes effect immediately. */
#reconcile(): void {
const rows = this.#db.select().from(devices).where(eq(devices.category, "access")).all();
const seen = new Set<string>();
for (const row of rows) {
if (!row.enabled) continue;
for (const spec of alertRelaysOf(row)) {
const key = lampKey(row.id, spec.relay);
seen.add(key);
const existing = this.#lamps.get(key);
if (existing) {
existing.spec = spec; // pick up a changed trigger input / blink cadence
} else {
this.#lamps.set(key, {
controllerId: row.id,
spec,
present: false,
rendered: null,
blink: null,
blinkOn: false,
desiredOn: false,
confirmedOn: null,
sending: false,
});
}
}
}
// Drop lamps whose controller no longer declares one (or was disabled/removed).
for (const [key, lamp] of this.#lamps) {
if (seen.has(key)) continue;
if (lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = null;
}
this.#finalOff(lamp); // best-effort fail-OFF before forgetting it
this.#lamps.delete(key);
}
}
/** A radar (presence) edge updates that controller's `present` flag. We resolve the
* edge the SAME way the entry flow does (relayForPresence on an entry/both relay),
* so the lamp and the one-car-one-ticket gate always agree on "a car is here". */
#onInput(e: DeviceInputEvent): void {
// Reconcile first so a lamp added/changed since boot (no restart) is picked up.
this.#reconcile();
const present = e.edge === "on";
for (const lamp of this.#lamps.values()) {
if (lamp.controllerId !== e.deviceId) continue;
// A lamp's trigger is its own `triggerInput`; if unset, fall back to the controller's
// entry-relay presence terminal (resolved the SAME way the entry flow does) so the
// lamp and the one-car-one-ticket gate always agree on "a car is here".
const trigger =
lamp.spec.triggerInput ?? relayForPresence(this.#db, e.deviceId, e.input)?.presenceInput;
if (trigger !== e.input) continue; // not this lamp's trigger terminal
if (present === lamp.present) continue;
lamp.present = present;
this.#apply(lamp);
}
}
/** Lane status changed: a camera-confirmed car in the entry and/or exit zone. */
#onLane(s: LaneStatusEvent): void {
if (s.entry === this.#entryBusy && s.exit === this.#exitBusy) return;
this.#entryBusy = s.entry;
this.#exitBusy = s.exit;
// Re-render every lamp (each picks its own lane's camera in #apply).
for (const lamp of this.#lamps.values()) this.#apply(lamp);
}
/** Compute + render the target state for one lamp. Drives are fire-and-forget (the
* timer/state machine is synchronous; the UDP write resolves on its own). */
#apply(lamp: LampState): void {
// SOLID only once THIS lamp's lane camera confirms a car (default entry).
const laneBusy = lamp.spec.lockLane === "exit" ? this.#exitBusy : this.#entryBusy;
const target: LightState = !lamp.present ? "off" : laneBusy ? "solid" : "blink";
if (target === lamp.rendered) return; // already rendering this state
// Tear down any running blink before switching states.
if (lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = null;
}
lamp.rendered = target;
if (target === "off") {
lamp.desiredOn = false;
this.#pump(lamp);
} else if (target === "solid") {
lamp.desiredOn = true;
this.#pump(lamp);
} else {
// BLINK: a wall-clock timer flips ONLY the desired flag; #pump does the actual
// (serialized) UDP send. A symmetric cadence uses one interval; an asymmetric one
// re-arms each phase with its own duration. Sends never overlap or reorder, so the
// relay can't get stuck on a stale packet.
const onMs = lamp.spec.blinkOnMs && lamp.spec.blinkOnMs > 0 ? lamp.spec.blinkOnMs : DEFAULT_BLINK_MS;
const offMs = lamp.spec.blinkOffMs && lamp.spec.blinkOffMs > 0 ? lamp.spec.blinkOffMs : DEFAULT_BLINK_MS;
lamp.blinkOn = true;
lamp.desiredOn = true;
const tick = () => {
lamp.blinkOn = !lamp.blinkOn;
lamp.desiredOn = lamp.blinkOn;
this.#pump(lamp);
if (onMs !== offMs && lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs);
lamp.blink.unref?.();
}
};
lamp.blink = setInterval(tick, onMs);
lamp.blink.unref?.();
this.#pump(lamp);
}
}
/** Serialized per-lamp worker: drive the relay toward `desiredOn`, one UDP send at a
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
* the relay stuck on a stale packet. Here a single in-flight send is guaranteed
* (`sending` guard); when it resolves, if the desired state moved on we send again —
* so the LAST desired state is always the one finally asserted on the device. */
#pump(lamp: LampState): void {
if (lamp.sending) return; // a send is already in flight; it'll re-check on completion
if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP
const aux = this.#resolveAux(lamp.controllerId);
if (!aux) return;
const target = lamp.desiredOn;
lamp.sending = true;
void aux
.setAux(lamp.spec.relay, target)
.then(() => {
lamp.confirmedOn = target;
})
.catch((err: unknown) => {
// Leave confirmedOn unchanged so the next pump retries this state. Never escalates.
this.#logger.error(`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}`);
})
.finally(() => {
lamp.sending = false;
// Desired state may have changed (or the send failed) while we were busy —
// re-pump to converge. This is what makes the final state authoritative.
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(lamp);
});
}
/** Build the live aux-output adapter for a controller, or null (logged once). */
#auxFromRegistry(controllerId: string): AuxOutputDevice | null {
const row = this.#db.select().from(devices).where(eq(devices.id, controllerId)).get();
if (!row) return null;
const driver = registry.get(row.driverId);
if (!driver) return null;
let device: unknown;
try {
device = driver.create(row.config as never);
} catch {
return null;
}
if (!hasAuxOutput(device)) {
if (!this.#warned.has(controllerId)) {
this.#warned.add(controllerId);
this.#logger.warn(`button-light: controller ${controllerId} (${row.driverId}) has no aux-output — lamp ignored`);
}
return null;
}
return device;
}
/** Unsubscribe, stop all blink timers, and best-effort drive every lamp OFF. */
stop(): void {
this.#unsubInput?.();
this.#unsubLane?.();
this.#unsubInput = null;
this.#unsubLane = null;
for (const lamp of this.#lamps.values()) {
if (lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = null;
}
// Best-effort fail-OFF on shutdown.
this.#finalOff(lamp);
}
}
/** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired
* OFF and pump. The serialized worker still applies, so this can't collide with an
* in-flight send — it converges to OFF. */
#finalOff(lamp: LampState): void {
lamp.desiredOn = false;
this.#pump(lamp);
}
/** Test seam: current high-level state being rendered for a lamp (controller + relay).
* `relay` defaults to the controller's only/first alert relay for single-lamp tests. */
stateOf(controllerId: string, relay?: number): LightState | null {
return this.#lamp(controllerId, relay)?.rendered ?? null;
}
/** Test seam: the state last CONFIRMED on the device for a lamp (after a successful
* send). null = unknown / nothing sent yet. `relay` defaults to the only alert relay. */
confirmedOf(controllerId: string, relay?: number): boolean | null {
return this.#lamp(controllerId, relay)?.confirmedOn ?? null;
}
/** Resolve a lamp by controller + relay. When `relay` is omitted, returns the
* controller's single lamp (the common single-alert case); ambiguous if several. */
#lamp(controllerId: string, relay?: number): LampState | undefined {
if (relay != null) return this.#lamps.get(lampKey(controllerId, relay));
for (const lamp of this.#lamps.values()) if (lamp.controllerId === controllerId) return lamp;
return undefined;
}
}
/** Composite key for the lamp map (a controller may carry several alert relays). */
function lampKey(controllerId: string, relay: number): string {
return `${controllerId}:${relay}`;
}
/** Build a controller row's live aux device (exported for reuse/tests). */
export function buildAux(db: Db, row: DeviceRow): AuxOutputDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
const device = driver.create(row.config as never);
return hasAuxOutput(device) ? device : null;
} catch {
return null;
}
}
+43 -1
View File
@@ -30,7 +30,7 @@ export interface DeviceReadEvent {
/** /**
* The decision a read produced. Returned by the read flows so a SYNCHRONOUS reader * The decision a read produced. Returned by the read flows so a SYNCHRONOUS reader
* (e.g. the QR reader, whose HTTP reply drives its beep + output) can answer the * (e.g. the QR reader, whose HTTP reply drives its beep + output) can answer the
* device. A fire-and-forget reader simply ignores it. See wiki/entities/gee-qr-er80.md. * device. A fire-and-forget reader simply ignores it. See wiki/entities/dingtian-dt008-reader.md.
*/ */
export interface ReadOutcome { export interface ReadOutcome {
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */ /** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
@@ -86,6 +86,28 @@ export interface LaneStatusEvent {
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity) readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
} }
/** A plate was RECOGNIZED for a session AFTER its entry/exit event already shipped. Plate
* recognition is async/advisory (a vision round-trip off the snapshot), so it lands a
* moment after the signed event — too late for the event's own WS push to carry it. This
* notifies the booth so it can fill in the plate badge on the already-rendered feed row /
* active session in place, no refresh. Advisory; never touches the signed ledger. See
* snapshot.ts (recognizePlate) + event-enrich.ts. */
export interface PlateRecognizedEvent {
readonly identity: string; // the session identity the plate is tied to
readonly plate: string; // normalized plate text (trimmed, upper)
readonly direction: "entry" | "exit";
}
/** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the
* entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has
* confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink:
* radar-present + camera-not-busy. Drives the booth's barrier light blink. Advisory only —
* it gates nothing. See wiki/concepts/button-light-indicator.md. */
export interface LanePresenceEvent {
readonly entry: boolean; // true = a presence input on an entry barrier is active
readonly exit: boolean; // true = a presence input on an exit barrier is active
}
class DeviceEventBus extends EventEmitter { class DeviceEventBus extends EventEmitter {
emitInput(event: DeviceInputEvent): void { emitInput(event: DeviceInputEvent): void {
this.emit("input", event); this.emit("input", event);
@@ -148,6 +170,26 @@ class DeviceEventBus extends EventEmitter {
this.on("lane-status", cb); this.on("lane-status", cb);
return () => this.off("lane-status", cb); return () => this.off("lane-status", cb);
} }
/** Emitted whenever a lane's RADAR presence CHANGES (a presence input shorted/cleared
* at an entry/exit barrier). Drives the booth barrier light's blink. Advisory only. */
emitLanePresence(event: LanePresenceEvent): void {
this.emit("lane-presence", event);
}
onLanePresence(cb: (event: LanePresenceEvent) => void): () => void {
this.on("lane-presence", cb);
return () => this.off("lane-presence", cb);
}
/** Emitted when an async plate recognition completes for a session (after its event
* already shipped). Lets the booth backfill the plate badge in place. Advisory only. */
emitPlateRecognized(event: PlateRecognizedEvent): void {
this.emit("plate-recognized", event);
}
onPlateRecognized(cb: (event: PlateRecognizedEvent) => void): () => void {
this.on("plate-recognized", cb);
return () => this.off("plate-recognized", cb);
}
} }
/** Process-wide device event bus. */ /** Process-wide device event bus. */
+6 -1
View File
@@ -39,7 +39,12 @@ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
return d; return d;
} }
case "access": { case "access": {
const dirs = new Set(relaysOf(row).map((r) => r.direction)); // Only barrier relays carry a role direction; alert (radarAlert) relays don't.
const dirs = new Set(
relaysOf(row)
.map((r) => r.direction)
.filter((d): d is "entry" | "exit" | "both" => d !== "radarAlert"),
);
if (dirs.size === 0) return null; if (dirs.size === 0) return null;
if (dirs.size > 1) return "mixed"; if (dirs.size > 1) return "mixed";
const only = [...dirs][0]; // entry | exit | both const only = [...dirs][0]; // entry | exit | both
+91
View File
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it } from "vitest";
import { devices, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { inputsOf, relayForButton, relayForPresence } from "./device-resolve.js";
// device-resolve: the input resolution layer. Inputs live in config.inputs[] (the first-class
// model); a pre-inputs[] controller is back-compat-synthesized from the legacy per-relay
// button/presenceInput fields. relayForButton/relayForPresence must resolve IDENTICALLY from
// either shape, so an exit radar = just another presence row.
let db: Db;
const CTL = "ctl-1";
function seed(config: Record<string, unknown>): void {
({ db } = createTestDb());
db.insert(devices).values({ id: CTL, category: "access", driverId: "dingtian", config, enabled: true }).run();
}
describe("inputsOf back-compat synth", () => {
it("synthesizes inputs[] from legacy relay button/presence fields", () => {
seed({
relays: [
{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "radar", presenceActiveLow: true },
{ relay: 2, direction: "exit" },
],
});
const row = db.select().from(devices).get()!;
const inputs = inputsOf(row);
expect(inputs).toEqual([
{ input: 1, role: "button", relay: 1, cooldownSec: undefined },
{ input: 2, role: "presence", relay: 1, kind: "radar", activeLow: true },
]);
});
it("prefers an explicit inputs[] over the legacy fields", () => {
seed({
relays: [{ relay: 1, direction: "entry", button: 9 /* legacy ignored */ }],
inputs: [{ input: 1, role: "button", relay: 1 }],
});
const row = db.select().from(devices).get()!;
expect(inputsOf(row)).toEqual([{ input: 1, role: "button", relay: 1 }]);
});
});
describe("relayForButton / relayForPresence", () => {
it("resolves a button + presence from inputs[]", () => {
seed({
relays: [{ relay: 1, direction: "entry" }],
inputs: [
{ input: 1, role: "button", relay: 1 },
{ input: 2, role: "presence", relay: 1, kind: "radar" },
],
});
const byBtn = relayForButton(db, CTL, 1);
expect(byBtn).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2, presenceKind: "radar" });
const byPres = relayForPresence(db, CTL, 2);
expect(byPres).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2 });
});
it("resolves IDENTICALLY from the legacy shape (no inputs[])", () => {
seed({ relays: [{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "loop" }] });
expect(relayForButton(db, CTL, 1)).toMatchObject({ relay: 1, presenceInput: 2, presenceKind: "loop" });
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1, presenceInput: 2 });
});
it("resolves an EXIT presence row to the exit relay (the exit radar)", () => {
seed({
relays: [
{ relay: 1, direction: "entry" },
{ relay: 2, direction: "exit" },
],
inputs: [
{ input: 2, role: "presence", relay: 1, kind: "radar" }, // entry radar
{ input: 5, role: "presence", relay: 2, kind: "radar" }, // exit radar
],
});
// NOTE: relayForPresence only gates entry/both relays (transient entry). The exit radar
// resolves to null HERE (the exit barrier has no entry gate) — but it's still a valid
// inputs[] row the lamp can trigger on. The entry radar resolves to relay 1.
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1 });
expect(relayForPresence(db, CTL, 5)).toBeNull(); // exit relay isn't a transient-entry gate
});
it("a button on an exit-only relay is not a transient-entry trigger", () => {
seed({
relays: [{ relay: 2, direction: "exit" }],
inputs: [{ input: 1, role: "button", relay: 2 }],
});
expect(relayForButton(db, CTL, 1)).toBeNull();
});
});
+164 -41
View File
@@ -10,35 +10,74 @@ export type Direction = "entry" | "exit" | "both";
/** A concrete flow a credential/button drives (never "both"). */ /** A concrete flow a credential/button drives (never "both"). */
export type FlowDirection = "entry" | "exit"; export type FlowDirection = "entry" | "exit";
/** One relay on an access controller: which barrier it opens, in which direction, /** The EVENT a relay reacts to. The barrier events (entry/exit/both) `pulseOpen`; the
* and (optionally) the input terminals its entry button + presence loop are wired to. */ * `radarAlert` event drives a non-barrier alert lamp (blink while the trigger input is
* active, locked SOLID by the camera). A relay is "when EVENT X happens, do its action" —
* the action is implied by the event. See wiki/concepts/button-light-indicator.md. */
export type RelayEvent = Direction | "radarAlert";
/** What a controller input terminal MEANS. `button` = a transient-entry button; `presence`
* = a one-car-one-ticket sensor (induction loop or radar); `alertTrigger` = the edge that
* starts a `radarAlert` lamp blinking. See wiki/concepts/entry-double-press.md. */
export type InputRole = "button" | "presence" | "alertTrigger";
/** One INPUT terminal the host reads, as a first-class citizen (the twin of RelaySpec).
* An exit radar is just another `presence` row serving the exit relay. */
export interface InputSpec {
/** 1-based input terminal the host reads. */
readonly input: number;
readonly role: InputRole;
/** The barrier relay this input serves. Required for `button`/`presence` (the gate is
* keyed per relay); optional for `alertTrigger` (a standalone lamp trigger). */
readonly relay?: number;
/** `presence` only — induction LOOP or RADAR. Label only (gate is identical). Default loop. */
readonly kind?: "loop" | "radar";
/** This terminal is ACTIVE-LOW (idles HIGH) — e.g. a radar wired opposite the button.
* Maps to the driver's per-input `inputActiveLow`. See wiki/entities/hikvision-radar.md. */
readonly activeLow?: boolean;
/** `button` only — presence-less fallback: suppress repeat presses for N seconds after a
* ticket. A timer (mitigation, not a guarantee); used when no `presence` row serves this relay. */
readonly cooldownSec?: number;
}
/** One relay on an access controller: the event it reacts to. Input wiring (button,
* presence) lives in `config.inputs[]`; the LEGACY per-relay fields below are still read
* (back-compat) but no longer written by the UI. */
export interface RelaySpec { export interface RelaySpec {
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */ /** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
readonly relay: number; readonly relay: number;
readonly direction: Direction; /** The event this relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` →
/** 1-based input terminal of the entry button that fires this relay (transient * drive an alert lamp (blink + camera-lock) via `setAux`, NEVER pulseOpen. */
* entry). Absent = no button at this barrier (subscriber/reader-driven only). */ readonly direction: RelayEvent;
// ── LEGACY input fields (read-only back-compat; superseded by config.inputs[]) ──
// Pre-inputs[] configs wired the entry button + presence sensor here. `inputsOf()`
// synthesizes InputSpec rows from these when a controller has no `inputs[]` yet.
readonly button?: number; readonly button?: number;
/**
* Anti-double-press for the transient entry button (one car must yield ONE ticket).
* Two modes, chosen by what barrier feedback exists at this lane:
* - PRESENCE (preferred, when a vehicle loop is wired): `presenceInput` = the
* 1-based input terminal of an induction loop / barrier presence signal on THIS
* controller. A press prints only while a car is present, and no second ticket
* issues until the loop CLEARS (car drove in) and a new car re-occupies it. This
* makes one-car-one-ticket physical.
* - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses
* on this relay for N seconds after a ticket prints. A pure timer — mitigation,
* not a guarantee. Used when `presenceInput` is unset (or as a secondary guard).
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
*/
readonly presenceInput?: number; readonly presenceInput?: number;
readonly presenceKind?: "loop" | "radar";
readonly presenceActiveLow?: boolean;
readonly entryCooldownSec?: number; readonly entryCooldownSec?: number;
// ── radarAlert-only (direction === "radarAlert") ──
// A non-barrier indicator lamp wired to this (spare) relay — e.g. the entry button's
// 12 V light. Driven by the server ButtonLightController off its trigger input vs. the
// camera lane status: blink while the trigger is active + lane free, SOLID once the
// camera confirms a car, OFF otherwise. NOT a barrier (uses setAux, never pulseOpen).
/** 1-based input terminal whose active edge starts the blink (the radar). */
readonly triggerInput?: number;
/** Which lane's camera locks this lamp SOLID — the entry or the exit camera. Default
* "entry". An exit radar's lamp must lock on the EXIT camera. */
readonly lockLane?: FlowDirection;
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
readonly blinkOnMs?: number;
readonly blinkOffMs?: number;
} }
/** Access controller config (the `relays[]` map + connection fields). */ /** Access controller config (the `relays[]` + `inputs[]` maps + connection fields). */
interface AccessConfig { interface AccessConfig {
readonly relays?: RelaySpec[]; readonly relays?: RelaySpec[];
readonly inputs?: InputSpec[];
readonly [k: string]: unknown; readonly [k: string]: unknown;
} }
@@ -60,9 +99,11 @@ export interface ResolvedRelay {
readonly controller: DeviceRow; readonly controller: DeviceRow;
readonly relay: number; readonly relay: number;
readonly direction: Direction; readonly direction: Direction;
/** 1-based presence-loop input gating this relay's entry (when wired). */ /** 1-based presence input gating this relay's entry (loop or radar, when wired). */
readonly presenceInput?: number; readonly presenceInput?: number;
/** Cooldown seconds suppressing repeat presses (fallback when no presence loop). */ /** Sensor kind on the presence input (loop|radar) — telemetry/label only. */
readonly presenceKind?: "loop" | "radar";
/** Cooldown seconds suppressing repeat presses (fallback when no presence input). */
readonly entryCooldownSec?: number; readonly entryCooldownSec?: number;
} }
@@ -83,9 +124,49 @@ export function relaysOf(row: DeviceRow): RelaySpec[] {
} }
/** /**
* Resolve a button press to the relay it fires: the access controller with this * The INPUT terminals declared on an access controller — the back-compat keystone. Returns
* deviceId, and the relay whose `button` terminal matches the pressed input. Only * `config.inputs[]` when present; otherwise SYNTHESIZES InputSpec rows from the LEGACY
* an ENTRY (or both) relay is a transient-entry trigger. Returns null otherwise. * per-relay fields (`relays[].button` → a `button` row; `relays[].presenceInput` → a
* `presence` row) so a pre-inputs[] controller resolves identically. Everything that reads
* inputs goes through here, so the legacy fold lives in exactly one place.
*/
export function inputsOf(row: DeviceRow): InputSpec[] {
const cfg = row.config as AccessConfig;
if (Array.isArray(cfg.inputs) && cfg.inputs.length > 0) return cfg.inputs;
const synth: InputSpec[] = [];
for (const r of relaysOf(row)) {
if (typeof r.button === "number") {
synth.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec });
}
if (typeof r.presenceInput === "number") {
synth.push({
input: r.presenceInput,
role: "presence",
relay: r.relay,
kind: r.presenceKind ?? "loop",
activeLow: r.presenceActiveLow,
});
}
}
return synth;
}
/** The barrier RelaySpec a `button`/`presence` input row serves (its `relay`), or null —
* only entry/both relays gate transient entry. Narrows `direction` to a barrier Direction. */
function barrierForInput(row: DeviceRow, spec: InputSpec): (RelaySpec & { direction: Direction }) | null {
if (typeof spec.relay !== "number") return null;
const relay = relaysOf(row).find((r) => r.relay === spec.relay);
if (!relay) return null;
if (relay.direction !== "entry" && relay.direction !== "both") return null;
return { ...relay, direction: relay.direction };
}
/**
* Resolve a button press to the relay it fires: the access controller with this deviceId,
* and the relay served by the `button` input on this terminal (via inputsOf). Only an
* ENTRY (or both) relay is a transient-entry trigger. Carries the one-car-one-ticket
* config (presence input + cooldown) for that relay so the entry flow can enforce it.
* Returns null otherwise.
*/ */
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null { export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
const row = db const row = db
@@ -94,23 +175,28 @@ export function relayForButton(db: Db, controllerId: string, terminal: number):
.where(and(eq(devices.id, controllerId), eq(devices.category, "access"))) .where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
.get(); .get();
if (!row || !row.enabled) return null; if (!row || !row.enabled) return null;
const spec = relaysOf(row).find((r) => r.button === terminal); const inputs = inputsOf(row);
if (!spec) return null; const btn = inputs.find((i) => i.role === "button" && i.input === terminal);
if (spec.direction !== "entry" && spec.direction !== "both") return null; if (!btn) return null;
const relay = barrierForInput(row, btn);
if (!relay) return null;
// The presence sensor (if any) serving the SAME relay supplies the gate.
const presence = inputs.find((i) => i.role === "presence" && i.relay === relay.relay);
return { return {
controller: row, controller: row,
relay: spec.relay, relay: relay.relay,
direction: spec.direction, direction: relay.direction,
presenceInput: spec.presenceInput, presenceInput: presence?.input,
entryCooldownSec: spec.entryCooldownSec, presenceKind: presence?.kind ?? "loop",
entryCooldownSec: btn.cooldownSec,
}; };
} }
/** /**
* Resolve a PRESENCE-LOOP input edge to the entry relay it gates: the controller with * Resolve a PRESENCE input edge to the entry relay it gates: the controller with this
* this deviceId, and the relay whose `presenceInput` terminal matches the fired input. * deviceId, and the relay served by the `presence` input on this terminal. Lets the entry
* Lets the entry flow track "a car is physically at this entry barrier" so it issues * flow track "a car is physically at this entry barrier" so it issues exactly one ticket
* exactly one ticket per car. Only entry/both relays gate transient entry. Null otherwise. * per car. Only entry/both relays gate transient entry. Null otherwise.
*/ */
export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null { export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
const row = db const row = db
@@ -119,10 +205,43 @@ export function relayForPresence(db: Db, controllerId: string, terminal: number)
.where(and(eq(devices.id, controllerId), eq(devices.category, "access"))) .where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
.get(); .get();
if (!row || !row.enabled) return null; if (!row || !row.enabled) return null;
const spec = relaysOf(row).find((r) => r.presenceInput === terminal); const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
if (!spec) return null; if (!presence) return null;
if (spec.direction !== "entry" && spec.direction !== "both") return null; const relay = barrierForInput(row, presence);
return { controller: row, relay: spec.relay, direction: spec.direction }; if (!relay) return null;
return {
controller: row,
relay: relay.relay,
direction: relay.direction,
presenceInput: presence.input,
presenceKind: presence.kind ?? "loop",
};
}
/** The alert (radarAlert) relay rows declared on an access controller — the lamps the
* ButtonLightController drives. Each is a `relays[]` row whose event is `radarAlert`. */
export function alertRelaysOf(row: DeviceRow): RelaySpec[] {
return relaysOf(row).filter((r) => r.direction === "radarAlert" && typeof r.relay === "number");
}
/**
* Which LANE a presence input belongs to — for the booth's barrier-light blink (advisory).
* Unlike `relayForPresence` (entry-gated, for the one-car-one-ticket gate), this resolves a
* presence input on ANY barrier: entry/both → "entry", exit → "exit". Returns null if the
* terminal isn't a presence input on a barrier relay. See lane-presence.ts.
*/
export function presenceLaneOf(db: Db, controllerId: string, terminal: number): FlowDirection | null {
const row = db
.select()
.from(devices)
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
.get();
if (!row || !row.enabled) return null;
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
if (!presence || typeof presence.relay !== "number") return null;
const relay = relaysOf(row).find((r) => r.relay === presence.relay);
if (!relay) return null;
return relay.direction === "exit" ? "exit" : relay.direction === "radarAlert" ? null : "entry";
} }
/** /**
@@ -144,7 +263,10 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
.get(); .get();
if (controller && controller.enabled) { if (controller && controller.enabled) {
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay); const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
if (spec) return { controller, relay: spec.relay, direction: spec.direction }; // Only a barrier relay opens; an alert (radarAlert) relay is never a barrier.
if (spec && spec.direction !== "radarAlert") {
return { controller, relay: spec.relay, direction: spec.direction };
}
} }
return null; return null;
} }
@@ -164,7 +286,8 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null { export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
for (const controller of accessRows(db)) { for (const controller of accessRows(db)) {
const spec = relaysOf(controller).find( const spec = relaysOf(controller).find(
(r) => r.direction === direction || r.direction === "both", (r): r is RelaySpec & { direction: Direction } =>
r.direction === direction || r.direction === "both",
); );
if (spec) return { controller, relay: spec.relay, direction: spec.direction }; if (spec) return { controller, relay: spec.relay, direction: spec.direction };
} }
+144
View File
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it } from "vitest";
import { eq, devices, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { LanePresence } from "./lane-presence.js";
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
import { silentLogger } from "./test-helpers.js";
// LanePresence: a vehicle-presence INPUT edge (loop/radar) on an entry/exit barrier marks
// that lane "present" — the same signal that blinks the physical button lamp (relay 3). It
// resolves the edge via relayForPresence (the SAME path relay 3 + the entry gate use), and
// emits a lane-presence change only when a lane's present/clear state actually flips.
let db: Db;
const CTL = "ctl-1";
const ENTRY_RADAR = 2;
const EXIT_RADAR = 5;
beforeEach(() => {
({ db } = createTestDb());
// Entry relay 1 with a radar on I2; exit relay 2 with a radar on I5.
db.insert(devices).values({
id: CTL,
category: "access",
driverId: "dingtian",
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry" },
{ relay: 2, direction: "exit" },
],
inputs: [
{ input: ENTRY_RADAR, role: "presence", relay: 1, kind: "radar" },
{ input: EXIT_RADAR, role: "presence", relay: 2, kind: "radar" },
],
},
enabled: true,
}).run();
});
function edge(input: number, on: boolean): void {
const e: DeviceInputEvent = {
driverId: "dingtian",
deviceId: CTL,
input,
edge: on ? "on" : "off",
at: new Date().toISOString(),
source: "poll",
};
deviceEvents.emitInput(e);
}
/** Collect lane-presence emissions while running `fn`. */
function capture(fn: () => void): LanePresenceEvent[] {
const seen: LanePresenceEvent[] = [];
const off = deviceEvents.onLanePresence((p) => seen.push(p));
try {
fn();
} finally {
off();
}
return seen;
}
describe("LanePresence", () => {
it("starts clear and snapshots clear", () => {
const lp = new LanePresence(db, silentLogger());
lp.start();
expect(lp.snapshot()).toEqual({ entry: false, exit: false });
lp.stop();
});
it("an ENTRY radar edge marks the entry lane present, then clears", () => {
const lp = new LanePresence(db, silentLogger());
lp.start();
const events = capture(() => {
edge(ENTRY_RADAR, true);
edge(ENTRY_RADAR, false);
});
expect(events).toEqual([
{ entry: true, exit: false },
{ entry: false, exit: false },
]);
lp.stop();
});
it("an EXIT radar edge marks the exit lane independently", () => {
const lp = new LanePresence(db, silentLogger());
lp.start();
const events = capture(() => {
edge(EXIT_RADAR, true);
});
expect(events).toEqual([{ entry: false, exit: true }]);
expect(lp.snapshot()).toEqual({ entry: false, exit: true });
lp.stop();
});
it("de-dupes: a second 'on' from another presence input on the same lane emits once", () => {
// Two radars both serving the entry lane.
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [{ relay: 1, direction: "entry" }],
inputs: [
{ input: 2, role: "presence", relay: 1, kind: "radar" },
{ input: 3, role: "presence", relay: 1, kind: "radar" },
],
},
})
.where(eq(devices.id, CTL))
.run();
const lp = new LanePresence(db, silentLogger());
lp.start();
const events = capture(() => {
edge(2, true); // entry → present (emit)
edge(3, true); // still present (no emit — same lane)
edge(2, false); // still present via I3 (no emit)
edge(3, false); // now clear (emit)
});
expect(events).toEqual([
{ entry: true, exit: false },
{ entry: false, exit: false },
]);
lp.stop();
});
it("ignores a non-presence input (e.g. a button terminal)", () => {
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [{ relay: 1, direction: "entry" }],
inputs: [{ input: 1, role: "button", relay: 1 }],
},
})
.where(eq(devices.id, CTL))
.run();
const lp = new LanePresence(db, silentLogger());
lp.start();
const events = capture(() => edge(1, true));
expect(events).toEqual([]);
lp.stop();
});
});
+61
View File
@@ -0,0 +1,61 @@
import type { Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
import { presenceLaneOf } from "./device-resolve.js";
// Per-lane RADAR presence for the booth's barrier lights. A vehicle-presence INPUT
// (loop/radar) shorted at an entry/exit barrier means "something is in the lane vicinity"
// BEFORE the camera confirms a vehicle. This is the SAME signal that makes the physical
// button lamp (relay 3) blink — see button-light.ts (#onInput) — so the on-screen light
// and the lamp stay in lockstep: both react to a presence edge resolved the SAME way
// (relayForPresence, on an entry/both relay). ADVISORY ONLY: it gates nothing.
//
// A radar serving an entry (or "both") barrier marks the ENTRY lane present; an exit radar
// marks EXIT. The lane is resolved via `presenceLaneOf` (direction-agnostic — unlike the
// entry-gated `relayForPresence` the one-car-one-ticket gate uses), so both lanes blink.
export class LanePresence {
readonly #db: Db;
readonly #logger: FastifyBaseLogger;
/** Active presence terminals per lane, keyed `${deviceId}:${input}` (several radars may
* serve one lane). A lane is "present" while its set is non-empty. */
readonly #entry = new Set<string>();
readonly #exit = new Set<string>();
#unsub: (() => void) | null = null;
constructor(db: Db, logger: FastifyBaseLogger) {
this.#db = db;
this.#logger = logger;
}
/** Subscribe to presence input edges. */
start(): void {
this.#unsub = deviceEvents.onInput((e) => this.#onInput(e));
}
/** Current snapshot (for the WS hello). */
snapshot(): LanePresenceEvent {
return { entry: this.#entry.size > 0, exit: this.#exit.size > 0 };
}
#onInput(e: DeviceInputEvent): void {
const lane = presenceLaneOf(this.#db, e.deviceId, e.input);
if (!lane) return; // not a presence terminal on a barrier relay
const key = `${e.deviceId}:${e.input}`;
const set = lane === "entry" ? this.#entry : this.#exit;
const before = set.size > 0;
if (e.edge === "on") set.add(key);
else set.delete(key);
const after = set.size > 0;
if (before !== after) {
this.#logger.info(`lane-presence: ${lane} -> ${after ? "present" : "clear"}`);
deviceEvents.emitLanePresence(this.snapshot());
}
}
/** Unsubscribe on shutdown. */
stop(): void {
this.#unsub?.();
this.#unsub = null;
}
}
+26
View File
@@ -29,6 +29,13 @@ interface ThemeBody {
theme: Theme; theme: Theme;
} }
// UI font scale: percent of base, clamped to [80, 160] in steps of 10. Integer percent.
const FONT_SCALE_MIN = 80;
const FONT_SCALE_MAX = 160;
interface FontScaleBody {
fontScale: number;
}
// Self-service profile: a signed-in user edits their OWN display name + email. This is // Self-service profile: a signed-in user edits their OWN display name + email. This is
// NOT the admin user-management path (routes/users.ts) — it only ever touches the caller // NOT the admin user-management path (routes/users.ts) — it only ever touches the caller
// (req.user.sub), needs no `user:*` permission, and can't change username, role, or any // (req.user.sub), needs no `user:*` permission, and can't change username, role, or any
@@ -67,6 +74,7 @@ function sessionView(
roleId: string; roleId: string;
language: string; language: string;
theme: string; theme: string;
fontScale: number;
fullName?: string | null; fullName?: string | null;
email?: string | null; email?: string | null;
}, },
@@ -81,6 +89,7 @@ function sessionView(
permissions, permissions,
language: user.language, language: user.language,
theme: user.theme, theme: user.theme,
fontScale: user.fontScale,
fullName: user.fullName ?? null, fullName: user.fullName ?? null,
email: user.email ?? null, email: user.email ?? null,
}; };
@@ -171,6 +180,23 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
}, },
); );
// Change MY own UI font scale (any signed-in user). Percent of base, clamped to
// [80, 160] in steps of 10. Persisted like `theme`, restored on the next login.
app.put<{ Body: FontScaleBody }>(
"/api/auth/font-scale",
{ preHandler: requireAuth },
async (req, reply) => {
const raw = req.body?.fontScale;
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return reply.code(400).send({ error: "fontScale must be a number" });
}
// Snap to a 10-step and clamp to the allowed band (defensive — the UI already does).
const fontScale = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round(raw / 10) * 10));
await db.update(users).set({ fontScale }).where(eq(users.id, req.user.sub)).run();
return { fontScale };
},
);
// Edit MY own display name / email (any signed-in user; no permission needed — it only // Edit MY own display name / email (any signed-in user; no permission needed — it only
// touches the caller). Cannot change username or role — those stay admin-only (users.ts). // touches the caller). Cannot change username or role — those stay admin-only (users.ts).
app.put<{ Body: ProfileBody }>( app.put<{ Body: ProfileBody }>(
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js";
import { seedUser, login } from "../test-helpers.js";
// HTTP integration for the backup routes — the security seam + the unconfigured-state
// behaviour. The booted test app has no BACKUP_TARGET_DIR/BACKUP_KEY, so the service is
// "not configured": status reports it, and a manual run is a clean 409 (not a 500).
// See wiki/concepts/backup-recovery.md.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
describe("GET /api/backup/status", () => {
it("401 without a session", async () => {
const res = await app.inject({ method: "GET", url: "/api/backup/status" });
expect(res.statusCode).toBe(401);
});
it("403 for a user lacking backup:read", async () => {
const { username, password } = await seedUser(db, {
username: "viewer", roleId: "viewer", permissions: ["site:read"],
});
const { cookie } = await login(app, username, password);
const res = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
expect(res.statusCode).toBe(403);
});
it("an admin sees the (unconfigured) status shape", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie } = await login(app, username, password);
const res = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body).toMatchObject({
configured: false,
targetDir: null,
running: false,
lastSuccessAt: null,
lastError: null,
});
});
});
describe("PUT /api/backup/config — admin-chosen target", () => {
it("403 for a user lacking backup:update", async () => {
const { username, password } = await seedUser(db, {
username: "viewer", roleId: "viewer", permissions: ["backup:read"],
});
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "PUT", url: "/api/backup/config",
headers: { cookie, "x-csrf-token": csrf },
payload: { targetDir: "/tmp/x" },
});
expect(res.statusCode).toBe(403);
});
it("persists the target dir and reflects it in status", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie, csrf } = await login(app, username, password);
const put = await app.inject({
method: "PUT", url: "/api/backup/config",
headers: { cookie, "x-csrf-token": csrf },
payload: { targetDir: " /mnt/backup " }, // trimmed server-side
});
expect(put.statusCode).toBe(200);
expect(put.json()).toMatchObject({ targetDir: "/mnt/backup" });
const status = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
expect(status.json().targetDir).toBe("/mnt/backup");
});
it("clears the target dir when given empty/null", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie, csrf } = await login(app, username, password);
await app.inject({
method: "PUT", url: "/api/backup/config",
headers: { cookie, "x-csrf-token": csrf }, payload: { targetDir: "/mnt/backup" },
});
const clear = await app.inject({
method: "PUT", url: "/api/backup/config",
headers: { cookie, "x-csrf-token": csrf }, payload: { targetDir: "" },
});
expect(clear.json().targetDir).toBeNull();
});
});
describe("POST /api/backup/test — path probe", () => {
it("reports ok for a writable directory and a reason for a missing one", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie, csrf } = await login(app, username, password);
const good = await app.inject({
method: "POST", url: "/api/backup/test",
headers: { cookie, "x-csrf-token": csrf },
payload: { targetDir: process.cwd() }, // an existing, writable dir
});
expect(good.json()).toMatchObject({ ok: true });
const bad = await app.inject({
method: "POST", url: "/api/backup/test",
headers: { cookie, "x-csrf-token": csrf },
payload: { targetDir: "/no/such/path/here-xyz" },
});
expect(bad.json()).toMatchObject({ ok: false, reason: "missing" });
});
});
describe("POST /api/backup/run", () => {
it("403 for a user lacking backup:create", async () => {
const { username, password } = await seedUser(db, {
username: "viewer", roleId: "viewer", permissions: ["backup:read"], // read but not create
});
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "POST", url: "/api/backup/run",
headers: { cookie, "x-csrf-token": csrf },
});
expect(res.statusCode).toBe(403);
});
it("requires CSRF on the mutation", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie } = await login(app, username, password);
const res = await app.inject({
method: "POST", url: "/api/backup/run",
headers: { cookie }, // no csrf header
});
expect(res.statusCode).toBe(403);
});
it("returns 409 backup_not_configured when no target/key is set (not a 500)", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "POST", url: "/api/backup/run",
headers: { cookie, "x-csrf-token": csrf },
});
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ error: "backup_not_configured" });
});
});
+69
View File
@@ -0,0 +1,69 @@
import type { FastifyInstance } from "fastify";
import { eq, siteConfig, type Db } from "@parking/db";
import { requirePermission } from "../auth.js";
import { checkTargetDir, type BackupService } from "../backup-service.js";
// On-site encrypted DB backup — admin-driven. See wiki/concepts/backup-recovery.md.
// - GET /api/backup/status : config + last-run success/error. (backup:read)
// - PUT /api/backup/config : set the admin-chosen target directory. (backup:update)
// - POST /api/backup/test : probe a candidate path (exists/dir/writable). (backup:update)
// - POST /api/backup/run : trigger a manual "back up now". (backup:create)
// The target dir lives in site_config (admin picks it from the UI); the encryption key stays an
// env secret. RESTORE is intentionally absent — out-of-band runbook on a fresh appliance.
interface ConfigBody {
targetDir?: string | null;
}
interface TestBody {
targetDir?: string;
}
export async function backupRoutes(app: FastifyInstance, db: Db, backups: BackupService): Promise<void> {
app.get("/api/backup/status", { preHandler: requirePermission("backup:read") }, async () =>
backups.status(),
);
// Set (or clear) the target directory. Empty/null clears it (backups become a no-op).
app.put<{ Body: ConfigBody }>(
"/api/backup/config",
{ preHandler: requirePermission("backup:update") },
async (req, reply) => {
const raw = req.body?.targetDir;
if (raw != null && typeof raw !== "string") {
return reply.code(400).send({ error: "targetDir must be a string or null" });
}
const next = raw == null ? null : raw.trim() || null;
const updatedAt = new Date().toISOString();
// Single-row site_config (id=1): upsert, since a fresh install may not have it yet.
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (existing) {
db.update(siteConfig).set({ backupTargetDir: next, updatedAt }).where(eq(siteConfig.id, 1)).run();
} else {
db.insert(siteConfig).values({ id: 1, backupTargetDir: next, updatedAt }).run();
}
return backups.status();
},
);
// Probe a candidate path before relying on it (the UI "Test target" button).
app.post<{ Body: TestBody }>(
"/api/backup/test",
{ preHandler: requirePermission("backup:update") },
async (req) => {
const dir = typeof req.body?.targetDir === "string" ? req.body.targetDir : "";
return checkTargetDir(dir);
},
);
app.post("/api/backup/run", { preHandler: requirePermission("backup:create") }, async (_req, reply) => {
if (!backups.configured) {
return reply.code(409).send({ error: "backup_not_configured" });
}
try {
const res = await backups.run("manual");
return reply.send({ ok: true, path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles });
} catch (err) {
return reply.code(500).send({ error: "backup_failed", message: (err as Error).message });
}
});
}
+54
View File
@@ -128,3 +128,57 @@ describe("PUT /api/auth/password (self-service)", () => {
expect(res.statusCode).toBe(400); expect(res.statusCode).toBe(400);
}); });
}); });
describe("PUT /api/auth/font-scale (self-service)", () => {
it("persists a valid scale and returns it on the next session", async () => {
const { username, password } = await seedUser(db, { username: "f1", roleId: "viewer", permissions: [] });
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "PUT", url: "/api/auth/font-scale",
headers: { cookie, "x-csrf-token": csrf },
payload: { fontScale: 120 },
});
expect(res.statusCode).toBe(200);
expect(res.json().fontScale).toBe(120);
// Persisted to the caller's row…
expect(db.select().from(users).where(eq(users.username, "f1")).get()?.fontScale).toBe(120);
// …and surfaced on /me (the session bootstrap).
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
expect(me.json().fontScale).toBe(120);
});
it("clamps + snaps out-of-band / off-step values", async () => {
const { username, password } = await seedUser(db, { username: "f2", roleId: "viewer", permissions: [] });
const { cookie, csrf } = await login(app, username, password);
const tooBig = await app.inject({
method: "PUT", url: "/api/auth/font-scale",
headers: { cookie, "x-csrf-token": csrf },
payload: { fontScale: 999 },
});
expect(tooBig.json().fontScale).toBe(160); // clamped to max
const offStep = await app.inject({
method: "PUT", url: "/api/auth/font-scale",
headers: { cookie, "x-csrf-token": csrf },
payload: { fontScale: 113 },
});
expect(offStep.json().fontScale).toBe(110); // snapped to the 10-step
});
it("rejects a non-numeric scale (400)", async () => {
const { username, password } = await seedUser(db, { username: "f3", roleId: "viewer", permissions: [] });
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "PUT", url: "/api/auth/font-scale",
headers: { cookie, "x-csrf-token": csrf },
payload: { fontScale: "big" },
});
expect(res.statusCode).toBe(400);
});
it("defaults to 100 for a fresh user", async () => {
const { username, password } = await seedUser(db, { username: "f4", roleId: "viewer", permissions: [] });
const { cookie } = await login(app, username, password);
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
expect(me.json().fontScale).toBe(100);
});
});
+6 -6
View File
@@ -4,10 +4,10 @@ import type { DeviceReadEvent } from "../device-events.js";
import type { ReadDispatcher } from "../read-dispatch.js"; import type { ReadDispatcher } from "../read-dispatch.js";
import type { CredentialCapture } from "../credential-capture.js"; import type { CredentialCapture } from "../credential-capture.js";
// GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our // Dingtian DT-008 QR/RFID reader endpoint. The reader is configured (vendor tool) with
// host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on // our host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
// our JSON reply — host-in-the-loop and synchronous. Protocol from the QRCode SDK // our JSON reply — host-in-the-loop and synchronous. Protocol from the QRCode SDK
// v1.6.5; see wiki/sources/qrcode-sdk.md and wiki/entities/gee-qr-er80.md. // v1.6.5; see wiki/sources/qrcode-sdk.md and wiki/entities/dingtian-dt008-reader.md.
// //
// reader → GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2ch>&time=<utc> // reader → GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2ch>&time=<utc>
// server → {"data":[{cardid,cjihao,mjihao,status,time,output}],"code":0,"message":""} // server → {"data":[{cardid,cjihao,mjihao,status,time,output}],"code":0,"message":""}
@@ -35,7 +35,7 @@ export async function qrReaderRoutes(
): Promise<void> { ): Promise<void> {
// Resolve the lane_devices row whose config.serial matches the reader's reported // Resolve the lane_devices row whose config.serial matches the reader's reported
// serial (cjihao). The row id is a normal UUID; the serial is config the admin // serial (cjihao). The row id is a normal UUID; the serial is config the admin
// enters when assigning the gee-qr-reader. Returns the row id, or null if no // enters when assigning the dingtian-qr-reader. Returns the row id, or null if no
// reader is assigned for that serial. (Small device set → scan in JS.) // reader is assigned for that serial. (Small device set → scan in JS.)
const readerRowIdForSerial = (serial: string): string | null => { const readerRowIdForSerial = (serial: string): string | null => {
if (!serial) return null; if (!serial) return null;
@@ -52,7 +52,7 @@ export async function qrReaderRoutes(
// drive output) once the socket CLOSES — every vendor demo replies // drive output) once the socket CLOSES — every vendor demo replies
// `Connection: close` and shuts the socket. Without it the reader waits out a // `Connection: close` and shuts the socket. Without it the reader waits out a
// ~10 s keep-alive timeout before beeping. So force-close the connection. // ~10 s keep-alive timeout before beeping. So force-close the connection.
// See wiki/sources/qrcode-sdk.md, entities/gee-qr-er80.md. // See wiki/sources/qrcode-sdk.md, entities/dingtian-dt008-reader.md.
reply.header("connection", "close"); reply.header("connection", "close");
const cardid = (q.cardid ?? "").trim(); const cardid = (q.cardid ?? "").trim();
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0; const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
@@ -75,7 +75,7 @@ export async function qrReaderRoutes(
accepted = true; // beep "ok" so the operator knows the card was read accepted = true; // beep "ok" so the operator knows the card was read
} else { } else {
const read: DeviceReadEvent = { const read: DeviceReadEvent = {
driverId: "gee-qr-reader", driverId: "dingtian-qr-reader",
deviceId, deviceId,
value: cardid, value: cardid,
kind: "qr", kind: "qr",
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it } from "vitest";
import { randomUUID } from "node:crypto";
import { devices, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { storedSecrets } from "./setup.js";
// storedSecrets re-merges a device's machine-only secrets (relayPassword/pushPassword)
// into a test/save — but ONLY when the submitted config addresses the SAME device at the
// SAME host/port. This guards against a redirected probe exfiltrating the secret to an
// attacker host (an admin keeps a real device id but swaps the host). The booth operator
// is the threat-model adversary, so an authenticated-admin redirect must NOT leak.
let db: Db;
const ID = "ctl-secret";
const HOST = "10.0.10.5";
beforeEach(() => {
({ db } = createTestDb());
db.insert(devices).values({
id: ID,
category: "access",
driverId: "dingtian",
config: { host: HOST, binaryPort: 60000, relayPassword: 1996, pushPassword: "p-secret" },
enabled: true,
}).run();
});
describe("storedSecrets identity guard", () => {
it("re-merges secrets when host/port/driver match the stored device", () => {
const out = storedSecrets(db, ID, "dingtian", { host: HOST, binaryPort: 60000 });
expect(out.relayPassword).toBe(1996);
expect(out.pushPassword).toBe("p-secret");
});
it("re-merges when identity fields are OMITTED (fall back to the stored device)", () => {
const out = storedSecrets(db, ID, "dingtian", {});
expect(out.relayPassword).toBe(1996);
});
it("REFUSES secrets when the host is redirected (exfiltration attempt)", () => {
const out = storedSecrets(db, ID, "dingtian", { host: "10.66.66.66", binaryPort: 60000 });
expect(out).toEqual({});
});
it("REFUSES secrets when a control port is changed", () => {
const out = storedSecrets(db, ID, "dingtian", { host: HOST, binaryPort: 9999 });
expect(out).toEqual({});
});
it("REFUSES secrets when the driver doesn't match the stored row", () => {
const out = storedSecrets(db, ID, "stub-access", { host: HOST });
expect(out).toEqual({});
});
it("returns nothing for an unknown device id", () => {
expect(storedSecrets(db, randomUUID(), "dingtian", { host: HOST })).toEqual({});
});
});
+124 -2
View File
@@ -7,6 +7,7 @@ import {
isCamera, isCamera,
isDiscoverable, isDiscoverable,
isHardenable, isHardenable,
isPrinter,
registerBuiltinDrivers, registerBuiltinDrivers,
registry, registry,
setDeviceLogSink, setDeviceLogSink,
@@ -36,6 +37,11 @@ interface AssignBody {
interface TestBody { interface TestBody {
driverId: string; driverId: string;
config: Record<string, string | number | boolean>; config: Record<string, string | number | boolean>;
/** When editing an EXISTING device, its id — so the test re-merges the stored
* machine secrets (relayPassword/pushPassword) the client never received. Without
* this, testing an edited device would send no relay password → the device ignores
* the probe → a false "offline". Omitted when testing a brand-new device. */
id?: string;
} }
// Config keys that hold MACHINE-ONLY secrets — never sent back to the client. // Config keys that hold MACHINE-ONLY secrets — never sent back to the client.
@@ -54,6 +60,41 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
return out; return out;
} }
// Connection-identity keys: the fields that decide WHERE a probe is sent. A stored
// secret may only be re-merged when these match the stored row — otherwise an admin
// could point a test at an attacker host while keeping a real device id and have the
// secret sent there (exfiltration). host/port/binaryPort/httpPort cover the Dingtian's
// UDP + CGI targets; serial covers serial-bound readers.
const IDENTITY_KEYS = ["host", "port", "binaryPort", "httpPort", "serial"] as const;
/** Stored machine-only secrets (relayPassword/pushPassword) for a device `id`, but ONLY
* when the submitted config addresses the SAME device — same driver, and every
* connection-identity field (host/port/…) that the submitted config sets equals the
* stored value. If the admin redirected the probe (different host/port) or the driver
* doesn't match, NO secret is returned: they must re-enter it explicitly. This stops a
* redirected test from exfiltrating the secret to an attacker host. */
export function storedSecrets(
db: Db,
id: string,
driverId: string,
submitted: Record<string, unknown>,
): Record<string, unknown> {
const row = db.select().from(devices).where(eq(devices.id, id)).get();
if (!row || row.driverId !== driverId) return {};
const cfg = row.config as Record<string, unknown>;
// Any identity field the client SENT must equal the stored value. (A field the client
// omits falls back to the stored device, so it can't be used to redirect.)
for (const k of IDENTITY_KEYS) {
const sent = submitted[k];
if (sent !== undefined && sent !== "" && String(sent) !== String(cfg[k] ?? "")) {
return {};
}
}
const out: Record<string, unknown> = {};
for (const k of SECRET_CONFIG_KEYS) if (cfg[k] !== undefined) out[k] = cfg[k];
return out;
}
/** Result of the device configure pipeline: a ready-to-persist config, or an /** Result of the device configure pipeline: a ready-to-persist config, or an
* HTTP error to send back. Shared by assign (create) and patch (edit). */ * HTTP error to send back. Shared by assign (create) and patch (edit). */
type ConfigureOutcome = type ConfigureOutcome =
@@ -249,13 +290,30 @@ export async function setupRoutes(
"/api/setup/test", "/api/setup/test",
{ preHandler: adminGuard }, { preHandler: adminGuard },
async (req, reply) => { async (req, reply) => {
const { driverId, config } = req.body; const { driverId, config, id } = req.body;
const driver = registry.get(driverId); const driver = registry.get(driverId);
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` }); if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
// When editing an existing device, re-merge its stored machine secrets (e.g.
// relayPassword) — redacted from the client, so the submitted config omits them.
// Submitted values win (an admin can override), but a blank/0 field falls back to
// the stored secret so the probe authenticates. Without this, an edited Dingtian
// tests with no relay password → false "offline". The submitted-value-wins rule:
// only fill a secret from the store when the form didn't send a real one.
// Re-merge stored secrets ONLY when this addresses the same device at the same
// host/port (storedSecrets enforces identity) — so a redirected probe can't leak
// the secret to an attacker host. Submitted values still win.
const merged: Record<string, string | number | boolean | undefined> = { ...config };
if (id) {
for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) {
const sent = merged[k];
if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number;
}
}
let device; let device;
try { try {
device = registry.create(driverId, config); device = registry.create(driverId, merged as Record<string, string | number | boolean>);
} catch (err) { } catch (err) {
return reply.code(400).send({ error: (err as Error).message }); return reply.code(400).send({ error: (err as Error).message });
} }
@@ -334,6 +392,70 @@ export async function setupRoutes(
}, },
); );
// Print a TEST SLIP on a printer config WITHOUT saving. healthCheck only opens the
// transport (TCP connect / USB open) — it proves reachability, NOT that paper feeds
// and the head fires. This pushes a real short slip through the device-agnostic
// printReport(), so the admin can physically confirm the printer is live (the USB
// /dev/usb/lpN path or the network printer). Fail-soft like test-anpr: a print error
// is reported, never a 500. Mirrors /test's stored-secret re-merge so an edited
// network printer still authenticates.
app.post<{ Body: TestBody }>(
"/api/setup/test-print",
{ preHandler: adminGuard },
async (req, reply) => {
const { driverId, config, id } = req.body;
const driver = registry.get(driverId);
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
if (driver.category !== "printer") {
return reply.code(400).send({ error: `driver ${driverId} is not a printer` });
}
const merged: Record<string, string | number | boolean | undefined> = { ...config };
if (id) {
for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) {
const sent = merged[k];
if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number;
}
}
let device;
try {
device = registry.create(driverId, merged as Record<string, string | number | boolean>);
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
if (!isPrinter(device)) {
return reply.code(400).send({ error: `driver ${driverId} cannot print` });
}
const startedAt = Date.now();
try {
await device.printReport({
title: "TEST PRINT",
lines: [
"Parking System",
"Printer test slip",
new Date().toLocaleString("sv"), // YYYY-MM-DD HH:MM:SS, locale-stable
"",
"If you can read this, the",
"printer is connected and",
"printing correctly.",
],
});
} catch (err) {
// The failure we're testing for (paper out, head fault, transport drop) —
// report it, don't 500.
return reply.send({
ok: false,
reason: "print-failed",
detail: (err as Error).message,
tookMs: Date.now() - startedAt,
});
}
return reply.send({ ok: true, tookMs: Date.now() - startedAt });
},
);
// Candidate backend IPs the device can push to, for a given device host. The // Candidate backend IPs the device can push to, for a given device host. The
// wizard pre-fills with the on-subnet one and lets the admin override (matters // wizard pre-fills with the on-subnet one and lets the admin override (matters
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md. // on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
+35 -4
View File
@@ -2,10 +2,16 @@ import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db"; import type { Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared"; import type { LedgerEvent } from "@parking/shared";
import { roleHasPermissions } from "../auth.js"; import { roleHasPermissions } from "../auth.js";
import { deviceEvents, type LaneStatusEvent } from "../device-events.js"; import {
deviceEvents,
type LaneStatusEvent,
type LanePresenceEvent,
type PlateRecognizedEvent,
} from "../device-events.js";
import { enrichEvent } from "../event-enrich.js"; import { enrichEvent } from "../event-enrich.js";
import type { DeviceMonitor } from "../device-monitor.js"; import type { DeviceMonitor } from "../device-monitor.js";
import type { LaneStatus } from "../lane-status.js"; import type { LaneStatus } from "../lane-status.js";
import type { LanePresence } from "../lane-presence.js";
import { getOccupancy } from "../occupancy.js"; import { getOccupancy } from "../occupancy.js";
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives // Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
@@ -53,17 +59,26 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
} }
type OutMsg = type OutMsg =
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown; lanes: LaneStatusEvent } | {
kind: "hello";
occupancy: ReturnType<typeof getOccupancy>;
devices: unknown;
lanes: LaneStatusEvent;
radar: LanePresenceEvent;
}
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> } | { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
| { kind: "printer-status"; event: unknown } | { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: unknown } | { kind: "device-status"; event: unknown }
| { kind: "lane-status"; lanes: LaneStatusEvent }; | { kind: "lane-status"; lanes: LaneStatusEvent }
| { kind: "lane-presence"; radar: LanePresenceEvent }
| { kind: "plate-recognized"; plate: PlateRecognizedEvent };
export async function wsRoutes( export async function wsRoutes(
app: FastifyInstance, app: FastifyInstance,
db: Db, db: Db,
deviceMonitor: DeviceMonitor, deviceMonitor: DeviceMonitor,
laneStatus: LaneStatus, laneStatus: LaneStatus,
lanePresence: LanePresence,
): Promise<void> { ): Promise<void> {
app.get( app.get(
"/api/ws", "/api/ws",
@@ -96,7 +111,13 @@ export async function wsRoutes(
// Initial snapshot so the client renders immediately, before any event: // Initial snapshot so the client renders immediately, before any event:
// occupancy AND the current device-status set (for the footer). // occupancy AND the current device-status set (for the footer).
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot(), lanes: laneStatus.snapshot() }); send({
kind: "hello",
occupancy: getOccupancy(db),
devices: deviceMonitor.snapshot(),
lanes: laneStatus.snapshot(),
radar: lanePresence.snapshot(),
});
// Subscribe to the live buses. Each handler recomputes occupancy from the // Subscribe to the live buses. Each handler recomputes occupancy from the
// ledger (cheap fold) so the pushed count is always authoritative. // ledger (cheap fold) so the pushed count is always authoritative.
@@ -117,12 +138,22 @@ export async function wsRoutes(
const offLane = deviceEvents.onLaneStatus((lanes) => { const offLane = deviceEvents.onLaneStatus((lanes) => {
send({ kind: "lane-status", lanes }); send({ kind: "lane-status", lanes });
}); });
// Lane RADAR presence (presence-input edge → barrier-light blink). Advisory.
const offPresence = deviceEvents.onLanePresence((radar) => {
send({ kind: "lane-presence", radar });
});
// A late async plate recognition → backfill the badge on the matching feed row. Advisory.
const offPlate = deviceEvents.onPlateRecognized((plate) => {
send({ kind: "plate-recognized", plate });
});
socket.on("close", () => { socket.on("close", () => {
offLedger(); offLedger();
offPrinter(); offPrinter();
offDevice(); offDevice();
offLane(); offLane();
offPresence();
offPlate();
}); });
}, },
); );
+57 -3
View File
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db"; import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js"; import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
import { deviceEvents } from "./device-events.js"; import { deviceEvents } from "./device-events.js";
import { ButtonLightController } from "./button-light.js";
import { EntryFlow } from "./entry-flow.js"; import { EntryFlow } from "./entry-flow.js";
import { EventLog } from "./event-log.js"; import { EventLog } from "./event-log.js";
import { ExitFlow } from "./exit-flow.js"; import { ExitFlow } from "./exit-flow.js";
@@ -19,6 +20,9 @@ import { PrinterMonitor } from "./printer-monitor.js";
import { DeviceMonitor } from "./device-monitor.js"; import { DeviceMonitor } from "./device-monitor.js";
import { buildSigner, buildVerifier } from "./signer.js"; import { buildSigner, buildVerifier } from "./signer.js";
import { LogService, pinoDbStream } from "./log-service.js"; import { LogService, pinoDbStream } from "./log-service.js";
import { pruneSnapshots } from "./snapshot-retention.js";
import { BackupService } from "./backup-service.js";
import { backupRoutes } from "./routes/backup.js";
import { logRoutes } from "./routes/logs.js"; import { logRoutes } from "./routes/logs.js";
import { VisionClient } from "./vision-client.js"; import { VisionClient } from "./vision-client.js";
import { authRoutes } from "./routes/auth.js"; import { authRoutes } from "./routes/auth.js";
@@ -27,6 +31,7 @@ import { roleRoutes } from "./routes/roles.js";
import { deviceRoutes } from "./routes/devices.js"; import { deviceRoutes } from "./routes/devices.js";
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js"; import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
import { LaneStatus } from "./lane-status.js"; import { LaneStatus } from "./lane-status.js";
import { LanePresence } from "./lane-presence.js";
import { AnprBridge } from "./anpr-entry.js"; import { AnprBridge } from "./anpr-entry.js";
import { eventRoutes } from "./routes/events.js"; import { eventRoutes } from "./routes/events.js";
import { reportRoutes } from "./routes/reports.js"; import { reportRoutes } from "./routes/reports.js";
@@ -124,6 +129,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
const laneStatus = new LaneStatus(db, app.log); const laneStatus = new LaneStatus(db, app.log);
app.addHook("onClose", async () => laneStatus.stop()); app.addHook("onClose", async () => laneStatus.stop());
// Per-lane RADAR presence (presence-input edges → barrier-light blink). Mirrors the
// physical button lamp (relay 3): the SAME presence signal, surfaced to the booth UI.
const lanePresence = new LanePresence(db, app.log);
lanePresence.start();
app.addHook("onClose", async () => lanePresence.stop());
// NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read // NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read
// flows are constructed — because the ANPR bridge they carry depends on the // flows are constructed — because the ANPR bridge they carry depends on the
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring. // SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
@@ -168,7 +179,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// Live booth feed: server-pushed ledger + occupancy + printer-status over a // Live booth feed: server-pushed ledger + occupancy + printer-status over a
// single authenticated WebSocket (/api/ws). See routes/ws.ts. // single authenticated WebSocket (/api/ws). See routes/ws.ts.
await wsRoutes(app, db, deviceMonitor, laneStatus); await wsRoutes(app, db, deviceMonitor, laneStatus, lanePresence);
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts. // Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
await snapshotRoutes(app, db); await snapshotRoutes(app, db);
@@ -188,6 +199,13 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
}); });
app.addHook("onClose", async () => unsubscribeEntry()); app.addHook("onClose", async () => unsubscribeEntry());
// Button-light indicator: drives the entry button's lamp on a spare relay from the
// RADAR input vs. the camera lane status (blink = radar-only, solid = radar+camera,
// off otherwise). A non-barrier aux output; fails OFF. See button-light.ts.
const buttonLight = new ButtonLightController(db, app.log);
buttonLight.start();
app.addHook("onClose", async () => buttonLight.stop());
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the // Read-driven flows: a credential read (ticket scan / plate / card) routes via the
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the // dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
// transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts, // transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
@@ -218,10 +236,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// reader's live flow. Single-shot + TTL. See credential-capture.ts. // reader's live flow. Single-shot + TTL. See credential-capture.ts.
const credentialCapture = new CredentialCapture(); const credentialCapture = new CredentialCapture();
// GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON // Dingtian DT-008 QR/RFID reader: it HTTP-GETs on each scan and beeps/acts on our JSON
// verdict (host-in-the-loop, synchronous). The capture service can intercept a read // verdict (host-in-the-loop, synchronous). The capture service can intercept a read
// on an armed reader for enrollment; otherwise the read routes through the // on an armed reader for enrollment; otherwise the read routes through the
// dispatcher. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md. // dispatcher. See wiki/entities/dingtian-dt008-reader.md, qrcode-sdk.md.
await qrReaderRoutes(app, db, readDispatcher, credentialCapture); await qrReaderRoutes(app, db, readDispatcher, credentialCapture);
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report // Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
@@ -258,6 +276,13 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md. // read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
await logRoutes(app, logService); await logRoutes(app, logService);
// On-site encrypted DB backup (durability for the signed ledger). Admin-driven: the target
// directory is admin-chosen (site_config), the key is an env secret; status + a manual "back
// up now"; the scheduled run is the daily timer below. A no-op until a target dir is set AND
// BACKUP_KEY is present. See wiki/concepts/backup-recovery.md.
const backupService = new BackupService(db, app.log);
await backupRoutes(app, db, backupService);
// Periodic retention prune (age + row cap) so the log table stays bounded on the // Periodic retention prune (age + row cap) so the log table stays bounded on the
// offline appliance. Runs hourly; unref'd so it never holds the process open. // offline appliance. Runs hourly; unref'd so it never holds the process open.
const pruneTimer = setInterval(() => { const pruneTimer = setInterval(() => {
@@ -268,6 +293,35 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
logService.prune(); // once at startup logService.prune(); // once at startup
app.addHook("onClose", async () => clearInterval(pruneTimer)); app.addHook("onClose", async () => clearInterval(pruneTimer));
// Snapshot retention prune — DISK-PRESSURE safety valve: only when the DB's filesystem
// crosses the high-water mark do we delete the oldest snapshots + VACUUM. A no-op the rest
// of the time. Daily, unref'd, plus once at startup. See snapshot-retention.ts.
const runSnapPrune = async () => {
const res = await pruneSnapshots(db, {}, app.log);
if (res.deletedRows > 0) {
app.log.info(
`pruned ${res.deletedRows} snapshots, freed ~${(res.freedBytesEst / 1048576).toFixed(0)} MB ` +
`(disk was ${res.usedPctBefore.toFixed(0)}% used${res.vacuumed ? ", vacuumed" : ""})`,
);
}
};
const snapPruneTimer = setInterval(() => void runSnapPrune(), 24 * 60 * 60 * 1000);
snapPruneTimer.unref();
void runSnapPrune(); // once at startup
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
// Scheduled encrypted backup — daily, unref'd. A no-op (silent) until BACKUP_TARGET_DIR +
// BACKUP_KEY are configured; tolerates an unreachable/unmounted target by recording the
// error and trying again next run. NOT run once at startup (a just-booted appliance after a
// power cut shouldn't immediately write to a possibly-not-yet-mounted disk; the daily cadence
// and the manual button cover it). See wiki/concepts/backup-recovery.md.
const backupTimer = setInterval(() => void backupService.runScheduled(), 24 * 60 * 60 * 1000);
backupTimer.unref();
app.addHook("onClose", async () => clearInterval(backupTimer));
if (backupService.configured) {
app.log.info("backup: scheduled daily encrypted backup enabled");
}
// Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the // Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the
// retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs // retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs
// every 6h, unref'd, plus once at startup. See recycle-bin.ts. // every 6h, unref'd, plus once at startup. See recycle-bin.ts.
+2 -1
View File
@@ -548,8 +548,9 @@ export class ShiftService {
"", "",
"-- Arkëtime sipas burimit --", "-- Arkëtime sipas burimit --",
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`, `Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`, `Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
` shitje: ${money(r.subscriptionSalesMinor)} ${cur}`,
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`, ` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
"", "",
"-- Arka --", "-- Arka --",
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { snapshots, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { pruneSnapshots, type DiskUsage, type SnapshotRetention } from "./snapshot-retention.js";
// Snapshot retention: DISK-PRESSURE prune. No-op unless the DB's filesystem is over the
// high-water mark; then delete the OLDEST until ~freeTargetPct of disk is freed (estimated from
// the deleted BLOB sizes), honoring a MIN_KEEP floor, then VACUUM once. Disk usage is injected
// so the test controls the trigger without touching the real filesystem.
let db: Db;
beforeEach(() => {
({ db } = createTestDb());
});
/** Insert `n` snapshots, oldest first (s-0 is the oldest), each `bytes` long. */
function seed(n: number, bytes = 1000): void {
const t0 = Date.now() - n * 1000;
for (let i = 0; i < n; i++) {
db.insert(snapshots)
.values({
id: `s-${i}`,
direction: "entry",
deviceId: "cam",
identity: `s-${i}`,
contentType: "image/jpeg",
bytes: Buffer.alloc(bytes, 1),
capturedAt: new Date(t0 + i * 1000).toISOString(), // s-0 oldest … s-(n-1) newest
})
.run();
}
}
function count(): number {
return db.select().from(snapshots).all().length;
}
function ids(): string[] {
return db.select().from(snapshots).all().map((r) => r.id).sort();
}
/** A fake disk at a given used% on a 1 GB volume. */
const disk = (usedPct: number, totalBytes = 1_000_000_000): (() => Promise<DiskUsage>) =>
() => Promise.resolve({ usedPct, totalBytes });
const ret = (o: Partial<SnapshotRetention>): SnapshotRetention => ({
highPct: 70,
freeTargetPct: 10,
minKeep: 2,
batch: 5,
...o,
});
describe("pruneSnapshots (disk-pressure)", () => {
it("no-op when disk is below the high-water mark", async () => {
seed(10);
const res = await pruneSnapshots(db, { retention: ret({}), diskUsage: disk(50) });
expect(res.deletedRows).toBe(0);
expect(res.vacuumed).toBe(false);
expect(count()).toBe(10);
});
it("over the mark: deletes the OLDEST until ~freeTargetPct is freed, then VACUUMs", async () => {
// 1 GB disk, target 10% = 100 MB. Each snapshot 20 MB → ~5 deletions reach the target.
seed(20, 20 * 1048576);
const vacuumSpy = vi.spyOn(db.$client as { exec: (s: string) => void }, "exec");
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2, batch: 100 }), diskUsage: disk(80) });
expect(res.deletedRows).toBeGreaterThanOrEqual(5);
expect(res.freedBytesEst).toBeGreaterThanOrEqual(0.1 * 1_000_000_000);
expect(res.vacuumed).toBe(true);
expect(vacuumSpy).toHaveBeenCalledWith("VACUUM");
// The survivors are the NEWEST (oldest went first).
const survivors = ids();
expect(survivors).toContain(`s-19`); // newest kept
expect(survivors).not.toContain(`s-0`); // oldest pruned
vacuumSpy.mockRestore();
});
it("honors the MIN_KEEP floor even when still over target", async () => {
// Target 10% of 1 GB = 100 MB, but only 3 tiny snapshots exist and minKeep=2 → at most 1 deleted.
seed(3, 1000);
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2, freeTargetPct: 90 }), diskUsage: disk(95) });
expect(res.deletedRows).toBe(1); // 3 − minKeep(2)
expect(count()).toBe(2);
expect(res.floorHitWhileOver).toBe(true); // couldn't reach target without crossing the floor
});
it("skips VACUUM when nothing was deleted", async () => {
seed(2); // == minKeep, so nothing to delete even over the mark
const vacuumSpy = vi.spyOn(db.$client as { exec: (s: string) => void }, "exec");
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2 }), diskUsage: disk(99) });
expect(res.deletedRows).toBe(0);
expect(res.vacuumed).toBe(false);
expect(vacuumSpy).not.toHaveBeenCalled();
vacuumSpy.mockRestore();
});
});
+148
View File
@@ -0,0 +1,148 @@
import { statfs } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { asc, snapshots, sql, type Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
// Snapshot retention — DISK-PRESSURE model. Camera snapshots are unsigned, advisory, prunable
// BLOBs (see snapshot.ts); they're referenced by the signed ledger only by id, so pruning an
// old image never affects the chain. They no longer dominate the DB day-to-day (captures are
// re-encoded small at SNAPSHOT_MAX_EDGE/JPEG_QUALITY), so this is a SAFETY VALVE: only when the
// filesystem holding the DB crosses a high-water mark do we delete the OLDEST snapshots and
// VACUUM to return disk to the OS.
//
// Why estimated-bytes, not live disk%: a DELETE only frees SQLite *pages* — the file (and thus
// OS disk usage) doesn't shrink until VACUUM. So the prune loop can't watch usedPct fall in real
// time. Instead it sums LENGTH(bytes) of the rows it deletes and stops when that estimate reaches
// the free-target, then VACUUMs ONCE at the end to realize the space. A MIN_KEEP floor always
// wins — we never delete evidence below it, even under pressure (if the disk is full of something
// else, that's not ours to fix).
export interface SnapshotRetention {
/** Prune when the DB's filesystem is at least this % used. */
readonly highPct: number;
/** Try to free roughly this % of the disk per run (the delete target). */
readonly freeTargetPct: number;
/** Never prune below this many snapshots (the floor). */
readonly minKeep: number;
/** Delete oldest in batches of this size (re-checks between batches). */
readonly batch: number;
}
export const DEFAULT_SNAPSHOT_RETENTION: SnapshotRetention = {
highPct: Number(process.env.SNAPSHOT_DISK_HIGH_PCT ?? 70),
freeTargetPct: Number(process.env.SNAPSHOT_DISK_FREE_TARGET_PCT ?? 10),
minKeep: Number(process.env.SNAPSHOT_MIN_KEEP ?? 500),
batch: Number(process.env.SNAPSHOT_PRUNE_BATCH ?? 200),
};
/** Disk usage of the filesystem holding the DB. Injectable so tests don't touch the real FS. */
export interface DiskUsage {
readonly usedPct: number;
readonly totalBytes: number;
}
export interface PruneOptions {
readonly retention?: SnapshotRetention;
/** Override how disk usage is read (tests inject a fake; default = statfs the DB's FS). */
readonly diskUsage?: () => Promise<DiskUsage>;
}
export interface PruneResult {
readonly deletedRows: number;
readonly freedBytesEst: number;
readonly vacuumed: boolean;
readonly usedPctBefore: number;
/** True if we hit the MIN_KEEP floor while the disk was still over the high-water mark. */
readonly floorHitWhileOver: boolean;
}
/** Read the used% + total bytes of the filesystem holding the DB file. */
async function diskUsageForDb(db: Db): Promise<DiskUsage> {
const file = (db.$client as { name?: string }).name ?? process.env.DATABASE_URL ?? "./parking.sqlite";
const st = await statfs(dirname(resolve(file)));
const total = st.blocks * st.bsize;
const avail = st.bavail * st.bsize;
const usedPct = total > 0 ? (1 - avail / total) * 100 : 0;
return { usedPct, totalBytes: total };
}
/**
* Prune snapshots under DISK PRESSURE. No-op unless the DB's filesystem is ≥ highPct used. When
* over, deletes the OLDEST snapshots until an estimated freeTargetPct of the disk is freed (or the
* minKeep floor is hit, or no rows remain), then VACUUMs once. Best-effort; safe on a timer.
*/
export async function pruneSnapshots(
db: Db,
opts: PruneOptions = {},
logger?: FastifyBaseLogger,
): Promise<PruneResult> {
const r = opts.retention ?? DEFAULT_SNAPSHOT_RETENTION;
const readDisk = opts.diskUsage ?? (() => diskUsageForDb(db));
let usedPctBefore = 0;
try {
const disk = await readDisk();
usedPctBefore = disk.usedPct;
// The overwhelmingly common case: plenty of headroom → do nothing.
if (disk.usedPct < r.highPct) {
return { deletedRows: 0, freedBytesEst: 0, vacuumed: false, usedPctBefore, floorHitWhileOver: false };
}
// Target bytes to free this run (≈ freeTargetPct of the whole disk).
const targetBytes = (r.freeTargetPct / 100) * disk.totalBytes;
let freedBytesEst = 0;
let deletedRows = 0;
let floorHitWhileOver = false;
// Delete the oldest in batches, summing their BLOB sizes, until we've freed the target — or
// we'd cross the MIN_KEEP floor — or there are no more rows.
for (;;) {
const count = db.select({ c: sql<number>`count(*)` }).from(snapshots).get()?.c ?? 0;
if (count <= r.minKeep) {
floorHitWhileOver = true; // still over the high-water mark but can't delete below the floor
break;
}
if (freedBytesEst >= targetBytes) break;
const room = count - r.minKeep; // how many we may still delete before the floor
const take = Math.min(r.batch, room);
const oldest = db
.select({ id: snapshots.id, len: sql<number>`length(${snapshots.bytes})` })
.from(snapshots)
.orderBy(asc(snapshots.capturedAt))
.limit(take)
.all();
if (oldest.length === 0) break;
const ids = oldest.map((o) => o.id);
db.delete(snapshots).where(sql`${snapshots.id} in (${sql.join(ids, sql`, `)})`).run();
deletedRows += oldest.length;
freedBytesEst += oldest.reduce((s, o) => s + (o.len ?? 0), 0);
}
// Realize the freed space: VACUUM returns pages to the OS (the file shrinks). Only if we
// actually deleted something. Non-fatal on failure — pages are still freed for reuse.
let vacuumed = false;
if (deletedRows > 0) {
try {
(db.$client as { exec: (sql: string) => void }).exec("VACUUM");
vacuumed = true;
} catch (err) {
logger?.warn(`snapshot prune: VACUUM failed (pages freed for reuse): ${(err as Error).message}`);
}
}
if (floorHitWhileOver) {
logger?.warn(
`snapshot prune: disk ${usedPctBefore.toFixed(0)}% used but hit MIN_KEEP floor (${r.minKeep}) ` +
`after deleting ${deletedRows} — disk pressure is not from snapshots`,
);
}
return { deletedRows, freedBytesEst, vacuumed, usedPctBefore, floorHitWhileOver };
} catch (err) {
logger?.warn(`snapshot prune failed: ${(err as Error).message}`);
return { deletedRows: 0, freedBytesEst: 0, vacuumed: false, usedPctBefore, floorHitWhileOver: false };
}
}
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, it, vi } from "vitest";
import sharp from "sharp";
import type { CameraDevice, Snapshot } from "@parking/devices";
import { captureSnapshotShared, encodeForStorage } from "./snapshot.js";
import { silentLogger } from "./test-helpers.js";
// captureSnapshotShared: one HTTP pull per camera per vehicle. A Hikvision unit serves
// snapshots SINGLE-THREADED (a 2nd concurrent GET → HTTP 503). On an entry the ANPR
// bridge AND the advisory snapshotAsync both capture the same camera within ~1s, each
// from a SEPARATE adapter instance — so this deviceId-keyed cache coalesces in-flight
// captures and serves a brief freshness window, collapsing the two into one real pull.
// (Root cause of the slow 2026-06-25 subscriber entry.)
/** A fake camera whose captureSnapshot is controllable (count calls, delay, fail). */
function fakeCamera(opts: { delayMs?: number; fail?: boolean; tag?: string } = {}): {
camera: CameraDevice;
calls: () => number;
} {
let calls = 0;
const tag = opts.tag ?? "x";
const camera = {
async captureSnapshot(): Promise<Snapshot> {
calls++;
if (opts.delayMs) await new Promise((r) => setTimeout(r, opts.delayMs));
if (opts.fail) throw new Error("HTTP 503");
// Tag distinguishes frames from different cameras (the per-camera keying test).
return { bytes: Buffer.from(`shot-${tag}-${calls}`), contentType: "image/jpeg", capturedAt: new Date().toISOString() };
},
} as unknown as CameraDevice;
return { camera, calls: () => calls };
}
/** A unique deviceId per test so the module-level cache never bleeds across cases. */
function id(): string {
return `cam-${Math.random().toString(36).slice(2)}`;
}
describe("captureSnapshotShared", () => {
it("coalesces CONCURRENT captures into a single hardware pull (the 503 fix)", async () => {
const { camera, calls } = fakeCamera({ delayMs: 20 });
const dev = id();
// The bridge and the advisory path fire at nearly the same instant.
const [a, b] = await Promise.all([
captureSnapshotShared(dev, camera, { direction: "entry" }),
captureSnapshotShared(dev, camera, { direction: "entry" }),
]);
expect(calls()).toBe(1); // ONE GET, not two — no concurrent 503
expect(a.bytes.equals(b.bytes)).toBe(true); // both got the same frame
});
it("reuses a fresh capture within the TTL (sequential, same vehicle)", async () => {
const { camera, calls } = fakeCamera();
const dev = id();
const a = await captureSnapshotShared(dev, camera, { direction: "entry" });
const b = await captureSnapshotShared(dev, camera, { direction: "entry" }); // ~0ms later
expect(calls()).toBe(1); // 2nd call served from the freshness cache
expect(a.bytes.equals(b.bytes)).toBe(true);
});
it("pulls AGAIN after the TTL lapses (a later, different vehicle)", async () => {
vi.useFakeTimers();
try {
const { camera, calls } = fakeCamera();
const dev = id();
await captureSnapshotShared(dev, camera, { direction: "entry" });
expect(calls()).toBe(1);
await vi.advanceTimersByTimeAsync(2000); // past SNAPSHOT_TTL_MS (1500)
await captureSnapshotShared(dev, camera, { direction: "entry" });
expect(calls()).toBe(2); // stale → a real new pull (never a stale frame for a new car)
} finally {
vi.useRealTimers();
}
});
it("does NOT cache a failure — the next caller retries", async () => {
const dev = id();
const failing = fakeCamera({ fail: true });
await expect(captureSnapshotShared(dev, failing.camera, { direction: "entry" })).rejects.toThrow("503");
// A subsequent capture (camera recovered) must actually pull, not inherit the error.
const ok = fakeCamera();
const shot = await captureSnapshotShared(dev, ok.camera, { direction: "entry" });
expect(shot.bytes.toString()).toBe("shot-x-1");
expect(ok.calls()).toBe(1);
});
it("keys by deviceId — different cameras never share a frame", async () => {
const c1 = fakeCamera({ tag: "A" });
const c2 = fakeCamera({ tag: "B" });
const s1 = await captureSnapshotShared("cam-A", c1.camera, { direction: "entry" });
const s2 = await captureSnapshotShared("cam-B", c2.camera, { direction: "entry" });
expect(c1.calls()).toBe(1);
expect(c2.calls()).toBe(1);
expect(s1.bytes.equals(s2.bytes)).toBe(false);
});
});
// encodeForStorage: downscale + re-compress a captured frame for STORAGE (smaller, plate
// still readable). Recognition uses the original; this never runs on the OCR path. Fail-soft.
describe("encodeForStorage", () => {
/** A big synthetic JPEG (2688×1520, the Hikvision main-stream size) to downscale. */
async function bigJpeg(): Promise<Buffer> {
return sharp({
create: { width: 2688, height: 1520, channels: 3, background: { r: 120, g: 130, b: 140 } },
})
.jpeg({ quality: 95 })
.toBuffer();
}
it("downscales the long edge to ≤1280 and emits clean image/jpeg", async () => {
const bytes = await bigJpeg();
const shot: Snapshot = { bytes, contentType: 'image/jpeg; charset="UTF-8"', capturedAt: new Date().toISOString() };
const out = await encodeForStorage(shot, silentLogger());
expect(out.contentType).toBe("image/jpeg"); // charset cruft stripped
const meta = await sharp(out.bytes).metadata();
expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(1280);
expect(out.bytes.length).toBeLessThan(bytes.length); // smaller than the original
});
it("never enlarges an already-small image", async () => {
const small = await sharp({ create: { width: 640, height: 360, channels: 3, background: { r: 0, g: 0, b: 0 } } })
.jpeg()
.toBuffer();
const out = await encodeForStorage(
{ bytes: small, contentType: "image/jpeg", capturedAt: new Date().toISOString() },
silentLogger(),
);
const meta = await sharp(out.bytes).metadata();
expect(meta.width).toBe(640); // withoutEnlargement
expect(meta.height).toBe(360);
});
it("fails soft: a non-image body is stored unchanged with a cleaned type", async () => {
const garbage = Buffer.from("this is not an image");
const out = await encodeForStorage(
{ bytes: garbage, contentType: 'text/plain; charset="UTF-8"', capturedAt: new Date().toISOString() },
silentLogger(),
);
expect(out.bytes.equals(garbage)).toBe(true); // original bytes, never dropped
expect(out.contentType).toBe("text/plain"); // charset stripped even on the fallback
});
});
+115 -4
View File
@@ -1,8 +1,10 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import sharp from "sharp";
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db"; import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
import { registry, type CameraDevice } from "@parking/devices"; import { registry, type CameraDevice, type Snapshot } from "@parking/devices";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import { devicesByDirection, type FlowDirection } from "./device-resolve.js"; import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
import { deviceEvents } from "./device-events.js";
import type { VisionClient } from "./vision-client.js"; import type { VisionClient } from "./vision-client.js";
// Camera snapshot capture, fired AFTER the barrier opens and never awaited on the // Camera snapshot capture, fired AFTER the barrier opens and never awaited on the
@@ -26,6 +28,43 @@ import type { VisionClient } from "./vision-client.js";
// fire-and-forget: it never blocks the open and never changes the entry/exit decision — // fire-and-forget: it never blocks the open and never changes the entry/exit decision —
// it's a record ("session X entered on plate AA558EE"). No polling; recognition only // it's a record ("session X entered on plate AA558EE"). No polling; recognition only
// happens on a real entry/exit. See wiki/entities/opencv-anpr-service.md. // happens on a real entry/exit. See wiki/entities/opencv-anpr-service.md.
//
// STORAGE RE-ENCODE (2026-06-28). Cameras serve full-res JPEGs (a Hikvision main stream is
// 2688×1520 / ~600 KB); stored raw, snapshots dominated the appliance DB (~72%). Each frame
// is now downscaled (long edge ≤ SNAPSHOT_MAX_EDGE) + re-compressed (q SNAPSHOT_JPEG_QUALITY)
// BEFORE storage — ~6–10× smaller, plate still clearly readable. RECOGNITION runs on the
// ORIGINAL full-res bytes (downscaling hurts OCR); the re-encode is storage-only. Fail-soft:
// a re-encode error stores the original, never drops the snapshot or blocks the open.
/** Long-edge cap (px) + JPEG quality for the STORED snapshot. Env-overridable per appliance. */
const SNAP_MAX_EDGE = Number(process.env.SNAPSHOT_MAX_EDGE ?? 1280);
const SNAP_QUALITY = Number(process.env.SNAPSHOT_JPEG_QUALITY ?? 80);
/** Strip a camera's `; charset=...` cruft from a content type (a JPEG is binary). */
function cleanType(ct: string): string {
const base = ct.split(";")[0]?.trim();
return base || "image/jpeg";
}
/** Downscale + re-encode a captured frame for STORAGE (evidence, not OCR). Caps the long edge
* and re-compresses to JPEG. Fail-soft: any error (e.g. a non-image body) returns the original
* bytes with a cleaned content type, so a snapshot is never lost. */
export async function encodeForStorage(
shot: Snapshot,
logger: FastifyBaseLogger,
): Promise<{ bytes: Buffer; contentType: string }> {
try {
const out = await sharp(shot.bytes, { failOn: "none" })
.rotate() // honor EXIF orientation before we drop the metadata
.resize({ width: SNAP_MAX_EDGE, height: SNAP_MAX_EDGE, fit: "inside", withoutEnlargement: true })
.jpeg({ quality: SNAP_QUALITY, mozjpeg: true })
.toBuffer();
return { bytes: out, contentType: "image/jpeg" };
} catch (err) {
logger.warn(`snapshot re-encode failed, storing original: ${(err as Error).message}`);
return { bytes: shot.bytes, contentType: cleanType(shot.contentType) };
}
}
interface SnapshotJob { interface SnapshotJob {
readonly db: Db; readonly db: Db;
@@ -62,16 +101,21 @@ export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
return null; return null;
} }
try { try {
const shot = await camera.captureSnapshot({ direction }); // Shared capture: if the ANPR bridge just pulled this camera's frame for the
// same vehicle, reuse it instead of a 2nd concurrent GET (which 503s).
const shot = await captureSnapshotShared(row.id, camera, { direction });
const id: string = randomUUID(); const id: string = randomUUID();
// Re-encode for STORAGE only (downscale + recompress). Recognition below still
// uses the original full-res `shot`.
const stored = await encodeForStorage(shot, logger);
db.insert(snapshots) db.insert(snapshots)
.values({ .values({
id, id,
direction, direction,
deviceId: row.id, deviceId: row.id,
identity, identity,
contentType: shot.contentType, contentType: stored.contentType,
bytes: shot.bytes, bytes: stored.bytes,
capturedAt: shot.capturedAt, capturedAt: shot.capturedAt,
}) })
.run(); .run();
@@ -136,6 +180,9 @@ async function recognizePlate(
}) })
.run(); .run();
logger.info(`anpr plate '${plate}' (${result.plate.confidence.toFixed(3)}) for ${identity}`); logger.info(`anpr plate '${plate}' (${result.plate.confidence.toFixed(3)}) for ${identity}`);
// The session's entry/exit event already shipped without this (async) plate — tell the
// booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched.
deviceEvents.emitPlateRecognized({ identity, plate, direction });
} catch (err) { } catch (err) {
logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`); logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`);
} }
@@ -153,6 +200,70 @@ export function buildCamera(row: { driverId: string; config: unknown }): CameraD
} }
} }
// --- shared snapshot capture (one HTTP pull per camera per vehicle) -----------
// A Hikvision camera serves /ISAPI/.../picture SINGLE-THREADED: two concurrent
// snapshot GETs to the same unit return HTTP 503 "service busy". On a vehicle entry
// TWO paths capture the SAME camera within ~1s — the ANPR bridge (barrier-driving,
// anpr-entry.ts) and the advisory snapshotAsync (evidence + telemetry, below). They
// each `buildCamera()` a SEPARATE adapter instance, so a per-instance cache can't
// dedupe them. This module-level, deviceId-keyed cache does: it coalesces in-flight
// captures (the 2nd caller awaits the 1st's pull) AND serves a result captured within
// SNAPSHOT_TTL_MS, so the bridge + advisory share ONE frame instead of colliding into
// a 503 (which then burned the bridge's 12s debounce → the slow entry observed
// 2026-06-25; see wiki/concepts/lane-presence-and-anpr-entry.md).
/** How long a fresh capture is reused for the same camera. A car is one event for a
* couple of seconds; 1.5s comfortably spans the bridge→advisory gap without ever
* serving a stale frame for a *different* vehicle (entries are seconds apart). */
const SNAPSHOT_TTL_MS = 1500;
interface CacheEntry {
/** A capture in flight — concurrent callers await this instead of issuing a 2nd GET. */
inflight?: Promise<Snapshot>;
/** The last SUCCESSFUL capture + when it resolved, for the freshness window. */
last?: { shot: Snapshot; at: number };
}
const snapshotCache = new Map<string, CacheEntry>();
/**
* Capture a snapshot for a camera, sharing ONE HTTP pull across concurrent/near-
* simultaneous callers (the ANPR bridge and the advisory snapshot). Same contract as
* `camera.captureSnapshot` (throws on failure) — a failed pull is NOT cached, so the
* next caller retries rather than inheriting the error. Key by the stable `deviceId`.
*/
export function captureSnapshotShared(
deviceId: string,
camera: CameraDevice,
ctx: { direction: FlowDirection },
): Promise<Snapshot> {
const now = Date.now();
let entry = snapshotCache.get(deviceId);
if (!entry) {
entry = {};
snapshotCache.set(deviceId, entry);
}
// Fresh enough → reuse the last frame (same vehicle, no second hardware hit).
if (entry.last && now - entry.last.at < SNAPSHOT_TTL_MS) {
return Promise.resolve(entry.last.shot);
}
// A capture is already running → join it (this is what prevents the 503 collision).
if (entry.inflight) return entry.inflight;
// Otherwise issue the single real pull; record it as the in-flight promise.
const pull = camera
.captureSnapshot(ctx)
.then((shot) => {
entry.last = { shot, at: Date.now() };
return shot;
})
.finally(() => {
// Clear the in-flight slot whether it resolved or threw; a failure is never cached.
if (entry.inflight === pull) entry.inflight = undefined;
});
entry.inflight = pull;
return pull;
}
function recordFailure( function recordFailure(
db: Db, db: Db,
direction: FlowDirection, direction: FlowDirection,
+9
View File
@@ -310,6 +310,15 @@ export class SubscriptionFlow {
* subscription, (b) pick which occurrence a read closes, and (c) enforce * subscription, (b) pick which occurrence a read closes, and (c) enforce
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that. * `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
*/ */
/** How many occurrences this subscription currently has OPEN (entries not yet exited).
* Public so the ANPR bridge can detect a credential (card/QR) exit landing mid-poll — if
* the count drops while it's polling, the subscriber already transacted and the bridge must
* NOT also emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. for
* a fleet sub). See anpr-entry.ts. */
openOccurrenceCount(subscriptionId: string): number {
return this.#openOccurrences(subscriptionId).length;
}
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] { #openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all(); const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
// Net entries−exits per occurrence identity, keeping the entry order (oldest first). // Net entries−exits per occurrence identity, keeping the entry order (oldest first).
+5 -3
View File
@@ -3,14 +3,16 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"//": "Thin shim so this Python service is a first-class node in the Turbo task graph (it is NOT a JS package — deps are managed by uv/pyproject.toml). Each script shells to Python tooling. See wiki/decisions/vision-service-packaging.md.", "//": "Thin shim so this Python service is a first-class node in the Turbo task graph (it is NOT a JS package — deps are managed by uv/pyproject.toml). Each script shells to Python tooling. See wiki/decisions/vision-service-packaging.md.",
"//alpr": "DEV self-heals real ANPR: `dev`/`start` run `uv sync --extra alpr` FIRST, because a plain `uv run` re-resolves the venv to the lockfile DEFAULTS and STRIPS fast-alpr (the cause of silent 'snapshot but no plate' after a prior pnpm dev). Syncing the extra here guarantees the recognizer survives every run. Use `dev:stub` for a lean, model-free local run. The BOOTH is unaffected — it runs the Docker image, which bakes `--extra alpr` at build (see Dockerfile + docker-compose.prod.yml).",
"scripts": { "scripts": {
"dev": "uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089", "dev": "uv sync --extra alpr && uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
"start": "uv run uvicorn vision_service.app:app --host 0.0.0.0 --port 8089", "dev:stub": "uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
"start": "uv sync --extra alpr && uv run uvicorn vision_service.app:app --host 0.0.0.0 --port 8089",
"lint": "uv run ruff check .", "lint": "uv run ruff check .",
"format": "uv run ruff format .", "format": "uv run ruff format .",
"typecheck": "uv run mypy vision_service", "typecheck": "uv run mypy vision_service",
"test": "uv run pytest -q", "test": "uv run pytest -q",
"recognize": "uv run python -m vision_service.cli", "recognize": "uv sync --extra alpr && uv run python -m vision_service.cli",
"build": "echo 'no build step (Python service; models fetched at deploy)'" "build": "echo 'no build step (Python service; models fetched at deploy)'"
} }
} }
+12 -9
View File
@@ -1,11 +1,14 @@
# Production build env for the SPA (auto-loaded by `vite build`, which the Tauri # Production build env for the SPA (auto-loaded by `vite build`). NOT loaded by `vite` dev.
# desktop bundle runs via beforeBuildCommand). NOT loaded by `vite` dev.
# #
# The desktop shell serves the bundled SPA from tauri://localhost (no proxy, not # RELATIVE /api base (empty value). The booth serves the SPA same-origin (Fastify serves
# same-origin), so the SPA must reach Fastify by absolute origin. This is the # dist/, reached via Caddy on :80), so requests must stay relative — baking an absolute
# appliance's local Fastify address. Not a secret — committed for reproducible # origin here would point the browser at the wrong host. This matches the deploy
# desktop builds. Override per-deployment if Fastify binds elsewhere. # (wiki/decisions/container-deployment.md "Web access"; the 77b2acb fix).
# #
# NOTE: a plain browser prod build (Fastify serving dist/ same-origin) does NOT # DESKTOP (Tauri) NOTE: the desktop shell serves the SPA from tauri://localhost (no proxy,
# want this set. If you build the SPA for that, override VITE_API_BASE="" . # not same-origin) and DOES need an absolute Fastify origin — but the desktop app is a
VITE_API_BASE=http://127.0.0.1:3000 # DEFERRED, separate task (it's currently hardcoded to localhost:3000; see apps/desktop +
# the desktop-app-hardcoded-localhost note). When that work resumes, set VITE_API_BASE to
# the appliance's Fastify origin for the desktop build only (e.g. via apps/desktop or an
# exported override), NOT here.
VITE_API_BASE=
+60 -68
View File
@@ -26,26 +26,8 @@ import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
// reconciles via the pay/exit modal — never a free barrier open. // reconciles via the pay/exit modal — never a free barrier open.
// See wiki/concepts/booth-exit-flow.md. // See wiki/concepts/booth-exit-flow.md.
type StatusFilter = "unpaid" | "paid" | "exiting" | "overstay";
type KindFilter = "transient" | "subscription"; type KindFilter = "transient" | "subscription";
function statusOf(s: ActiveSession): StatusFilter | "subscription" {
if (s.subscription) return "subscription";
if (s.overstay) return "overstay";
if (!s.open && s.withinGrace) return "exiting";
if (s.paidAt) return "paid";
return "unpaid";
}
function statusBadge(s: ActiveSession): { key: string; titleKey?: string; cls: string } {
if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" };
if (s.overstay)
return { key: "booth.badgeOverstay", titleKey: "booth.badgeOverstayTitle", cls: "text-term-red" };
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
}
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) { export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
@@ -70,9 +52,9 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
}); });
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null); const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
// Filters: free-text search, status, and transient-vs-subscriber. // Filters: free-text search + transient-vs-subscriber. (No status filter — the status
// column was dropped; an unpaid transient is normal and a subscriber is marked ★.)
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [status, setStatus] = useState<StatusFilter | "">("");
const [kind, setKind] = useState<KindFilter | "">(""); const [kind, setKind] = useState<KindFilter | "">("");
const sessions = useMemo(() => data?.sessions ?? [], [data]); const sessions = useMemo(() => data?.sessions ?? [], [data]);
@@ -81,21 +63,15 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
return sessions.filter((s) => { return sessions.filter((s) => {
if (kind === "transient" && s.subscription) return false; if (kind === "transient" && s.subscription) return false;
if (kind === "subscription" && !s.subscription) return false; if (kind === "subscription" && !s.subscription) return false;
if (status && statusOf(s) !== status) return false;
if (q) { if (q) {
const hay = `${s.identity} ${s.subscriptionHolder ?? ""}`.toLowerCase(); // Include the enriched plate (`s.plate`, the displayed badge) so a plate search hits.
const hay = `${s.identity} ${s.subscriptionHolder ?? ""} ${s.plate ?? ""}`.toLowerCase();
if (!hay.includes(q)) return false; if (!hay.includes(q)) return false;
} }
return true; return true;
}); });
}, [sessions, search, status, kind]); }, [sessions, search, kind]);
const statusOpts: SegOption<StatusFilter>[] = [
{ value: "unpaid", label: t("booth.fStatusUnpaid") },
{ value: "paid", label: t("booth.fStatusPaid") },
{ value: "exiting", label: t("booth.fStatusExiting") },
{ value: "overstay", label: t("booth.fStatusOverstay") },
];
const kindOpts: SegOption<KindFilter>[] = [ const kindOpts: SegOption<KindFilter>[] = [
{ value: "transient", label: t("booth.fKindTransient") }, { value: "transient", label: t("booth.fKindTransient") },
{ value: "subscription", label: t("booth.fKindSubscription") }, { value: "subscription", label: t("booth.fKindSubscription") },
@@ -119,7 +95,7 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
<Panel <Panel
title={t("booth.activeSessions")} title={t("booth.activeSessions")}
right={ right={
<span className="text-[10px] uppercase tracking-wider text-term-muted"> <span className="text-[0.625rem] uppercase tracking-wider text-term-muted">
{filtered.length} {filtered.length}
{filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")} {filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")}
</span> </span>
@@ -128,7 +104,6 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
> >
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<FilterBar search={search} onSearch={setSearch} searchPlaceholder={t("booth.filterSearchSessions")}> <FilterBar search={search} onSearch={setSearch} searchPlaceholder={t("booth.filterSearchSessions")}>
<SegGroup value={status} options={statusOpts} onChange={setStatus} allLabel={t("booth.filterAll")} />
<SegGroup value={kind} options={kindOpts} onChange={setKind} allLabel={t("booth.filterAll")} /> <SegGroup value={kind} options={kindOpts} onChange={setKind} allLabel={t("booth.filterAll")} />
</FilterBar> </FilterBar>
@@ -142,23 +117,46 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
: t("booth.noMatch")} : t("booth.noMatch")}
</div> </div>
) : ( ) : (
filtered.map((s) => { // A real table — aligned columns (who · plate · entry · elapsed · action). No
const badge = statusBadge(s); // status column: an unpaid transient is the normal case, and a subscriber is
// already marked with ★ + holder name. Overstay (a top-up is owed) keeps a row
// tint so that fraud-relevant signal isn't lost. The whole row is clickable
// (→ pay/exit modal); the trailing cell holds the audited Open-barrier action.
<table className="w-full text-[0.75rem] tabular-nums">
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
<tr>
<th className="px-2 py-1.5 text-left font-semibold">{t("booth.colWho")}</th>
<th className="px-2 py-1.5 text-left font-semibold">{t("booth.colPlate")}</th>
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colEntry")}</th>
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colElapsed")}</th>
<th className="px-2 py-1.5" />
</tr>
</thead>
<tbody>
{filtered.map((s) => {
const msg = reopenMsg?.id === s.identity ? reopenMsg : null; const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
// Paid-and-in-grace TRANSIENT only: an audited re-pulse for a car that paid
// but the barrier didn't confirm. NOT overstay (owes a top-up → modal) and
// NOT a subscription (assist-open lives in the modal). An unpaid transient
// gets no button (no-unpaid-bypass). Mirrors reopenBarrier's server guard.
const canReopen = s.paidAt && !s.overstay && !s.subscription;
return ( return (
<div <tr
key={s.identity} key={s.identity}
className="flex items-center gap-3 border-b border-term-border/50 py-1.5 text-[12px] tabular-nums"
>
<button
type="button"
onClick={() => onPick(s.identity)} onClick={() => onPick(s.identity)}
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber" className={`cursor-pointer border-t border-term-border/50 hover:bg-term-panel-2 ${
s.overstay ? "bg-term-red/5" : ""
}`}
title={t("booth.openPayExit")} title={t("booth.openPayExit")}
> >
<span className="text-term-text"> <td className="px-2 py-1.5 text-term-text">
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity} {s.subscription ? (
</span> <span className="text-term-cyan">★ {s.subscriptionHolder ?? t("subs.unnamed")}</span>
) : (
s.identity
)}
</td>
<td className="px-2 py-1.5">
{s.plate && ( {s.plate && (
<span <span
className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber" className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber"
@@ -167,45 +165,39 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
{s.plate} {s.plate}
</span> </span>
)} )}
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span> </td>
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span> <td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
<span {formatRelativeDateTime(s.enteredAt, t)}
className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`} </td>
title={badge.titleKey ? t(badge.titleKey) : undefined} <td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
> {formatDuration(s.enteredAt, new Date().toISOString())}
{t(badge.key)} </td>
</span> <td className="px-2 py-1.5 text-right">
</button> {canReopen && (
{/* Open barrier — PAID-and-still-in-grace TRANSIENT only: an audited
re-pulse for a car that paid but the barrier didn't confirm. NOT an
OVERSTAY (grace expired → owes a top-up; routes to the pay/exit modal)
and NOT a SUBSCRIPTION (the assist-open, and any out-of-window payment,
live in the pay/exit modal — the list must not offer a one-click open,
which would bypass an unpaid window charge). An unpaid transient has no
button either (no-unpaid-bypass). Mirrors reopenBarrier's server guard. */}
{s.paidAt && !s.overstay && !s.subscription ? (
<button <button
type="button" type="button"
disabled={reopen.isPending || !shiftReady} disabled={reopen.isPending || !shiftReady}
onClick={() => handleReopen(s)} onClick={(e) => {
className="btn btn-pay btn-sm shrink-0" e.stopPropagation(); // don't also open the pay/exit modal
void handleReopen(s);
}}
className="btn btn-pay btn-sm"
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")} title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
> >
{t("booth.openBarrier")} {t("booth.openBarrier")}
</button> </button>
) : (
<span className="w-[88px] shrink-0" />
)} )}
{msg && ( {msg && (
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}> <span className={`ml-2 text-[0.625rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
{msg.text} {msg.text}
</span> </span>
)} )}
</div> </td>
</tr>
); );
}) })}
</tbody>
</table>
)} )}
</div> </div>
</div> </div>
+6 -4
View File
@@ -5,7 +5,7 @@ import { fetchMe, type SessionUser } from "./api.js";
import { Login } from "./Login.js"; import { Login } from "./Login.js";
import { queryClient } from "./lib/query.js"; import { queryClient } from "./lib/query.js";
import { setLanguage } from "./lib/i18n/index.js"; import { setLanguage } from "./lib/i18n/index.js";
import { applyTheme } from "./lib/theme.js"; import { applyTheme, applyFontScale } from "./lib/theme.js";
import { router } from "./router.js"; import { router } from "./router.js";
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands // App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
@@ -24,15 +24,17 @@ export function App() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
// Apply the signed-in user's preferred language + theme whenever they resolve/ // Apply the signed-in user's preferred language + theme + font scale whenever they
// change (login, bootstrap, or a toggle). Albanian + dark are the defaults before // resolve/change (login, bootstrap, or a toggle). Albanian + dark + 100% are the defaults
// auth resolves; on logout, fall back to dark so the Login screen is consistent. // before auth resolves; on logout, fall back so the Login screen is consistent.
useEffect(() => { useEffect(() => {
if (user) { if (user) {
setLanguage(user.language); setLanguage(user.language);
applyTheme(user.theme); applyTheme(user.theme);
applyFontScale(user.fontScale);
} else { } else {
applyTheme("dark"); applyTheme("dark");
applyFontScale(100);
} }
}, [user]); }, [user]);
+236
View File
@@ -0,0 +1,236 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ApiError,
fetchBackupStatus,
runBackup,
setBackupTarget,
testBackupTarget,
type BackupStatus,
type TargetCheck,
} from "./api.js";
import { formatRelativeDateTime } from "./lib/format.js";
// Admin screen for the on-site encrypted DB backup. The admin picks the TARGET DIRECTORY here
// (stored in site_config; a mounted USB/SATA/SMB/NFS path) — the encryption key stays a server
// secret. Shows status + last-run outcome, a "Test target" probe, and a manual "Back up now".
// Gated by backup:read (config/test by backup:update, run by backup:create). RESTORE is absent
// by design — out-of-band on a fresh appliance. See wiki/concepts/backup-recovery.md.
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
const mb = n / 1048576;
if (mb < 1024) return `${mb.toFixed(1)} MB`;
return `${(mb / 1024).toFixed(2)} GB`;
}
/** Map a target-check result to a localized message. */
function checkMessage(c: TargetCheck, t: (k: string) => string): string {
if (c.ok) return t("backup.testOk");
switch (c.reason) {
case "empty":
return t("backup.testEmpty");
case "not_a_dir":
return t("backup.testNotDir");
case "not_writable":
return t("backup.testNotWritable");
default:
return t("backup.testMissing");
}
}
function StatusBadge({ status }: { status: BackupStatus }) {
const { t } = useTranslation();
if (!status.configured) {
return <span className="text-[0.75rem] font-semibold text-term-muted">{t("backup.notConfigured")}</span>;
}
if (status.running) {
return <span className="text-[0.75rem] font-semibold text-term-amber">{t("backup.running")}</span>;
}
return <span className="text-[0.75rem] font-semibold text-term-green">{t("backup.configured")}</span>;
}
export function BackupSettings() {
const { t } = useTranslation();
const qc = useQueryClient();
const [toast, setToast] = useState<{ kind: "ok" | "err"; msg: string } | null>(null);
const [target, setTarget] = useState("");
const [check, setCheck] = useState<{ kind: "ok" | "err"; msg: string } | null>(null);
const q = useQuery({
queryKey: ["backup-status"],
queryFn: fetchBackupStatus,
refetchInterval: (query) => (query.state.data?.running ? 2000 : false),
});
const status = q.data;
// Seed the editable field from the saved value once it loads (and when it changes server-side).
useEffect(() => {
if (status) setTarget(status.targetDir ?? "");
}, [status?.targetDir]);
const save = useMutation({
mutationFn: () => setBackupTarget(target.trim() || null),
onSuccess: (next) => {
setToast({ kind: "ok", msg: t("backup.saved") });
setCheck(null);
qc.setQueryData(["backup-status"], next);
},
onError: () => setToast({ kind: "err", msg: t("backup.runFailed") }),
});
const test = useMutation({
mutationFn: () => testBackupTarget(target.trim()),
onSuccess: (res) => setCheck({ kind: res.ok ? "ok" : "err", msg: checkMessage(res, t) }),
});
const run = useMutation({
mutationFn: runBackup,
onSuccess: () => {
setToast({ kind: "ok", msg: t("backup.runSuccess") });
void qc.invalidateQueries({ queryKey: ["backup-status"] });
},
onError: (err: unknown) => {
const code = err instanceof ApiError ? err.message : "";
setToast({
kind: "err",
msg: code === "backup_not_configured" ? t("backup.notConfiguredError") : t("backup.runFailed"),
});
void qc.invalidateQueries({ queryKey: ["backup-status"] });
},
});
const dirty = (status?.targetDir ?? "") !== target.trim();
return (
<div className="">
<div className="mb-3 flex items-center justify-between">
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("backup.title")}</h1>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!status?.configured || status?.running || run.isPending || dirty}
onClick={() => {
setToast(null);
run.mutate();
}}
>
{status?.running || run.isPending ? t("backup.running") : t("backup.runNow")}
</button>
</div>
<p className="mb-3 max-w-2xl text-[0.75rem] text-term-muted">{t("backup.intro")}</p>
{toast && (
<div
className={`mb-3 rounded-term border px-3 py-2 text-[0.75rem] ${
toast.kind === "ok"
? "border-term-green/40 bg-term-green/5 text-term-green"
: "border-term-red/40 bg-term-red/5 text-term-red"
}`}
>
{toast.msg}
</div>
)}
{/* Target directory — the admin-chosen destination. */}
<div className="card mb-3 p-4">
<div className="field">
<span className="label">{t("backup.targetLabel")}</span>
<div className="flex flex-wrap items-center gap-2">
<input
className="input w-96 max-w-full"
value={target}
placeholder={t("backup.targetPlaceholder")}
onChange={(e) => {
setTarget(e.target.value);
setCheck(null);
}}
/>
<button
type="button"
className="btn btn-ghost btn-sm"
disabled={test.isPending || !target.trim()}
onClick={() => test.mutate()}
>
{t("backup.test")}
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={save.isPending || !dirty}
onClick={() => {
setToast(null);
save.mutate();
}}
>
{t("backup.save")}
</button>
</div>
<span className="mt-1 text-[0.6875rem] text-term-muted">{t("backup.targetHint")}</span>
{check && (
<span className={`mt-1 text-[0.75rem] ${check.kind === "ok" ? "text-term-green" : "text-term-red"}`}>
{check.msg}
</span>
)}
</div>
</div>
<div className="card p-4">
{q.isLoading || !status ? (
<div className="text-[0.75rem] text-term-muted">{t("common.loading")}</div>
) : (
<dl className="grid grid-cols-[10rem_1fr] gap-x-4 gap-y-2 text-[0.8125rem]">
<dt className="text-term-muted">{t("backup.statusTitle")}</dt>
<dd>
<StatusBadge status={status} />
</dd>
{!status.keyPresent && (
<>
<dt className="text-term-muted" />
<dd className="text-[0.75rem] text-term-amber">{t("backup.keyMissing")}</dd>
</>
)}
<dt className="text-term-muted">{t("backup.lastSuccess")}</dt>
<dd className="text-term-text">
{status.lastSuccessAt ? formatRelativeDateTime(status.lastSuccessAt, t) : t("backup.never")}
</dd>
{status.lastResult && (
<>
<dt className="text-term-muted">{t("backup.size")}</dt>
<dd className="text-term-text tabular-nums">
{formatBytes(status.lastResult.bytes)}
{status.lastResult.prunedFiles > 0 && (
<span className="ml-2 text-term-muted">
({t("backup.pruned")}: {status.lastResult.prunedFiles})
</span>
)}
</dd>
</>
)}
{status.lastError && (
<>
<dt className="text-term-muted">{t("backup.lastError")}</dt>
<dd className="text-term-red">
{status.lastError}
{status.lastErrorAt && (
<span className="ml-2 text-term-muted">
({formatRelativeDateTime(status.lastErrorAt, t)})
</span>
)}
</dd>
</>
)}
</dl>
)}
</div>
<p className="mt-3 max-w-2xl text-[0.6875rem] text-term-muted">{t("backup.restoreNote")}</p>
</div>
);
}
+15 -15
View File
@@ -226,7 +226,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
aria-describedby={undefined} aria-describedby={undefined}
> >
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2"> <div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber"> <Dialog.Title className="m-0 text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
{isSubscription {isSubscription
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}` ? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
: `${t("pay.ticket")} ${identity}`} : `${t("pay.ticket")} ${identity}`}
@@ -244,19 +244,19 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
<div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2"> <div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2">
{blockedByOther ? ( {blockedByOther ? (
<> <>
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber"> <div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
{t("shift.gateOtherTitle")} {t("shift.gateOtherTitle")}
</div> </div>
<div className="mt-1 text-[12px] text-term-text"> <div className="mt-1 text-[0.75rem] text-term-text">
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })} {t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
</div> </div>
</> </>
) : ( ) : (
<> <>
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber"> <div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
{t("shift.gateTitle")} {t("shift.gateTitle")}
</div> </div>
<div className="mt-1 text-[12px] text-term-text">{t("shift.gateBody")}</div> <div className="mt-1 text-[0.75rem] text-term-text">{t("shift.gateBody")}</div>
<button <button
type="button" type="button"
onClick={handleOpenShift} onClick={handleOpenShift}
@@ -321,7 +321,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
out-of-window window charge; then show that amount. For an overstay the out-of-window window charge; then show that amount. For an overstay the
amount is the TOP-UP delta, not the whole stay. */} amount is the TOP-UP delta, not the whole stay. */}
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2"> <div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted"> <span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">
{subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")} {subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")}
</span> </span>
<span className="text-3xl font-bold text-term-cyan"> <span className="text-3xl font-bold text-term-cyan">
@@ -341,22 +341,22 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
gate; once paid, prompt the operator to open the barrier; a prepaid gate; once paid, prompt the operator to open the barrier; a prepaid
subscriber sees the assist explanation only after revealing it. */} subscriber sees the assist explanation only after revealing it. */}
{subWindowDue && !windowPaid ? ( {subWindowDue && !windowPaid ? (
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[12px] text-term-text"> <div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[0.75rem] text-term-text">
{t("pay.windowChargeHint")} {t("pay.windowChargeHint")}
</div> </div>
) : isSubscription && windowPaid ? ( ) : isSubscription && windowPaid ? (
<div className="rounded-term border border-term-green/40 bg-term-green/5 px-3 py-2 text-[12px] text-term-text"> <div className="rounded-term border border-term-green/40 bg-term-green/5 px-3 py-2 text-[0.75rem] text-term-text">
{t("pay.windowPaidHint")} {t("pay.windowPaidHint")}
</div> </div>
) : isSubscription && assistRevealed ? ( ) : isSubscription && assistRevealed ? (
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text"> <div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[0.75rem] text-term-text">
{t("pay.subAssistHint")} {t("pay.subAssistHint")}
</div> </div>
) : null} ) : null}
{/* For an overstay, explain why a top-up is required (no free exit). */} {/* For an overstay, explain why a top-up is required (no free exit). */}
{isOverstay && ( {isOverstay && (
<div className="rounded-term border border-term-red/40 bg-term-red/5 px-3 py-2 text-[12px] text-term-text"> <div className="rounded-term border border-term-red/40 bg-term-red/5 px-3 py-2 text-[0.75rem] text-term-text">
{t("pay.overstayHint")} {t("pay.overstayHint")}
</div> </div>
)} )}
@@ -368,7 +368,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
subscriber window charge that's still unpaid). */} subscriber window charge that's still unpaid). */}
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && ( {phase !== "done" && canPay && !(subWindowDue && windowPaid) && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span> <span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
{(["cash", "card"] as const).map((tn) => ( {(["cash", "card"] as const).map((tn) => (
<button <button
key={tn} key={tn}
@@ -384,7 +384,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */} {/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */}
{phase !== "done" && !isSubscription && ( {phase !== "done" && !isSubscription && (
<label className="flex items-center gap-2 text-[12px]"> <label className="flex items-center gap-2 text-[0.75rem]">
<input <input
type="checkbox" type="checkbox"
className="accent-term-amber" className="accent-term-amber"
@@ -401,10 +401,10 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
signed `void` event — the entry is never edited. */} signed `void` event — the entry is never edited. */}
{voiding && phase !== "done" && ( {voiding && phase !== "done" && (
<div className="rounded-term border border-term-amber/50 bg-term-amber/5 px-3 py-2"> <div className="rounded-term border border-term-amber/50 bg-term-amber/5 px-3 py-2">
<div className="text-[11px] font-semibold uppercase tracking-wider text-term-amber"> <div className="text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
{t("pay.cancelTicketTitle")} {t("pay.cancelTicketTitle")}
</div> </div>
<div className="mt-1 text-[12px] text-term-text">{t("pay.cancelTicketHint")}</div> <div className="mt-1 text-[0.75rem] text-term-text">{t("pay.cancelTicketHint")}</div>
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 flex flex-wrap gap-1.5">
{(["misprint", "test", "wrongVehicle"] as const).map((k) => ( {(["misprint", "test", "wrongVehicle"] as const).map((k) => (
<button <button
@@ -560,7 +560,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) { function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
return ( return (
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span> <span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</span>
<span className={`text-sm ${valueClass}`}>{value}</span> <span className={`text-sm ${valueClass}`}>{value}</span>
</div> </div>
); );
+30 -30
View File
@@ -49,13 +49,13 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
<div className="flex items-end gap-4"> <div className="flex items-end gap-4">
<div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div> <div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
<div className="pb-1 text-term-muted"> <div className="pb-1 text-term-muted">
<div className="text-[11px] uppercase tracking-wider">{t("booth.inside")}</div> <div className="text-[0.6875rem] uppercase tracking-wider">{t("booth.inside")}</div>
<div className="text-sm tabular-nums"> <div className="text-sm tabular-nums">
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`} {occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
</div> </div>
</div> </div>
<div className="ml-auto text-right"> <div className="ml-auto text-right">
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("booth.free")}</div> <div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("booth.free")}</div>
<div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}> <div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
{occ.free == null ? "∞" : occ.free} {occ.free == null ? "∞" : occ.free}
</div> </div>
@@ -67,7 +67,7 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
</div> </div>
)} )}
{occ.full && ( {occ.full && (
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[11px] font-bold uppercase tracking-widest text-term-red"> <div className="rounded-term border border-term-red px-2 py-1 text-center text-[0.6875rem] font-bold uppercase tracking-widest text-term-red">
{t("booth.lotFull")} {t("booth.lotFull")}
</div> </div>
)} )}
@@ -106,46 +106,51 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
className="input h-11 flex-1 px-3 text-lg tabular-nums" className="input h-11 flex-1 px-3 text-lg tabular-nums"
/> />
<button type="submit" className="btn btn-primary btn-lg"> <button type="submit" className="btn btn-primary btn-lg">
{t("booth.open")} {t("booth.openTicket")}
</button> </button>
</form> </form>
); );
} }
/** One barrier light — green = free, red = busy (a vehicle is at the lane vicinity, /** One barrier light — a 3-state indicator mirroring the physical button lamp (relay 3):
* from camera detection). Advisory only; it gates nothing. */ * - radar present + camera NOT busy → BLINK green↔red (~1 Hz): "detected, not yet confirmed"
function BarrierLight({ label, busy }: { label: string; busy: boolean }) { * - camera busy → SOLID red: a vehicle is confirmed at the lane vicinity
* - otherwise → SOLID green: free
* Advisory only; it gates nothing. The blink uses the `.lane-blink` keyframe (index.css),
* whose children inherit the alternating colour via `currentColor`. */
function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; radar: boolean }) {
// Blink only when the radar sees something the camera hasn't confirmed.
const blinking = radar && !busy;
const solid = busy ? "border-term-red bg-term-red/10 text-term-red" : "border-term-green bg-term-green/10 text-term-green";
return ( return (
<div <div
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${ className={`flex items-center gap-2 rounded-term border px-3 py-2 ${blinking ? "lane-blink" : solid}`}
busy ? "border-term-red bg-term-red/10" : "border-term-green bg-term-green/10"
}`}
title={label} title={label}
> >
{/* Barrier glyph: a post + an arm. Colour carries the state. */} {/* Barrier glyph: a post + an arm. `currentColor` follows the (possibly blinking) state. */}
<svg viewBox="0 0 24 24" className={`h-5 w-5 ${busy ? "text-term-red" : "text-term-green"}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="5" y1="21" x2="5" y2="9" /> <line x1="5" y1="21" x2="5" y2="9" />
<line x1="5" y1="10" x2="21" y2="6" /> <line x1="5" y1="10" x2="21" y2="6" />
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" /> <circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
</svg> </svg>
<div className="leading-tight"> <div className="leading-tight">
<div className="text-[10px] uppercase tracking-wider text-term-muted">{label}</div> <div className="text-[0.625rem] uppercase tracking-wider text-term-muted">{label}</div>
<div className={`text-xs font-bold ${busy ? "text-term-red" : "text-term-green"}`}> <div className="text-xs font-bold">{busy ? "●" : blinking ? "◐" : "○"}</div>
{busy ? "●" : "○"}
</div>
</div> </div>
</div> </div>
); );
} }
/** The two lane barrier lights (entry / exit) fed by the live lane-status. */ /** The two lane barrier lights (entry / exit) fed by the live lane-status (camera busy/free)
* and lane-presence (radar). */
function LaneIndicators() { function LaneIndicators() {
const { t } = useTranslation(); const { t } = useTranslation();
const lanes = useLiveStore((s) => s.lanes); const lanes = useLiveStore((s) => s.lanes);
const radar = useLiveStore((s) => s.radar);
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} /> <BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} radar={radar?.entry ?? false} />
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} /> <BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} radar={radar?.exit ?? false} />
</div> </div>
); );
} }
@@ -176,10 +181,10 @@ export function BoothScreen() {
// abandon an in-progress payment (the operator finishes/closes, then scans the next). // abandon an in-progress payment (the operator finishes/closes, then scans the next).
useScanner({ onScan: setActiveTicket, paused: activeTicket != null || detailEvent != null }); useScanner({ onScan: setActiveTicket, paused: activeTicket != null || detailEvent != null });
// Live-feed filters: free-text search, event category, and direction/source. // Live-feed filters: free-text search, event type, and source. (No direction filter —
// HYRJE/DALJE there just duplicated the entry/exit options already in the Type filter.)
const [feedSearch, setFeedSearch] = useState(""); const [feedSearch, setFeedSearch] = useState("");
const [feedType, setFeedType] = useState<FeedCat | "">(""); const [feedType, setFeedType] = useState<FeedCat | "">("");
const [feedDir, setFeedDir] = useState<"entry" | "exit" | "">("");
const [feedSrc, setFeedSrc] = useState<"booth" | "reader" | "">(""); const [feedSrc, setFeedSrc] = useState<"booth" | "reader" | "">("");
// Live overlays from the WS store. // Live overlays from the WS store.
@@ -202,17 +207,17 @@ export function BoothScreen() {
// Apply the live-feed filters. Source maps to booth (operator-initiated `manual`) // Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
// vs reader (device-initiated: wiegand/lpr/qr/ticket). Search spans identity, // vs reader (device-initiated: wiegand/lpr/qr/ticket). Search spans identity,
// subscriber label, and any advisory plate on the payload. // subscriber label, and the enriched advisory plate (`e.plate` — the displayed field;
// the plate is NOT in the signed payload, so `payload.plate` would never match).
const fq = feedSearch.trim().toLowerCase(); const fq = feedSearch.trim().toLowerCase();
const events = scoped.filter((e) => { const events = scoped.filter((e) => {
if (feedType && feedCat(e.type) !== feedType) return false; if (feedType && feedCat(e.type) !== feedType) return false;
if (feedDir && e.direction !== feedDir) return false;
if (feedSrc) { if (feedSrc) {
const isBooth = e.source === "manual"; const isBooth = e.source === "manual";
if (feedSrc === "booth" ? !isBooth : isBooth) return false; if (feedSrc === "booth" ? !isBooth : isBooth) return false;
} }
if (fq) { if (fq) {
const hay = `${e.identity ?? ""} ${e.subscriberLabel ?? ""} ${e.payload?.plate ?? ""}`.toLowerCase(); const hay = `${e.identity ?? ""} ${e.subscriberLabel ?? ""} ${e.plate ?? ""}`.toLowerCase();
if (!hay.includes(fq)) return false; if (!hay.includes(fq)) return false;
} }
return true; return true;
@@ -225,10 +230,6 @@ export function BoothScreen() {
{ value: "void", label: t("booth.fEvtVoid") }, { value: "void", label: t("booth.fEvtVoid") },
{ value: "anomaly", label: t("booth.fEvtAnomaly") }, { value: "anomaly", label: t("booth.fEvtAnomaly") },
]; ];
const feedDirOpts: SegOption<"entry" | "exit">[] = [
{ value: "entry", label: t("booth.fDirEntry") },
{ value: "exit", label: t("booth.fDirExit") },
];
const feedSrcOpts: SegOption<"booth" | "reader">[] = [ const feedSrcOpts: SegOption<"booth" | "reader">[] = [
{ value: "booth", label: t("booth.fSrcBooth") }, { value: "booth", label: t("booth.fSrcBooth") },
{ value: "reader", label: t("booth.fSrcReader") }, { value: "reader", label: t("booth.fSrcReader") },
@@ -266,7 +267,7 @@ export function BoothScreen() {
<Panel <Panel
title={t("booth.liveFeed")} title={t("booth.liveFeed")}
right={ right={
<span className="text-[10px] uppercase tracking-wider text-term-muted"> <span className="text-[0.625rem] uppercase tracking-wider text-term-muted">
{events.length} {events.length}
{events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")} {events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")}
</span> </span>
@@ -282,7 +283,6 @@ export function BoothScreen() {
onChange={setFeedType} onChange={setFeedType}
allLabel={t("booth.filterAll")} allLabel={t("booth.filterAll")}
/> />
<SegGroup value={feedDir} options={feedDirOpts} onChange={setFeedDir} allLabel={t("booth.filterAll")} />
<SegGroup value={feedSrc} options={feedSrcOpts} onChange={setFeedSrc} allLabel={t("booth.filterAll")} /> <SegGroup value={feedSrc} options={feedSrcOpts} onChange={setFeedSrc} allLabel={t("booth.filterAll")} />
</FilterBar> </FilterBar>
)} )}
+1 -1
View File
@@ -46,7 +46,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
autoComplete="current-password" autoComplete="current-password"
/> />
</div> </div>
{error && <p className="mb-3 text-[12px] text-term-red">{error}</p>} {error && <p className="mb-3 text-[0.75rem] text-term-red">{error}</p>}
<button type="submit" className="btn btn-primary btn-lg w-full" disabled={busy || !username || !password}> <button type="submit" className="btn btn-primary btn-lg w-full" disabled={busy || !username || !password}>
{busy ? t("auth.signingIn") : t("auth.signIn")} {busy ? t("auth.signingIn") : t("auth.signIn")}
</button> </button>
+7 -7
View File
@@ -31,7 +31,7 @@ function LogRow({ log }: { log: AppLogRecord }) {
<button <button
type="button" type="button"
onClick={() => hasDetail && setOpen((v) => !v)} onClick={() => hasDetail && setOpen((v) => !v)}
className={`grid w-full grid-cols-[auto_4rem_5rem_1fr_auto] items-center gap-x-3 px-1 py-1 text-left text-[12px] ${ className={`grid w-full grid-cols-[auto_4rem_5rem_1fr_auto] items-center gap-x-3 px-1 py-1 text-left text-[0.75rem] ${
hasDetail ? "hover:bg-term-panel-2" : "cursor-default" hasDetail ? "hover:bg-term-panel-2" : "cursor-default"
}`} }`}
> >
@@ -44,17 +44,17 @@ function LogRow({ log }: { log: AppLogRecord }) {
{open && hasDetail && ( {open && hasDetail && (
<div className="border-t border-term-border/40 bg-term-bg px-3 py-2"> <div className="border-t border-term-border/40 bg-term-bg px-3 py-2">
{log.path && ( {log.path && (
<div className="mb-1 text-[11px] text-term-muted"> <div className="mb-1 text-[0.6875rem] text-term-muted">
{t("logs.path")}: <code className="text-term-text">{log.path}</code> {t("logs.path")}: <code className="text-term-text">{log.path}</code>
</div> </div>
)} )}
{log.context && Object.keys(log.context).length > 0 && ( {log.context && Object.keys(log.context).length > 0 && (
<pre className="mb-2 overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[11px] text-term-text"> <pre className="mb-2 overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[0.6875rem] text-term-text">
{JSON.stringify(log.context, null, 2)} {JSON.stringify(log.context, null, 2)}
</pre> </pre>
)} )}
{log.stack && ( {log.stack && (
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[11px] text-term-red/90"> <pre className="overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[0.6875rem] text-term-red/90">
{log.stack} {log.stack}
</pre> </pre>
)} )}
@@ -136,12 +136,12 @@ export function LogsViewer() {
<div className="card p-2"> <div className="card p-2">
{q.isLoading ? ( {q.isLoading ? (
<div className="p-3 text-[12px] text-term-muted">{t("common.loading")}</div> <div className="p-3 text-[0.75rem] text-term-muted">{t("common.loading")}</div>
) : logs.length === 0 ? ( ) : logs.length === 0 ? (
<div className="p-3 text-[12px] text-term-muted">{t("logs.empty")}</div> <div className="p-3 text-[0.75rem] text-term-muted">{t("logs.empty")}</div>
) : ( ) : (
<> <>
<div className="grid grid-cols-[auto_4rem_5rem_1fr_auto] gap-x-3 border-b border-term-border px-1 pb-1 text-[10px] uppercase tracking-wider text-term-muted"> <div className="grid grid-cols-[auto_4rem_5rem_1fr_auto] gap-x-3 border-b border-term-border px-1 pb-1 text-[0.625rem] uppercase tracking-wider text-term-muted">
<span>{t("logs.time")}</span> <span>{t("logs.time")}</span>
<span>{t("logs.level")}</span> <span>{t("logs.level")}</span>
<span>{t("logs.source")}</span> <span>{t("logs.source")}</span>
+8 -8
View File
@@ -82,7 +82,7 @@ export function Profile({
<h2 className="text-sm uppercase tracking-wider text-term-muted"> <h2 className="text-sm uppercase tracking-wider text-term-muted">
{t("profile.accountSection")} {t("profile.accountSection")}
</h2> </h2>
<div className="grid grid-cols-2 gap-3 text-[11px] text-term-muted"> <div className="grid grid-cols-2 gap-3 text-[0.6875rem] text-term-muted">
<div> <div>
<span className="block">{t("profile.username")}</span> <span className="block">{t("profile.username")}</span>
<span className="text-sm text-term-text">{user.username}</span> <span className="text-sm text-term-text">{user.username}</span>
@@ -92,7 +92,7 @@ export function Profile({
<span className="text-sm text-term-text">{user.roleName}</span> <span className="text-sm text-term-text">{user.roleName}</span>
</div> </div>
</div> </div>
<label className="flex flex-col gap-1 text-[11px] text-term-muted"> <label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
{t("profile.fullName")} {t("profile.fullName")}
<input <input
className="input" className="input"
@@ -101,7 +101,7 @@ export function Profile({
onChange={(e) => setFullName(e.target.value)} onChange={(e) => setFullName(e.target.value)}
/> />
</label> </label>
<label className="flex flex-col gap-1 text-[11px] text-term-muted"> <label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
{t("profile.email")} {t("profile.email")}
<input <input
className="input" className="input"
@@ -115,7 +115,7 @@ export function Profile({
<button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}> <button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}>
{t("profile.saveProfile")} {t("profile.saveProfile")}
</button> </button>
{accountMsg && <span className="text-[11px] text-term-muted">{accountMsg}</span>} {accountMsg && <span className="text-[0.6875rem] text-term-muted">{accountMsg}</span>}
</div> </div>
</section> </section>
@@ -124,7 +124,7 @@ export function Profile({
<h2 className="text-sm uppercase tracking-wider text-term-muted"> <h2 className="text-sm uppercase tracking-wider text-term-muted">
{t("profile.passwordSection")} {t("profile.passwordSection")}
</h2> </h2>
<label className="flex flex-col gap-1 text-[11px] text-term-muted"> <label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
{t("profile.currentPassword")} {t("profile.currentPassword")}
<input <input
className="input" className="input"
@@ -134,7 +134,7 @@ export function Profile({
onChange={(e) => setCurrent(e.target.value)} onChange={(e) => setCurrent(e.target.value)}
/> />
</label> </label>
<label className="flex flex-col gap-1 text-[11px] text-term-muted"> <label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
{t("profile.newPassword")} {t("profile.newPassword")}
<input <input
className="input" className="input"
@@ -144,7 +144,7 @@ export function Profile({
onChange={(e) => setNext(e.target.value)} onChange={(e) => setNext(e.target.value)}
/> />
</label> </label>
<label className="flex flex-col gap-1 text-[11px] text-term-muted"> <label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
{t("profile.confirmPassword")} {t("profile.confirmPassword")}
<input <input
className="input" className="input"
@@ -163,7 +163,7 @@ export function Profile({
> >
{t("profile.changePassword")} {t("profile.changePassword")}
</button> </button>
{pwMsg && <span className="text-[11px] text-term-muted">{pwMsg}</span>} {pwMsg && <span className="text-[0.6875rem] text-term-muted">{pwMsg}</span>}
</div> </div>
</section> </section>
</div> </div>
+7 -7
View File
@@ -76,13 +76,13 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
{t("recycleBin.title")} {t("recycleBin.title")}
</h1> </h1>
{retentionDays > 0 && ( {retentionDays > 0 && (
<span className="text-[12px] text-term-muted"> <span className="text-[0.75rem] text-term-muted">
{t("recycleBin.retentionNote", { days: retentionDays })} {t("recycleBin.retentionNote", { days: retentionDays })}
</span> </span>
)} )}
</div> </div>
{error && <p className="mb-2 text-[12px] text-term-red">{error}</p>} {error && <p className="mb-2 text-[0.75rem] text-term-red">{error}</p>}
{binQ.isLoading && <p className="text-term-muted">{t("common.loading")}</p>} {binQ.isLoading && <p className="text-term-muted">{t("common.loading")}</p>}
{!binQ.isLoading && items.length === 0 ? ( {!binQ.isLoading && items.length === 0 ? (
@@ -90,9 +90,9 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
{t("recycleBin.empty")} {t("recycleBin.empty")}
</p> </p>
) : ( ) : (
<table className="w-full text-[13px]"> <table className="w-full text-[0.8125rem]">
<thead> <thead>
<tr className="border-b border-term-border text-left text-[11px] uppercase tracking-wider text-term-muted"> <tr className="border-b border-term-border text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
<th className="py-1.5 pr-3">{t("recycleBin.col.type")}</th> <th className="py-1.5 pr-3">{t("recycleBin.col.type")}</th>
<th className="py-1.5 pr-3">{t("recycleBin.col.item")}</th> <th className="py-1.5 pr-3">{t("recycleBin.col.item")}</th>
<th className="py-1.5 pr-3">{t("recycleBin.col.deleted")}</th> <th className="py-1.5 pr-3">{t("recycleBin.col.deleted")}</th>
@@ -103,7 +103,7 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
{items.map((it) => ( {items.map((it) => (
<tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50"> <tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50">
<td className="py-1.5 pr-3"> <td className="py-1.5 pr-3">
<span className="rounded-term border border-term-border px-1.5 py-0.5 text-[11px] text-term-muted"> <span className="rounded-term border border-term-border px-1.5 py-0.5 text-[0.6875rem] text-term-muted">
{t(KIND_KEY[it.kind])} {t(KIND_KEY[it.kind])}
</span> </span>
</td> </td>
@@ -146,10 +146,10 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
{purging && ( {purging && (
<Modal open onClose={() => setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}> <Modal open onClose={() => setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}>
<p className="text-[13px] text-term-text"> <p className="text-[0.8125rem] text-term-text">
{t("recycleBin.purgeConfirmBody", { label: purging.label })} {t("recycleBin.purgeConfirmBody", { label: purging.label })}
</p> </p>
<p className="mt-1 text-[12px] text-term-red">{t("recycleBin.purgeIrreversible")}</p> <p className="mt-1 text-[0.75rem] text-term-red">{t("recycleBin.purgeIrreversible")}</p>
<div className="mt-3 flex justify-end gap-2"> <div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}> <button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}>
{t("common.cancel")} {t("common.cancel")}
+6 -6
View File
@@ -95,7 +95,7 @@ export function Reports() {
</button> </button>
))} ))}
</div> </div>
<div className="ml-2 flex items-center gap-1 text-[12px] text-term-muted"> <div className="ml-2 flex items-center gap-1 text-[0.75rem] text-term-muted">
<span>{t("reports.groupBy")}</span> <span>{t("reports.groupBy")}</span>
<select <select
className="select input-sm w-auto" className="select input-sm w-auto"
@@ -247,7 +247,7 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
{/* Cash / card + duration + subscription breakdown (numbers). */} {/* Cash / card + duration + subscription breakdown (numbers). */}
<Panel title={t("reports.chart.breakdown")}> <Panel title={t("reports.chart.breakdown")}>
<dl className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-[13px]"> <dl className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-[0.8125rem]">
<Row label={t("reports.row.cash")} value={money(tot.cashMinor)} /> <Row label={t("reports.row.cash")} value={money(tot.cashMinor)} />
<Row label={t("reports.row.card")} value={money(tot.cardMinor)} /> <Row label={t("reports.row.card")} value={money(tot.cardMinor)} />
<Row label={t("reports.mix.ticket")} value={money(tot.ticketMinor)} /> <Row label={t("reports.mix.ticket")} value={money(tot.ticketMinor)} />
@@ -262,7 +262,7 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
</Panel> </Panel>
</div> </div>
<p className="text-[11px] text-term-muted"> <p className="text-[0.6875rem] text-term-muted">
{t("reports.footnote", { tz: data.tz })} {t("reports.footnote", { tz: data.tz })}
</p> </p>
</div> </div>
@@ -290,7 +290,7 @@ function Kpi({ label, value, accent }: { label: string; value: string; accent?:
: "text-term-text"; : "text-term-text";
return ( return (
<div className="rounded-term border border-term-border bg-term-panel p-2.5"> <div className="rounded-term border border-term-border bg-term-panel p-2.5">
<div className="text-[11px] uppercase tracking-wider text-term-muted">{label}</div> <div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</div>
<div className={`mt-0.5 text-lg font-bold tabular-nums ${color}`}>{value}</div> <div className={`mt-0.5 text-lg font-bold tabular-nums ${color}`}>{value}</div>
</div> </div>
); );
@@ -299,7 +299,7 @@ function Kpi({ label, value, accent }: { label: string; value: string; accent?:
function Panel({ title, children }: { title: string; children: React.ReactNode }) { function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<div className="rounded-term border border-term-border bg-term-panel p-3"> <div className="rounded-term border border-term-border bg-term-panel p-3">
<h2 className="mb-2 text-[11px] uppercase tracking-wider text-term-muted">{title}</h2> <h2 className="mb-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">{title}</h2>
{children} {children}
</div> </div>
); );
@@ -315,5 +315,5 @@ function Row({ label, value }: { label: string; value: string }) {
} }
function Empty({ t }: { t: TFunction }) { function Empty({ t }: { t: TFunction }) {
return <p className="py-12 text-center text-[12px] text-term-muted">{t("reports.noData")}</p>; return <p className="py-12 text-center text-[0.75rem] text-term-muted">{t("reports.noData")}</p>;
} }
+6 -6
View File
@@ -63,7 +63,7 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
)} )}
</div> </div>
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>} {error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[0.75rem] text-term-red">{error}</div>}
<Modal <Modal
open={editing != null} open={editing != null}
@@ -93,13 +93,13 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
<div key={r.id} className="rounded-term border border-term-border bg-term-panel p-3"> <div key={r.id} className="rounded-term border border-term-border bg-term-panel p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-term-text">{r.name}</span> <span className="text-[0.8125rem] font-semibold text-term-text">{r.name}</span>
{r.builtin && ( {r.builtin && (
<span className="rounded-term border border-term-amber/50 px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-term-amber"> <span className="rounded-term border border-term-amber/50 px-1.5 py-0.5 text-[0.625rem] uppercase tracking-wider text-term-amber">
{t("roles.builtin")} {t("roles.builtin")}
</span> </span>
)} )}
<span className="text-[11px] text-term-muted"> <span className="text-[0.6875rem] text-term-muted">
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })} {t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
</span> </span>
</div> </div>
@@ -155,11 +155,11 @@ function RoleEditor({
<div className="mt-1 grid grid-cols-1 gap-1"> <div className="mt-1 grid grid-cols-1 gap-1">
{Object.entries(grouped).map(([resource, list]) => ( {Object.entries(grouped).map(([resource, list]) => (
<div key={resource} className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-term-border py-1.5"> <div key={resource} className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-term-border py-1.5">
<span className="w-28 shrink-0 text-[12px] font-semibold text-term-text">{resource}</span> <span className="w-28 shrink-0 text-[0.75rem] font-semibold text-term-text">{resource}</span>
{list.map((p) => { {list.map((p) => {
const action = p.split(":")[1]!; const action = p.split(":")[1]!;
return ( return (
<label key={p} className="flex items-center gap-1 text-[12px] text-term-text"> <label key={p} className="flex items-center gap-1 text-[0.75rem] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={perms.has(p)} onChange={() => toggle(p)} /> <input type="checkbox" className="accent-term-amber" checked={perms.has(p)} onChange={() => toggle(p)} />
{action} {action}
</label> </label>
+589 -82
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, Fragment } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
assignDevice, assignDevice,
@@ -9,8 +9,10 @@ import {
fetchState, fetchState,
testAnpr, testAnpr,
testDevice, testDevice,
testPrint,
unassignDevice, unassignDevice,
type AnprTestResult, type AnprTestResult,
type PrintTestResult,
type Assignment, type Assignment,
type BackendIpCandidate, type BackendIpCandidate,
type Catalog, type Catalog,
@@ -19,6 +21,9 @@ import {
type DeviceConfig, type DeviceConfig,
type Direction, type Direction,
type DiscoveredDevice, type DiscoveredDevice,
type InputRole,
type InputSpec,
type RelayEvent,
type RelaySpec, type RelaySpec,
type TestResult, type TestResult,
} from "./api.js"; } from "./api.js";
@@ -46,13 +51,63 @@ const BOUND: { key: DeviceCategory; titleKey: string; nounKey: string }[] = [
{ key: "printer", titleKey: "setup.catPrinters", nounKey: "setup.nounPrinter" }, { key: "printer", titleKey: "setup.catPrinters", nounKey: "setup.nounPrinter" },
]; ];
// Translated direction label (relay direction / inherited binding). // Translated relay-event label (barrier direction, inherited binding, or alert).
const DIRECTION_KEYS: Record<Direction, string> = { const DIRECTION_KEYS: Record<RelayEvent, string> = {
entry: "setup.dirEntry", entry: "setup.dirEntry",
exit: "setup.dirExit", exit: "setup.dirExit",
both: "setup.dirBoth", both: "setup.dirBoth",
radarAlert: "setup.eventRadarAlert",
}; };
// The input-role dropdown folds presence `kind` into the choice: one select offers Button,
// Presence (loop), Presence (radar), Alert trigger. Each maps to a {role, kind} pair.
type InputChoice = "button" | "presenceLoop" | "presenceRadar" | "alertTrigger";
const INPUT_CHOICE_KEYS: Record<InputChoice, string> = {
button: "setup.roleButton",
presenceLoop: "setup.rolePresenceLoop",
presenceRadar: "setup.rolePresenceRadar",
alertTrigger: "setup.roleAlertTrigger",
};
function choiceOf(i: InputSpec): InputChoice {
if (i.role === "button") return "button";
if (i.role === "alertTrigger") return "alertTrigger";
return i.kind === "radar" ? "presenceRadar" : "presenceLoop";
}
function applyChoice(choice: InputChoice): { role: InputRole; kind?: "loop" | "radar" } {
switch (choice) {
case "button":
return { role: "button" };
case "alertTrigger":
return { role: "alertTrigger" };
case "presenceLoop":
return { role: "presence", kind: "loop" };
case "presenceRadar":
return { role: "presence", kind: "radar" };
}
}
/** Synthesize an inputs[] list from the LEGACY per-relay button/presence fields, so an
* existing controller (saved before inputs[]) opens with its inputs populated. Mirrors the
* server's `inputsOf()` back-compat fold. */
function synthInputsFromRelays(relays: RelaySpec[]): InputSpec[] {
const out: InputSpec[] = [];
for (const r of relays) {
if (typeof r.button === "number") {
out.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec });
}
if (typeof r.presenceInput === "number") {
out.push({
input: r.presenceInput,
role: "presence",
relay: r.relay,
kind: r.presenceKind ?? "loop",
activeLow: r.presenceActiveLow,
});
}
}
return out;
}
export function SetupWizard() { export function SetupWizard() {
const { t } = useTranslation(); const { t } = useTranslation();
const [catalog, setCatalog] = useState<Catalog | null>(null); const [catalog, setCatalog] = useState<Catalog | null>(null);
@@ -79,7 +134,7 @@ export function SetupWizard() {
return ( return (
<section className="px-4 py-6"> <section className="px-4 py-6">
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2> <h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2>
<p className="hint mb-4 max-w-prose">{t("setup.intro")}</p> <p className="hint mb-4 max-w">{t("setup.intro")}</p>
<CategorySection <CategorySection
category={CONTROLLER.key} category={CONTROLLER.key}
@@ -151,8 +206,8 @@ function CategorySection({
{warnings.length > 0 && ( {warnings.length > 0 && (
<div className="mb-3 rounded-term border border-term-amber/60 bg-term-amber/10 px-3 py-2"> <div className="mb-3 rounded-term border border-term-amber/60 bg-term-amber/10 px-3 py-2">
<strong className="text-[12px] text-term-amber">{t("setup.warnTitle")}</strong> <strong className="text-[0.75rem] text-term-amber">{t("setup.warnTitle")}</strong>
<ul className="mt-1 list-disc pl-5 text-[12px] text-term-amber"> <ul className="mt-1 list-disc pl-5 text-[0.75rem] text-term-amber">
{warnings.map((w, i) => ( {warnings.map((w, i) => (
<li key={i}>{w}</li> <li key={i}>{w}</li>
))} ))}
@@ -178,7 +233,7 @@ function CategorySection({
)} )}
{blockedNoController ? ( {blockedNoController ? (
<p className="m-0 text-[12px] text-term-amber">{t("setup.needControllerFirst", { noun })}</p> <p className="m-0 text-[0.75rem] text-term-amber">{t("setup.needControllerFirst", { noun })}</p>
) : ( ) : (
<button type="button" className="btn btn-sm" onClick={() => setFormFor("new")}> <button type="button" className="btn btn-sm" onClick={() => setFormFor("new")}>
{assignments.length === 0 ? t("setup.add", { noun }) : t("setup.addAnother", { noun })} {assignments.length === 0 ? t("setup.add", { noun }) : t("setup.addAnother", { noun })}
@@ -246,7 +301,7 @@ function AssignmentRow({
} }
return ( return (
<li className="flex items-center gap-2 border-b border-term-border/60 px-1 py-2 text-[12px]"> <li className="flex items-center gap-2 border-b border-term-border/60 px-1 py-2 text-[0.75rem]">
<strong className="text-term-text">{assignment.driverId}</strong> <strong className="text-term-text">{assignment.driverId}</strong>
{host && <span className="tabular-nums text-term-muted">{host}</span>} {host && <span className="tabular-nums text-term-muted">{host}</span>}
<DeviceSummary assignment={assignment} controllers={controllers} /> <DeviceSummary assignment={assignment} controllers={controllers} />
@@ -270,11 +325,25 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
if (assignment.category === "access") { if (assignment.category === "access") {
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : []; const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>; if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
// Effective inputs: config.inputs[] if present, else synthesized from legacy relay fields.
const inputs = Array.isArray(cfg.inputs) ? (cfg.inputs as InputSpec[]) : synthInputsFromRelays(relays);
return ( return (
<span className="flex gap-1.5"> <span className="flex flex-wrap gap-1.5">
{relays.map((r) => ( {relays.map((r) => {
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} /> // Alert relay: trigger input + lock lane. Barrier: its button + presence inputs.
))} let wiring = "";
if (r.direction === "radarAlert") {
if (r.triggerInput) wiring += `·trig${r.triggerInput}`;
if (r.lockLane === "exit") wiring += "·lockExit";
} else {
const served = inputs.filter((x) => x.relay === r.relay);
const btn = served.find((x) => x.role === "button");
const pres = served.find((x) => x.role === "presence");
if (btn) wiring += `·btn${btn.input}`;
if (pres) wiring += `·${pres.kind === "radar" ? "radar" : "loop"}${pres.input}`;
}
return <DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${wiring}`} />;
})}
</span> </span>
); );
} }
@@ -325,9 +394,17 @@ function DeviceForm({
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id); const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
const isController = category === "access"; const isController = category === "access";
const isCamera = category === "camera"; const isCamera = category === "camera";
// ANPR opt-in for a camera: when true, the VisionReader polls this camera for plates const isPrinter = category === "printer";
// (config.anpr). Off by default. See wiki/entities/opencv-anpr-service.md. // ANPR opt-in for a camera: when true, this camera's snapshots are run through the
// recognizer (plate recorded as evidence, both directions). (config.anpr). Off by default.
// See wiki/entities/opencv-anpr-service.md.
const [anpr, setAnpr] = useState<boolean>(editCfg?.anpr === true); const [anpr, setAnpr] = useState<boolean>(editCfg?.anpr === true);
// Auto-trigger: when true, THIS camera's vehicle detection may auto-open the barrier
// (subscriber entry/exit). Separate from `anpr` so a shared entry/exit lane can keep
// RECOGNITION on both cameras but disable auto-open on, e.g., the exit camera (whose
// back-plate read would otherwise phantom-exit the car that just entered). Defaults ON
// when anpr is on (back-compat). (config.anprAutoTrigger).
const [anprAuto, setAnprAuto] = useState<boolean>(editCfg?.anprAutoTrigger !== false);
// Pre-fill scalar config fields from the existing assignment when editing. // Pre-fill scalar config fields from the existing assignment when editing.
// (relays/controllerId/relay are model fields handled by their own state below.) // (relays/controllerId/relay are model fields handled by their own state below.)
@@ -341,10 +418,20 @@ function DeviceForm({
} }
return out; return out;
}); });
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal). // Controllers: the unified relay map. Each relay reacts to an EVENT — entry/exit/both
// (pulse a barrier) or radarAlert (drive an alert lamp). Alert relays carry a trigger
// input + blink cadence; barriers carry no input wiring (that lives in `inputs` below).
const [relays, setRelays] = useState<RelaySpec[]>(() => const [relays, setRelays] = useState<RelaySpec[]>(() =>
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }], Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
); );
// Controller INPUTS — a first-class list (button / presence / alertTrigger), each naming
// the relay it serves. Seed from config.inputs[] if present, else SYNTHESIZE from the
// legacy per-relay button/presence fields so an existing controller opens populated.
const [inputs, setInputs] = useState<InputSpec[]>(() => {
const stored = editCfg?.inputs;
if (Array.isArray(stored) && stored.length > 0) return stored as InputSpec[];
return synthInputsFromRelays(Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : []);
});
// Bound devices: which controller + relay this device sits at. // Bound devices: which controller + relay this device sits at.
const [controllerId, setControllerId] = useState<string>( const [controllerId, setControllerId] = useState<string>(
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "", typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
@@ -357,9 +444,21 @@ function DeviceForm({
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null); const [testError, setTestError] = useState<string | null>(null);
// ANPR probe (camera + anpr on): snapshot → vision analyze, reported below. // ANPR probe (camera + anpr on): snapshot → vision analyze, reported below.
const [alarmUrlCopied, setAlarmUrlCopied] = useState(false);
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null); const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
const [anprTesting, setAnprTesting] = useState(false); const [anprTesting, setAnprTesting] = useState(false);
const [anprError, setAnprError] = useState<string | null>(null); const [anprError, setAnprError] = useState<string | null>(null);
const [printResult, setPrintResult] = useState<PrintTestResult | null>(null);
const [printTesting, setPrintTesting] = useState(false);
const [printError, setPrintError] = useState<string | null>(null);
// Which `secret` fields are currently unmasked. The device web password is an
// operational credential the admin legitimately needs (to reach the device's web
// UI) — it's stored + sent to this admin-only view; a per-field reveal toggle just
// makes the already-present value readable. (Machine secrets — relay/push pw — are
// redacted server-side and never reach here, so there's nothing to reveal.)
const [revealed, setRevealed] = useState<Record<string, boolean>>({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [found, setFound] = useState<DiscoveredDevice[] | null>(null); const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
@@ -368,22 +467,31 @@ function DeviceForm({
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null); const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
const [backendIp, setBackendIp] = useState<string>(""); const [backendIp, setBackendIp] = useState<string>("");
// The server's listen port (e.g. 3000) the device must POST to — NOT the page's
// port (the SPA may be served by Vite on :5173 in dev, or behind a proxy on :80).
// Comes from the same /api/setup/backend-ips probe as the IPs.
const [backendPort, setBackendPort] = useState<number | null>(null);
const testedHost = tested ? String(mergedScalarConfig().host ?? "") : ""; const testedHost = tested ? String(mergedScalarConfig().host ?? "") : "";
useEffect(() => { useEffect(() => {
if (!testedHost || !pushesToBackend) { if (!testedHost || !pushesToBackend) {
setBackendIps(null); setBackendIps(null);
setBackendPort(null);
return; return;
} }
let live = true; let live = true;
fetchBackendIps(testedHost) fetchBackendIps(testedHost)
.then(({ candidates }) => { .then(({ candidates, port }) => {
if (!live) return; if (!live) return;
setBackendIps(candidates); setBackendIps(candidates);
setBackendPort(port);
setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || ""); setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || "");
}) })
.catch(() => { .catch(() => {
if (live) setBackendIps(null); if (live) {
setBackendIps(null);
setBackendPort(null);
}
}); });
return () => { return () => {
live = false; live = false;
@@ -437,12 +545,30 @@ function DeviceForm({
function mergedConfig(): DeviceConfig { function mergedConfig(): DeviceConfig {
const out: DeviceConfig = { ...mergedScalarConfig() }; const out: DeviceConfig = { ...mergedScalarConfig() };
if (isController) { if (isController) {
out.relays = relays.map((r) => ({ // Relays carry ONLY the event (+ alert fields). Input wiring lives in out.inputs.
out.relays = relays.map((r) =>
r.direction === "radarAlert"
? {
// Alert lamp: trigger input + lock lane + blink cadence.
relay: r.relay, relay: r.relay,
direction: r.direction, direction: r.direction,
...(r.button ? { button: r.button } : {}), ...(r.triggerInput ? { triggerInput: r.triggerInput } : {}),
...(r.presenceInput ? { presenceInput: r.presenceInput } : {}), ...(r.lockLane && r.lockLane !== "entry" ? { lockLane: r.lockLane } : {}),
...(r.entryCooldownSec ? { entryCooldownSec: r.entryCooldownSec } : {}), ...(r.blinkOnMs ? { blinkOnMs: r.blinkOnMs } : {}),
...(r.blinkOffMs ? { blinkOffMs: r.blinkOffMs } : {}),
}
: { relay: r.relay, direction: r.direction },
);
// Inputs: a button/presence row needs its relay; alertTrigger may be standalone.
out.inputs = inputs
.filter((i) => typeof i.input === "number" && i.input > 0)
.map((i) => ({
input: i.input,
role: i.role,
...(typeof i.relay === "number" ? { relay: i.relay } : {}),
...(i.role === "presence" && i.kind ? { kind: i.kind } : {}),
...(i.role === "presence" && i.activeLow ? { activeLow: true } : {}),
...(i.role === "button" && i.cooldownSec ? { cooldownSec: i.cooldownSec } : {}),
})); }));
} else if (controllerId && boundRelay !== "") { } else if (controllerId && boundRelay !== "") {
out.controllerId = controllerId; out.controllerId = controllerId;
@@ -450,6 +576,10 @@ function DeviceForm({
} }
// Camera ANPR opt-in (only persisted when on, to keep configs minimal). // Camera ANPR opt-in (only persisted when on, to keep configs minimal).
if (isCamera && anpr) out.anpr = true; if (isCamera && anpr) out.anpr = true;
// Auto-trigger flag — only meaningful when anpr is on. Persist it (true OR false) so a
// park can explicitly DISABLE auto-open on a camera (e.g. the exit cam of a shared lane)
// while keeping recognition. Absent ⇒ defaults ON (back-compat for existing cameras).
if (isCamera && anpr) out.anprAutoTrigger = anprAuto;
return out; return out;
} }
@@ -467,7 +597,7 @@ function DeviceForm({
setTestError(null); setTestError(null);
setTested(null); setTested(null);
try { try {
setTested(await testDevice(selected.id, mergedScalarConfig())); setTested(await testDevice(selected.id, mergedScalarConfig(), editing?.id));
} catch (e) { } catch (e) {
setTestError((e as Error).message); setTestError((e as Error).message);
} finally { } finally {
@@ -492,6 +622,23 @@ function DeviceForm({
} }
} }
// Push a real test slip to the printer — proves it physically prints (healthCheck
// only opens the transport). Passes editing?.id so an edited network printer's
// stored secrets re-merge. Never blocks save.
async function testPrintNow() {
if (!selected) return;
setPrintTesting(true);
setPrintError(null);
setPrintResult(null);
try {
setPrintResult(await testPrint(selected.id, mergedScalarConfig(), editing?.id));
} catch (e) {
setPrintError((e as Error).message);
} finally {
setPrintTesting(false);
}
}
async function save() { async function save() {
if (!selected) return; if (!selected) return;
// Bound devices must point at a controller relay (binding is optional in the // Bound devices must point at a controller relay (binding is optional in the
@@ -543,19 +690,19 @@ function DeviceForm({
{selected && ( {selected && (
<div className="mt-3"> <div className="mt-3">
<p className="mb-2 text-[12px] text-term-muted">{selected.description}</p> <p className="mb-2 text-[0.75rem] text-term-muted">{selected.description}</p>
{canDiscover && ( {canDiscover && (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2"> <div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<button type="button" className="btn btn-sm" onClick={scan} disabled={scanning}> <button type="button" className="btn btn-sm" onClick={scan} disabled={scanning}>
{scanning ? t("setup.scanning") : t("setup.scan")} {scanning ? t("setup.scanning") : t("setup.scan")}
</button> </button>
{scanError && <span className="ml-2 text-[12px] text-term-red">{scanError}</span>} {scanError && <span className="ml-2 text-[0.75rem] text-term-red">{scanError}</span>}
{found && found.length === 0 && <p className="mt-2 text-[12px] text-term-muted">{t("setup.noControllersFound")}</p>} {found && found.length === 0 && <p className="mt-2 text-[0.75rem] text-term-muted">{t("setup.noControllersFound")}</p>}
{found && found.length > 0 && ( {found && found.length > 0 && (
<ul className="mt-2 list-none p-0"> <ul className="mt-2 list-none p-0">
{found.map((d) => ( {found.map((d) => (
<li key={d.id} className="my-1 flex items-center gap-2 text-[12px]"> <li key={d.id} className="my-1 flex items-center gap-2 text-[0.75rem]">
<button type="button" className="btn btn-sm" onClick={() => applyDiscovered(d)}> <button type="button" className="btn btn-sm" onClick={() => applyDiscovered(d)}>
{t("setup.use")} {t("setup.use")}
</button> </button>
@@ -569,11 +716,25 @@ function DeviceForm({
</div> </div>
)} )}
{selected.configFields.map((f) => {selected.configFields
// pulseMs + inputRestingHigh are surfaced in the Outputs / Inputs model
// sections below (a relay setting and an input setting, respectively), so
// skip them here to avoid rendering them twice. See OutputEditor/InputEditor.
.filter((f) => !(isController && (f.key === "pulseMs" || f.key === "inputRestingHigh")))
// Printer transport is exclusive: when Connection = USB the network fields
// (host/port/status-page) don't apply, and vice-versa the USB device path
// doesn't. Hide the irrelevant side so the form can't mislead (e.g. a USB
// path lingering under a Network printer). Driven by config.transport.
.filter((f) => {
const transport = String(config.transport ?? "tcp-ip");
if (transport === "usb") return !["host", "port", "httpPort"].includes(f.key);
return f.key !== "devicePath";
})
.map((f) =>
f.type === "boolean" ? ( f.type === "boolean" ? (
// Boolean config field → a real checkbox (stores a true/false boolean, not // Boolean config field → a real checkbox (stores a true/false boolean, not
// the string "true"). The label sits beside the box, with the help below. // the string "true"). The label sits beside the box, with the help below.
<label key={f.key} className="my-2 flex max-w-sm items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]"> <label key={f.key} className="my-2 flex max-w-sm items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
<input <input
type="checkbox" type="checkbox"
className="mt-0.5" className="mt-0.5"
@@ -611,10 +772,36 @@ function DeviceForm({
</option> </option>
))} ))}
</select> </select>
) : f.type === "secret" ? (
// Secret field with a reveal toggle: the device web password is shown
// here (admin-only view) so an admin can read/copy it to reach the
// device's own web UI. Masked by default; click the eye to reveal.
<div className="flex gap-1">
<input
className="input flex-1"
type={revealed[f.key] ? "text" : "password"}
value={(config[f.key] ?? (f.default as string | number | undefined) ?? "") as string | number}
placeholder={f.help}
onChange={(e) => {
const v = e.target.value;
setConfig((c) => ({ ...c, [f.key]: v }));
resetStatus();
}}
/>
<button
type="button"
className="btn btn-sm"
aria-label={revealed[f.key] ? t("setup.hideSecret") : t("setup.revealSecret")}
title={revealed[f.key] ? t("setup.hideSecret") : t("setup.revealSecret")}
onClick={() => setRevealed((r) => ({ ...r, [f.key]: !r[f.key] }))}
>
{revealed[f.key] ? "🙈" : "👁"}
</button>
</div>
) : ( ) : (
<input <input
className="input" className="input"
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"} type={f.type === "number" || f.type === "port" ? "number" : "text"}
value={(config[f.key] ?? (f.default as string | number | undefined) ?? "") as string | number} value={(config[f.key] ?? (f.default as string | number | undefined) ?? "") as string | number}
placeholder={f.help} placeholder={f.help}
onChange={(e) => { onChange={(e) => {
@@ -628,8 +815,33 @@ function DeviceForm({
), ),
)} )}
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */} {/* CONTROLLER — OUTPUTS: the unified relays (barriers pulse, alert relays blink). */}
{isController && <RelayEditor relays={relays} onChange={setRelays} />} {isController && (
<OutputEditor
relays={relays}
onChange={setRelays}
pulseMs={config.pulseMs as number | undefined}
onPulseMsChange={(v) => {
setConfig((c) => ({ ...c, pulseMs: v }));
resetStatus();
}}
/>
)}
{/* CONTROLLER — INPUTS: a generic terminal list (button / presence / alert trigger),
each naming the relay it serves. Separated from the outputs above. */}
{isController && (
<InputEditor
inputs={inputs}
onChange={setInputs}
relays={relays}
inputsIdleHigh={config.inputRestingHigh as boolean | undefined}
onInputsIdleHighChange={(v) => {
setConfig((c) => ({ ...c, inputRestingHigh: v }));
resetStatus();
}}
/>
)}
{/* BOUND device: which controller + relay it sits at. */} {/* BOUND device: which controller + relay it sits at. */}
{!isController && ( {!isController && (
@@ -647,7 +859,7 @@ function DeviceForm({
{/* CAMERA: opt this camera into ANPR (the VisionReader polls it for plates). */} {/* CAMERA: opt this camera into ANPR (the VisionReader polls it for plates). */}
{isCamera && ( {isCamera && (
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]"> <label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
<input <input
type="checkbox" type="checkbox"
className="mt-0.5" className="mt-0.5"
@@ -661,6 +873,82 @@ function DeviceForm({
</label> </label>
)} )}
{/* Auto-trigger is only meaningful with ANPR on. Off = this camera RECOGNISES plates
(evidence) but does NOT auto-open the barrier — for a shared entry/exit lane where
the exit cam's back-plate read would phantom-exit a car that just entered. */}
{isCamera && anpr && (
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
<input
type="checkbox"
className="mt-0.5"
checked={anprAuto}
onChange={(e) => setAnprAuto(e.target.checked)}
/>
<span>
<span className="font-semibold text-term-text">{t("setup.anprAuto")}</span>
<span className="hint mt-0.5 block">{t("setup.anprAutoHint")}</span>
</span>
</label>
)}
{/* CAMERA + Alarm Server push ON: show the camera's Alarm Server settings,
ready to copy, so the operator never has to find the deviceId or memorise the
endpoint. The CAMERA reaches us over the device VLAN, NOT via the browser's
origin — so host/port are the BACKEND address (backendIp on the camera's
subnet + the server's listen port), resolved by the same probe the push-IP
picker uses, NOT window.location (which is the SPA's dev/proxy origin). The
URL embeds the deviceId, so it needs a SAVED camera; and the backend IP needs
a Test connection first. We surface each field separately, matching the
camera's Alarm Settings form (Destination IP / URL / Protocol / Port). */}
{isCamera && Boolean(config.alarmPushEnabled) && (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
<div className="font-semibold text-term-text">{t("setup.alarmUrlTitle")}</div>
{!editing?.id ? (
<p className="hint mt-1">{t("setup.alarmUrlSaveFirst")}</p>
) : !backendIp || backendPort == null ? (
<p className="hint mt-1">{t("setup.alarmUrlTestFirst")}</p>
) : (
(() => {
const path = `/api/devices/hikvision/${editing.id}/event`;
// What the operator pastes into the camera's Alarm Settings form.
const fields: [string, string][] = [
[t("setup.alarmFieldHost"), backendIp],
[t("setup.alarmFieldUrl"), path],
[t("setup.alarmFieldProtocol"), "HTTP"],
[t("setup.alarmFieldPort"), String(backendPort)],
];
const copyText = fields.map(([k, v]) => `${k}: ${v}`).join("\n");
return (
<>
<div className="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
{fields.map(([k, v]) => (
<Fragment key={k}>
<span className="text-term-muted">{k}</span>
<code className="break-all rounded bg-term-panel px-2 py-0.5 text-term-green">{v}</code>
</Fragment>
))}
</div>
<div className="mt-2 flex items-center gap-2">
<button
type="button"
className="btn btn-sm"
onClick={() => {
void navigator.clipboard?.writeText(copyText);
setAlarmUrlCopied(true);
setTimeout(() => setAlarmUrlCopied(false), 2000);
}}
>
{alarmUrlCopied ? t("setup.alarmUrlCopied") : t("setup.alarmUrlCopy")}
</button>
</div>
<p className="hint mt-1">{t("setup.alarmUrlHint")}</p>
</>
);
})()
)}
</div>
)}
{/* Test (no save/no device change) then Save (configures + persists). */} {/* Test (no save/no device change) then Save (configures + persists). */}
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
<button type="button" className="btn btn-sm" onClick={test} disabled={testing}> <button type="button" className="btn btn-sm" onClick={test} disabled={testing}>
@@ -676,9 +964,9 @@ function DeviceForm({
)} )}
</div> </div>
{testError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: testError })}</p>} {testError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: testError })}</p>}
{tested && ( {tested && (
<div className="mt-2 text-[12px]"> <div className="mt-2 text-[0.75rem]">
<div className="text-term-text"> <div className="text-term-text">
{t("setup.deviceLabel")} <HealthBadge status={tested.health.status} /> {t("setup.deviceLabel")} <HealthBadge status={tested.health.status} />
{tested.health.detail && <span className="text-term-muted"> — {tested.health.detail}</span>} {tested.health.detail && <span className="text-term-muted"> — {tested.health.detail}</span>}
@@ -705,10 +993,10 @@ function DeviceForm({
</button> </button>
<p className="hint mt-1">{t("setup.testAnprHint")}</p> <p className="hint mt-1">{t("setup.testAnprHint")}</p>
{anprError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: anprError })}</p>} {anprError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: anprError })}</p>}
{anprResult && {anprResult &&
(anprResult.ok ? ( (anprResult.ok ? (
<div className="mt-2 text-[12px] text-term-green"> <div className="mt-2 text-[0.75rem] text-term-green">
{t("setup.anprOk", { {t("setup.anprOk", {
plate: anprResult.plate, plate: anprResult.plate,
confidence: Math.round(anprResult.confidence * 100), confidence: Math.round(anprResult.confidence * 100),
@@ -719,7 +1007,7 @@ function DeviceForm({
)} )}
</div> </div>
) : ( ) : (
<div className="mt-2 text-[12px] text-term-amber"> <div className="mt-2 text-[0.75rem] text-term-amber">
⚠ {t(`setup.anprFail.${anprResult.reason}`, { defaultValue: anprResult.reason })} ⚠ {t(`setup.anprFail.${anprResult.reason}`, { defaultValue: anprResult.reason })}
{anprResult.detail && <span className="text-term-muted"> — {anprResult.detail}</span>} {anprResult.detail && <span className="text-term-muted"> — {anprResult.detail}</span>}
{anprResult.tookMs != null && ( {anprResult.tookMs != null && (
@@ -730,6 +1018,30 @@ function DeviceForm({
</div> </div>
)} )}
{/* PRINTER: push a real test slip so the admin can confirm it physically
prints (healthCheck only opens the transport / USB node). */}
{isPrinter && (
<div className="mt-3 rounded-term border border-term-border bg-term-bg p-2">
<button type="button" className="btn btn-sm" onClick={testPrintNow} disabled={printTesting}>
{printTesting ? t("setup.printTesting") : t("setup.testPrint")}
</button>
<p className="hint mt-1">{t("setup.testPrintHint")}</p>
{printError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: printError })}</p>}
{printResult &&
(printResult.ok ? (
<div className="mt-2 text-[0.75rem] text-term-green">
{t("setup.printOk", { ms: printResult.tookMs })}
</div>
) : (
<div className="mt-2 text-[0.75rem] text-term-amber">
⚠ {t(`setup.printFail.${printResult.reason}`, { defaultValue: printResult.reason })}
{printResult.detail && <span className="text-term-muted"> — {printResult.detail}</span>}
</div>
))}
</div>
)}
{backendIps && backendIps.length > 0 && ( {backendIps && backendIps.length > 0 && (
<div className="mt-3"> <div className="mt-3">
<div className="field max-w-md"> <div className="field max-w-md">
@@ -748,21 +1060,37 @@ function DeviceForm({
</select> </select>
</div> </div>
{!backendIps.some((c) => c.onDeviceSubnet) && ( {!backendIps.some((c) => c.onDeviceSubnet) && (
<span className="text-[12px] text-term-amber">{t("setup.noNicOnSubnet")}</span> <span className="text-[0.75rem] text-term-amber">{t("setup.noNicOnSubnet")}</span>
)} )}
<p className="hint mt-1">{t("setup.backendIpHint")}</p> <p className="hint mt-1">{t("setup.backendIpHint")}</p>
</div> </div>
)} )}
{saveError && <p className="mt-2 text-[12px] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>} {saveError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>}
</div> </div>
)} )}
</div> </div>
); );
} }
/** Controller relay map editor: each row = a relay + its direction + (optional) // ── Controller OUTPUTS (relays) ────────────────────────────────────────────
* the input terminal its entry button is wired to. */ // A relay is an OUTPUT reacting to an EVENT: entry/exit/both PULSE a barrier; radarAlert
function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) { // BLINKS an indicator lamp (and a camera-confirmed car locks it solid). This section owns
// the relay number + event, the pulse-open hold time (barriers), and — for alert relays —
// the trigger input + blink cadence. The barrier INPUT terminals (entry button, presence)
// live in InputEditor below; the two are deliberately separated.
/** Relays = the unified event→action outputs + the pulse-open hold time. */
function OutputEditor({
relays,
onChange,
pulseMs,
onPulseMsChange,
}: {
relays: RelaySpec[];
onChange: (r: RelaySpec[]) => void;
pulseMs: number | undefined;
onPulseMsChange: (v: number) => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
function update(i: number, patch: Partial<RelaySpec>) { function update(i: number, patch: Partial<RelaySpec>) {
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
@@ -777,11 +1105,27 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
return ( return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2"> <div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.relaysTitle")}</strong> <strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.outputsTitle")}</strong>
<p className="hint mt-0.5 mb-2">{t("setup.relaysHint")}</p> <p className="hint mt-0.5 mb-2">{t("setup.outputsHint")}</p>
{/* Pulse-open time applies to every barrier relay (how long it's held open). */}
<label className="my-1 inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.pulseOpenHint")}>
{t("setup.pulseOpenMs")}
<input
type="number"
min={100}
value={pulseMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) => onPulseMsChange(Number(e.target.value))}
/>
</label>
{/* Each relay: number + event. radarAlert reveals its trigger input + blink cadence;
barriers pulse (their button/presence terminals are in the Inputs section). */}
{relays.map((r, i) => ( {relays.map((r, i) => (
<div key={i} className="my-1 flex flex-wrap items-center gap-2"> <div key={i} className="my-1 flex flex-wrap items-center gap-2">
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted"> <label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.relay")} {t("setup.relay")}
<input <input
type="number" type="number"
@@ -791,56 +1135,68 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
onChange={(e) => update(i, { relay: Number(e.target.value) })} onChange={(e) => update(i, { relay: Number(e.target.value) })}
/> />
</label> </label>
<select className="select input-sm w-auto" value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}> <select
{(["entry", "exit", "both"] as Direction[]).map((d) => ( className="select input-sm w-auto"
value={r.direction}
onChange={(e) => update(i, { direction: e.target.value as RelayEvent })}
>
{(["entry", "exit", "both", "radarAlert"] as RelayEvent[]).map((d) => (
<option key={d} value={d}> <option key={d} value={d}>
{t(DIRECTION_KEYS[d])} {t(DIRECTION_KEYS[d])}
</option> </option>
))} ))}
</select> </select>
{(r.direction === "entry" || r.direction === "both") && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted"> {/* Alert relay: which input fires the blink + the blink cadence. */}
{t("setup.entryButtonTerminal")} {r.direction === "radarAlert" && (
<>
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.triggerInputHint")}>
{t("setup.triggerInput")}
<input <input
type="number" type="number"
min={1} min={1}
value={r.button ?? ""} value={r.triggerInput ?? ""}
placeholder="—" placeholder="—"
className="input input-sm w-16" className="input input-sm w-16"
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })} onChange={(e) => update(i, { triggerInput: e.target.value === "" ? undefined : Number(e.target.value) })}
/> />
</label> </label>
)} <label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.lockLaneHint")}>
{(r.direction === "entry" || r.direction === "both") && ( {t("setup.lockLane")}
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceInputHint")}> <select
{t("setup.presenceInput")} className="select input-sm w-auto"
value={r.lockLane ?? "entry"}
onChange={(e) => update(i, { lockLane: e.target.value as "entry" | "exit" })}
>
<option value="entry">{t("setup.lockLaneEntry")}</option>
<option value="exit">{t("setup.lockLaneExit")}</option>
</select>
</label>
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.blinkOnMs")}
<input <input
type="number" type="number"
min={1} min={50}
value={r.presenceInput ?? ""} value={r.blinkOnMs ?? ""}
placeholder="—" placeholder="500"
className="input input-sm w-16" className="input input-sm w-20"
onChange={(e) => onChange={(e) => update(i, { blinkOnMs: e.target.value === "" ? undefined : Number(e.target.value) })}
update(i, { presenceInput: e.target.value === "" ? undefined : Number(e.target.value) })
}
/> />
</label> </label>
)} <label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{(r.direction === "entry" || r.direction === "both") && !r.presenceInput && ( {t("setup.blinkOffMs")}
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
{t("setup.entryCooldown")}
<input <input
type="number" type="number"
min={0} min={50}
value={r.entryCooldownSec ?? ""} value={r.blinkOffMs ?? ""}
placeholder="—" placeholder="500"
className="input input-sm w-16" className="input input-sm w-20"
onChange={(e) => onChange={(e) => update(i, { blinkOffMs: e.target.value === "" ? undefined : Number(e.target.value) })}
update(i, { entryCooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })
}
/> />
</label> </label>
</>
)} )}
{relays.length > 1 && ( {relays.length > 1 && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}> <button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
✕ ✕
@@ -855,6 +1211,152 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
); );
} }
// ── Controller INPUTS (terminals) ──────────────────────────────────────────
// An input is a TERMINAL the host READS. It's a first-class list (the twin of the relays
// list above): each row is a terminal + a ROLE (entry button / presence loop / presence
// radar / alert trigger) + the relay it serves. Adding an exit radar = adding a row. The
// button never SETS a pulse — its electrical pulse is the device's to report — so no timing
// field lives here (pulse-open is an OUTPUT setting, in OutputEditor).
/** Generic controller-input list: terminal + role + the relay it serves. */
function InputEditor({
inputs,
onChange,
relays,
inputsIdleHigh,
onInputsIdleHighChange,
}: {
inputs: InputSpec[];
onChange: (v: InputSpec[]) => void;
relays: RelaySpec[];
inputsIdleHigh: boolean | undefined;
onInputsIdleHighChange: (v: boolean) => void;
}) {
const { t } = useTranslation();
function update(i: number, patch: Partial<InputSpec>) {
onChange(inputs.map((row, idx) => (idx === i ? { ...row, ...patch } : row)));
}
function add() {
const firstEntry = relays.find((r) => r.direction === "entry" || r.direction === "both");
onChange([...inputs, { input: 1, role: "button", relay: firstEntry?.relay }]);
}
function remove(i: number) {
onChange(inputs.filter((_, idx) => idx !== i));
}
// Barrier relays an input can serve (button/presence gate a barrier; alert triggers don't).
const barrierRelays = relays.filter((r) => r.direction !== "radarAlert");
// A button row shows its cooldown fallback only if no presence row serves the same relay.
const hasPresenceFor = (relay?: number) =>
relay != null && inputs.some((x) => x.role === "presence" && x.relay === relay);
return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.inputsTitle")}</strong>
<p className="hint mt-0.5 mb-2">{t("setup.inputsHint")}</p>
{/* Board-wide resting level (idle HIGH vs LOW) — an input property. */}
<label className="my-1 inline-flex items-start gap-2 text-[0.75rem] text-term-muted">
<input
type="checkbox"
className="mt-0.5"
checked={inputsIdleHigh ?? true}
onChange={(e) => onInputsIdleHighChange(e.target.checked)}
/>
<span>
<span className="font-semibold text-term-text">{t("setup.inputsIdleHigh")}</span>
<span className="hint mt-0.5 block">{t("setup.inputsIdleHighHint")}</span>
</span>
</label>
{inputs.map((row, i) => {
const choice = choiceOf(row);
const isPresence = row.role === "presence";
const isButton = row.role === "button";
return (
<div key={i} className="my-1 flex flex-wrap items-center gap-2 border-t border-term-border pt-2">
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.inputTerminal")}
<input
type="number"
min={1}
value={row.input}
className="input input-sm w-16"
onChange={(e) => update(i, { input: Number(e.target.value) })}
/>
</label>
<select
className="select input-sm w-auto"
value={choice}
onChange={(e) => update(i, applyChoice(e.target.value as InputChoice))}
>
{(["button", "presenceLoop", "presenceRadar", "alertTrigger"] as InputChoice[]).map((c) => (
<option key={c} value={c}>
{t(INPUT_CHOICE_KEYS[c])}
</option>
))}
</select>
{/* Which barrier this input serves — button/presence only (alert triggers a lamp). */}
{row.role !== "alertTrigger" && (
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.inputServesRelay")}
<select
className="select input-sm w-auto"
value={row.relay ?? ""}
onChange={(e) => update(i, { relay: e.target.value === "" ? undefined : Number(e.target.value) })}
>
<option value="" disabled>
{t("setup.choose")}
</option>
{barrierRelays.map((r) => (
<option key={r.relay} value={r.relay}>
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
</option>
))}
</select>
</label>
)}
{/* Presence: active-low (a radar wired opposite the button). */}
{isPresence && (
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.activeLowHint")}>
<input
type="checkbox"
checked={!!row.activeLow}
onChange={(e) => update(i, { activeLow: e.target.checked || undefined })}
/>
{t("setup.activeLow")}
</label>
)}
{/* Button cooldown fallback — only when no presence sensor serves this relay. */}
{isButton && !hasPresenceFor(row.relay) && (
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.entryCooldownHint")}>
{t("setup.entryCooldown")}
<input
type="number"
min={0}
value={row.cooldownSec ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) => update(i, { cooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
)}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
✕
</button>
</div>
);
})}
<button type="button" className="btn btn-sm mt-1" onClick={add}>
{t("setup.addInput")}
</button>
</div>
);
}
/** Binding picker for readers/cameras/printers: choose the controller + relay this /** Binding picker for readers/cameras/printers: choose the controller + relay this
* device sits at. Direction is inherited from the chosen relay (shown). */ * device sits at. Direction is inherited from the chosen relay (shown). */
function BindingPicker({ function BindingPicker({
@@ -879,9 +1381,9 @@ function BindingPicker({
return ( return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2"> <div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.whichBarrier")}</strong> <strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.whichBarrier")}</strong>
<div className="mt-1.5 flex flex-wrap items-center gap-2"> <div className="mt-1.5 flex flex-wrap items-center gap-2">
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted"> <label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.controller")} {t("setup.controller")}
<select className="select input-sm w-auto" value={controllerId} onChange={(e) => onControllerChange(e.target.value)}> <select className="select input-sm w-auto" value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
<option value="" disabled> <option value="" disabled>
@@ -898,7 +1400,7 @@ function BindingPicker({
})} })}
</select> </select>
</label> </label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted"> <label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.relay")} {t("setup.relay")}
<select <select
className="select input-sm w-auto" className="select input-sm w-auto"
@@ -909,7 +1411,10 @@ function BindingPicker({
<option value="" disabled> <option value="" disabled>
{t("setup.choose")} {t("setup.choose")}
</option> </option>
{relays.map((r) => ( {/* Only barrier relays are bindable — an alert lamp opens nothing. */}
{relays
.filter((r) => r.direction !== "radarAlert")
.map((r) => (
<option key={r.relay} value={r.relay}> <option key={r.relay} value={r.relay}>
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })} {t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
</option> </option>
@@ -919,22 +1424,24 @@ function BindingPicker({
{chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />} {chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
</div> </div>
{controller && relays.length === 0 && ( {controller && relays.length === 0 && (
<p className="mt-1.5 text-[12px] text-term-amber">{t("setup.noRelaysConfigured")}</p> <p className="mt-1.5 text-[0.75rem] text-term-amber">{t("setup.noRelaysConfigured")}</p>
)} )}
</div> </div>
); );
} }
function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) { function DirectionBadge({ direction, label }: { direction: RelayEvent; label?: string }) {
// entry=green, exit=amber, both=muted — aligned to the terminal accent palette. // entry=green, exit=amber, radarAlert=red (an alert), both=muted — terminal accents.
const cls = const cls =
direction === "entry" direction === "entry"
? "border-term-green text-term-green" ? "border-term-green text-term-green"
: direction === "exit" : direction === "exit"
? "border-term-amber text-term-amber" ? "border-term-amber text-term-amber"
: direction === "radarAlert"
? "border-term-red text-term-red"
: "border-term-muted text-term-muted"; : "border-term-muted text-term-muted";
return ( return (
<span className={`rounded-term border px-1.5 text-[10px] font-semibold uppercase tracking-wider ${cls}`}> <span className={`rounded-term border px-1.5 text-[0.625rem] font-semibold uppercase tracking-wider ${cls}`}>
{label ?? direction} {label ?? direction}
</span> </span>
); );
+33 -26
View File
@@ -175,7 +175,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
</div> </div>
{q.isError && ( {q.isError && (
<div className="mb-2 shrink-0 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{t("shifts.loadFailed")}</div> <div className="mb-2 shrink-0 rounded-term border border-term-red px-3 py-2 text-[0.75rem] text-term-red">{t("shifts.loadFailed")}</div>
)} )}
{/* Two-pane: shift list (left) + selected shift's activity log (right). Both {/* Two-pane: shift list (left) + selected shift's activity log (right). Both
@@ -183,7 +183,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
<div className="grid min-h-0 flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]"> <div className="grid min-h-0 flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
<div className="flex min-h-0 flex-col gap-1.5 overflow-y-auto pr-1"> <div className="flex min-h-0 flex-col gap-1.5 overflow-y-auto pr-1">
{!q.isLoading && list.length === 0 && ( {!q.isLoading && list.length === 0 && (
<p className="rounded-term border border-term-border px-3 py-3 text-[12px] text-term-muted">{t("shifts.none")}</p> <p className="rounded-term border border-term-border px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.none")}</p>
)} )}
{list.map((s) => ( {list.map((s) => (
<ShiftCard key={s.id} s={s} showOperator={isAdmin} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} /> <ShiftCard key={s.id} s={s} showOperator={isAdmin} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
@@ -202,7 +202,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
onChanged={refreshAll} onChanged={refreshAll}
/> />
) : ( ) : (
<p className="px-3 py-6 text-center text-[12px] text-term-muted">{t("shifts.selectAShift")}</p> <p className="px-3 py-6 text-center text-[0.75rem] text-term-muted">{t("shifts.selectAShift")}</p>
)} )}
</div> </div>
</div> </div>
@@ -228,7 +228,7 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
} }
return ( return (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{err && <span className="text-[12px] text-term-red">{err}</span>} {err && <span className="text-[0.75rem] text-term-red">{err}</span>}
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}> <button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
{busy ? t("shift.starting") : t("shift.startShift")} {busy ? t("shift.starting") : t("shift.startShift")}
</button> </button>
@@ -244,11 +244,11 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className={`card w-full p-2.5 text-left text-[12px] transition-colors ${selected ? "border-term-amber bg-term-panel-2" : "hover:bg-term-panel-2"}`} className={`card w-full p-2.5 text-left text-[0.75rem] transition-colors ${selected ? "border-term-amber bg-term-panel-2" : "hover:bg-term-panel-2"}`}
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2 font-semibold text-term-text"> <span className="flex items-center gap-2 font-semibold text-term-text">
{open && <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("shifts.current")}</span>} {open && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
{showOperator ? s.operator : when(s.startedAt)} {showOperator ? s.operator : when(s.startedAt)}
</span> </span>
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span> <span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
@@ -300,9 +300,9 @@ function ShiftActivityLog({
return ( return (
<div className="flex h-full min-h-0 flex-col"> <div className="flex h-full min-h-0 flex-col">
<div className="shrink-0 border-b border-term-border bg-term-panel-2 px-3 py-2"> <div className="shrink-0 border-b border-term-border bg-term-panel-2 px-3 py-2">
<div className="flex flex-wrap items-center justify-between gap-2 text-[12px]"> <div className="flex flex-wrap items-center justify-between gap-2 text-[0.75rem]">
<span className="flex items-center gap-2 font-semibold text-term-text"> <span className="flex items-center gap-2 font-semibold text-term-text">
{isCurrent && <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("shifts.current")}</span>} {isCurrent && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
{showOperator && `${shift.operator} · `} {showOperator && `${shift.operator} · `}
{formatRelativeDateTime(shift.startedAt, t)} {formatRelativeDateTime(shift.startedAt, t)}
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`} {!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
@@ -316,10 +316,10 @@ function ShiftActivityLog({
</span> </span>
)} )}
</div> </div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[11px] tabular-nums sm:grid-cols-4"> <div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[0.6875rem] tabular-nums sm:grid-cols-4">
<Figure label={t("shifts.srcTickets")} value={money(shift.ticketTotalMinor, cur)} /> <Figure label={t("shifts.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} /> <Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
<Figure label={t("shifts.srcSubSales")} value={money(shift.subscriptionSalesMinor, cur)} sub /> {/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub /> <Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} /> <Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} /> <Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
@@ -331,8 +331,8 @@ function ShiftActivityLog({
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto px-1"> <div className="min-h-0 flex-1 overflow-y-auto px-1">
{q.isLoading && <p className="px-3 py-3 text-[12px] text-term-muted">{t("common.loading")}</p>} {q.isLoading && <p className="px-3 py-3 text-[0.75rem] text-term-muted">{t("common.loading")}</p>}
{!q.isLoading && events.length === 0 && <p className="px-3 py-3 text-[12px] text-term-muted">{t("shifts.noActivity")}</p>} {!q.isLoading && events.length === 0 && <p className="px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.noActivity")}</p>}
{events.map((e) => ( {events.map((e) => (
<EventRow key={e.id} e={e} onOpen={setDetailEvent} /> <EventRow key={e.id} e={e} onOpen={setDetailEvent} />
))} ))}
@@ -372,14 +372,15 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
<Modal open onClose={onClose} title={t("shift.endShift")} width="max-w-md"> <Modal open onClose={onClose} title={t("shift.endShift")} width="max-w-md">
{report ? ( {report ? (
// Result — the signed Z-report. // Result — the signed Z-report.
<div className="text-[13px] tabular-nums"> <div className="text-[0.8125rem] tabular-nums">
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div> <div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5"> <div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
<Figure label={t("shift.payments")} value={String(report.paymentCount)} /> <Figure label={t("shift.payments")} value={String(report.paymentCount)} />
<span /> <span />
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} /> <Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} />
<Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} /> <Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} />
<Figure label={t("shift.srcSubSales")} value={money(report.subscriptionSalesMinor, report.currency)} sub /> {/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<span />
<Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub /> <Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub />
</div> </div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1"> <div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
@@ -399,20 +400,24 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
</div> </div>
) : ( ) : (
// Confirm — show the live takings (split by source) + drawer before closing. // Confirm — show the live takings (split by source) + drawer before closing.
<div className="text-[13px] tabular-nums"> <div className="text-[0.8125rem] tabular-nums">
<p className="text-term-muted">{t("shift.endConfirm")}</p> <p className="text-term-muted">{t("shift.endConfirm")}</p>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5"> <div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
<Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} /> <Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} /> <Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
<Figure label={t("shift.srcSubSales")} value={money(shift.subscriptionSalesMinor, cur)} sub /> {/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<span />
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub /> <Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
</div> </div>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2"> <div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
<Figure label={t("shift.cash")} value={money(shift.cashTotalMinor, cur)} /> <Figure label={t("shift.cash")} value={money(shift.cashTotalMinor, cur)} />
<Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} /> <Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />
{/* Drawer math made explicit: opening cash + cash taken = expected drawer. */}
<Figure label={t("shift.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
<span />
<Figure label={t("shift.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold /> <Figure label={t("shift.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
</div> </div>
{err && <p className="mt-2 text-[12px] text-term-red">{err}</p>} {err && <p className="mt-2 text-[0.75rem] text-term-red">{err}</p>}
<div className="mt-3 flex justify-end gap-2"> <div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={onClose}>{t("subs.cancel")}</button> <button type="button" className="btn btn-sm" onClick={onClose}>{t("subs.cancel")}</button>
<button type="button" className="btn btn-sm btn-danger" onClick={confirm} disabled={busy}> <button type="button" className="btn btn-sm btn-danger" onClick={confirm} disabled={busy}>
@@ -452,7 +457,7 @@ function VoucherModal({ currency, onClose, onDone }: { currency: string | null;
return ( return (
<Modal open onClose={onClose} title={t("shift.drawerVoucher")} width="max-w-md"> <Modal open onClose={onClose} title={t("shift.drawerVoucher")} width="max-w-md">
<div className="flex flex-col gap-2 text-[13px]"> <div className="flex flex-col gap-2 text-[0.8125rem]">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<input className="input w-28" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder={t("shift.amount")} inputMode="decimal" /> <input className="input w-28" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder={t("shift.amount")} inputMode="decimal" />
<input className="input min-w-36 flex-1" value={reason} onChange={(e) => setReason(e.target.value)} placeholder={t("shift.reasonPlaceholder")} /> <input className="input min-w-36 flex-1" value={reason} onChange={(e) => setReason(e.target.value)} placeholder={t("shift.reasonPlaceholder")} />
@@ -461,8 +466,8 @@ function VoucherModal({ currency, onClose, onDone }: { currency: string | null;
<input className="input w-36" value={authName} onChange={(e) => setAuthName(e.target.value)} placeholder={t("shift.authName")} autoComplete="off" /> <input className="input w-36" value={authName} onChange={(e) => setAuthName(e.target.value)} placeholder={t("shift.authName")} autoComplete="off" />
<input className="input w-36" type="password" value={authPassword} onChange={(e) => setAuthPassword(e.target.value)} placeholder={t("shift.authPassword")} autoComplete="off" /> <input className="input w-36" type="password" value={authPassword} onChange={(e) => setAuthPassword(e.target.value)} placeholder={t("shift.authPassword")} autoComplete="off" />
</div> </div>
<div className="text-[11px] text-term-muted">{t("shift.voucherHint")}</div> <div className="text-[0.6875rem] text-term-muted">{t("shift.voucherHint")}</div>
{msg && <div className="text-[12px] text-term-muted">{msg}</div>} {msg && <div className="text-[0.75rem] text-term-muted">{msg}</div>}
<div className="mt-1 flex justify-end gap-2"> <div className="mt-1 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button> <button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
<button type="button" className="btn btn-go btn-sm" onClick={() => submit("cash_in")}>{t("shift.mandatArketimi")}</button> <button type="button" className="btn btn-go btn-sm" onClick={() => submit("cash_in")}>{t("shift.mandatArketimi")}</button>
@@ -480,16 +485,17 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
return ( return (
<Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md"> <Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md">
{!x ? ( {!x ? (
<p className="text-[12px] text-term-muted">{t("common.loading")}</p> <p className="text-[0.75rem] text-term-muted">{t("common.loading")}</p>
) : ( ) : (
<div className="text-[13px] tabular-nums"> <div className="text-[0.8125rem] tabular-nums">
<div className="text-term-muted">{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}</div> <div className="text-term-muted">{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}</div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5"> <div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
<Figure label={t("shift.payments")} value={String(x.paymentCount)} /> <Figure label={t("shift.payments")} value={String(x.paymentCount)} />
<span /> <span />
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} /> <Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} />
<Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} /> <Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} />
<Figure label={t("shift.srcSubSales")} value={money(x.subscriptionSalesMinor, x.currency)} sub /> {/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<span />
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub /> <Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
</div> </div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1"> <div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
@@ -500,7 +506,7 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
<Figure label={t("shift.cashRemoved")} value={money(x.cashRemovedMinor, x.currency)} /> <Figure label={t("shift.cashRemoved")} value={money(x.cashRemovedMinor, x.currency)} />
<Figure label={t("shift.expectedDrawer")} value={money(x.expectedDrawerMinor, x.currency)} bold /> <Figure label={t("shift.expectedDrawer")} value={money(x.expectedDrawerMinor, x.currency)} bold />
</div> </div>
<div className="mt-2 text-[11px] text-term-muted">{t("shift.xReportHint")}</div> <div className="mt-2 text-[0.6875rem] text-term-muted">{t("shift.xReportHint")}</div>
<div className="mt-3 flex justify-end"> <div className="mt-3 flex justify-end">
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button> <button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
</div> </div>
@@ -513,8 +519,9 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) { function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
return ( return (
<div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}> <div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
<span className={sub ? "text-term-muted/70" : "text-term-muted"}>{label}</span> <span className={`whitespace-nowrap ${sub ? "text-term-muted/70" : "text-term-muted"}`}>{label}</span>
<span className={bold ? "font-semibold text-term-text" : "text-term-text"}>{value}</span> {/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
</div> </div>
); );
} }
+6 -6
View File
@@ -69,7 +69,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
return ( return (
<section className="card mt-6 max-w-md p-4"> <section className="card mt-6 max-w-md p-4">
<div className="flex flex-wrap items-center gap-1.5 text-[13px]"> <div className="flex flex-wrap items-center gap-1.5 text-[0.8125rem]">
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong> <strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
{occ == null ? ( {occ == null ? (
<span className="text-term-muted">…</span> <span className="text-term-muted">…</span>
@@ -93,7 +93,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
<span className="label">{t("site.capacityLabel")}</span> <span className="label">{t("site.capacityLabel")}</span>
<input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} /> <input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} />
</div> </div>
<label className="flex items-center gap-2 text-[12px] text-term-text"> <label className="flex items-center gap-2 text-[0.75rem] text-term-text">
<input <input
type="checkbox" type="checkbox"
className="accent-term-amber" className="accent-term-amber"
@@ -103,7 +103,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
{t("site.printExitDefault")} {t("site.printExitDefault")}
<span className="hint">{t("site.printExitHint")}</span> <span className="hint">{t("site.printExitHint")}</span>
</label> </label>
<label className="flex items-start gap-2 text-[12px] text-term-text"> <label className="flex items-start gap-2 text-[0.75rem] text-term-text">
<input <input
type="checkbox" type="checkbox"
className="mt-0.5 accent-term-amber" className="mt-0.5 accent-term-amber"
@@ -115,7 +115,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
<span className="hint block">{t("site.reserveSubsHint")}</span> <span className="hint block">{t("site.reserveSubsHint")}</span>
</span> </span>
</label> </label>
<label className="flex items-start gap-2 text-[12px] text-term-text"> <label className="flex items-start gap-2 text-[0.75rem] text-term-text">
<input <input
type="checkbox" type="checkbox"
className="mt-0.5 accent-term-amber" className="mt-0.5 accent-term-amber"
@@ -127,7 +127,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
<span className="hint block">{t("site.anprEntryHint")}</span> <span className="hint block">{t("site.anprEntryHint")}</span>
</span> </span>
</label> </label>
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted"> <div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
{t("site.parkDetails")} {t("site.parkDetails")}
</div> </div>
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => ( {META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
@@ -153,7 +153,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
))} ))}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("site.save")}</button> <button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("site.save")}</button>
{msg && <span className="text-[12px] text-term-muted">{msg}</span>} {msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
</div> </div>
</div> </div>
)} )}
+24 -16
View File
@@ -412,7 +412,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
<h2 className="mb-3 text-h4 font-semibold text-term-text">{t("subs.title")}</h2> <h2 className="mb-3 text-h4 font-semibold text-term-text">{t("subs.title")}</h2>
<ul className="mb-3 list-none p-0"> <ul className="mb-3 list-none p-0">
{subs.map((s) => ( {subs.map((s) => (
<li key={s.id} className="flex flex-wrap items-center gap-2 border-b border-term-border/60 py-2 text-[12px]"> <li key={s.id} className="flex flex-wrap items-center gap-2 border-b border-term-border/60 py-2 text-[0.75rem]">
<strong className="text-term-text">{s.holderName ?? t("subs.unnamed")}</strong> <strong className="text-term-text">{s.holderName ?? t("subs.unnamed")}</strong>
<span className={s.status === "active" ? "text-term-green" : "text-term-amber"}>{t(STATUS_KEY[s.status])}</span> <span className={s.status === "active" ? "text-term-green" : "text-term-amber"}>{t(STATUS_KEY[s.status])}</span>
<span className="tabular-nums text-term-cyan">{priceLabel(s, t)}</span> <span className="tabular-nums text-term-cyan">{priceLabel(s, t)}</span>
@@ -464,13 +464,13 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
</option> </option>
))} ))}
</select> </select>
{plans.length === 0 && <span className="text-[12px] text-term-amber">{t("subs.planNoneAvail")}</span>} {plans.length === 0 && <span className="text-[0.75rem] text-term-amber">{t("subs.planNoneAvail")}</span>}
</span> </span>
</> </>
) : ( ) : (
<> <>
<label className="label">{t("subs.plan")}</label> <label className="label">{t("subs.plan")}</label>
<span className="text-[13px] text-term-text">{form.planId || t("subs.noPrice")}</span> <span className="text-[0.8125rem] text-term-text">{form.planId || t("subs.noPrice")}</span>
{/* VERSION CORRECTION (admins). The plan itself is frozen, but an admin may {/* VERSION CORRECTION (admins). The plan itself is frozen, but an admin may
move the sub to a different VERSION of that same plan (e.g. one with move the sub to a different VERSION of that same plan (e.g. one with
different timeframes). Price stays as billed. Only shown when the sub has different timeframes). Price stays as billed. Only shown when the sub has
@@ -486,7 +486,15 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
if (cur) versions.unshift(cur); if (cur) versions.unshift(cur);
} }
if (versions.length < 2 && versions.some((v) => v.id === form.planVersionId)) { if (versions.length < 2 && versions.some((v) => v.id === form.planVersionId)) {
return <span className="text-[12px] text-term-muted">{t("subs.versionOnlyOne")}</span>; // Keep the 2-col grid flow intact: a lone cell here would shift every
// following row by one column (label↔input swap). Emit a full row —
// the version label + the "only one version" hint as its control.
return (
<>
<label className="label">{t("subs.version")}</label>
<span className="text-[0.75rem] text-term-muted">{t("subs.versionOnlyOne")}</span>
</>
);
} }
return ( return (
<> <>
@@ -504,7 +512,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
</option> </option>
))} ))}
</select> </select>
<span className="text-[12px] text-term-muted">{t("subs.versionHint")}</span> <span className="text-[0.75rem] text-term-muted">{t("subs.versionHint")}</span>
</span> </span>
</> </>
); );
@@ -523,7 +531,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
inputMode="numeric" inputMode="numeric"
onChange={(e) => setForm((f) => ({ ...f, quantity: e.target.value, maxConcurrent: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, quantity: e.target.value, maxConcurrent: e.target.value }))}
/> />
<span className="text-[12px] text-term-muted">{t("subs.quantityHint")}</span> <span className="text-[0.75rem] text-term-muted">{t("subs.quantityHint")}</span>
</span> </span>
</> </>
)} )}
@@ -533,7 +541,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
<> <>
<label className="label">{t("subs.tender")}</label> <label className="label">{t("subs.tender")}</label>
<span className="flex items-center gap-3"> <span className="flex items-center gap-3">
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text"> <label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-text">
<input <input
type="radio" type="radio"
name="tender" name="tender"
@@ -543,7 +551,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
/> />
{t("subs.tenderCash")} {t("subs.tenderCash")}
</label> </label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text"> <label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-text">
<input <input
type="radio" type="radio"
name="tender" name="tender"
@@ -553,13 +561,13 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
/> />
{t("subs.tenderCard")} {t("subs.tenderCard")}
</label> </label>
<span className="text-[12px] text-term-muted">{t("subs.tenderHint")}</span> <span className="text-[0.75rem] text-term-muted">{t("subs.tenderHint")}</span>
</span> </span>
</> </>
)} )}
<label className="label">{t("subs.carLimit")}</label> <label className="label">{t("subs.carLimit")}</label>
<span className="flex items-center gap-3"> <span className="flex items-center gap-3">
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text"> <label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")} <input type="checkbox" className="accent-term-amber" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
</label> </label>
{form.carBound && ( {form.carBound && (
@@ -580,7 +588,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
inputMode="numeric" inputMode="numeric"
onChange={(e) => setForm((f) => ({ ...f, count: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, count: e.target.value }))}
/> />
<span className="text-[12px] text-term-muted"> <span className="text-[0.75rem] text-term-muted">
× {t(PERIOD_KEY[selectedPlan.period])} × {t(PERIOD_KEY[selectedPlan.period])}
</span> </span>
</span> </span>
@@ -592,7 +600,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
{/* Live SERVER quote: ceil(periods) × per-period price. The operator can't {/* Live SERVER quote: ceil(periods) × per-period price. The operator can't
override it — this is exactly what will be charged + signed. */} override it — this is exactly what will be charged + signed. */}
{editing === "new" && form.planId.trim() !== "" && ( {editing === "new" && form.planId.trim() !== "" && (
<span className="text-[12px] text-term-cyan"> <span className="text-[0.75rem] text-term-cyan">
{quoting {quoting
? t("subs.quoting") ? t("subs.quoting")
: quote : quote
@@ -610,7 +618,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
<input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} /> <input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
</div> </div>
<h4 className="mt-4 mb-1 text-[12px] font-semibold uppercase tracking-wider text-term-muted">{t("subs.credentials")}</h4> <h4 className="mt-4 mb-1 text-[0.75rem] font-semibold uppercase tracking-wider text-term-muted">{t("subs.credentials")}</h4>
{form.credentials.map((c, i) => ( {form.credentials.map((c, i) => (
<div key={i} className="mb-1.5 flex items-center gap-2"> <div key={i} className="mb-1.5 flex items-center gap-2">
{/* Operator chooses the credential type: QR (auto-generated) or RFID {/* Operator chooses the credential type: QR (auto-generated) or RFID
@@ -625,7 +633,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
c.value.trim() ? ( c.value.trim() ? (
<input className="input input-sm flex-1 opacity-70" value={c.value} readOnly /> <input className="input input-sm flex-1 opacity-70" value={c.value} readOnly />
) : ( ) : (
<span className="flex-1 self-center text-[12px] italic text-term-muted">{t("subs.qrAutoGen")}</span> <span className="flex-1 self-center text-[0.75rem] italic text-term-muted">{t("subs.qrAutoGen")}</span>
) )
) : ( ) : (
// RFID: the value is read off a physical card (or typed). "Read card" // RFID: the value is read off a physical card (or typed). "Read card"
@@ -643,7 +651,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
{/* Capture panel: pick a reader, present the card; the captured value fills {/* Capture panel: pick a reader, present the card; the captured value fills
the credential. The OTHER reader keeps serving the live flow. */} the credential. The OTHER reader keeps serving the live flow. */}
{capture && ( {capture && (
<div className="mt-3 rounded-term border border-term-cyan/50 bg-term-cyan/5 p-3 text-[12px]"> <div className="mt-3 rounded-term border border-term-cyan/50 bg-term-cyan/5 p-3 text-[0.75rem]">
{capture.phase === "pick" ? ( {capture.phase === "pick" ? (
<> <>
<div className="mb-1.5 text-term-text">{t("subs.captureChooseReader")}</div> <div className="mb-1.5 text-term-text">{t("subs.captureChooseReader")}</div>
@@ -673,7 +681,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
<button type="button" className="btn btn-sm" onClick={() => setEditing(null)}>{t("subs.cancel")}</button> <button type="button" className="btn btn-sm" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
</div> </div>
</Modal> </Modal>
{msg && <p className={msg.kind === "ok" ? "mt-3 text-[12px] text-term-green" : "mt-3 text-[12px] text-term-red"}>{msg.text}</p>} {msg && <p className={msg.kind === "ok" ? "mt-3 text-[0.75rem] text-term-green" : "mt-3 text-[0.75rem] text-term-red"}>{msg.text}</p>}
</section> </section>
); );
} }
+18 -18
View File
@@ -237,19 +237,19 @@ export function SubscriptionPlansManager() {
return ( return (
<section className="px-4 py-6"> <section className="px-4 py-6">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h3 className="text-[13px] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3> <h3 className="text-[0.8125rem] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
<button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}> <button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
{t("plans.add")} {t("plans.add")}
</button> </button>
</div> </div>
<p className="mb-3 text-[12px] text-term-muted">{t("plans.intro")}</p> <p className="mb-3 text-[0.75rem] text-term-muted">{t("plans.intro")}</p>
{msg && ( {msg && (
<div className={`mb-3 text-[12px] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div> <div className={`mb-3 text-[0.75rem] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
)} )}
{groups.length === 0 ? ( {groups.length === 0 ? (
<p className="text-[13px] text-term-muted">{t("plans.noneYet")}</p> <p className="text-[0.8125rem] text-term-muted">{t("plans.noneYet")}</p>
) : ( ) : (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{groups.map(({ planId, head: p, active, versions }) => { {groups.map(({ planId, head: p, active, versions }) => {
@@ -263,15 +263,15 @@ export function SubscriptionPlansManager() {
<div className="flex flex-wrap items-center gap-x-2 gap-y-1"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-semibold text-term-text">{p.name}</span> <span className="font-semibold text-term-text">{p.name}</span>
{active ? ( {active ? (
<span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("plans.inForce")}</span> <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("plans.inForce")}</span>
) : ( ) : (
<span className="rounded border border-term-border px-1 text-[10px] text-term-muted">{t("plans.retired")}</span> <span className="rounded border border-term-border px-1 text-[0.625rem] text-term-muted">{t("plans.retired")}</span>
)} )}
{versions > 1 && <span className="text-[10px] text-term-muted">{t("plans.versionCount", { count: versions })}</span>} {versions > 1 && <span className="text-[0.625rem] text-term-muted">{t("plans.versionCount", { count: versions })}</span>}
</div> </div>
{/* Details: price · hours · effective */} {/* Details: price · hours · effective */}
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[12px] text-term-muted"> <div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[0.75rem] text-term-muted">
<span className="tabular-nums text-term-text"> <span className="tabular-nums text-term-text">
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])} {(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
</span> </span>
@@ -280,7 +280,7 @@ export function SubscriptionPlansManager() {
</div> </div>
{/* Used by */} {/* Used by */}
<div className="mt-1 text-[12px]"> <div className="mt-1 text-[0.75rem]">
{users.length > 0 ? ( {users.length > 0 ? (
<button <button
type="button" type="button"
@@ -295,12 +295,12 @@ export function SubscriptionPlansManager() {
)} )}
</div> </div>
{isOpen && users.length > 0 && ( {isOpen && users.length > 0 && (
<ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1 rounded-term bg-term-bg px-3 py-2 text-[12px]"> <ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1 rounded-term bg-term-bg px-3 py-2 text-[0.75rem]">
{users.map((s) => ( {users.map((s) => (
<li key={s.id} className={s.status === "active" ? "text-term-text" : "text-term-muted"}> <li key={s.id} className={s.status === "active" ? "text-term-text" : "text-term-muted"}>
{s.holderName || t("subs.unnamed")} {s.holderName || t("subs.unnamed")}
{s.quantity > 1 && <span className="text-term-muted"> ×{s.quantity}</span>} {s.quantity > 1 && <span className="text-term-muted"> ×{s.quantity}</span>}
{s.status !== "active" && <span className="ml-1 text-[10px]">({t(STATUS_KEY[s.status])})</span>} {s.status !== "active" && <span className="ml-1 text-[0.625rem]">({t(STATUS_KEY[s.status])})</span>}
</li> </li>
))} ))}
</ul> </ul>
@@ -349,14 +349,14 @@ export function SubscriptionPlansManager() {
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" /> <input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })} /> <input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })} />
<span className="text-[12px] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span> <span className="text-[0.75rem] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
</span> </span>
</div> </div>
{/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the {/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the
window they're charged the transient tariff for the gap. Off = 24/7. */} window they're charged the transient tariff for the gap. Off = 24/7. */}
<div className="mt-3 border-t border-term-border pt-3"> <div className="mt-3 border-t border-term-border pt-3">
<label className="flex items-center gap-2 text-[12px] text-term-text"> <label className="flex items-center gap-2 text-[0.75rem] text-term-text">
<input <input
type="checkbox" type="checkbox"
className="accent-term-amber" className="accent-term-amber"
@@ -370,7 +370,7 @@ export function SubscriptionPlansManager() {
<label className="label">{t("plans.days")}</label> <label className="label">{t("plans.days")}</label>
<span className="flex flex-wrap gap-2"> <span className="flex flex-wrap gap-2">
{DOW_ORDER.map((d) => ( {DOW_ORDER.map((d) => (
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text"> <label key={d} className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
<input <input
type="checkbox" type="checkbox"
className="accent-term-amber" className="accent-term-amber"
@@ -386,7 +386,7 @@ export function SubscriptionPlansManager() {
))} ))}
</span> </span>
<label className="label">{t("plans.window")}</label> <label className="label">{t("plans.window")}</label>
<span className="flex flex-wrap items-center gap-2 text-[12px] text-term-muted"> <span className="flex flex-wrap items-center gap-2 text-[0.75rem] text-term-muted">
{t("plans.enterAfter")} {t("plans.enterAfter")}
<input type="time" className="input w-28" value={form.winFrom} onChange={(e) => setForm((f) => f && { ...f, winFrom: e.target.value })} /> <input type="time" className="input w-28" value={form.winFrom} onChange={(e) => setForm((f) => f && { ...f, winFrom: e.target.value })} />
{t("plans.exitBefore")} {t("plans.exitBefore")}
@@ -395,14 +395,14 @@ export function SubscriptionPlansManager() {
<label className="label">{t("plans.grace")}</label> <label className="label">{t("plans.grace")}</label>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<input className="input w-16" value={form.graceMin} inputMode="numeric" onChange={(e) => setForm((f) => f && { ...f, graceMin: e.target.value })} /> <input className="input w-16" value={form.graceMin} inputMode="numeric" onChange={(e) => setForm((f) => f && { ...f, graceMin: e.target.value })} />
<span className="text-[12px] text-term-muted">{t("plans.graceHint")}</span> <span className="text-[0.75rem] text-term-muted">{t("plans.graceHint")}</span>
</span> </span>
</div> </div>
)} )}
<p className="mt-1.5 text-[11px] text-term-muted">{t("plans.timeframesHint")}</p> <p className="mt-1.5 text-[0.6875rem] text-term-muted">{t("plans.timeframesHint")}</p>
</div> </div>
{form.planId && <p className="mt-2 text-[11px] text-term-amber">{t("plans.newVersionHint")}</p>} {form.planId && <p className="mt-2 text-[0.6875rem] text-term-amber">{t("plans.newVersionHint")}</p>}
<div className="mt-4 flex justify-end gap-2"> <div className="mt-4 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button> <button type="button" className="btn btn-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button>
<button type="button" className="btn btn-go btn-sm" onClick={save}>{t("subs.save")}</button> <button type="button" className="btn btn-go btn-sm" onClick={save}>{t("subs.save")}</button>
+9 -9
View File
@@ -354,11 +354,11 @@ export function TariffComposer() {
<section className="px-4 py-6"> <section className="px-4 py-6">
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2> <h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2>
{!state?.active ? ( {!state?.active ? (
<p className="mb-4 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[12px] text-term-amber"> <p className="mb-4 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[0.75rem] text-term-amber">
{t("tariff.noRateCard")} {t("tariff.noRateCard")}
</p> </p>
) : ( ) : (
<p className="mb-4 text-[12px] text-term-muted"> <p className="mb-4 text-[0.75rem] text-term-muted">
{t("tariff.activeSince", { {t("tariff.activeSince", {
date: new Date(state.active.effectiveFrom).toLocaleString(), date: new Date(state.active.effectiveFrom).toLocaleString(),
count: state.versions.length, count: state.versions.length,
@@ -407,7 +407,7 @@ export function TariffComposer() {
{/* A stepped ("up-to") base rate cannot be combined with time tiers — the {/* A stepped ("up-to") base rate cannot be combined with time tiers — the
engine would ignore them. Warn up-front; publishing is also blocked server-side. */} engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
{form.base.mode === "stepped" && form.tiers.length > 0 && ( {form.base.mode === "stepped" && form.tiers.length > 0 && (
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[12px] text-term-red"> <p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[0.75rem] text-term-red">
{t("tariff.steppedTiersConflict")} {t("tariff.steppedTiersConflict")}
</p> </p>
)} )}
@@ -432,7 +432,7 @@ export function TariffComposer() {
<label className="label">{t("tariff.tierDays")}</label> <label className="label">{t("tariff.tierDays")}</label>
<span className="flex flex-wrap gap-2"> <span className="flex flex-wrap gap-2">
{[1, 2, 3, 4, 5, 6, 0].map((d) => ( {[1, 2, 3, 4, 5, 6, 0].map((d) => (
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text"> <label key={d} className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} /> <input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
{t(`tariff.dow${d}`)} {t(`tariff.dow${d}`)}
</label> </label>
@@ -444,7 +444,7 @@ export function TariffComposer() {
<span className="text-term-muted">–</span> <span className="text-term-muted">–</span>
<input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" /> <input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" />
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && ( {tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
<span className="text-[11px] text-term-muted">{t("tariff.tierOvernight")}</span> <span className="text-[0.6875rem] text-term-muted">{t("tariff.tierOvernight")}</span>
)} )}
</span> </span>
<label className="label">{t("tariff.tierDates")}</label> <label className="label">{t("tariff.tierDates")}</label>
@@ -478,7 +478,7 @@ export function TariffComposer() {
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")} {saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
</button> </button>
{msg && ( {msg && (
<span className={msg.kind === "ok" ? "text-[12px] text-term-green" : "text-[12px] text-term-red"}>{msg.text}</span> <span className={msg.kind === "ok" ? "text-[0.75rem] text-term-green" : "text-[0.75rem] text-term-red"}>{msg.text}</span>
)} )}
</div> </div>
</section> </section>
@@ -504,7 +504,7 @@ function PricingEditor(props: {
const { t, pricing: p } = props; const { t, pricing: p } = props;
return ( return (
<div> <div>
<div className="mb-3 flex gap-4 text-[12px]"> <div className="mb-3 flex gap-4 text-[0.75rem]">
<label className="inline-flex items-center gap-1.5 text-term-text"> <label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} /> <input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
{t("tariff.modeLadder")} {t("tariff.modeLadder")}
@@ -538,7 +538,7 @@ function PricingEditor(props: {
<td className="px-2 py-1"> <td className="px-2 py-1">
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
<input className="input w-20" value={s.hours} onChange={(e) => props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} /> <input className="input w-20" value={s.hours} onChange={(e) => props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span> <span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
</span> </span>
</td> </td>
<td className="px-2 py-1"> <td className="px-2 py-1">
@@ -587,7 +587,7 @@ function PricingEditor(props: {
) : ( ) : (
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
<input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} /> <input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span> <span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
</span> </span>
)} )}
</td> </td>
+7 -7
View File
@@ -131,7 +131,7 @@ export function TariffLab() {
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}> <button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}>
{t("lab.load")} {t("lab.load")}
</button> </button>
{loadMsg && <span className="text-[12px] text-term-green">{loadMsg}</span>} {loadMsg && <span className="text-[0.75rem] text-term-green">{loadMsg}</span>}
</div> </div>
{/* Hypothetical session inputs */} {/* Hypothetical session inputs */}
@@ -167,7 +167,7 @@ export function TariffLab() {
<label className="label">{t("lab.payment")}</label> <label className="label">{t("lab.payment")}</label>
<span className="flex flex-wrap items-center gap-2"> <span className="flex flex-wrap items-center gap-2">
<label className="inline-flex items-center gap-1 text-[12px] text-term-text"> <label className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={paid} onChange={(e) => setPaid(e.target.checked)} /> <input type="checkbox" className="accent-term-amber" checked={paid} onChange={(e) => setPaid(e.target.checked)} />
{t("lab.paid")} {t("lab.paid")}
</label> </label>
@@ -190,7 +190,7 @@ export function TariffLab() {
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}> <button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
{busy ? t("lab.pricing") : t("lab.price")} {busy ? t("lab.pricing") : t("lab.price")}
</button> </button>
{err && <span className="text-[12px] text-term-red">{err}</span>} {err && <span className="text-[0.75rem] text-term-red">{err}</span>}
</div> </div>
{result && ( {result && (
@@ -198,19 +198,19 @@ export function TariffLab() {
{/* Outcome */} {/* Outcome */}
<div className="card card-body"> <div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3> <h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[13px]"> <dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[0.8125rem]">
<dt className="text-term-muted">{t("lab.amountDue")}</dt> <dt className="text-term-muted">{t("lab.amountDue")}</dt>
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd> <dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt> <dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
<dd className="text-term-text"> <dd className="text-term-text">
{formatDuration(result.pricing.periodStart, fromLocalInput(asOf))} {formatDuration(result.pricing.periodStart, fromLocalInput(asOf))}
{result.pricing.overstay && ( {result.pricing.overstay && (
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[10px] uppercase text-term-red"> <span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-red">
{t("lab.overstay")} {t("lab.overstay")}
</span> </span>
)} )}
{result.pricing.withinGrace && ( {result.pricing.withinGrace && (
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[10px] uppercase text-term-green"> <span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-green">
{t("lab.settled")} {t("lab.settled")}
</span> </span>
)} )}
@@ -230,7 +230,7 @@ export function TariffLab() {
<div className="card card-body"> <div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3> <h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
<p className="hint mb-2">{t("lab.curveHint")}</p> <p className="hint mb-2">{t("lab.curveHint")}</p>
<table className="w-full text-[12px] tabular-nums"> <table className="w-full text-[0.75rem] tabular-nums">
<tbody> <tbody>
{result.curve.map((c) => ( {result.curve.map((c) => (
<tr key={c.minutes} className="border-b border-term-border/40"> <tr key={c.minutes} className="border-b border-term-border/40">
+4 -4
View File
@@ -51,7 +51,7 @@ export function UsersManager({ user }: { user: SessionUser | null }) {
)} )}
</div> </div>
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>} {error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[0.75rem] text-term-red">{error}</div>}
<Modal open={adding} onClose={() => setAdding(false)} title={t("users.new")} width="max-w-2xl"> <Modal open={adding} onClose={() => setAdding(false)} title={t("users.new")} width="max-w-2xl">
<UserForm <UserForm
@@ -100,8 +100,8 @@ export function UsersManager({ user }: { user: SessionUser | null }) {
</Modal> </Modal>
<div className="overflow-hidden rounded-term border border-term-border"> <div className="overflow-hidden rounded-term border border-term-border">
<table className="w-full text-[12px]"> <table className="w-full text-[0.75rem]">
<thead className="bg-term-panel-2 text-[11px] uppercase tracking-wider text-term-muted"> <thead className="bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
<tr> <tr>
<th className="px-3 py-1.5 text-left">{t("users.username")}</th> <th className="px-3 py-1.5 text-left">{t("users.username")}</th>
<th className="px-3 py-1.5 text-left">{t("users.role")}</th> <th className="px-3 py-1.5 text-left">{t("users.role")}</th>
@@ -291,7 +291,7 @@ function UserForm({
{!isEdit && <div className="hint mt-1">{t("users.passwordHint")}</div>} {!isEdit && <div className="hint mt-1">{t("users.passwordHint")}</div>}
{/* Optional profile metadata. */} {/* Optional profile metadata. */}
<div className="mt-4 mb-2 text-[11px] uppercase tracking-wider text-term-muted">{t("users.detailsSection")}</div> <div className="mt-4 mb-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("users.detailsSection")}</div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="field"> <div className="field">
<span className="label">{t("users.fullName")}</span> <span className="label">{t("users.fullName")}</span>
+124 -10
View File
@@ -73,6 +73,8 @@ export interface SessionUser {
language: Lang; language: Lang;
/** Preferred UI theme (loaded from the server on login). */ /** Preferred UI theme (loaded from the server on login). */
theme: Theme; theme: Theme;
/** Preferred UI font scale, percent of base (100 = base; clamped 80–160). */
fontScale: number;
/** Optional display name (profile metadata); null if unset. */ /** Optional display name (profile metadata); null if unset. */
fullName: string | null; fullName: string | null;
/** Optional contact email (profile metadata); null if unset. */ /** Optional contact email (profile metadata); null if unset. */
@@ -105,6 +107,16 @@ export function setThemePref(theme: Theme): Promise<{ theme: Theme }> {
return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) }); return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) });
} }
/** Allowed font-scale band (percent of base) + step. The header control clamps to these. */
export const FONT_SCALE_MIN = 80;
export const FONT_SCALE_MAX = 160;
export const FONT_SCALE_STEP = 10;
/** Persist the current user's UI font scale (percent; restored on next login). */
export function setFontScalePref(fontScale: number): Promise<{ fontScale: number }> {
return apiFetch("/api/auth/font-scale", { method: "PUT", body: JSON.stringify({ fontScale }) });
}
/** Edit MY own profile (display name / email). Returns the refreshed session. /** Edit MY own profile (display name / email). Returns the refreshed session.
* Self-service — touches only the signed-in user; no `user:*` permission needed. */ * Self-service — touches only the signed-in user; no `user:*` permission needed. */
export function updateMyProfile(patch: { export function updateMyProfile(patch: {
@@ -215,6 +227,54 @@ export function fetchLogs(params: {
return apiFetch(`/api/logs${qs ? `?${qs}` : ""}`); return apiFetch(`/api/logs${qs ? `?${qs}` : ""}`);
} }
// --- Backup ---------------------------------------------------------------
// On-site encrypted DB backup. See wiki/concepts/backup-recovery.md.
export interface BackupStatus {
configured: boolean;
/** Admin-chosen target directory (null = not set). */
targetDir: string | null;
/** Whether the env encryption key is present (a missing key is flagged distinctly). */
keyPresent: boolean;
running: boolean;
lastSuccessAt: string | null;
lastResult: { path: string; bytes: number; prunedFiles: number } | null;
lastErrorAt: string | null;
lastError: string | null;
}
export async function fetchBackupStatus(): Promise<BackupStatus> {
return apiFetch("/api/backup/status");
}
/** Set (or clear, with "") the admin-chosen target directory. Returns the new status. */
export async function setBackupTarget(targetDir: string | null): Promise<BackupStatus> {
return apiFetch("/api/backup/config", { method: "PUT", body: JSON.stringify({ targetDir }) });
}
export interface TargetCheck {
ok: boolean;
/** "empty" | "missing" | "not_a_dir" | "not_writable" when !ok. */
reason?: string;
}
/** Probe a candidate target path server-side (exists / is a dir / is writable). */
export async function testBackupTarget(targetDir: string): Promise<TargetCheck> {
return apiFetch("/api/backup/test", { method: "POST", body: JSON.stringify({ targetDir }) });
}
export interface BackupRunResult {
ok: true;
path: string;
bytes: number;
prunedFiles: number;
}
/** Trigger a manual "back up now". Throws on 409 (not configured) / 500 (run failed). */
export async function runBackup(): Promise<BackupRunResult> {
return apiFetch("/api/backup/run", { method: "POST" });
}
// --- Device setup --------------------------------------------------------- // --- Device setup ---------------------------------------------------------
export interface ConfigField { export interface ConfigField {
@@ -275,18 +335,52 @@ export type DeviceConfig = Record<string, ConfigValue>;
/** Direction a barrier/relay (or a device bound to it) serves. */ /** Direction a barrier/relay (or a device bound to it) serves. */
export type Direction = "entry" | "exit" | "both"; export type Direction = "entry" | "exit" | "both";
/** One relay on an access controller: which barrier it opens, in which direction, /** The EVENT a relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` → drive a
* and (optionally) the input terminal its entry button is wired to. */ * non-barrier alert lamp (blink while its trigger input is active, SOLID once the camera
* confirms a car). The action is implied by the event. */
export type RelayEvent = Direction | "radarAlert";
/** What a controller input terminal means: a transient-entry `button`, a one-car-one-ticket
* `presence` sensor (loop/radar), or an `alertTrigger` for a radarAlert lamp. */
export type InputRole = "button" | "presence" | "alertTrigger";
/** One INPUT terminal the host reads (the twin of RelaySpec). An exit radar is just another
* `presence` row serving the exit relay. */
export interface InputSpec {
input: number;
role: InputRole;
/** The barrier relay this input serves (required for button/presence; optional for
* alertTrigger). */
relay?: number;
/** presence only — induction LOOP or RADAR (label only). */
kind?: "loop" | "radar";
/** This terminal idles HIGH / is active-LOW (e.g. a radar wired opposite the button). */
activeLow?: boolean;
/** button only — presence-less fallback cooldown (seconds). */
cooldownSec?: number;
}
/** One relay on an access controller: the event it reacts to. Input wiring lives in
* `config.inputs[]`; the legacy per-relay button/presence fields are still read for
* back-compat but no longer written. */
export interface RelaySpec { export interface RelaySpec {
relay: number; relay: number;
direction: Direction; /** The event this relay reacts to (UI label: "Event"). */
/** Input terminal of the entry button that fires this relay (transient entry). */ direction: RelayEvent;
// ── legacy input fields (read-only back-compat; superseded by config.inputs[]) ──
button?: number; button?: number;
/** Anti-double-press (one car = one ticket). PRESENCE: input terminal of a vehicle
* loop/barrier-feedback signal; a press prints only with a car present + re-arms when
* it clears. COOLDOWN (fallback, no feedback): suppress repeat presses for N seconds. */
presenceInput?: number; presenceInput?: number;
presenceKind?: "loop" | "radar";
presenceActiveLow?: boolean;
entryCooldownSec?: number; entryCooldownSec?: number;
// ── radarAlert-only ──
/** Input terminal whose active edge starts the blink (the radar). */
triggerInput?: number;
/** Which lane's camera locks this lamp SOLID (default entry). An exit radar locks on exit. */
lockLane?: "entry" | "exit";
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
blinkOnMs?: number;
blinkOffMs?: number;
} }
export interface TestResult { export interface TestResult {
@@ -297,11 +391,13 @@ export interface TestResult {
}; };
} }
/** Test a device config (reachability + preconditions) without saving. */ /** Test a device config (reachability + preconditions) without saving. Pass the
export function testDevice(driverId: string, config: DeviceConfig): Promise<TestResult> { * device `id` when editing an existing one so the server re-merges its stored
* machine secrets (e.g. the relay password redacted from the client). */
export function testDevice(driverId: string, config: DeviceConfig, id?: string): Promise<TestResult> {
return apiFetch<TestResult>("/api/setup/test", { return apiFetch<TestResult>("/api/setup/test", {
method: "POST", method: "POST",
body: JSON.stringify({ driverId, config }), body: JSON.stringify({ driverId, config, ...(id ? { id } : {}) }),
}); });
} }
@@ -333,6 +429,24 @@ export function testAnpr(driverId: string, config: DeviceConfig): Promise<AnprTe
}); });
} }
/** Result of a physical print test: a real test slip is pushed to the printer. */
export type PrintTestResult =
| { ok: true; tookMs: number }
| { ok: false; reason: string; detail?: string; tookMs?: number };
/** Print a real test slip on the printer — without saving. Confirms the printer
* actually feeds paper + fires the head (healthCheck only opens the transport). */
export function testPrint(
driverId: string,
config: DeviceConfig,
id?: string,
): Promise<PrintTestResult> {
return apiFetch<PrintTestResult>("/api/setup/test-print", {
method: "POST",
body: JSON.stringify({ driverId, config, id }),
});
}
// --- Admin reports ------------------------------------------------------- // --- Admin reports -------------------------------------------------------
export type ReportBucket = "hour" | "day" | "month"; export type ReportBucket = "hour" | "day" | "month";
+35 -8
View File
@@ -180,7 +180,7 @@ body {
background: var(--color-term-bg); background: var(--color-term-bg);
color: var(--color-term-text); color: var(--color-term-text);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 13px; font-size: 0.8125rem;
line-height: 1.4; line-height: 1.4;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
/* Crisp text and no rubber-banding on the fixed appliance display. */ /* Crisp text and no rubber-banding on the fixed appliance display. */
@@ -266,23 +266,23 @@ body {
/* Small / dense variant for inline table cells */ /* Small / dense variant for inline table cells */
.input-sm { .input-sm {
height: var(--control-h-sm); height: var(--control-h-sm);
@apply px-2 text-[12px]; @apply px-2 text-[0.75rem];
} }
.field { .field {
@apply flex flex-col gap-1; @apply flex flex-col gap-1;
} }
.label { .label {
@apply text-[11px] uppercase tracking-wider text-term-muted; @apply text-[0.6875rem] uppercase tracking-wider text-term-muted;
} }
.hint { .hint {
@apply text-[11px] leading-snug text-term-muted; @apply text-[0.6875rem] leading-snug text-term-muted;
} }
/* ---- Buttons: a button must look pressable, never like a field ---- */ /* ---- Buttons: a button must look pressable, never like a field ---- */
.btn { .btn {
@apply inline-flex items-center justify-center gap-1.5 rounded-term border @apply inline-flex items-center justify-center gap-1.5 rounded-term border
px-3 text-[12px] font-semibold uppercase tracking-wider px-3 text-[0.75rem] font-semibold uppercase tracking-wider
transition-colors select-none; transition-colors select-none;
height: var(--control-h-md); height: var(--control-h-md);
/* Neutral default: a filled grey body, not a bare outline. */ /* Neutral default: a filled grey body, not a bare outline. */
@@ -302,11 +302,11 @@ body {
} }
.btn-sm { .btn-sm {
height: var(--control-h-sm); height: var(--control-h-sm);
@apply px-2.5 text-[11px]; @apply px-2.5 text-[0.6875rem];
} }
.btn-lg { .btn-lg {
height: var(--control-h-lg); height: var(--control-h-lg);
@apply px-5 text-[13px]; @apply px-5 text-[0.8125rem];
} }
/* Primary: FILLED amber, dark text — the unmistakable main action. */ /* Primary: FILLED amber, dark text — the unmistakable main action. */
@@ -366,7 +366,7 @@ body {
} }
.card-head { .card-head {
@apply flex items-center justify-between border-b border-term-border @apply flex items-center justify-between border-b border-term-border
bg-term-panel-2 px-4 py-2 text-[12px] uppercase tracking-wider text-term-muted; bg-term-panel-2 px-4 py-2 text-[0.75rem] uppercase tracking-wider text-term-muted;
} }
.card-body { .card-body {
@apply p-4; @apply p-4;
@@ -428,3 +428,30 @@ html.theme-light .btn:hover:not(:disabled) {
html.theme-light .btn-primary { html.theme-light .btn-primary {
color: #fafaf7; color: #fafaf7;
} }
/* ── Barrier-light blink ────────────────────────────────────────────────────
The booth Entry/Exit indicator blinks green↔red (~1 Hz) when the radar/presence
input is active but the camera hasn't confirmed a vehicle yet — mirroring the
physical button lamp (relay 3). Toggles a CSS var the component maps onto its
border / tint / glyph, so green and red alternate every 500 ms. */
@keyframes lane-blink {
0%, 49% { --lane-c: var(--color-term-green); --lane-tint: color-mix(in srgb, var(--color-term-green) 10%, transparent); }
50%, 100% { --lane-c: var(--color-term-red); --lane-tint: color-mix(in srgb, var(--color-term-red) 10%, transparent); }
}
.lane-blink {
animation: lane-blink 1s steps(1, end) infinite;
border-color: var(--lane-c);
background: var(--lane-tint);
color: var(--lane-c);
}
@media (prefers-reduced-motion: reduce) {
/* No flashing for motion-sensitive users — hold the "attention" (red) state. */
.lane-blink {
animation: none;
--lane-c: var(--color-term-red);
--lane-tint: color-mix(in srgb, var(--color-term-red) 10%, transparent);
border-color: var(--lane-c);
background: var(--lane-tint);
color: var(--lane-c);
}
}
+109 -19
View File
@@ -14,6 +14,9 @@ export const en: Catalog = {
themeDark: "dark", themeDark: "dark",
themeLight: "light", themeLight: "light",
theme: "Theme", theme: "Theme",
fontSmaller: "Smaller text",
fontLarger: "Larger text",
fontSize: "Text size",
today: "Today", today: "Today",
yesterday: "Yesterday", yesterday: "Yesterday",
months: [ months: [
@@ -58,6 +61,7 @@ export const en: Catalog = {
reports: "Reports", reports: "Reports",
recycleBin: "Recycle bin", recycleBin: "Recycle bin",
logs: "Logs", logs: "Logs",
backup: "Backup",
profile: "Profile", profile: "Profile",
}, },
profile: { profile: {
@@ -118,7 +122,7 @@ export const en: Catalog = {
scanPlaceholder: "Scan or type ticket number…", scanPlaceholder: "Scan or type ticket number…",
laneEntry: "Entry", laneEntry: "Entry",
laneExit: "Exit", laneExit: "Exit",
open: "Open", openTicket: "Read",
occupancy: "Occupancy", occupancy: "Occupancy",
occUnavailable: "occupancy unavailable", occUnavailable: "occupancy unavailable",
inside: "inside", inside: "inside",
@@ -133,6 +137,11 @@ export const en: Catalog = {
insideCount: "inside", insideCount: "inside",
noActiveSessions: "No active sessions.", noActiveSessions: "No active sessions.",
noMatch: "No sessions match the filter.", noMatch: "No sessions match the filter.",
// Active-sessions table column headers.
colWho: "Ticket / subscriber",
colPlate: "Plate",
colEntry: "Entry",
colElapsed: "Elapsed",
badgeOverstay: "overstay", badgeOverstay: "overstay",
badgeOverstayTitle: badgeOverstayTitle:
"Paid session. The customer failed to exit during the grace period. A new period began.", "Paid session. The customer failed to exit during the grace period. A new period began.",
@@ -141,14 +150,8 @@ export const en: Catalog = {
filterSearchSessions: "Search ticket / subscriber / plate…", filterSearchSessions: "Search ticket / subscriber / plate…",
filterSearchFeed: "Search event / identity / plate…", filterSearchFeed: "Search event / identity / plate…",
filterAll: "All", filterAll: "All",
fStatusUnpaid: "Unpaid",
fStatusPaid: "Paid",
fStatusExiting: "Exiting",
fStatusOverstay: "Overstay",
fKindTransient: "Transient", fKindTransient: "Transient",
fKindSubscription: "Subscribers", fKindSubscription: "Subscribers",
fDirEntry: "Entry",
fDirExit: "Exit",
fSrcBooth: "Booth", fSrcBooth: "Booth",
fSrcReader: "Reader", fSrcReader: "Reader",
fEvtEntry: "Entry", fEvtEntry: "Entry",
@@ -305,7 +308,7 @@ export const en: Catalog = {
setup: { setup: {
title: "Setup", title: "Setup",
intro: intro:
"Add your barrier controllers first — set which relay is entry/exit and which terminal the entry button is wired to. Then add readers, cameras and printers and point each at the barrier it serves.", "Add your barrier controllers first. Then add readers (QR/RF), cameras, printers",
catControllers: "Controllers (barriers + entry button)", catControllers: "Controllers (barriers + entry button)",
catReaders: "Readers (QR / RFID)", catReaders: "Readers (QR / RFID)",
catCameras: "Cameras (snapshot + plate)", catCameras: "Cameras (snapshot + plate)",
@@ -359,18 +362,49 @@ export const en: Catalog = {
relaysTitle: "Relays on this controller", relaysTitle: "Relays on this controller",
relaysHint: relaysHint:
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.", "Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
outputsTitle: "Outputs — relays (barriers + lamp)",
outputsHint:
"Relays are OUTPUTS: each opens a barrier (or drives the button lamp). Set the relay number and direction. The input terminals (button, sensor) are in the Inputs section below.",
pulseOpenMs: "Pulse open (ms)",
pulseOpenHint: "How long a barrier relay is held open (jog). Applies to all barrier relays.",
inputsTitle: "Inputs — terminals (button, sensor)",
inputsHint:
"Inputs are TERMINALS the host READS: the entry button and the presence/radar sensor. Each belongs to an entry barrier — it triggers or gates that relay.",
inputsIdleHigh: "Inputs idle HIGH",
inputsIdleHighHint: "This board idles inputs HIGH (status 1111); a press pulls LOW.",
relay: "Relay", relay: "Relay",
entryButtonTerminal: "Entry button on terminal", // Generic input rows: terminal + role + the relay it serves.
presenceInput: "Presence loop (terminal)", inputTerminal: "Terminal",
presenceInputHint: inputServesRelay: "Serves relay",
"Input terminal the vehicle-presence loop / barrier feedback is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the loop clears (the car drove in) and a new car re-occupies it. Preferred mode.", roleButton: "Entry button",
rolePresenceLoop: "Presence (loop)",
rolePresenceRadar: "Presence (radar)",
roleAlertTrigger: "Alert trigger",
addInput: "+ Add input",
entryCooldown: "Cooldown after ticket (s)", entryCooldown: "Cooldown after ticket (s)",
entryCooldownHint: entryCooldownHint:
"When there's no presence loop: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.", "When there's no presence sensor: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
activeLow: "Active-low",
activeLowHint:
"Tick if the presence sensor (e.g. a radar) idles HIGH and goes LOW on detection — the opposite of the button. This inverts that terminal's reading so 'present' is read correctly.",
eventRadarAlert: "Radar alert (lamp)",
triggerInput: "Trigger input",
triggerInputHint:
"The input terminal (the radar) that starts this relay blinking. Blinks while the trigger is active but the camera doesn't confirm a car; solid on once the camera confirms; off otherwise.",
lockLane: "Lock from",
lockLaneHint:
"Which camera locks the lamp solid: the entry or the exit camera. An exit radar must lock on the EXIT camera.",
lockLaneEntry: "Entry camera",
lockLaneExit: "Exit camera",
blinkOnMs: "Blink on (ms)",
blinkOffMs: "Blink off (ms)",
addRelay: "+ Add relay", addRelay: "+ Add relay",
anpr: "Plate recognition (ANPR)", anpr: "Plate recognition (ANPR)",
anprHint: anprHint:
"Enable to scan plates on this camera: the vision service reads the plate from a snapshot and feeds it as a read (advisory only — it never opens a barrier on its own). Requires the vision service running.", "Read plates on this camera: the vision service reads the plate from each snapshot and records it (both entry and exit). Requires the vision service running.",
anprAuto: "Auto open/close on subscriber plate",
anprAutoHint:
"Let THIS camera auto-open the barrier when it recognises a subscriber's plate. Turn OFF on a shared entry/exit lane's exit camera, so a car driving IN isn't auto-EXITed by its back plate (recognition still runs — only the auto-trigger is off).",
testAnpr: "Test ANPR", testAnpr: "Test ANPR",
anprTesting: "Testing ANPR…", anprTesting: "Testing ANPR…",
testAnprHint: testAnprHint:
@@ -381,6 +415,29 @@ export const en: Catalog = {
"anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.", "anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.",
"anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).", "anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).",
"anprFail.no-plate": "No plate found in the snapshot.", "anprFail.no-plate": "No plate found in the snapshot.",
// Printer test slip — pushes a real slip so the admin can confirm it physically prints.
testPrint: "Print test slip",
printTesting: "Printing…",
testPrintHint:
"Sends a test slip to the printer now. ‘Connected’ only opens the link — this confirms the printer actually feeds paper.",
printOk: "✓ Test slip sent ({{ms}} ms). Check the printer.",
"printFail.print-failed": "The printer rejected the job (out of paper, cover open, or the link dropped).",
// Reveal/hide toggle for a secret field (e.g. the device web password).
revealSecret: "Show password",
hideSecret: "Hide password",
alarmUrlTitle: "Alarm Server settings (enter these in the camera)",
alarmUrlHint:
"Enter these in the camera at Configuration → Event → … → Alarm Settings (or Notify Surveillance Center). The camera POSTs every event here — no polling.",
alarmUrlCopy: "Copy all",
alarmUrlCopied: "Copied ✓",
alarmUrlSaveFirst:
"Save the camera first — the address is generated once the device has an ID. Re-open it for editing to see it.",
alarmUrlTestFirst:
"Click “Test connection” first — that resolves this host's IP on the camera's network (so the camera can reach it).",
alarmFieldHost: "Destination IP / Host",
alarmFieldUrl: "URL",
alarmFieldProtocol: "Protocol",
alarmFieldPort: "Port",
whichBarrier: "Which barrier does this device serve?", whichBarrier: "Which barrier does this device serve?",
controller: "Controller", controller: "Controller",
choose: "Choose…", choose: "Choose…",
@@ -561,7 +618,7 @@ export const en: Catalog = {
save: "Save", save: "Save",
saved: "Saved.", saved: "Saved.",
fieldParkName: "Park name", fieldParkName: "Park name",
fieldParkNamePh: "e.g. Acme Parking", fieldParkNamePh: "e.g. Airport Parking",
fieldOperator: "Operator (legal name)", fieldOperator: "Operator (legal name)",
fieldOperatorPh: "operating company", fieldOperatorPh: "operating company",
fieldNius: "NIUS", fieldNius: "NIUS",
@@ -645,10 +702,9 @@ export const en: Catalog = {
card: "Card:", card: "Card:",
srcTickets: "Tickets:", srcTickets: "Tickets:",
srcSubscriptions: "Subscriptions:", srcSubscriptions: "Subscriptions:",
srcSubSales: "sales",
srcSubWindow: "out-of-window", srcSubWindow: "out-of-window",
drawerSection: "— Drawer —", drawerSection: "— Drawer —",
openingFloat: "Opening float:", openingFloat: "Opening cash:",
cashTaken: "Cash taken:", cashTaken: "Cash taken:",
cashAdded: "Cash added:", cashAdded: "Cash added:",
cashRemoved: "Cash removed:", cashRemoved: "Cash removed:",
@@ -682,7 +738,6 @@ export const en: Catalog = {
card: "Card", card: "Card",
srcTickets: "Tickets", srcTickets: "Tickets",
srcSubscriptions: "Subscriptions", srcSubscriptions: "Subscriptions",
srcSubSales: "subs sales",
srcSubWindow: "out-of-window", srcSubWindow: "out-of-window",
expectedDrawer: "Expected drawer", expectedDrawer: "Expected drawer",
filterFrom: "From", filterFrom: "From",
@@ -700,7 +755,7 @@ export const en: Catalog = {
noActivity: "No activity in this shift.", noActivity: "No activity in this shift.",
current: "current", current: "current",
drawerSection: "Drawer", drawerSection: "Drawer",
openingFloat: "Opening float", openingFloat: "Opening cash",
cashTaken: "Cash taken", cashTaken: "Cash taken",
cashAdded: "Cash added", cashAdded: "Cash added",
cashRemoved: "Cash removed", cashRemoved: "Cash removed",
@@ -776,6 +831,41 @@ export const en: Catalog = {
path: "Path", path: "Path",
empty: "No logs.", empty: "No logs.",
}, },
backup: {
title: "Backup",
intro:
"An encrypted copy of the database (the signed ledger) to an external disk. Runs automatically every day and from the button below.",
statusTitle: "Status",
configured: "Enabled",
notConfigured: "Not configured",
notConfiguredHint: "Set BACKUP_TARGET_DIR and BACKUP_KEY on the server to enable backups.",
running: "Running…",
idle: "Idle",
lastSuccess: "Last successful backup",
lastError: "Last error",
never: "Never",
lastFile: "File",
size: "Size",
pruned: "Pruned",
runNow: "Back up now",
runSuccess: "Backup complete.",
runFailed: "Backup failed.",
notConfiguredError: "Backup is not configured.",
restoreNote:
"Restore is not done here — it's an out-of-band step when provisioning a fresh appliance (needs the backup file + the keys you escrowed offsite).",
targetLabel: "Backup location",
targetPlaceholder: "e.g. /mnt/backup or /media/usb",
targetHint: "An absolute path to a mounted disk (USB/SATA) or a network share (SMB/NFS).",
save: "Save",
saved: "Saved.",
test: "Test target",
testOk: "The location is writable.",
testEmpty: "Enter a path.",
testMissing: "The location does not exist.",
testNotDir: "The path is not a directory.",
testNotWritable: "The directory is not writable.",
keyMissing: "The encryption key (BACKUP_KEY) is missing on the server — set it to enable backups.",
},
pay: { pay: {
ticket: "Ticket", ticket: "Ticket",
entry: "Entry", entry: "Entry",
+120 -28
View File
@@ -14,6 +14,9 @@ export const sq = {
themeDark: "errët", themeDark: "errët",
themeLight: "çelët", themeLight: "çelët",
theme: "Tema", theme: "Tema",
fontSmaller: "Zvogëlo tekstin",
fontLarger: "Rrit tekstin",
fontSize: "Madhësia e tekstit",
today: "Sot", today: "Sot",
yesterday: "Dje", yesterday: "Dje",
// Month names (index 0 = January) — kept in the catalog because the appliance's // Month names (index 0 = January) — kept in the catalog because the appliance's
@@ -60,6 +63,7 @@ export const sq = {
reports: "Raportet", reports: "Raportet",
recycleBin: "Koshi", recycleBin: "Koshi",
logs: "Loget", logs: "Loget",
backup: "Kopje rezervë",
profile: "Profili", profile: "Profili",
}, },
profile: { profile: {
@@ -120,7 +124,7 @@ export const sq = {
scanPlaceholder: "Skano ose shkruaj numrin e biletës…", scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
laneEntry: "Hyrje", laneEntry: "Hyrje",
laneExit: "Dalje", laneExit: "Dalje",
open: "Hap", openTicket: "Lexo",
occupancy: "Prania", occupancy: "Prania",
occUnavailable: "zënia e padisponueshme", occUnavailable: "zënia e padisponueshme",
inside: "brenda", inside: "brenda",
@@ -135,6 +139,11 @@ export const sq = {
insideCount: "brenda", insideCount: "brenda",
noActiveSessions: "Asnjë sesion aktiv.", noActiveSessions: "Asnjë sesion aktiv.",
noMatch: "Asnjë rezultat për filtrin.", noMatch: "Asnjë rezultat për filtrin.",
// Kokat e kolonave të tabelës së sesioneve aktive.
colWho: "Biletë / abonent",
colPlate: "Targa",
colEntry: "Hyrja",
colElapsed: "Koha brenda",
badgeOverstay: "tej afatit", badgeOverstay: "tej afatit",
badgeOverstayTitle: badgeOverstayTitle:
"Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.", "Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.",
@@ -143,14 +152,8 @@ export const sq = {
filterSearchSessions: "Kërko biletë / abonent / targë…", filterSearchSessions: "Kërko biletë / abonent / targë…",
filterSearchFeed: "Kërko event / identitet / targë…", filterSearchFeed: "Kërko event / identitet / targë…",
filterAll: "Të gjitha", filterAll: "Të gjitha",
fStatusUnpaid: "Papaguar",
fStatusPaid: "Paguar",
fStatusExiting: "Duke dalë",
fStatusOverstay: "Tej afatit",
fKindTransient: "Kalimtarë", fKindTransient: "Kalimtarë",
fKindSubscription: "Abonentë", fKindSubscription: "Abonentë",
fDirEntry: "Hyrje",
fDirExit: "Dalje",
fSrcBooth: "Kabinë", fSrcBooth: "Kabinë",
fSrcReader: "Lexues", fSrcReader: "Lexues",
fEvtEntry: "Hyrje", fEvtEntry: "Hyrje",
@@ -308,21 +311,21 @@ export const sq = {
setup: { setup: {
title: "Konfigurimi", title: "Konfigurimi",
intro: intro:
"Shto fillimisht kontrolluesit e barrierave — cakto cili rele është hyrje/dalje dhe në cilin terminal është lidhur butoni i hyrjes. Pastaj shto lexues, kamera dhe printera dhe drejto secilin te barriera që shërben.", "Shto fillimisht kontrollerat e barrierave — Pastaj shto lexues (QR/RF), kamera, printera.",
// Category titles + the singular noun used in buttons/modal titles. // Category titles + the singular noun used in buttons/modal titles.
catControllers: "Kontrolluesit (barrierat + butoni i hyrjes)", catControllers: "Kontrollerat (barrierat + butoni i hyrjes)",
catReaders: "Lexuesit (QR / RFID)", catReaders: "Lexuesit (QR / RFID)",
catCameras: "Kamerat (foto + targë)", catCameras: "Kamerat (foto + targë)",
catPrinters: "Printerat (bileta / vouchera)", catPrinters: "Printerat (bileta / vouchera)",
nounController: "kontrollues", nounController: "kontroller",
nounReader: "lexues", nounReader: "lexues",
nounCamera: "kamerë", nounCamera: "kamerë",
nounPrinter: "printer", nounPrinter: "printer",
add: "+ Shto {{noun}}", add: "+ Shto {{noun}}",
addAnother: "+ Shto edhe një {{noun}}", addAnother: "+ Shto {{noun}}",
addTitle: "Shto {{noun}}", addTitle: "Shto {{noun}}",
editTitle: "Ndrysho {{noun}}", editTitle: "Ndrysho {{noun}}",
needControllerFirst: "Shto fillimisht një kontrollues — {{noun}} drejtohet te një prej releve të tij.", needControllerFirst: "Shto fillimisht një kontroller — {{noun}} drejtohet te një prej releve të tij.",
failedToLoad: "Ngarkimi i konfigurimit dështoi: {{error}}", failedToLoad: "Ngarkimi i konfigurimit dështoi: {{error}}",
loadingCatalog: "Duke ngarkuar katalogun e pajisjeve…", loadingCatalog: "Duke ngarkuar katalogun e pajisjeve…",
// Direction labels (relay direction + inherited binding). // Direction labels (relay direction + inherited binding).
@@ -344,9 +347,9 @@ export const sq = {
// Device form. // Device form.
noDrivers: "Asnjë drejtues i regjistruar.", noDrivers: "Asnjë drejtues i regjistruar.",
chooseDevice: "Zgjidh një pajisje…", chooseDevice: "Zgjidh një pajisje…",
scan: "Skano për kontrollues", scan: "Skano për kontroller",
scanning: "Duke skanuar…", scanning: "Duke skanuar…",
noControllersFound: "Asnjë kontrollues në LAN.", noControllersFound: "Asnjë kontroller në LAN.",
use: "Përdor", use: "Përdor",
test: "Testo lidhjen", test: "Testo lidhjen",
testing: "Duke testuar…", testing: "Duke testuar…",
@@ -365,22 +368,53 @@ export const sq = {
noNicOnSubnet: "⚠ asnjë NIC në subnetin e pajisjes — pajisja mund të mos arrijë backend-in", noNicOnSubnet: "⚠ asnjë NIC në subnetin e pajisjes — pajisja mund të mos arrijë backend-in",
backendIpHint: "Adresa te e cila kjo pajisje do të dërgojë eventet e hyrjes.", backendIpHint: "Adresa te e cila kjo pajisje do të dërgojë eventet e hyrjes.",
// Relay editor. // Relay editor.
relaysTitle: "Relet në këtë kontrollues", relaysTitle: "Relet në këtë kontroller",
relaysHint: relaysHint:
"Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.", "Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.",
outputsTitle: "Daljet — relet (barrierat + drita)",
outputsHint:
"Relet janë DALJE: secila hap një barrierë (ose ndez dritën e butonit). Cakto numrin e relesë dhe drejtimin. Terminalet hyrëse (butoni, sensori) janë te seksioni Hyrjet më poshtë.",
pulseOpenMs: "Kohëzgjatja e hapjes (ms)",
pulseOpenHint: "Sa kohë mbahet rele e barrierës e hapur (jog). Vlen për të gjitha relet e barrierave.",
inputsTitle: "Hyrjet — terminalet (buton, sensor)",
inputsHint:
"Hyrjet janë TERMINALE që hosti i LEXON: butoni i hyrjes dhe sensori i pranisë/radari. Secila i përket një barriere hyrëse — e gateron ose e nis atë rele.",
inputsIdleHigh: "Hyrjet në pushim HIGH",
inputsIdleHighHint: "Kjo pllakë i mban hyrjet HIGH në pushim (statusi 1111); një shtypje e ul në LOW.",
relay: "Rele", relay: "Rele",
entryButtonTerminal: "Butoni i hyrjes në terminalin", // Generic input rows: terminal + role + the relay it serves.
presenceInput: "Sensori i pranisë (terminali)", inputTerminal: "Terminali",
presenceInputHint: inputServesRelay: "I shërben reles",
"Terminali hyrës ku është lidhur sensori/laku i pranisë së automjetit. Kur vendoset, lëshohet vetëm NJË biletë për automjet: butoni printon vetëm kur ka makinë, dhe nuk lëshon biletë të dytë derisa laku të lirohet (makina hyri) dhe një makinë e re ta zërë. Mënyra e preferuar.", roleButton: "Butoni i hyrjes",
rolePresenceLoop: "Prania (lak induktiv)",
rolePresenceRadar: "Prania (radar)",
roleAlertTrigger: "Trigger alarmi",
addInput: "+ Shto hyrje",
entryCooldown: "Pritje pas biletës (sek)", entryCooldown: "Pritje pas biletës (sek)",
entryCooldownHint: entryCooldownHint:
"Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.", "Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.",
activeLow: "Aktiv-ulët",
activeLowHint:
"Shëno nëse sensori i pranisë (p.sh. radari) qëndron HIGH në pushim dhe shkon LOW kur detekton — e kundërta e butonit. Kjo përmbys leximin e atij terminali që 'prania' të lexohet saktë.",
eventRadarAlert: "Alarm radar (dritë)",
triggerInput: "Trigger input",
triggerInputHint:
"Terminali i hyrjes (radari) që nis pulsimin e kësaj rele. Pulson kur Trigger input është aktiv por kamera s'konfirmon makinë; ndizet fiks kur kamera konfirmon; përndryshe fiket.",
lockLane: "Bllokimi nga",
lockLaneHint:
"Cila kamerë e ndez dritën fiks: hyrja apo dalja. Një radar i daljes duhet të bllokohet nga kamera e DALJES.",
lockLaneEntry: "Kamera e hyrjes",
lockLaneExit: "Kamera e daljes",
blinkOnMs: "Pulsim ndezur (ms)",
blinkOffMs: "Pulsim fikur (ms)",
addRelay: "+ Shto rele", addRelay: "+ Shto rele",
// Camera ANPR opt-in. // Camera ANPR opt-in.
anpr: "Njohja e targave (ANPR)", anpr: "Njohja e targave (ANPR)",
anprHint: anprHint:
"Aktivizo që ky aparat të skanojë targat: shërbimi i vizionit lexon targën nga pamja dhe e dërgon si lexim (vetëm këshillues — nuk hap vetë barrierën). Kërkon shërbimin e vizionit aktiv.", "Lexo targat në këtë aparat: shërbimi i vizionit lexon targën nga çdo pamje dhe e regjistron (hyrje dhe dalje). Kërkon shërbimin e vizionit aktiv.",
anprAuto: "Hapje/mbyllje automatike me targën e abonentit",
anprAutoHint:
"Lejo që KY aparat të hapë vetë barrierën kur njeh targën e një abonenti. ÇAKTIVIZOJE te aparati i daljes në një korsi të përbashkët hyrje/dalje, që një makinë që HYN të mos DALË automatikisht nga targa e pasme (njohja vazhdon — fiket vetëm hapja automatike).",
testAnpr: "Testo ANPR", testAnpr: "Testo ANPR",
anprTesting: "Duke testuar ANPR…", anprTesting: "Duke testuar ANPR…",
testAnprHint: testAnprHint:
@@ -391,12 +425,36 @@ export const sq = {
"anprFail.vision-disabled": "Shërbimi i vizionit është çaktivizuar — aktivizoje (VISION_ENABLED) për ta testuar ANPR.", "anprFail.vision-disabled": "Shërbimi i vizionit është çaktivizuar — aktivizoje (VISION_ENABLED) për ta testuar ANPR.",
"anprFail.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).", "anprFail.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).",
"anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.", "anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.",
// Printer test slip — pushes a real slip so the admin can confirm it physically prints.
testPrint: "Printo provë",
printTesting: "Duke printuar…",
testPrintHint:
"Dërgon një fletë prove te printeri tani. ‘I lidhur’ vetëm hap lidhjen — kjo konfirmon se printeri vërtet nxjerr letër.",
printOk: "✓ Fleta e provës u dërgua ({{ms}} ms). Kontrollo printerin.",
"printFail.print-failed": "Printeri nuk pranoi punën (pa letër, kapaku hapur, ose lidhja ra).",
// Reveal/hide toggle for a secret field (e.g. the device web password).
revealSecret: "Shfaq fjalëkalimin",
hideSecret: "Fshih fjalëkalimin",
// Alarm Server push settings — generated for the camera's Event → Alarm Server form.
alarmUrlTitle: "Cilësimet e Alarm Server (vendosi te kamera)",
alarmUrlHint:
"Vendosi këto te kamera: Configuration → Event → … → Alarm Settings (ose Notify Surveillance Center). Kamera do të dërgojë çdo ngjarje këtu — pa polling.",
alarmUrlCopy: "Kopjo të gjitha",
alarmUrlCopied: "U kopjua ✓",
alarmUrlSaveFirst:
"Ruaje kamerën më parë — adresa gjenerohet pasi pajisja të marrë një ID. Hape sërish për editim që ta shohësh.",
alarmUrlTestFirst:
"Kliko “Testo lidhjen” më parë — kështu përcaktohet IP-ja e këtij hosti në rrjetin e kamerës (që kamera ta thërrasë).",
alarmFieldHost: "Destination IP / Host",
alarmFieldUrl: "URL",
alarmFieldProtocol: "Protokolli",
alarmFieldPort: "Porta",
// Binding picker. // Binding picker.
whichBarrier: "Cilën barrierë shërben kjo pajisje?", whichBarrier: "Cilën barrierë shërben kjo pajisje?",
controller: "Kontrolluesi", controller: "Kontrolleri",
choose: "Zgjidh…", choose: "Zgjidh…",
relayLabel: "Rele {{relay}} ({{direction}})", relayLabel: "Rele {{relay}} ({{direction}})",
noRelaysConfigured: "Ky kontrollues nuk ka rele të konfiguruar.", noRelaysConfigured: "Ky kontroller nuk ka rele të konfiguruar.",
}, },
lab: { lab: {
title: "Lab Tarife", title: "Lab Tarife",
@@ -572,7 +630,7 @@ export const sq = {
save: "Ruaj", save: "Ruaj",
saved: "U ruajt.", saved: "U ruajt.",
fieldParkName: "Emri i parkimit", fieldParkName: "Emri i parkimit",
fieldParkNamePh: "p.sh. Acme Parking", fieldParkNamePh: "p.sh. Parking Aeroport",
fieldOperator: "Operatori (emri ligjor)", fieldOperator: "Operatori (emri ligjor)",
fieldOperatorPh: "kompania operuese", fieldOperatorPh: "kompania operuese",
fieldNius: "NIUS", fieldNius: "NIUS",
@@ -629,7 +687,7 @@ export const sq = {
starting: "Duke filluar…", starting: "Duke filluar…",
endShift: "Mbyll turnin", endShift: "Mbyll turnin",
ending: "Duke mbyllur…", ending: "Duke mbyllur…",
endConfirm: "Të mbyllet ky turn? Regjistrohet dhe printohet një Raport Z i nënshkruar.", endConfirm: "",
drawer: "Arka:", drawer: "Arka:",
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)", openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin", drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
@@ -657,10 +715,9 @@ export const sq = {
card: "Kartë:", card: "Kartë:",
srcTickets: "Bileta:", srcTickets: "Bileta:",
srcSubscriptions: "Abonime:", srcSubscriptions: "Abonime:",
srcSubSales: "shitje",
srcSubWindow: "jashtë orarit", srcSubWindow: "jashtë orarit",
drawerSection: "— Arka —", drawerSection: "— Arka —",
openingFloat: "Bilanci fillestar:", openingFloat: "Arka fillestare:",
cashTaken: "Para të marra:", cashTaken: "Para të marra:",
cashAdded: "Para të shtuara:", cashAdded: "Para të shtuara:",
cashRemoved: "Para të hequra:", cashRemoved: "Para të hequra:",
@@ -694,7 +751,6 @@ export const sq = {
card: "Kartë", card: "Kartë",
srcTickets: "Bileta", srcTickets: "Bileta",
srcSubscriptions: "Abonime", srcSubscriptions: "Abonime",
srcSubSales: "shitje abonimesh",
srcSubWindow: "jashtë orarit", srcSubWindow: "jashtë orarit",
expectedDrawer: "Gjëndje arke", expectedDrawer: "Gjëndje arke",
// Filter (admin only). // Filter (admin only).
@@ -714,7 +770,7 @@ export const sq = {
current: "aktual", current: "aktual",
// Expanded drawer detail. // Expanded drawer detail.
drawerSection: "Arka", drawerSection: "Arka",
openingFloat: "Bilanci fillestar", openingFloat: "Arka fillestare",
cashTaken: "Para të marra", cashTaken: "Para të marra",
cashAdded: "Para të shtuara", cashAdded: "Para të shtuara",
cashRemoved: "Para të hequra", cashRemoved: "Para të hequra",
@@ -790,6 +846,42 @@ export const sq = {
path: "Rruga", path: "Rruga",
empty: "Asnjë regjistër.", empty: "Asnjë regjistër.",
}, },
backup: {
title: "Kopje rezervë",
intro:
"Kopje e enkriptuar e bazës së të dhënave (regjistri i nënshkruar) në një disk të jashtëm. Bëhet automatikisht çdo ditë dhe me butonin më poshtë.",
statusTitle: "Gjendja",
configured: "Aktive",
notConfigured: "E pakonfiguruar",
notConfiguredHint:
"Cakto BACKUP_TARGET_DIR dhe BACKUP_KEY në server që të aktivizohet kopja rezervë.",
running: "Duke u kryer…",
idle: "Në pritje",
lastSuccess: "Kopja e fundit e suksesshme",
lastError: "Gabimi i fundit",
never: "Asnjëherë",
lastFile: "Skedari",
size: "Madhësia",
pruned: "Të hequra",
runNow: "Bëj kopje tani",
runSuccess: "Kopja rezervë u krye.",
runFailed: "Kopja rezervë dështoi.",
notConfiguredError: "Kopja rezervë nuk është e konfiguruar.",
restoreNote:
"Rikthimi nuk bëhet nga këtu — është veprim i jashtëm gjatë instalimit të një aparati të ri (kërkon skedarin e kopjes + çelësat e ruajtur jashtë).",
targetLabel: "Vendndodhja e kopjes",
targetPlaceholder: "p.sh. /mnt/backup ose /media/usb",
targetHint: "Rrugë absolute drejt një disku të lidhur (USB/SATA) ose një ndarjeje rrjeti (SMB/NFS).",
save: "Ruaj",
saved: "U ruajt.",
test: "Testo vendndodhjen",
testOk: "Vendndodhja është e shkruajtshme.",
testEmpty: "Shkruaj një rrugë.",
testMissing: "Vendndodhja nuk ekziston.",
testNotDir: "Rruga nuk është një dosje.",
testNotWritable: "Dosja nuk është e shkruajtshme.",
keyMissing: "Çelësi i enkriptimit (BACKUP_KEY) mungon në server — caktoje që kopja të aktivizohet.",
},
pay: { pay: {
ticket: "Bileta", ticket: "Bileta",
entry: "Hyrja", entry: "Hyrja",
+23 -1
View File
@@ -16,6 +16,14 @@ export interface LaneStatus {
exit: boolean; // true = busy exit: boolean; // true = busy
} }
/** Per-lane RADAR presence — a presence input (loop/radar) is shorted at the barrier,
* i.e. "something is in the lane" BEFORE the camera confirms a vehicle. Drives the
* barrier light's BLINK (the same signal as the physical button lamp / relay 3). */
export interface LanePresence {
entry: boolean; // true = a radar/presence input on an entry barrier is active
exit: boolean; // true = … on an exit barrier
}
/** Cap the in-memory live feed so a long-running booth session can't grow it /** Cap the in-memory live feed so a long-running booth session can't grow it
* unbounded — the full history is always available via the /api/events query. */ * unbounded — the full history is always available via the /api/events query. */
const MAX_FEED = 200; const MAX_FEED = 200;
@@ -31,6 +39,8 @@ interface LiveState {
devices: Record<string, DeviceStatus>; devices: Record<string, DeviceStatus>;
/** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */ /** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */
lanes: LaneStatus | null; lanes: LaneStatus | null;
/** Per-lane radar presence (advisory blink). Null until the first WS hello. */
radar: LanePresence | null;
setStatus: (s: WsStatus) => void; setStatus: (s: WsStatus) => void;
setOccupancy: (o: Occupancy) => void; setOccupancy: (o: Occupancy) => void;
pushEvent: (e: LedgerEvent) => void; pushEvent: (e: LedgerEvent) => void;
@@ -40,6 +50,11 @@ interface LiveState {
upsertDevice: (d: DeviceStatus) => void; upsertDevice: (d: DeviceStatus) => void;
/** Set lane busy/free (WS hello + each lane-status push). */ /** Set lane busy/free (WS hello + each lane-status push). */
setLanes: (l: LaneStatus) => void; setLanes: (l: LaneStatus) => void;
/** Set lane radar presence (WS hello + each lane-presence push). */
setRadar: (r: LanePresence) => void;
/** Backfill the enriched plate on every feed event matching `identity` (a late async
* recognition that landed after the event's own push). No-op if no row matches. */
patchPlate: (identity: string, plate: string) => void;
reset: () => void; reset: () => void;
} }
@@ -56,6 +71,7 @@ export const useLiveStore = create<LiveState>((set) => ({
feed: [], feed: [],
devices: {}, devices: {},
lanes: null, lanes: null,
radar: null,
setStatus: (status) => set({ status }), setStatus: (status) => set({ status }),
setOccupancy: (occupancy) => set({ occupancy }), setOccupancy: (occupancy) => set({ occupancy }),
pushEvent: (e) => pushEvent: (e) =>
@@ -66,5 +82,11 @@ export const useLiveStore = create<LiveState>((set) => ({
setDevices: (list) => set({ devices: byId(list) }), setDevices: (list) => set({ devices: byId(list) }),
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })), upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
setLanes: (lanes) => set({ lanes }), setLanes: (lanes) => set({ lanes }),
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null }), setRadar: (radar) => set({ radar }),
patchPlate: (identity, plate) =>
set((s) => {
if (!s.feed.some((e) => e.identity === identity && !e.plate)) return s; // nothing to fill
return { feed: s.feed.map((e) => (e.identity === identity && !e.plate ? { ...e, plate } : e)) };
}),
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null, radar: null }),
})); }));
+18 -6
View File
@@ -1,14 +1,26 @@
import type { Theme } from "../api.js"; import type { Theme } from "../api.js";
import { FONT_SCALE_MAX, FONT_SCALE_MIN } from "../api.js";
// Theme application. The whole UI reads colour through the --color-term-* tokens; // Theme + font-scale application. The whole UI reads colour through the --color-term-*
// the light palette lives in index.css under `html.theme-light`. Applying a theme is // tokens; the light palette lives in index.css under `html.theme-light`. Applying a theme
// just toggling that class on <html>. The active theme is the LOGGED-IN USER's stored // is just toggling that class on <html>. Both are the LOGGED-IN USER's stored preferences
// preference (users.theme), applied via applyTheme() after auth resolves — mirroring // (users.theme / users.font_scale), applied after auth resolves — mirroring how language
// how language works. Dark is the default before auth resolves. Printed tickets are // works. Defaults (dark, 100%) apply before auth resolves. Printed tickets are unaffected.
// unaffected (always Albanian, dark-agnostic).
/** Apply a theme by toggling `theme-light` on <html>. Dark is the absence of the /** Apply a theme by toggling `theme-light` on <html>. Dark is the absence of the
* class (the base tokens). No-op-safe to call repeatedly. */ * class (the base tokens). No-op-safe to call repeatedly. */
export function applyTheme(theme: Theme): void { export function applyTheme(theme: Theme): void {
document.documentElement.classList.toggle("theme-light", theme === "light"); document.documentElement.classList.toggle("theme-light", theme === "light");
} }
/** Apply a font scale by setting the ROOT font-size (percent). The app's text is sized in
* rem (the `text-[…rem]` utilities + the .label/.input/.hint/.btn component classes all
* derive from the root), so only TEXT scales — viewport-locked layout (h-screen frame,
* max-h-[90vh] modals, vh units) is unaffected, so headers/footers never clip; taller
* content just scrolls its own container. NOT `zoom` (which scaled those vh boxes too and
* pushed modal chrome out of view). Clamped to the band; no-op-safe to call repeatedly. */
export function applyFontScale(pct: number): void {
const clamped = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round(pct)));
// 100% = the browser's 16px root. The app's rem units scale off this.
document.documentElement.style.fontSize = clamped === 100 ? "" : `${clamped}%`;
}
+15 -4
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js"; import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
import { qk } from "./query.js"; import { qk } from "./query.js";
import { useLiveStore, type LaneStatus } from "./live-store.js"; import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js";
import { wsUrl } from "./origin.js"; import { wsUrl } from "./origin.js";
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into // Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
@@ -14,16 +14,19 @@ import { wsUrl } from "./origin.js";
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */ /** Server → client message shapes (mirror routes/ws.ts OutMsg). */
type WsMessage = type WsMessage =
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus } | { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus; radar: LanePresence }
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy } | { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
| { kind: "printer-status"; event: unknown } | { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: DeviceStatus } | { kind: "device-status"; event: DeviceStatus }
| { kind: "lane-status"; lanes: LaneStatus }; | { kind: "lane-status"; lanes: LaneStatus }
| { kind: "lane-presence"; radar: LanePresence }
| { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } };
export function useLiveFeed(): void { export function useLiveFeed(): void {
const qc = useQueryClient(); const qc = useQueryClient();
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes } = useLiveStore(); const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } =
useLiveStore();
// Hold the socket + reconnect timer across renders; guard against StrictMode // Hold the socket + reconnect timer across renders; guard against StrictMode
// double-invoke and unmount. // double-invoke and unmount.
const sockRef = useRef<WebSocket | null>(null); const sockRef = useRef<WebSocket | null>(null);
@@ -56,10 +59,18 @@ export function useLiveFeed(): void {
// Initial device-status snapshot for the footer. // Initial device-status snapshot for the footer.
if (Array.isArray(msg.devices)) setDevices(msg.devices); if (Array.isArray(msg.devices)) setDevices(msg.devices);
if (msg.lanes) setLanes(msg.lanes); if (msg.lanes) setLanes(msg.lanes);
if (msg.radar) setRadar(msg.radar);
} else if (msg.kind === "device-status") { } else if (msg.kind === "device-status") {
upsertDevice(msg.event); upsertDevice(msg.event);
} else if (msg.kind === "lane-status") { } else if (msg.kind === "lane-status") {
setLanes(msg.lanes); setLanes(msg.lanes);
} else if (msg.kind === "lane-presence") {
setRadar(msg.radar);
} else if (msg.kind === "plate-recognized") {
// Backfill the badge on the already-rendered feed row, and refetch the
// Query-owned active-sessions list (re-runs enrichEvents → the now-written plate).
patchPlate(msg.plate.identity, msg.plate.plate);
void qc.invalidateQueries({ queryKey: qk.activeSessions });
} else if (msg.kind === "ledger") { } else if (msg.kind === "ledger") {
setOccupancy(msg.occupancy); setOccupancy(msg.occupancy);
pushEvent(msg.event); pushEvent(msg.event);
+78 -14
View File
@@ -10,11 +10,23 @@ import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { Lang, Permission, SessionUser, Theme } from "./api.js"; import type { Lang, Permission, SessionUser, Theme } from "./api.js";
import { can, closeShift, fetchShiftReport, logout, openShift, setLanguagePref, setThemePref } from "./api.js"; import {
can,
closeShift,
fetchShiftReport,
logout,
openShift,
setLanguagePref,
setThemePref,
setFontScalePref,
FONT_SCALE_MIN,
FONT_SCALE_MAX,
FONT_SCALE_STEP,
} from "./api.js";
import { qk, queryClient } from "./lib/query.js"; import { qk, queryClient } from "./lib/query.js";
import { Modal } from "./ui/Modal.js"; import { Modal } from "./ui/Modal.js";
import { setLanguage } from "./lib/i18n/index.js"; import { setLanguage } from "./lib/i18n/index.js";
import { applyTheme } from "./lib/theme.js"; import { applyTheme, applyFontScale } from "./lib/theme.js";
import { useLiveFeed } from "./lib/use-live-feed.js"; import { useLiveFeed } from "./lib/use-live-feed.js";
import { useShift } from "./lib/use-shift.js"; import { useShift } from "./lib/use-shift.js";
import { DeviceFooter } from "./ui/DeviceFooter.js"; import { DeviceFooter } from "./ui/DeviceFooter.js";
@@ -30,6 +42,7 @@ import { UsersManager } from "./UsersManager.js";
import { RolesManager } from "./RolesManager.js"; import { RolesManager } from "./RolesManager.js";
import { ShiftsHistory } from "./ShiftsHistory.js"; import { ShiftsHistory } from "./ShiftsHistory.js";
import { LogsViewer } from "./LogsViewer.js"; import { LogsViewer } from "./LogsViewer.js";
import { BackupSettings } from "./BackupSettings.js";
import { RecycleBin } from "./RecycleBin.js"; import { RecycleBin } from "./RecycleBin.js";
import { Profile } from "./Profile.js"; import { Profile } from "./Profile.js";
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's // Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
@@ -56,7 +69,7 @@ function NavLink({ to, label }: { to: string; label: string }) {
return ( return (
<Link <Link
to={to} to={to}
className="px-2 py-1 text-[11px] uppercase tracking-wider text-term-muted rounded-term hover:text-term-text [&.active]:text-term-amber [&.active]:bg-term-panel-2" className="px-2 py-1 text-[0.6875rem] uppercase tracking-wider text-term-muted rounded-term hover:text-term-text [&.active]:text-term-amber [&.active]:bg-term-panel-2"
> >
{label} {label}
</Link> </Link>
@@ -70,7 +83,7 @@ function SetupTab({ to, label, exact = false }: { to: string; label: string; exa
<Link <Link
to={to} to={to}
activeOptions={{ exact }} activeOptions={{ exact }}
className="border-b-2 border-transparent px-3 py-2 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text [&.active]:border-term-amber [&.active]:text-term-amber" className="border-b-2 border-transparent px-3 py-2 text-[0.75rem] uppercase tracking-wider text-term-muted hover:text-term-text [&.active]:border-term-amber [&.active]:text-term-amber"
> >
{label} {label}
</Link> </Link>
@@ -94,6 +107,7 @@ function SetupLayout() {
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />} {show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />} {show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />} {show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
</nav> </nav>
<Outlet /> <Outlet />
</div> </div>
@@ -147,7 +161,7 @@ function LanguageToggle({
} }
} }
return ( return (
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider"> <div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
{(["sq", "en"] as const).map((l) => ( {(["sq", "en"] as const).map((l) => (
<button <button
key={l} key={l}
@@ -193,7 +207,7 @@ function ThemeToggle({
} }
} }
return ( return (
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider"> <div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
{(["dark", "light"] as const).map((th) => ( {(["dark", "light"] as const).map((th) => (
<button <button
key={th} key={th}
@@ -210,6 +224,37 @@ function ThemeToggle({
); );
} }
/** Header font-size control: A−/value/A+ scaling the whole UI (root font-size). Persisted
* to the user profile like the theme, restored on next login. Local `active` state seeded
* from the prop (the router context doesn't re-render on setUser); App's effect keeps the
* DOM in sync with the persisted user on (re)login. */
function FontScaleToggle({ user, setUser }: { user: SessionUser; setUser: (u: SessionUser | null) => void }) {
const { t } = useTranslation();
const [active, setActive] = useState<number>(user.fontScale);
function step(delta: number) {
const next = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, active + delta));
if (next === active) return;
setActive(next);
applyFontScale(next); // instant UI
setUser({ ...user, fontScale: next });
void setFontScalePref(next).catch(() => {
/* non-fatal — the choice still applies this session */
});
}
const btn = "rounded-term px-1.5 py-0.5 text-term-muted hover:text-term-text disabled:opacity-40";
return (
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
<button type="button" className={btn} onClick={() => step(-FONT_SCALE_STEP)} disabled={active <= FONT_SCALE_MIN} title={t("common.fontSmaller")} aria-label={t("common.fontSmaller")}>
A−
</button>
<span className="min-w-[2.5rem] text-center text-term-muted" title={t("common.fontSize")}>{active}%</span>
<button type="button" className={btn} onClick={() => step(FONT_SCALE_STEP)} disabled={active >= FONT_SCALE_MAX} title={t("common.fontLarger")} aria-label={t("common.fontLarger")}>
A+
</button>
</div>
);
}
/** /**
* Header shift control — the site-wide single-open shift expressed as one button: * Header shift control — the site-wide single-open shift expressed as one button:
* - no shift open → "Open shift" (enabled; opens this operator's shift) * - no shift open → "Open shift" (enabled; opens this operator's shift)
@@ -273,14 +318,14 @@ function ShiftButton() {
disabled={busy || blockedByOther} disabled={busy || blockedByOther}
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined} title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
onClick={onClick} onClick={onClick}
className={`rounded-term border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${tone}`} className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider ${tone}`}
> >
{busy ? t("shift.opening") : label} {busy ? t("shift.opening") : label}
</button> </button>
{!isOpen && ( {!isOpen && (
<span className="text-[10px] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span> <span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
)} )}
{err && <span className="text-[10px] text-term-red">{err}</span>} {err && <span className="text-[0.625rem] text-term-red">{err}</span>}
{confirmingClose && ( {confirmingClose && (
<CloseShiftConfirm <CloseShiftConfirm
busy={busy} busy={busy}
@@ -315,7 +360,7 @@ function CloseShiftConfirm({
return ( return (
<Modal open onClose={onCancel} title={t("shift.endShift")} width="max-w-md"> <Modal open onClose={onCancel} title={t("shift.endShift")} width="max-w-md">
<div className="text-[13px] tabular-nums"> <div className="text-[0.8125rem] tabular-nums">
<p className="text-term-muted">{t("shift.endConfirm")}</p> <p className="text-term-muted">{t("shift.endConfirm")}</p>
{!x ? ( {!x ? (
<p className="mt-2 text-term-muted">{t("common.loading")}</p> <p className="mt-2 text-term-muted">{t("common.loading")}</p>
@@ -327,12 +372,17 @@ function CloseShiftConfirm({
{/* Split by source — the operator's ask: subscription money apart from tickets. */} {/* Split by source — the operator's ask: subscription money apart from tickets. */}
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} /> <ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} /> <ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
<ConfirmFigure label={t("shift.srcSubSales")} value={fmt(x.subscriptionSalesMinor)} sub /> {/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
part is broken out below it; subscription SALES is not (it's the remainder). */}
<span />
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub /> <ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
</div> </div>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2"> <div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} /> <ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
<ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} /> <ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
<span />
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold /> <ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
</div> </div>
</> </>
@@ -353,10 +403,13 @@ function CloseShiftConfirm({
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) { function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
return ( return (
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}> <div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
<span className={`text-[11px] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}> <span
className={`whitespace-nowrap text-[0.6875rem] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}
>
{label} {label}
</span> </span>
<span className={bold ? "font-semibold text-term-text" : "text-term-text"}>{value}</span> {/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
</div> </div>
); );
} }
@@ -393,18 +446,20 @@ function RootLayout() {
show("user:read") || show("user:read") ||
show("role:read") || show("role:read") ||
show("recyclebin:read") || show("recyclebin:read") ||
show("backup:read") ||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />} show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
</nav> </nav>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
{user && <ShiftButton />} {user && <ShiftButton />}
{user && <LanguageToggle user={user} setUser={setUser} />} {user && <LanguageToggle user={user} setUser={setUser} />}
{user && <ThemeToggle user={user} setUser={setUser} />} {user && <ThemeToggle user={user} setUser={setUser} />}
{user && <FontScaleToggle user={user} setUser={setUser} />}
<StatusDot /> <StatusDot />
{user && ( {user && (
<Link <Link
to="/profile" to="/profile"
title={t("nav.profile")} title={t("nav.profile")}
className="text-[11px] text-term-muted hover:text-term-text [&.active]:text-term-amber" className="text-[0.6875rem] text-term-muted hover:text-term-text [&.active]:text-term-amber"
> >
{user.username} · {user.roleName} {user.username} · {user.roleName}
</Link> </Link>
@@ -642,6 +697,14 @@ const logsRoute = createRoute({
component: LogsViewer, component: LogsViewer,
}); });
// Encrypted DB backup — status + manual run. Gated by backup:read (run by backup:create).
const backupRoute = createRoute({
getParentRoute: () => setupRoute,
path: "backup",
beforeLoad: ({ context }) => requirePerm("backup:read")(context),
component: BackupSettings,
});
// My profile — self-service for ANY signed-in user (no permission gate). Edits only // My profile — self-service for ANY signed-in user (no permission gate). Edits only
// the caller's own name/email/password. See Profile.tsx and routes/auth.ts. // the caller's own name/email/password. See Profile.tsx and routes/auth.ts.
const profileRoute = createRoute({ const profileRoute = createRoute({
@@ -674,6 +737,7 @@ const routeTree = rootRoute.addChildren([
rolesRoute, rolesRoute,
recycleBinRoute, recycleBinRoute,
logsRoute, logsRoute,
backupRoute,
]), ]),
]); ]);
+6 -6
View File
@@ -98,7 +98,7 @@ export function DeviceFooter() {
return ( return (
<footer <footer
ref={rootRef} ref={rootRef}
className="relative flex shrink-0 items-center gap-2 overflow-visible border-t border-term-border bg-term-panel px-3 py-1.5 text-[11px]" className="relative flex shrink-0 items-center gap-2 overflow-visible border-t border-term-border bg-term-panel px-3 py-1.5 text-[0.6875rem]"
> >
<span className="shrink-0 font-semibold uppercase tracking-wider text-term-muted"> <span className="shrink-0 font-semibold uppercase tracking-wider text-term-muted">
{t("devices.footerTitle")} {t("devices.footerTitle")}
@@ -156,7 +156,7 @@ export function DeviceFooter() {
{open && problems.length > 0 && ( {open && problems.length > 0 && (
<div className="absolute bottom-full right-2 z-50 mb-1 w-[360px] max-w-[95vw] rounded-term border border-term-border bg-term-panel shadow-2xl"> <div className="absolute bottom-full right-2 z-50 mb-1 w-[360px] max-w-[95vw] rounded-term border border-term-border bg-term-panel shadow-2xl">
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-3 py-1.5"> <div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-3 py-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-term-amber"> <span className="text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
{t("devices.issuesTitle")} {t("devices.issuesTitle")}
</span> </span>
<button <button
@@ -177,13 +177,13 @@ export function DeviceFooter() {
<span className={`mt-1 inline-block h-2 w-2 shrink-0 rounded-full ${DOT[d.state]}`} /> <span className={`mt-1 inline-block h-2 w-2 shrink-0 rounded-full ${DOT[d.state]}`} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-baseline justify-between gap-2">
<span className={`text-[12px] font-semibold ${TEXT[d.state]}`}>{label(d)}</span> <span className={`text-[0.75rem] font-semibold ${TEXT[d.state]}`}>{label(d)}</span>
<span className="shrink-0 text-[10px] uppercase tracking-wider text-term-muted"> <span className="shrink-0 text-[0.625rem] uppercase tracking-wider text-term-muted">
{t(`devices.state.${d.state}`)} {t(`devices.state.${d.state}`)}
</span> </span>
</div> </div>
{d.detail && <div className="mt-0.5 break-words text-[11px] text-term-muted">{d.detail}</div>} {d.detail && <div className="mt-0.5 break-words text-[0.6875rem] text-term-muted">{d.detail}</div>}
<div className="mt-0.5 text-[10px] tabular-nums text-term-muted/70"> <div className="mt-0.5 text-[0.625rem] tabular-nums text-term-muted/70">
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })} {t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
</div> </div>
</div> </div>
+2 -2
View File
@@ -27,7 +27,7 @@ export function SegGroup<V extends string>({
key={v || "all"} key={v || "all"}
type="button" type="button"
onClick={() => onChange(v)} onClick={() => onChange(v)}
className={`px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${ className={`px-2 py-0.5 text-[0.625rem] uppercase tracking-wider transition-colors ${
value === v ? "bg-term-border text-term-text" : "text-term-muted hover:text-term-text" value === v ? "bg-term-border text-term-text" : "text-term-muted hover:text-term-text"
}`} }`}
> >
@@ -61,7 +61,7 @@ export function FilterBar({
value={search} value={search}
onChange={(e) => onSearch(e.target.value)} onChange={(e) => onSearch(e.target.value)}
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
className="min-w-[8rem] flex-1 rounded border border-term-border/60 bg-transparent px-2 py-0.5 text-[12px] text-term-text placeholder:text-term-muted focus:border-term-amber focus:outline-none" className="min-w-[8rem] flex-1 rounded border border-term-border/60 bg-transparent px-2 py-0.5 text-[0.75rem] text-term-text placeholder:text-term-muted focus:border-term-amber focus:outline-none"
/> />
{children} {children}
</div> </div>
+1 -1
View File
@@ -30,7 +30,7 @@ export function Modal({
aria-describedby={undefined} aria-describedby={undefined}
> >
<div className="sticky top-0 flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2"> <div className="sticky top-0 flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber"> <Dialog.Title className="m-0 text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
{title} {title}
</Dialog.Title> </Dialog.Title>
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close"> <Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close">
+1 -1
View File
@@ -21,7 +21,7 @@ export function Panel({
> >
{title && ( {title && (
<header className="flex items-center justify-between px-3 py-1.5 bg-term-panel-2 border-b border-term-border"> <header className="flex items-center justify-between px-3 py-1.5 bg-term-panel-2 border-b border-term-border">
<h2 className="m-0 text-[11px] font-semibold uppercase tracking-wider text-term-amber"> <h2 className="m-0 text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
{title} {title}
</h2> </h2>
{right} {right}
+8 -8
View File
@@ -37,9 +37,9 @@ export function SnapshotStrip({ identity }: { identity: string }) {
const dirLabel = (dir: "entry" | "exit" | null): string => const dirLabel = (dir: "entry" | "exit" | null): string =>
dir === "entry" ? t("pay.snapEntry") : dir === "exit" ? t("pay.snapExit") : "—"; dir === "entry" ? t("pay.snapEntry") : dir === "exit" ? t("pay.snapExit") : "—";
if (isLoading) return <div className="text-[11px] text-term-muted">{t("pay.loadingSnapshots")}</div>; if (isLoading) return <div className="text-[0.6875rem] text-term-muted">{t("pay.loadingSnapshots")}</div>;
if (shots.length === 0 && failures.length === 0 && plates.length === 0) if (shots.length === 0 && failures.length === 0 && plates.length === 0)
return <div className="text-[11px] text-term-muted">{t("pay.noSnapshots")}</div>; return <div className="text-[0.6875rem] text-term-muted">{t("pay.noSnapshots")}</div>;
return ( return (
<> <>
@@ -50,15 +50,15 @@ export function SnapshotStrip({ identity }: { identity: string }) {
{dedupePlates(plates).map((p, i) => ( {dedupePlates(plates).map((p, i) => (
<span <span
key={`${p.plate}-${p.direction}-${i}`} key={`${p.plate}-${p.direction}-${i}`}
className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[11px]" className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[0.6875rem]"
title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${ title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${
p.at ? ` · ${new Date(p.at).toLocaleString()}` : "" p.at ? ` · ${new Date(p.at).toLocaleString()}` : ""
}`} }`}
> >
<span className="text-[9px] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span> <span className="text-[0.5625rem] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
<span className="font-mono font-semibold text-term-cyan">{p.plate}</span> <span className="font-mono font-semibold text-term-cyan">{p.plate}</span>
{typeof p.confidence === "number" && ( {typeof p.confidence === "number" && (
<span className="text-[10px] text-term-muted">{(p.confidence * 100).toFixed(0)}%</span> <span className="text-[0.625rem] text-term-muted">{(p.confidence * 100).toFixed(0)}%</span>
)} )}
</span> </span>
))} ))}
@@ -81,7 +81,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
loading="lazy" loading="lazy"
/> />
<span <span
className={`text-[9px] uppercase tracking-wider ${ className={`text-[0.5625rem] uppercase tracking-wider ${
s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted" s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted"
}`} }`}
> >
@@ -100,8 +100,8 @@ export function SnapshotStrip({ identity }: { identity: string }) {
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${new Date(f.occurredAt).toLocaleString()}` : ""}`} title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${new Date(f.occurredAt).toLocaleString()}` : ""}`}
> >
<span className="text-lg leading-none text-term-amber">⚠</span> <span className="text-lg leading-none text-term-amber">⚠</span>
<span className="text-[9px] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span> <span className="text-[0.5625rem] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
<span className="px-1 text-[9px] leading-tight text-term-muted">{t("pay.snapFailed")}</span> <span className="px-1 text-[0.5625rem] leading-tight text-term-muted">{t("pay.snapFailed")}</span>
</div> </div>
))} ))}
</div> </div>
+1 -1
View File
@@ -19,7 +19,7 @@ export function StatusDot() {
const { t } = useTranslation(); const { t } = useTranslation();
const status = useLiveStore((s) => s.status); const status = useLiveStore((s) => s.status);
return ( return (
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-term-muted"> <span className="flex items-center gap-1.5 text-[0.625rem] uppercase tracking-wider text-term-muted">
<span <span
className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`} className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`}
/> />
+31 -39
View File
@@ -95,9 +95,9 @@ export function displayIdentity(e: LedgerEvent): string {
return e.subscriberLabel ?? e.identity ?? "—"; return e.subscriberLabel ?? e.identity ?? "—";
} }
/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps /** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps the
* the time/label/identity/index columns aligned across rows; the detail line lives in * time/label/#index columns aligned across rows; the identity, plate, badges and reason flow
* its own row, indented under the identity column. */ * inline in the middle column and wrap there only when they run out of width. */
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) { export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const style = eventStyleFor(e); const style = eventStyleFor(e);
@@ -110,52 +110,44 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
const reason = renderReason(p, t); const reason = renderReason(p, t);
const amount = paymentSummary(p); const amount = paymentSummary(p);
const badges = eventBadges(p); const badges = eventBadges(p);
const via = viaKey(p);
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null); const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
const showDetail = detail != null || badges.length > 0 || via != null;
return ( return (
<button <button
type="button" type="button"
onClick={() => onOpen(e)} onClick={() => onOpen(e)}
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${ className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-start gap-x-3 border-b border-term-border/50 px-1 py-1 text-left text-[0.75rem] tabular-nums hover:bg-term-panel-2 ${
isAnomaly ? "bg-term-red/5" : refusedWarning ? "bg-term-amber/5" : "" isAnomaly ? "bg-term-red/5" : refusedWarning ? "bg-term-amber/5" : ""
}`} }`}
> >
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span> <span className="py-px text-term-muted">{hhmmss(e.occurredAt)}</span>
<span className={`shrink-0 font-semibold ${style.color}`}>{label}</span> <span className={`py-px shrink-0 font-semibold ${style.color}`}>{label}</span>
<span className="flex min-w-0 items-center gap-2"> {/* Identity + plate + detail all flow in ONE wrapping line — they fill the available
<span className="truncate text-term-text">{displayIdentity(e)}</span> width and only wrap to a second line when this cell actually runs out of room (no
forced second row). Keeps time/label/#index in their columns. */}
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 py-px">
<span className="break-all text-term-text">{displayIdentity(e)}</span>
{e.plate && ( {e.plate && (
<span <span
className="shrink-0 rounded border border-term-border px-1 text-[11px] font-semibold tracking-wide text-term-amber" className="shrink-0 rounded border border-term-border px-1 text-[0.6875rem] font-semibold tracking-wide text-term-amber"
title={t("booth.plateTitle")} title={t("booth.plateTitle")}
> >
{e.plate} {e.plate}
</span> </span>
)} )}
</span>
<span className="text-term-muted">#{e.index}</span>
{showDetail && (
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
{badges.map((k) => ( {badges.map((k) => (
<span <span
key={k} key={k}
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red" className="shrink-0 rounded-sm bg-term-red/15 px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide text-term-red"
> >
{t(k)} {t(k)}
</span> </span>
))} ))}
{via && (
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
{t(via)}
</span>
)}
{detail && ( {detail && (
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span> <span className={`${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
)}
</div>
)} )}
</span>
<span className="py-px text-term-muted">#{e.index}</span>
</button> </button>
); );
} }
@@ -163,8 +155,8 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
/** One label/value line in the event-detail modal. */ /** One label/value line in the event-detail modal. */
function DetailRow({ label, children }: { label: string; children: ReactNode }) { function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return ( return (
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[12px]"> <div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[0.75rem]">
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span> <span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</span>
<span className="min-w-0 break-words text-term-text">{children}</span> <span className="min-w-0 break-words text-term-text">{children}</span>
</div> </div>
); );
@@ -203,19 +195,19 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}> <div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
<div className={`text-sm font-bold uppercase tracking-widest ${style.color}`}>{label}</div> <div className={`text-sm font-bold uppercase tracking-widest ${style.color}`}>{label}</div>
{(reason || money) && ( {(reason || money) && (
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}> <div className={`mt-1 text-[0.8125rem] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
{reason ?? money} {reason ?? money}
</div> </div>
)} )}
{!reason && !money && isAnomaly && ( {!reason && !money && isAnomaly && (
<div className="mt-1 text-[13px] text-term-red/90">{t("booth.evtNoReason")}</div> <div className="mt-1 text-[0.8125rem] text-term-red/90">{t("booth.evtNoReason")}</div>
)} )}
{badges.length > 0 && ( {badges.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 flex flex-wrap gap-1.5">
{badges.map((k) => ( {badges.map((k) => (
<span <span
key={k} key={k}
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red" className="rounded-sm bg-term-red/15 px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide text-term-red"
> >
{t(k)} {t(k)}
</span> </span>
@@ -235,7 +227,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
(the SUBSESS-… session key) for traceability against the ledger. */} (the SUBSESS-… session key) for traceability against the ledger. */}
{e.subscriberLabel && e.identity && ( {e.subscriberLabel && e.identity && (
<DetailRow label={t("booth.edOccurrence")}> <DetailRow label={t("booth.edOccurrence")}>
<code className="text-[11px] text-term-muted">{e.identity}</code> <code className="text-[0.6875rem] text-term-muted">{e.identity}</code>
</DetailRow> </DetailRow>
)} )}
{money && ( {money && (
@@ -257,7 +249,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
)} )}
{tariffVersionId && ( {tariffVersionId && (
<DetailRow label={t("booth.edTariffVersion")}> <DetailRow label={t("booth.edTariffVersion")}>
<code className="text-[11px] text-term-muted">{tariffVersionId}</code> <code className="text-[0.6875rem] text-term-muted">{tariffVersionId}</code>
</DetailRow> </DetailRow>
)} )}
</div> </div>
@@ -265,7 +257,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
{/* The entry/exit evidence images for this session's identity. */} {/* The entry/exit evidence images for this session's identity. */}
{e.identity && ( {e.identity && (
<div> <div>
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div> <div className="mb-1.5 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
<SnapshotStrip identity={e.identity} /> <SnapshotStrip identity={e.identity} />
</div> </div>
)} )}
@@ -275,28 +267,28 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
operator's; tucking them behind a disclosure keeps the common view clean operator's; tucking them behind a disclosure keeps the common view clean
while preserving the tamper-evidence trail on demand. */} while preserving the tamper-evidence trail on demand. */}
<details className="rounded-term border border-term-border bg-term-panel-2"> <details className="rounded-term border border-term-border bg-term-panel-2">
<summary className="cursor-pointer select-none px-3 py-2 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text"> <summary className="cursor-pointer select-none px-3 py-2 text-[0.6875rem] uppercase tracking-wider text-term-muted hover:text-term-text">
{t("booth.edAuditData")} {t("booth.edAuditData")}
</summary> </summary>
<div className="border-t border-term-border px-3 pb-3 pt-1"> <div className="border-t border-term-border px-3 pb-3 pt-1">
<DetailRow label={t("booth.edSignature")}> <DetailRow label={t("booth.edSignature")}>
<code className="break-all text-[11px] text-term-muted">{e.signature}</code> <code className="break-all text-[0.6875rem] text-term-muted">{e.signature}</code>
</DetailRow> </DetailRow>
<DetailRow label={t("booth.edKeyId")}> <DetailRow label={t("booth.edKeyId")}>
<code className="text-[11px] text-term-muted">{e.keyId}</code> <code className="text-[0.6875rem] text-term-muted">{e.keyId}</code>
</DetailRow> </DetailRow>
<DetailRow label={t("booth.edPrevHash")}> <DetailRow label={t("booth.edPrevHash")}>
<code className="break-all text-[11px] text-term-muted">{e.prevHash ?? "—"}</code> <code className="break-all text-[0.6875rem] text-term-muted">{e.prevHash ?? "—"}</code>
</DetailRow> </DetailRow>
<div className="mb-1.5 mt-3 text-[11px] uppercase tracking-wider text-term-muted"> <div className="mb-1.5 mt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
{t("booth.edRawPayload")} {t("booth.edRawPayload")}
</div> </div>
{p && Object.keys(p).length > 0 ? ( {p && Object.keys(p).length > 0 ? (
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[11px] text-term-text"> <pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[0.6875rem] text-term-text">
{JSON.stringify(p, null, 2)} {JSON.stringify(p, null, 2)}
</pre> </pre>
) : ( ) : (
<div className="text-[12px] text-term-muted">{t("booth.edNoPayload")}</div> <div className="text-[0.75rem] text-term-muted">{t("booth.edNoPayload")}</div>
)} )}
</div> </div>
</details> </details>
+54 -13
View File
@@ -7,24 +7,25 @@
# See wiki/decisions/container-deployment.md. # See wiki/decisions/container-deployment.md.
services: services:
# Reverse proxy: :80 → server:3000 (WebSocket /api/ws upgrades pass through natively). # Reverse proxy: :80 → server (127.0.0.1:3000). On the HOST network (see the server note),
# Caddy is a single static binary with a one-line proxy config; swapping http:// for the # so it reaches the host-net server over loopback and publishes :80 directly on the host.
# site's real hostname later enables automatic HTTPS. The booth is reached at # WebSocket /api/ws upgrades pass through natively. Swapping http:// for the site's real
# http://<name-or-ip>/ (the name set via hosts/DNS on-site — NOT baked into any image). # hostname later enables automatic HTTPS. Reached at http://<name-or-ip>/ (name via hosts/DNS
# on-site — NOT baked into any image).
proxy: proxy:
image: caddy:2-alpine image: caddy:2-alpine
restart: always restart: always
ports: # Host network: Caddy listens on the host's :80 and proxies the host-net server on
- "80:80" # 127.0.0.1:3000. (No `ports:` mapping — host mode publishes directly.)
# - "443:443" # uncomment when moving to TLS (and set a real hostname in Caddyfile) network_mode: host
# host mode is mutually exclusive with a named network; the base file doesn't attach proxy,
# so nothing to null here (server does — see below).
volumes: volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro - ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data - caddy-data:/data
- caddy-config:/config - caddy-config:/config
depends_on: depends_on:
- server - server
networks:
- parking
logging: logging:
driver: json-file driver: json-file
options: options:
@@ -33,9 +34,45 @@ services:
server: server:
restart: always restart: always
# No published port — only the proxy reaches the server, over the private network. # HOST NETWORK — the crux of the appliance. The server is the ONLY container doing device
expose: # I/O (camera ISAPI snapshots, relay control, receiving reader/alarm pushes), all on the
- "3000" # booth's LAN / isolated device VLAN (10.0.10.x). On a bridge network it sees only the Docker
# subnet (172.18.0.x) — it can't reach the relay, can't be reached by push devices, and the
# backend-IP picker (net.ts networkInterfaces) only sees eth0. Host mode puts it on the real
# NICs. Vision stays bridged (it never touches a device — the server hands it JPEG bytes).
network_mode: host
# host mode is mutually exclusive with a named network — detach the base file's `parking`
# attachment (compose errors otherwise: "network_mode and networks cannot both be set").
networks: !reset []
# Listens on :3000 directly on the host (Caddy proxies it). Loopback to vision:
environment:
VISION_URL: http://127.0.0.1:8089
# NB: NO `sysctls:` here. net.ipv4.ping_group_range is a per-netns sysctl; under host net
# there is no separate namespace, and runc REFUSES it ("not allowed in host network
# namespace"). Reader liveness ping uses the HOST's setting instead — the booth host must
# set net.ipv4.ping_group_range (see appliance-provisioning §7 / disk-os-hardening).
#
# USB PRINTER PASSTHROUGH. A USB ESC/POS printer (Rongta/Cashino) is the kernel `usblp` char
# device /dev/usb/lpN on the HOST — the container has its own /dev and can't see it (probeUsb
# open() → ENOENT → printer always "offline"). Two parts, both needed:
# - bind-mount /dev/usb so the lpN NODES appear inside the container, and
# - a device-cgroup rule permitting the usblp char major (180) so the kernel allows the
# open(). `180:*` covers lp0/lp1/lp2… so a USB replug/boot-order renumber still works
# (the printer's path can move; set Connection=USB + the matching /dev/usb/lpN in setup).
# (Bind-mounting the dir, not a single `devices:` node, is what survives renumbering.)
#
# ...AND access: the lpN node is `crw-rw---- root:lp` (mode 660). The server runs as the
# non-root `app` user, which is NOT in `lp`, so open(O_WRONLY) → EACCES → still "offline".
# group_add the HOST's `lp` GID (numeric — `getent group lp`, typically 7 on Debian/Ubuntu)
# so the app process gains that supplementary group and can write the 660 node. Least-
# privilege (no world-writable device, no root, no rebuild). VERIFY the GID on the booth;
# if the host's lp GID differs, change the number here.
group_add:
- "7"
volumes:
- /dev/usb:/dev/usb
device_cgroup_rules:
- "c 180:* rmw"
logging: logging:
driver: json-file driver: json-file
options: options:
@@ -45,9 +82,13 @@ services:
vision: vision:
restart: always restart: always
# The real ANPR engine. The image baked the model weights at build (offline-first). # The real ANPR engine. The image baked the model weights at build (offline-first).
# Stays on the bridge network (isolated — it makes NO outbound device calls), but PUBLISHES
# 8089 on the host LOOPBACK ONLY so the host-net server can reach it. 127.0.0.1 binding keeps
# it off the booth LAN — nothing on the network can hit the ANPR service.
environment: environment:
VISION_RECOGNIZER: fast_alpr VISION_RECOGNIZER: fast_alpr
# No published ports — vision is reached only by the server over the private network. ports:
- "127.0.0.1:8089:8089"
logging: logging:
driver: json-file driver: json-file
options: options:
+5 -2
View File
@@ -13,8 +13,11 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
DATABASE_URL: /data/parking.sqlite DATABASE_URL: /data/parking.sqlite
# Reach the vision service over the private compose network by service name. # Reach the vision service. DEV: the private compose-network service name (`vision`).
VISION_URL: http://vision:8089 # PROD: the server runs on the HOST network (to see the booth LAN / device VLAN — it's the
# only container doing device I/O), where compose DNS doesn't resolve, so the prod override
# sets VISION_URL=http://127.0.0.1:8089 and vision publishes 8089 on the host loopback.
VISION_URL: ${VISION_URL:-http://vision:8089}
VISION_ENABLED: ${VISION_ENABLED:-1} VISION_ENABLED: ${VISION_ENABLED:-1}
# JWT signing secret MUST be provided at deploy (no insecure default — see auth.ts). # JWT signing secret MUST be provided at deploy (no insecure default — see auth.ts).
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in the env/.env} JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in the env/.env}
+238
View File
@@ -0,0 +1,238 @@
# i18n long-sentence review — EN vs SQ
Generated 2026-06-24. Source: `apps/web/src/lib/i18n/en.ts` + `sq.ts`. Threshold: strings ≥ 80 chars in either language (35 keys).
Review each pair for MEANING (does SQ say the same as EN?). Mark your decision in the **Verdict** line: `OK` / `FIX: <new text>` / `?`.
Keys with an automated note are tagged **⚑ NOTE**.
---
## 1. `badgeOverstayTitle` ⚑
- **EN** (en.ts:138, 86 ch): Paid session. The customer failed to exit during the grace period. A new period began.
- **SQ** (sq.ts:140, 93 ch): Sesion i paguar. Klienti nuk doli brenda afatit kohor. Ka filluar një periudhë e re tarifimi.
- **⚑ NOTE:** Spelling: "brënda" → standard is "brenda" (no ë).
- **Verdict:**
## 2. `activeSince`
- **EN** (en.ts:254, 133 ch): Active since {{date}} · {{count}} version(s) in history. Publishing creates a new version; past sessions keep their original pricing.
- **SQ** (sq.ts:257, 137 ch): Aktive që nga {{date}} · {{count}} version(e) në histori. Publikimi krijon një version të ri; sesionet e kaluara ruajnë çmimin origjinal.
- **Verdict:**
## 3. `rateBlocksHint`
- **EN** (en.ts:263, 256 ch): Each band lasts a number of hours and bills at its own price; bands are consumed in order (the first hours, then the next hours). The last band is \"thereafter\" (open-ended) — its price applies once the ladder is exhausted. Price is per billing increment.
- **SQ** (sq.ts:266, 251 ch): Çdo brez zgjat një numër orësh dhe faturohet me çmimin e tij; brezat konsumohen me radhë (orët e para, pastaj orët në vijim). Brezi i fundit është \"më pas\" (i hapur) — çmimi i tij zbatohet pas mbarimit të shkallës. Çmimi është për interval faturimi.
- **Verdict:**
## 4. `defaultCardHint`
- **EN** (en.ts:275, 98 ch): The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.
- **SQ** (sq.ts:278, 116 ch): Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.
- **Verdict:**
## 5. `steppedHint`
- **EN** (en.ts:280, 210 ch): Set the TOTAL price for a stay up to a given time (e.g. up to 3h = 500). The first row whose limit ≥ the duration wins (the limit is inclusive). The last row's total repeats as a per-day price for longer stays.
- **SQ** (sq.ts:283, 233 ch): Vendos çmimin TOTAL për një qëndrim deri në një kohë të caktuar (p.sh. deri 3 orë = 500). Fiton rreshti i parë me kufi ≥ kohëzgjatjes (kufiri përfshihet). Totali i rreshtit të fundit përsëritet si çmim ditor për qëndrime më të gjata.
- **Verdict:**
## 6. `steppedTiersConflict`
- **EN** (en.ts:285, 235 ch): ⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price'. Publishing is blocked until this is fixed.
- **SQ** (sq.ts:288, 244 ch): ⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks'. Publikimi bllokohet derisa kjo të rregullohet.
- **Verdict:**
## 7. `tiersHint`
- **EN** (en.ts:287, 174 ch): Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.
- **SQ** (sq.ts:290, 182 ch): Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.
- **Verdict:**
## 8. `intro`
- **EN** (en.ts:526, 160 ch): Admin-defined plans the operator sells from — the price is looked up, never typed. Editing a plan publishes a new version; past sales keep their recorded price.
- **SQ** (sq.ts:537, 193 ch): Planet i përcakton admini; operatori vetëm shet prej tyre — çmimi merret automatikisht, nuk shkruhet. Ndryshimi i një plani publikon një version të ri; shitjet e mëparshme ruajnë çmimin e tyre.
- **Verdict:**
## 9. `relaysHint`
- **EN** (en.ts:361, 124 ch): Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.
- **SQ** (sq.ts:370, 130 ch): Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.
- **Verdict:**
## 10. `outputsHint`
- **EN** (en.ts:364, 175 ch): Relays are OUTPUTS: each opens a barrier (or drives the button lamp). Set the relay number and direction. The input terminals (button, sensor) are in the Inputs section below.
- **SQ** (sq.ts:373, 178 ch): Relet janë DALJE: secila hap një barrierë (ose ndez dritën e butonit). Cakto numrin e relesë dhe drejtimin. Terminalet hyrëse (butoni, sensori) janë te seksioni Hyrjet më poshtë.
- **Verdict:**
## 11. `pulseOpenHint`
- **EN** (en.ts:366, 75 ch): How long a barrier relay is held open (jog). Applies to all barrier relays.
- **SQ** (sq.ts:375, 85 ch): Sa kohë mbahet rele e barrierës e hapur (jog). Vlen për të gjitha relet e barrierave.
- **Verdict:**
## 12. `inputsHint` ⚑
- **EN** (en.ts:369, 152 ch): Inputs are TERMINALS the host READS: the entry button and the presence/radar sensor. Each belongs to an entry barrier — it triggers or gates that relay.
- **SQ** (sq.ts:378, 153 ch): Hyrjet janë TERMINALE që hosti i LEXON: butoni i hyrjes dhe sensori i pranisë/radari. Secila i përket një barriere hyrëse — e gateron ose e nis atë rele.
- **⚑ NOTE:** "e gateron" is an anglicism ("gates it"). Native: "e kushtëzon" / "e lejon".
- **Verdict:**
## 13. `inputsIdleHighHint`
- **EN** (en.ts:371, 62 ch): This board idles inputs HIGH (status 1111); a press pulls LOW.
- **SQ** (sq.ts:380, 80 ch): Kjo pllakë i mban hyrjet HIGH në pushim (statusi 1111); një shtypje e ul në LOW.
- **Verdict:**
## 14. `inputsNoEntryRelay`
- **EN** (en.ts:373, 87 ch): No entry relay — add an 'Entry' or 'Entry + exit' relay in Outputs to assign terminals.
- **SQ** (sq.ts:382, 97 ch): Asnjë rele hyrëse — shto një rele 'Hyrje' ose 'Hyrje + dalje' te Daljet që të caktosh terminalet.
- **Verdict:**
## 15. `presenceInputHint` ⚑
- **EN** (en.ts:378, 290 ch): Input terminal the vehicle-presence sensor (induction loop or radar) is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the sensor clears (the car drove in) and a new car re-occupies it. Preferred mode.
- **SQ** (sq.ts:387, 275 ch): Terminali hyrës ku është lidhur sensori/laku i pranisë së automjetit. Kur vendoset, lëshohet vetëm NJË biletë për automjet: butoni printon vetëm kur ka makinë, dhe nuk lëshon biletë të dytë derisa laku të lirohet (makina hyri) dhe një makinë e re ta zërë. Mënyra e preferuar.
- **⚑ NOTE:** EN names "induction loop OR radar"; SQ "sensori/laku i pranisë" omits radar explicitly.
- **Verdict:**
## 16. `entryCooldownHint`
- **EN** (en.ts:381, 175 ch): When there's no presence sensor: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.
- **SQ** (sq.ts:390, 165 ch): Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.
- **Verdict:**
## 17. `presenceActiveLowHint`
- **EN** (en.ts:387, 178 ch): Tick if the presence sensor (e.g. a radar) idles HIGH and goes LOW on detection — the opposite of the button. This inverts that terminal's reading so 'present' is read correctly.
- **SQ** (sq.ts:396, 184 ch): Shëno nëse sensori i pranisë (p.sh. radari) qëndron HIGH në pushim dhe shkon LOW kur detekton — e kundërta e butonit. Kjo përmbys leximin e atij terminali që 'prania' të lexohet saktë.
- **Verdict:**
## 18. `buttonLightHint`
- **EN** (en.ts:391, 152 ch): The button's 12 V light on a spare relay. Blinks when the radar detects but the camera doesn't confirm a car; solid on when both confirm; off otherwise.
- **SQ** (sq.ts:400, 160 ch): Drita 12V e butonit e lidhur në një rele rezervë. Pulson kur radari detekton por kamera s'konfirmon makinë; ndizet fiks kur të dy konfirmojnë; përndryshe fiket.
- **Verdict:**
## 19. `anprHint` ⚑
- **EN** (en.ts:398, 203 ch): Enable to scan plates on this camera: the vision service reads the plate from a snapshot and feeds it as a read (advisory only — it never opens a barrier on its own). Requires the vision service running.
- **SQ** (sq.ts:408, 185 ch): Aktivizo që ky aparat të skanojë targat: shërbimi i vizionit lexon targën nga pamja dhe e dërgon si lexim (vetëm këshillues — nuk hap vetë barrierën). Kërkon shërbimin e vizionit aktiv.
- **⚑ NOTE:** SQ uses "aparat" for camera; elsewhere camera = "kamerë" (see testAnprHint). Inconsistent.
- **Verdict:**
## 20. `testAnprHint`
- **EN** (en.ts:402, 143 ch): Takes a live snapshot from this camera and tries to read a plate, reporting the result and the time it took. Point a plate at the camera first.
- **SQ** (sq.ts:412, 169 ch): Merr një pamje të drejtpërdrejtë nga kjo kamerë dhe përpiqet të lexojë një targë, duke raportuar rezultatin dhe kohën e nevojshme. Vendos një targë para kamerës më parë.
- **Verdict:**
## 21. `curveHint`
- **EN** (en.ts:443, 88 ch): Fee from entry at several durations — see where the daily cap flattens or windows shift.
- **SQ** (sq.ts:454, 96 ch): Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.
- **Verdict:**
## 22. `versionHint`
- **EN** (en.ts:465, 128 ch): Move this subscriber to another version of the same plan. The price stays as billed; only the access hours change going forward.
- **SQ** (sq.ts:476, 139 ch): Zhvendos këtë abonent në një version tjetër të të njëjtit plan. Çmimi mbetet siç u faturua; ndryshon vetëm orari i lejuar nga këtu e tutje.
- **Verdict:**
## 23. `deleteInUse`
- **EN** (en.ts:550, 68 ch): Can't delete — subscriptions still use this plan. Retire it instead.
- **SQ** (sq.ts:561, 80 ch): S'mund të fshihet — abonime ende e përdorin këtë plan. Tërhiqe në vend të kësaj.
- **Verdict:**
## 24. `newVersionHint`
- **EN** (en.ts:553, 84 ch): This publishes a NEW version of the plan — existing sales keep their original price.
- **SQ** (sq.ts:564, 86 ch): Kjo publikon një version TË RI të planit — shitjet ekzistuese ruajnë çmimin origjinal.
- **Verdict:**
## 25. `timeframesHint`
- **EN** (en.ts:570, 168 ch): A scan outside the allowed window is charged the normal transient tariff for the out-of-window minutes (early entry is deferred to exit; late exit is gated until paid).
- **SQ** (sq.ts:581, 184 ch): Një skanim jashtë intervalit të lejuar tarifohet me tarifën normale kalimtare për minutat jashtë intervalit (hyrja e hershme shtyhet në dalje; dalja e vonuar bllokohet derisa paguhet).
- **Verdict:**
## 26. `reserveSubsHint` ⚑
- **EN** (en.ts:582, 164 ch): Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).
- **SQ** (sq.ts:593, 198 ch): Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).
- **⚑ NOTE:** EN "handle overflow by valet" → SQ "parkim manual" (manual parking) — meaning shift; valet ≠ manual parking.
- **Verdict:**
## 27. `anprEntryHint`
- **EN** (en.ts:584, 195 ch): When on, a subscriber's plate read by a lane camera opens the barrier through the normal subscription gate. Off: subscribers must use their card/QR. Plate snapshots are still recorded either way.
- **SQ** (sq.ts:595, 207 ch): Kur është aktiv, targa e një abonenti e lexuar nga kamera e korsisë hap barrierën përmes portës normale të abonimit. Joaktiv: abonentët duhet të përdorin kartën/QR-në. Fotot e targave regjistrohen gjithsesi.
- **Verdict:**
## 28. `voucherHint`
- **EN** (en.ts:659, 128 ch): A receipt (Mandat Arkëtimi) adds cash; a disbursement (Mandat Pagese) removes it. The float only moves with an admin's sign-off.
- **SQ** (sq.ts:671, 97 ch): Mandat Arkëtimi shton para; Mandat Pagese heq para. Arka lëviz vetëm me autorizimin e një admini.
- **Verdict:**
## 29. `xReportHint`
- **EN** (en.ts:667, 83 ch): View only — nothing is recorded. These figures are signed when the shift is closed.
- **SQ** (sq.ts:679, 85 ch): Vetëm për shikim — asgjë nuk regjistrohet. Këto shifra nënshkruhen kur mbyllet turni.
- **Verdict:**
## 30. `gateBody`
- **EN** (en.ts:691, 80 ch): No shift is open. Open your shift so payments and exits are recorded against it.
- **SQ** (sq.ts:703, 95 ch): Asnjë turn nuk është i hapur. Hap turnin tënd që pagesat dhe daljet të regjistrohen te ky turn.
- **Verdict:**
## 31. `gateOtherBody`
- **EN** (en.ts:694, 120 ch): {{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.
- **SQ** (sq.ts:706, 140 ch): {{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.
- **Verdict:**
## 32. `overstayHint` ⚑
- **EN** (en.ts:813, 155 ch): Earlier session paid. The customer failed to exit during the grace period. Payment for the new period is required. The total below is the new period's fee.
- **SQ** (sq.ts:827, 151 ch): Sesion i mëparshëm i paguar. Klienti nuk doli brënda afatit kohor. Kërkohet pagesë për periudhën e re. Totali më poshtë është tarifa e periudhës së re.
- **⚑ NOTE:** Spelling: "brënda" → standard is "brenda" (no ë).
- **Verdict:**
## 33. `subAssistHint`
- **EN** (en.ts:837, 101 ch): Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.
- **SQ** (sq.ts:851, 108 ch): Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.
- **Verdict:**
## 34. `windowChargeHint`
- **EN** (en.ts:842, 154 ch): This subscriber parked outside their plan's allowed hours. They owe the transient tariff for the out-of-window time — take payment, then open the barrier.
- **SQ** (sq.ts:856, 153 ch): Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën, pastaj hap barrierën.
- **Verdict:**
## 35. `cancelTicketHint`
- **EN** (en.ts:852, 115 ch): Cancels a wrongly-printed ticket. A signed record is kept (operator + reason); the original entry is never deleted.
- **SQ** (sq.ts:866, 129 ch): Anulon një biletë të printuar gabimisht. Ruhet një gjurmë e nënshkruar (operatori + arsyeja); hyrja origjinale nuk fshihet kurrë.
- **Verdict:**
---
## Summary of flagged items
- `badgeOverstayTitle` — Spelling: "brënda" → standard is "brenda" (no ë).
- `inputsHint` — "e gateron" is an anglicism ("gates it"). Native: "e kushtëzon" / "e lejon".
- `presenceInputHint` — EN names "induction loop OR radar"; SQ "sensori/laku i pranisë" omits radar explicitly.
- `anprHint` — SQ uses "aparat" for camera; elsewhere camera = "kamerë" (see testAnprHint). Inconsistent.
- `reserveSubsHint` — EN "handle overflow by valet" → SQ "parkim manual" (manual parking) — meaning shift; valet ≠ manual parking.
- `overstayHint` — Spelling: "brënda" → standard is "brenda" (no ë).
_All other pairs: meaning judged faithful in automated review; confirm during your read._
+107
View File
@@ -0,0 +1,107 @@
# Komodo Stack environment — the COMPLETE reference of every env the booth stack reads:
# required, image-selection, set-by-compose (don't override), and the optional tunables
# with their code defaults. Under Komodo, plain env lives in the Stack definition
# (komodo/resources.toml); the two SECRETS come from Core's secret store, PER BOOTH and
# UNIQUE. This file is DOCUMENTATION — never fill in real secrets here. See
# wiki/decisions/fleet-deployment-komodo.md.
#
# A minimal working Stack only needs: REGISTRY, TAG, the two secrets, COOKIE_SECURE=0,
# VISION_ENABLED=1, WS_ALLOWED_ORIGINS. Everything under "OPTIONAL TUNABLES" has a safe
# default in code — set one only to override it.
# ════════════════════════════════════════════════════════════════════════════
# IMAGE SELECTION (picks which container image to pull — not server runtime env)
# ════════════════════════════════════════════════════════════════════════════
REGISTRY=git.infra.msai.al/mca/parking_solution
# IMMUTABLE per-commit tag. Manual + pinned. Bump per deploy. Never the moving `dev`
# on a PRODUCTION booth (a staging booth may track `dev`).
TAG=dev-830993b
# ════════════════════════════════════════════════════════════════════════════
# REQUIRED (no safe default — the server refuses to boot / login breaks without)
# ════════════════════════════════════════════════════════════════════════════
# Login signing secret (>=32 chars, no change-me/insecure/dev-only). openssl rand -hex 32.
JWT_SECRET=[[booth_<name>_jwt_secret]]
# Ledger-signing HMAC key — the anti-fraud root. DISTINCT per booth; never reuse. If unset
# it falls back to JWT_SECRET (warned). openssl rand -hex 32.
EVENT_SIGNING_KEY=[[booth_<name>_event_signing_key]]
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT, so
# without =0 the auth cookie never sends and operators CANNOT log in. Set 1 only behind TLS.
COOKIE_SECURE=0
# ════════════════════════════════════════════════════════════════════════════
# COMMONLY SET (have defaults, but you usually want these explicit on a booth)
# ════════════════════════════════════════════════════════════════════════════
# Turn the ANPR/vision call on. Default "" (off-ish). Set 1 to enable. (Prod compose also
# forces VISION_RECOGNIZER=fast_alpr on the vision container.)
VISION_ENABLED=1
# Extra origins the booth WebSocket (/api/ws) accepts beyond same-origin. Comma-separated,
# e.g. http://parksystems.msai.al. Blank = only the same-origin booth URL. Default "".
WS_ALLOWED_ORIGINS=
# ════════════════════════════════════════════════════════════════════════════
# SET BY COMPOSE — do NOT put these in the Stack (the compose files own them)
# ════════════════════════════════════════════════════════════════════════════
# VISION_URL=http://127.0.0.1:8089 # prod override (host-net server → loopback vision)
# DATABASE_URL=/data/parking.sqlite # the mounted volume (the signed ledger)
# VISION_RECOGNIZER=fast_alpr # prod override on the vision container
# ════════════════════════════════════════════════════════════════════════════
# OPTIONAL TUNABLES — all have code defaults; set only to override. (defaults shown)
# ════════════════════════════════════════════════════════════════════════════
# --- networking / process ---
# PORT=3000 # server listen port
# HOST=0.0.0.0 # bind interface (127.0.0.1 = loopback only)
# BACKEND_HOST_IP= # override the auto-picked IP devices push back to
# # (multi-NIC hosts; usually auto-detected fine)
# WEB_DIST_DIR= # where the built SPA lives (the image sets it)
# --- logging ---
# LOG_LEVEL=info # debug|info|warn|error
# LOG_RETENTION_DAYS=30 # app_logs auto-purge age
# LOG_RETENTION_MAX_ROWS=50000 # app_logs row cap
# RECYCLE_BIN_RETENTION_DAYS=30 # soft-deleted items auto-purge age (0 = keep forever)
# --- snapshots (camera evidence; stored re-encoded, then pruned under disk pressure) ---
# SNAPSHOT_MAX_EDGE=1280 # downscale long edge (px) before storing
# SNAPSHOT_JPEG_QUALITY=80 # stored JPEG quality (recognition uses full-res original)
# Disk-pressure prune (daily safety valve; deletes oldest + VACUUM only when the disk is tight):
# SNAPSHOT_DISK_HIGH_PCT=70 # prune only when the DB's filesystem is ≥ this % used
# SNAPSHOT_DISK_FREE_TARGET_PCT=10 # try to free ~this % of the disk per run
# SNAPSHOT_MIN_KEEP=500 # never prune below this many snapshots (floor)
# SNAPSHOT_PRUNE_BATCH=200 # delete oldest in batches of this many
# --- device monitor / lane ---
# DEVICE_POLL_MS=8000 # device health poll interval
# PRINTER_POLL_MS=5000 # printer status poll interval
# LANE_BUSY_TTL_MS=30000 # how long a lane stays "busy" after a vehicle push
# CAPTURE_TTL_MS=30000 # snapshot evidence cache TTL
# --- ANPR / vision (server side) ---
# VISION_URL is compose-set (above). These are the knobs you may tweak per booth:
# VISION_TIMEOUT_MS=1500 # per /analyze call timeout
# VISION_MIN_CONFIDENCE=0.5 # advisory floor (telemetry/lane); below = low_confidence
# VISION_ENTRY_MIN_CONFIDENCE=0.85 # STRICT barrier-driving floor (auto entry/exit). A
# # read below this is ignored (falls back to card/QR).
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this = one presentation
# ANPR_POLL_MS=1000 # poll-until-confident: re-pull a fresh frame every N ms
# ANPR_POLL_WINDOW_MS=8000 # ...for this long AFTER THE LAST vehicle push (sliding:
# # a car arriving mid-loop extends it). RAISE if a slow
# # barrier means the car waits >8s before reading clean.
# ANPR_POLL_MAX_MS=30000 # hard ceiling on one loop from start (so a continuously
# # busy lane can't slide the window forever)
# ════════════════════════════════════════════════════════════════════════════
# VISION CONTAINER env (the Python ANPR service — its OWN process, prefix VISION_)
# Mostly compose-set; documented here for completeness. (defaults shown)
# ════════════════════════════════════════════════════════════════════════════
# VISION_RECOGNIZER=fast_alpr # stub | fast_alpr (prod compose forces fast_alpr)
# VISION_HOST=0.0.0.0 # bind (prod publishes 127.0.0.1 only — see compose)
# VISION_PORT=8089
# VISION_DETECTOR_MODEL=yolo-v9-t-384-license-plate-end2end
# VISION_OCR_MODEL=cct-xs-v2-global-model
# VISION_MIN_CONFIDENCE=0.5 # the Python service's own floor (keep ~in sync w/ server)
# ════════════════════════════════════════════════════════════════════════════
# SECRETS — referenced by name in resources.toml, stored in Core (never inline here)
# ════════════════════════════════════════════════════════════════════════════
# JWT_SECRET -> [[booth_<name>_jwt_secret]] (login)
# EVENT_SIGNING_KEY -> [[booth_<name>_event_signing_key]] (ledger signing — fraud root)
# periphery passkey -> [[periphery_passkey_booth_<name>]] (agent onboarding)
# registry account -> [[gitea_registry_account]] (image pull)
+60
View File
@@ -0,0 +1,60 @@
# `komodo/` — fleet deployment as code
Infra-as-code for the **Komodo Core** control plane that deploys the parking appliance to the
booth fleet over the **NetBird** mesh. See `wiki/decisions/fleet-deployment-komodo.md` for the
rationale, threat-model analysis, and the three settled choices (many/growing fleet · deploys
are **manual + pinned** · secrets are **Komodo-managed, per-booth**).
This directory does **not** change how images are built or how the app runs — it's only the
control plane. The booth still runs the same `docker-compose.yml` + `docker-compose.prod.yml`
([[container-deployment]]); Komodo just drives them remotely instead of someone SSH-ing in to
run `booth.sh`.
## Files
- **`resources.toml`** — the Komodo resource definitions (Servers, Stacks, optional Builders/
Procedures), synced into Core via a **ResourceSync**. This is the reviewable, version-
controlled source of truth for *which booth runs what*.
- **`.env.komodo.example`** — the variables a Stack expects, documenting what comes from Core's
**secret store** (per-booth `JWT_SECRET` / `EVENT_SIGNING_KEY`) vs. plain Stack env.
## How Core consumes this (one-time)
In Komodo Core, create a **ResourceSync** pointing at this repo + path (`komodo/resources.toml`),
on the branch you manage from (e.g. `main`). Core reads the file and reconciles Servers/Stacks to
match. Thereafter, a PR to this directory + a sync is how you change the fleet — no clicking.
> Komodo's TOML schema evolves across releases. Treat `resources.toml` as a **starting sketch**:
> `resources.toml` mirrors the **working `park-buzi` Stack** (built by hand in the Core UI, then
> exported to TOML — so field names match the running Komodo version, v2.2). Import it into the
> sync **Unmanaged** first and review the diff; it should be ~empty against the live Stack.
## How servers get created — NOT here
There is **no `[[server]]` block** in `resources.toml`. Servers are created by the **Periphery
agent onboarding outbound**: in Core, create a one-time **Onboarding Key** (Settings →
Onboarding), then install Periphery on the booth passing `--onboarding-key` + `--core-address`
(Core's reverse-proxy URL, reached over the NetBird mesh) + `--connect-as=<booth-name>`. The
agent self-registers, generates its own auto-rotating key pair (private key never leaves the
booth), and connects **outbound** — the booth opens **no inbound port**. The sync owns only the
**Stack**, which references the server by the name it onboarded as (`server = "park-buzi"`). See
`wiki/decisions/fleet-deployment-komodo.md`.
## Adding booth N
Copy the `[[stack]]` block, change `name`, `server` (its onboarded name), and the per-booth
secret references (`[[park_<site>_jwt_secret]]`, `[[park_<site>_event_signing_key]]`). Create
those secrets in Core's store first.
## Hard rules encoded here (do not relax without updating the decision page)
1. **No deploy webhook on a booth Stack.** Deploys are a human action; pin `TAG=dev-<sha>` before
a production booth goes live. A moving `:dev` on a production booth is the non-determinism we
rejected. (`TAG=dev` here is fine while staging.)
2. **Onboarding, outbound, mesh-only.** Servers self-register via an onboarding key; Periphery
connects outbound to Core's mesh URL and exposes no inbound port. Never a LAN/WAN address.
3. **Secrets are per-booth and unique.** `EVENT_SIGNING_KEY` signs the anti-fraud ledger — one
leak must taint one booth, never the fleet. Reference Core secrets by name; never inline a
real value in this file (it's in git).
4. **Volumes preserved.** The Stack must never run `compose down -v` — that would wipe the
`parking-data` volume (the signed ledger). Komodo's "destroy" is gated for the same reason.
+51
View File
@@ -0,0 +1,51 @@
# Komodo resources — parking appliance fleet (control plane as code)
#
# Synced into Komodo Core via a ResourceSync pointing at this file. Drives the SAME
# compose files the booth runs locally (docker-compose.yml + docker-compose.prod.yml);
# Komodo Periphery on each booth executes them. See:
# wiki/decisions/fleet-deployment-komodo.md (rationale + threat model)
# wiki/decisions/container-deployment.md (image build/tag/registry — unchanged)
#
# This file mirrors the WORKING park-buzi Stack (built by hand in the Core UI, then
# exported to TOML). Field names match the running Komodo version (v2.2).
#
# NO [[server]] block: servers are created by the AGENT onboarding outbound (a one-time
# onboarding key → Periphery self-registers with auto-rotating key pairs). The sync owns
# only the Stack; it references the server by the name it onboarded as (`connect_as`).
#
# Secrets ([[park_buzi_jwt_secret]] etc.) are REFERENCES to Komodo Core's secret store —
# per-booth + unique, never inlined here (this file is in git). JWT_SECRET gates login;
# EVENT_SIGNING_KEY signs the append-only anti-fraud ledger.
#
# Deploys are MANUAL + PINNED in spirit: bump TAG to an immutable dev-<sha> before a
# production booth goes live (TAG=dev here is the moving tag, fine while staging). NO
# deploy webhook is attached to a booth Stack.
##############################################################################
# Stack — the deployable unit for booth "park-buzi". One Stack per booth; add a
# new [[stack]] block per site (unique name, its own per-booth secret refs).
##############################################################################
[[stack]]
name = "park-buzi"
[stack.config]
server = "park-buzi"
git_provider = "git.infra.msai.al"
git_account = "komodo"
repo = "mca/parking_solution"
branch = "dev"
file_paths = [
"docker-compose.yml",
"docker-compose.prod.yml"
]
registry_provider = "git.infra.msai.al"
registry_account = "komodo"
environment = """
REGISTRY=git.infra.msai.al/mca/parking_solution
TAG=dev
COOKIE_SECURE=0
VISION_ENABLED=1
WS_ALLOWED_ORIGINS=
JWT_SECRET=[[park_buzi_jwt_secret]]
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
"""
@@ -0,0 +1,5 @@
-- Per-user UI font scale (PERCENT of base; 100 = base). Persisted like `theme`/`language`
-- so an operator's chosen text size is restored on the next login from any booth. Additive
-- ALTER ADD COLUMN — backward-compatible; existing users default to 100 (base). The client
-- clamps to 80–160 in steps of 10. Printed tickets are unaffected (server-rendered).
ALTER TABLE `users` ADD `font_scale` integer DEFAULT 100 NOT NULL;
@@ -0,0 +1,5 @@
-- The QR/RFID access reader was misnamed "GEE" on a wrong early assumption; it's actually a
-- Dingtian DT-008 (same vendor as the relay board). The driver was renamed
-- `gee-qr-reader` → `dingtian-qr-reader`. `devices.driver_id` is the persisted registry
-- lookup key, so rewrite existing reader rows or they'd fail to resolve a driver. Idempotent.
UPDATE `devices` SET `driver_id` = 'dingtian-qr-reader' WHERE `driver_id` = 'gee-qr-reader';
@@ -0,0 +1,5 @@
-- The on-site admin chooses where encrypted DB backups are written (a mounted USB/SATA/
-- SMB/NFS path), from the Backup screen — not a server env var. Additive, nullable ALTER:
-- null = not configured (backups stay a no-op). The BACKUP_KEY stays an env/Komodo secret
-- (a key must NEVER live in the DB it backs up). See wiki/concepts/backup-recovery.md.
ALTER TABLE `site_config` ADD `backup_target_dir` text;
+21
View File
@@ -99,6 +99,27 @@
"when": 1781885600000, "when": 1781885600000,
"tag": "0013_anpr_entry_toggle", "tag": "0013_anpr_entry_toggle",
"breakpoints": true "breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1781885700000,
"tag": "0014_user_font_scale",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1781885800000,
"tag": "0015_dingtian_qr_reader_driverid",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1781885900000,
"tag": "0016_backup_target_dir",
"breakpoints": true
} }
] ]
} }
+10
View File
@@ -73,6 +73,11 @@ export const users = sqliteTable("users", {
theme: text("theme", { enum: ["dark", "light"] }) theme: text("theme", { enum: ["dark", "light"] })
.notNull() .notNull()
.default("dark"), .default("dark"),
// Preferred UI font scale (PERCENT of base, e.g. 100 = base, 120 = 20% larger).
// Persisted like `theme` (read on login, restored from any booth). Integer percent
// avoids float drift; the client clamps to 80–160 in steps of 10. Printed tickets are
// unaffected (server-rendered).
fontScale: integer("font_scale").notNull().default(100),
// Optional operator profile metadata — display name + contact details. All // Optional operator profile metadata — display name + contact details. All
// nullable; only username/password/role are required to create a user. fullName // nullable; only username/password/role are required to create a user. fullName
// (when set) is the human label for audit/Z-report display. // (when set) is the human label for audit/Z-report display.
@@ -259,6 +264,11 @@ export const siteConfig = sqliteTable("site_config", {
* payload so V2 category pricing reprices identically at exit. null ⇒ the shared * payload so V2 category pricing reprices identically at exit. null ⇒ the shared
* DEFAULT_VEHICLE_CATEGORY fallback. See wiki/concepts/tariff-time-tiers.md. */ * DEFAULT_VEHICLE_CATEGORY fallback. See wiki/concepts/tariff-time-tiers.md. */
defaultVehicleCategory: text("default_vehicle_category"), defaultVehicleCategory: text("default_vehicle_category"),
/** Admin-chosen directory encrypted DB backups are written to — a mounted local/USB/
* SATA/SMB/NFS path. null = not configured (backups are a no-op). Set from the Backup
* screen; the encryption key (BACKUP_KEY) stays an env/Komodo secret and is NEVER stored
* here (a key must not live in the DB it backs up). See wiki/concepts/backup-recovery.md. */
backupTargetDir: text("backup_target_dir"),
updatedAt: text("updated_at") updatedAt: text("updated_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
+10
View File
@@ -30,3 +30,13 @@ export function createTestDb(url = ":memory:"): { db: Db; sqlite: Database.Datab
migrate(db, { migrationsFolder: MIGRATIONS_DIR }); migrate(db, { migrationsFolder: MIGRATIONS_DIR });
return { db, sqlite, close: () => sqlite.close() }; return { db, sqlite, close: () => sqlite.close() };
} }
/**
* Open an EXISTING SQLite file raw, WITHOUT running migrations — for tests that need to
* inspect a file produced elsewhere (e.g. a restored backup) exactly as written, without
* mutating it. Returns the raw better-sqlite3 handle so the caller depends only on
* `@parking/db/testing`, never on `better-sqlite3` directly.
*/
export function openRawDb(url: string): Database.Database {
return new Database(url);
}
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { activeLowFrom, inputActive } from "./access-dingtian.js";
// Per-input active-level normalisation. The board has ONE resting level, but a radar
// can idle opposite the button — listing its terminal in `activeLow` inverts just that
// input so "present" reads correctly. See wiki/entities/hikvision-radar.md.
describe("inputActive (per-input active-level)", () => {
const none = new Set<number>();
const radarOnI2 = new Set<number>([2]);
it("default board (resting HIGH): a pull LOW is active, HIGH is rest", () => {
// Button on I1, board idles HIGH → active when LOW.
expect(inputActive(false, 1, true, none)).toBe(true); // LOW = pressed
expect(inputActive(true, 1, true, none)).toBe(false); // HIGH = rest
});
it("resting LOW board: a pull HIGH is active", () => {
expect(inputActive(true, 1, false, none)).toBe(true);
expect(inputActive(false, 1, false, none)).toBe(false);
});
it("active-low override inverts ONLY the listed input", () => {
// Board idles HIGH (button on I1), radar on I2 idles HIGH and goes LOW on detect →
// mark I2 active-low so detection (LOW) reads active.
// I1 (button) keeps the board default:
expect(inputActive(false, 1, true, radarOnI2)).toBe(true); // button LOW = active
expect(inputActive(true, 1, true, radarOnI2)).toBe(false);
// I2 (radar) overridden to active-low: active when LOW.
expect(inputActive(false, 2, true, radarOnI2)).toBe(true); // radar LOW = detecting
expect(inputActive(true, 2, true, radarOnI2)).toBe(false); // radar HIGH = clear
});
});
describe("activeLowFrom (config → active-low terminal set)", () => {
it("reads a config.inputs[] presence row with activeLow", () => {
const set = activeLowFrom({
inputs: [
{ input: 1, role: "button", relay: 1 },
{ input: 2, role: "presence", relay: 1, kind: "radar", activeLow: true },
{ input: 5, role: "presence", relay: 2, kind: "radar", activeLow: true }, // exit radar
],
});
expect([...set].sort()).toEqual([2, 5]); // both radars inverted; the button is not
});
it("still reads the LEGACY relays[].presenceActiveLow (back-compat)", () => {
const set = activeLowFrom({
relays: [{ relay: 1, direction: "entry", presenceInput: 2, presenceActiveLow: true }],
});
expect([...set]).toEqual([2]);
});
it("honours an explicit top-level inputActiveLow escape hatch + merges all sources", () => {
const set = activeLowFrom({
inputActiveLow: [3],
inputs: [{ input: 2, role: "presence", activeLow: true }],
relays: [{ relay: 1, direction: "entry", presenceInput: 4, presenceActiveLow: true }],
});
expect([...set].sort()).toEqual([2, 3, 4]);
});
});
@@ -3,6 +3,7 @@ import { createSocket } from "node:dgram";
import { request as httpRequest } from "node:http"; import { request as httpRequest } from "node:http";
import type { import type {
AccessControlDevice, AccessControlDevice,
AuxOutputDevice,
DeviceHealth, DeviceHealth,
HardenableDevice, HardenableDevice,
HardenResult, HardenResult,
@@ -166,6 +167,49 @@ interface DingtianStatus {
channels: number; channels: number;
} }
/**
* Normalise one input line to "active". `high` = the line is currently HIGH. An input
* whose 1-based channel is in `activeLow` is active when LOW (idles HIGH), overriding
* the board-wide `restingHigh`; otherwise active = differs from the resting level. This
* is the seam that lets a radar (wired opposite the button) read correctly. Exported for
* unit testing the bit logic without a UDP socket. See wiki/entities/hikvision-radar.md.
*/
export function inputActive(
high: boolean,
channel1Based: number,
restingHigh: boolean,
activeLow: ReadonlySet<number>,
): boolean {
return activeLow.has(channel1Based) ? !high : high !== restingHigh;
}
/** Build the set of 1-based ACTIVE-LOW input terminals from a controller config. Three
* sources, all merged: (a) `config.inputs[]` presence rows with `activeLow:true` (the
* first-class model); (b) LEGACY per-relay `presenceActiveLow` (pre-inputs[] configs);
* (c) an explicit top-level `inputActiveLow` array (escape hatch). A radar terminal wired
* opposite the button idles HIGH, so it must be read inverted. */
export function activeLowFrom(config: Record<string, unknown>): Set<number> {
const set = new Set<number>();
const add = (n: unknown) => {
const v = Number(n);
if (Number.isInteger(v) && v > 0) set.add(v);
};
if (Array.isArray(config.inputActiveLow)) {
for (const n of config.inputActiveLow as unknown[]) add(n);
}
if (Array.isArray(config.inputs)) {
for (const i of config.inputs as Array<Record<string, unknown>>) {
if (i?.role === "presence" && i?.activeLow === true) add(i.input);
}
}
if (Array.isArray(config.relays)) {
for (const r of config.relays as Array<Record<string, unknown>>) {
if (r?.presenceActiveLow === true) add(r.presenceInput);
}
}
return set;
}
const INPUT_LINK_ISSUE = { const INPUT_LINK_ISSUE = {
key: "input_link_relay", key: "input_link_relay",
message: message:
@@ -223,6 +267,7 @@ function configApi(
class DingtianController class DingtianController
implements implements
AccessControlDevice, AccessControlDevice,
AuxOutputDevice,
InputDevice, InputDevice,
PreconditionDevice, PreconditionDevice,
PushConfigurableDevice, PushConfigurableDevice,
@@ -242,6 +287,13 @@ class DingtianController
readonly #channels: number; readonly #channels: number;
/** Input level at rest; an input is "active" when it differs from this. */ /** Input level at rest; an input is "active" when it differs from this. */
readonly #restingHigh: boolean; readonly #restingHigh: boolean;
/** 1-based input terminals whose ACTIVE level is LOW, overriding the board-wide
* #restingHigh for just those inputs. A button and a radar can idle oppositely:
* the button (NO-to-GND) pulls LOW on press while the board idles HIGH, but a
* radar's dry contact may idle LOW and go HIGH on detection. Listing the radar's
* terminal here flips its edge so "active" still means "detecting". See
* wiki/entities/hikvision-radar.md. */
readonly #inputActiveLow: Set<number>;
readonly #pulseMs: number; readonly #pulseMs: number;
/** Device web-UI login user (gates the browser UI only, not the CGI API). */ /** Device web-UI login user (gates the browser UI only, not the CGI API). */
readonly #webUser: string; readonly #webUser: string;
@@ -269,6 +321,7 @@ class DingtianController
this.#channels = config.channels ? Number(config.channels) : 4; this.#channels = config.channels ? Number(config.channels) : 4;
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW. // This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
this.#restingHigh = config.inputRestingHigh !== false; this.#restingHigh = config.inputRestingHigh !== false;
this.#inputActiveLow = activeLowFrom(config);
this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500; this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
this.#webUser = config.webUser ? String(config.webUser) : "admin"; this.#webUser = config.webUser ? String(config.webUser) : "admin";
// webPassword = the DESIRED login (admin's choice; blank → harden generates). // webPassword = the DESIRED login (admin's choice; blank → harden generates).
@@ -319,6 +372,14 @@ class DingtianController
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress); await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
} }
/** AuxOutputDevice: latch a NON-barrier output (e.g. a button lamp) on a spare
* relay. Same wire op as setRelay — separated so business logic drives indicators
* through the aux capability, never the barrier relay methods. Holding/blinking an
* aux output is allowed (it is not a barrier). See button-light-indicator.md. */
async setAux(channel: number, on: boolean): Promise<void> {
await this.setRelay(channel, on);
}
async getDoorStatus(doorId: number): Promise<"open" | "closed"> { async getDoorStatus(doorId: number): Promise<"open" | "closed"> {
this.#assertChannel(doorId); this.#assertChannel(doorId);
const { relays } = await this.#status(); const { relays } = await this.#status();
@@ -672,8 +733,10 @@ class DingtianController
for (let i = 0; i < this.#channels; i++) { for (let i = 0; i < this.#channels; i++) {
const high = (inputVal & (1 << i)) !== 0; const high = (inputVal & (1 << i)) !== 0;
relays.push((relayVal & (1 << i)) !== 0); relays.push((relayVal & (1 << i)) !== 0);
// active = differs from the resting level (a press pulls the line). // active = differs from the resting level (a press pulls the line); a terminal in
inputs.push(high !== this.#restingHigh); // inputActiveLow is read inverted (active when LOW) — so a radar wired opposite the
// button reads right. See inputActive().
inputs.push(inputActive(high, i + 1, this.#restingHigh, this.#inputActiveLow));
} }
return { relays, inputs, channels: this.#channels }; return { relays, inputs, channels: this.#channels };
} }
@@ -728,6 +791,19 @@ export const dingtianDriver: AccessDriver = {
{ key: "binaryPort", label: "Binary protocol port", type: "port", required: false, default: 60000, help: "Dingtian binary protocol UDP port — authenticated relay control (default 60000)." }, { key: "binaryPort", label: "Binary protocol port", type: "port", required: false, default: 60000, help: "Dingtian binary protocol UDP port — authenticated relay control (default 60000)." },
{ key: "httpPort", label: "HTTP config port", type: "port", required: false, default: 80, help: "Device web/config-API port (default 80)." }, { key: "httpPort", label: "HTTP config port", type: "port", required: false, default: 80, help: "Device web/config-API port (default 80)." },
{ key: "channels", label: "Channels (relays/inputs)", type: "number", required: true, default: 4 }, { key: "channels", label: "Channels (relays/inputs)", type: "number", required: true, default: 4 },
{
// relay_pw — the BINARY-protocol control/status password (NOT the web-UI login
// below). Every relay command + the status read embeds it; with the wrong/no
// value the device silently ignores the packet → healthCheck times out → the
// controller shows "offline" even though it pings. Redacted from the client
// (SECRET_CONFIG_KEYS), so it renders as a secret: blank KEEPS the stored value
// (the server re-merges it on test/save); type a value to set/change it.
key: "relayPassword",
label: "Relay control password",
type: "secret",
required: false,
help: "Binary-protocol relay password (relay_pw). Leave blank to keep the current one; a wrong/missing value makes the device ignore commands (Test connection times out).",
},
{ {
key: "pulseMs", key: "pulseMs",
label: "Pulse open (ms)", label: "Pulse open (ms)",
+115
View File
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DigestGetResult } from "./http-digest.js";
// The HTTP layer is mocked so the camera driver's RETRY logic is tested without a
// network. Hikvision returns 503 "Device Busy" (sometimes 500) transiently when its
// snapshot encoder is occupied — captureSnapshot must retry those and succeed, but
// fail FAST on a config error (401 auth / 404 path). See camera.ts.
const digestGet = vi.fn<(...a: unknown[]) => Promise<DigestGetResult>>();
vi.mock("./http-digest.js", () => ({ digestGet: (...a: unknown[]) => digestGet(...a) }));
// Import the driver AFTER the mock is registered.
const { hikvisionDriver } = await import("./camera.js");
function reply(status: number, body = "jpeg-bytes"): DigestGetResult {
return { status, contentType: "image/jpeg", body: Buffer.from(body) };
}
function makeCamera() {
return hikvisionDriver.create({ host: "10.0.10.12", port: 80, username: "admin", password: "x", channel: 1 });
}
beforeEach(() => {
digestGet.mockReset();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("hikvision captureSnapshot — 503 Device Busy retry", () => {
it("retries a transient 503 and succeeds", async () => {
digestGet
.mockResolvedValueOnce(reply(503))
.mockResolvedValueOnce(reply(503))
.mockResolvedValueOnce(reply(200, "the-frame"));
const cam = makeCamera();
const p = cam.captureSnapshot({ direction: "entry" });
await vi.runAllTimersAsync(); // let the backoff sleeps fire
const shot = await p;
expect(shot.bytes.toString()).toBe("the-frame");
expect(digestGet).toHaveBeenCalledTimes(3); // 503, 503, 200
});
it("also retries a transient 500", async () => {
digestGet.mockResolvedValueOnce(reply(500)).mockResolvedValueOnce(reply(200));
const cam = makeCamera();
const p = cam.captureSnapshot({ direction: "entry" });
await vi.runAllTimersAsync();
await p;
expect(digestGet).toHaveBeenCalledTimes(2);
});
it("gives up after the attempt cap, naming it 'device busy'", async () => {
digestGet.mockResolvedValue(reply(503)); // always busy
const cam = makeCamera();
// Attach the rejection assertion BEFORE flushing timers so the rejection always
// has a handler (no unhandled-rejection noise), then drive the backoff sleeps.
const assertion = expect(cam.captureSnapshot({ direction: "entry" })).rejects.toThrow(/HTTP 503 \(device busy\)/);
await vi.runAllTimersAsync();
await assertion;
expect(digestGet).toHaveBeenCalledTimes(4); // SNAPSHOT_MAX_ATTEMPTS
});
it("does NOT retry a 401 (auth error self-won't-heal) — fails fast", async () => {
digestGet.mockResolvedValue(reply(401));
const cam = makeCamera();
const assertion = expect(cam.captureSnapshot({ direction: "entry" })).rejects.toThrow(/HTTP 401/);
await vi.runAllTimersAsync();
await assertion;
expect(digestGet).toHaveBeenCalledTimes(1); // no retry
});
it("does NOT retry a 404 (wrong path/channel) — fails fast", async () => {
digestGet.mockResolvedValue(reply(404));
const cam = makeCamera();
const assertion = expect(cam.captureSnapshot({ direction: "entry" })).rejects.toThrow(/HTTP 404/);
await vi.runAllTimersAsync();
await assertion;
expect(digestGet).toHaveBeenCalledTimes(1);
});
it("succeeds first try with no retry on a clean 200", async () => {
digestGet.mockResolvedValue(reply(200));
const cam = makeCamera();
const shot = await cam.captureSnapshot({ direction: "entry" });
expect(shot.contentType).toBe("image/jpeg");
expect(digestGet).toHaveBeenCalledTimes(1);
});
});
describe("hikvision snapshot stream selection (main vs sub)", () => {
function pathFor(config: Record<string, unknown>): string {
digestGet.mockReset();
digestGet.mockResolvedValue(reply(200));
hikvisionDriver.create(config as never).captureSnapshot({ direction: "entry" });
return String((digestGet.mock.calls[0]![0] as { path: string }).path);
}
it("defaults to the MAIN stream (…/channels/101/picture) — back-compat", () => {
expect(pathFor({ host: "1.2.3.4", channel: 1 })).toBe("/ISAPI/Streaming/channels/101/picture");
});
it("stream=2 selects the SUB stream (…/channels/102/picture) — the G3H 503 fix", () => {
expect(pathFor({ host: "1.2.3.4", channel: 1, stream: 2 })).toBe("/ISAPI/Streaming/channels/102/picture");
});
it("honours the channel number with the stream (ch2 sub = 202)", () => {
expect(pathFor({ host: "1.2.3.4", channel: 2, stream: 2 })).toBe("/ISAPI/Streaming/channels/202/picture");
});
it("an invalid stream falls back to main (1)", () => {
expect(pathFor({ host: "1.2.3.4", channel: 1, stream: 9 })).toBe("/ISAPI/Streaming/channels/101/picture");
});
});
+118 -18
View File
@@ -1,6 +1,17 @@
import type { CameraDevice, DeviceHealth, Snapshot, SnapshotContext } from "../interfaces.js"; import type {
CameraDevice,
DeviceHealth,
Snapshot,
SnapshotContext,
} from "../interfaces.js";
import type { CameraDriver, ConfigField, DeviceConfig } from "../registry.js"; import type { CameraDriver, ConfigField, DeviceConfig } from "../registry.js";
import { hostField, passwordField, portField, usernameField, stubLog } from "./common.js"; import {
hostField,
passwordField,
portField,
usernameField,
stubLog,
} from "./common.js";
import { digestGet } from "./http-digest.js"; import { digestGet } from "./http-digest.js";
// Camera drivers — entry/exit snapshot-on-event. The host pulls a still over // Camera drivers — entry/exit snapshot-on-event. The host pulls a still over
@@ -15,12 +26,32 @@ import { digestGet } from "./http-digest.js";
const DEFAULT_TIMEOUT_MS = 8000; const DEFAULT_TIMEOUT_MS = 8000;
// Hikvision returns HTTP 503 (statusCode 2 / "Device Busy" / subStatus deviceBusy) —
// and occasionally 500 — when its snapshot encoder is momentarily occupied (another
// snapshot in flight, a stream starting, on-camera VCA). It is TRANSIENT: a retry a
// few hundred ms later succeeds. The newer G3H sensors (e.g. DS-2CD1047G3H) hit it
// more readily. So a standalone capture retries a few times before giving up; we do
// NOT retry config errors (401 auth, 404 path/channel) — those won't self-heal.
// (Concurrent same-camera hits are separately de-duped by captureSnapshotShared in
// the server.) See wiki/entities/lpr-camera.md ("503 Device Busy").
const SNAPSHOT_RETRY_STATUSES = new Set([500, 503]);
const SNAPSHOT_MAX_ATTEMPTS = 4;
const SNAPSHOT_RETRY_BASE_MS = 250;
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
class HttpCamera implements CameraDevice { class HttpCamera implements CameraDevice {
readonly #host: string; readonly #host: string;
readonly #port: number; readonly #port: number;
readonly #user: string; readonly #user: string;
readonly #password: string; readonly #password: string;
readonly #channel: number; readonly #channel: number;
/** Hikvision stream within the channel: 1 = main (high-res), 2 = sub (lighter).
* Some models (e.g. the G3H) keep the MAIN encoder saturated and return a
* persistent 503 deviceBusy on the main-stream snapshot, while the sub-stream
* serves fine — so this is selectable. Ignored by drivers (Dahua) that don't
* encode a stream in the path. See wiki/entities/lpr-camera.md ("503 Device Busy"). */
readonly #stream: number;
readonly #timeout: number; readonly #timeout: number;
// Source outbound from the device-facing NIC on a multi-homed host (the // Source outbound from the device-facing NIC on a multi-homed host (the
// multi-subnet source-address trap — see wiki/concepts/wsl-dev-networking.md). // multi-subnet source-address trap — see wiki/concepts/wsl-dev-networking.md).
@@ -29,16 +60,20 @@ class HttpCamera implements CameraDevice {
constructor( constructor(
readonly driverId: string, readonly driverId: string,
config: DeviceConfig, config: DeviceConfig,
/** Builds the snapshot path from the configured channel. */ /** Builds the snapshot path from the configured channel + stream (1=main, 2=sub). */
private readonly snapshotPath: (channel: number) => string, private readonly snapshotPath: (channel: number, stream: number) => string,
) { ) {
this.#host = String(config.host); this.#host = String(config.host);
this.#port = Number(config.port ?? 80); this.#port = Number(config.port ?? 80);
this.#user = String(config.username ?? ""); this.#user = String(config.username ?? "");
this.#password = String(config.password ?? ""); this.#password = String(config.password ?? "");
this.#channel = Number(config.channel ?? 1); this.#channel = Number(config.channel ?? 1);
// 1 = main, 2 = sub. Clamp to those two; default main for back-compat.
this.#stream = Number(config.stream) === 2 ? 2 : 1;
this.#timeout = Number(config.timeoutMs ?? DEFAULT_TIMEOUT_MS); this.#timeout = Number(config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
this.#localAddress = config.localAddress ? String(config.localAddress) : undefined; this.#localAddress = config.localAddress
? String(config.localAddress)
: undefined;
} }
async connect(): Promise<void> {} async connect(): Promise<void> {}
@@ -49,8 +84,13 @@ class HttpCamera implements CameraDevice {
// frame: it exercises reachability + auth + the path/channel in one shot. // frame: it exercises reachability + auth + the path/channel in one shot.
try { try {
const res = await this.#get(); const res = await this.#get();
if (res.status === 200) return { status: "ready", detail: `${res.body.length} bytes` }; if (res.status === 200)
if (res.status === 401) return { status: "degraded", detail: "auth rejected (check username/password)" }; return { status: "ready", detail: `${res.body.length} bytes` };
if (res.status === 401)
return {
status: "degraded",
detail: "auth rejected (check username/password)",
};
return { status: "degraded", detail: `HTTP ${res.status}` }; return { status: "degraded", detail: `HTTP ${res.status}` };
} catch (err) { } catch (err) {
return { status: "offline", detail: (err as Error).message }; return { status: "offline", detail: (err as Error).message };
@@ -58,13 +98,37 @@ class HttpCamera implements CameraDevice {
} }
async captureSnapshot(ctx: SnapshotContext): Promise<Snapshot> { async captureSnapshot(ctx: SnapshotContext): Promise<Snapshot> {
const res = await this.#get(); // Retry transient "Device Busy" (503/500); a config error (401/404) fails fast.
let res = await this.#get();
for (
let attempt = 1;
res.status !== 200 &&
SNAPSHOT_RETRY_STATUSES.has(res.status) &&
attempt < SNAPSHOT_MAX_ATTEMPTS;
attempt++
) {
// Linear backoff (250/500/750ms) — the encoder frees within a frame or two.
await sleep(SNAPSHOT_RETRY_BASE_MS * attempt);
stubLog(
this.driverId,
`captureSnapshot ${ctx.direction} retry ${attempt} (was HTTP ${res.status})`,
);
res = await this.#get();
}
if (res.status !== 200) { if (res.status !== 200) {
// Name the busy case so the operator/telemetry can tell "camera busy" from a
// real fault (offline / auth / wrong path).
const busy = SNAPSHOT_RETRY_STATUSES.has(res.status)
? " (device busy)"
: "";
throw new Error( throw new Error(
`${this.driverId} snapshot failed (${ctx.direction}): HTTP ${res.status}`, `${this.driverId} snapshot failed (${ctx.direction}): HTTP ${res.status}${busy}`,
); );
} }
stubLog(this.driverId, `captureSnapshot ${ctx.direction} (${res.body.length} bytes)`); stubLog(
this.driverId,
`captureSnapshot ${ctx.direction} (${res.body.length} bytes)`,
);
return { return {
bytes: res.body, bytes: res.body,
contentType: res.contentType || "image/jpeg", contentType: res.contentType || "image/jpeg",
@@ -76,7 +140,7 @@ class HttpCamera implements CameraDevice {
return digestGet({ return digestGet({
host: this.#host, host: this.#host,
port: this.#port, port: this.#port,
path: this.snapshotPath(this.#channel), path: this.snapshotPath(this.#channel, this.#stream),
user: this.#user, user: this.#user,
password: this.#password, password: this.#password,
timeoutMs: this.#timeout, timeoutMs: this.#timeout,
@@ -93,7 +157,32 @@ const channelField: ConfigField = {
default: 1, default: 1,
}; };
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, channelField]; // Hikvision stream-within-channel for the snapshot: main (01) is full-res; sub (02)
// is lighter. Default MAIN (back-compat). Switch to SUB when the main encoder is
// saturated and returns a persistent 503 deviceBusy (seen on DS-2CD1047G3H-LIU) —
// the sub-stream is also the better fit for snapshot/ANPR (smaller, faster, doesn't
// contend with live-view/recording). See wiki/entities/lpr-camera.md.
const streamField: ConfigField = {
key: "stream",
label: "Snapshot stream",
type: "select",
required: false,
default: "1",
options: [
{ value: "1", label: "Main (01)" },
{ value: "2", label: "Sub (02)" },
],
};
// Dahua has no stream selector (its CGI snapshot isn't stream-encoded in the path).
const cameraConfigFields = [
hostField,
portField(80),
usernameField,
passwordField,
channelField,
];
const hikvisionConfigFields = [...cameraConfigFields, streamField];
// Hikvision "Alarm Server" PUSH config. The newer firmware (Event → Smart/VCA → // Hikvision "Alarm Server" PUSH config. The newer firmware (Event → Smart/VCA →
// "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm Settings → // "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm Settings →
@@ -138,15 +227,21 @@ export const hikvisionDriver: CameraDriver = {
id: "hikvision", id: "hikvision",
category: "camera", category: "camera",
label: "Hikvision camera", label: "Hikvision camera",
description: "Hikvision snapshot via ISAPI (HTTP Digest) + optional Alarm Server event push.", description:
"Hikvision snapshot via ISAPI (HTTP Digest) + optional Alarm Server event push.",
transports: ["tcp-ip"], transports: ["tcp-ip"],
// The camera PULLS snapshots, but with Alarm Server on it ALSO pushes events to us — // The camera PULLS snapshots, but with Alarm Server on it ALSO pushes events to us —
// so it may need the backend push IP at assign time (like the Dingtian). // so it may need the backend push IP at assign time (like the Dingtian).
pushesToBackend: true, pushesToBackend: true,
configFields: [...cameraConfigFields, ...alarmPushFields], configFields: [...hikvisionConfigFields, ...alarmPushFields],
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch2 main = 201. // ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch1 sub = 102, ch2 main = 201.
// stream 1 → "01" (main), 2 → "02" (sub).
create: (c) => create: (c) =>
new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`), new HttpCamera(
"hikvision",
c,
(ch, stream) => `/ISAPI/Streaming/channels/${ch}0${stream}/picture`,
),
}; };
export const dahuaDriver: CameraDriver = { export const dahuaDriver: CameraDriver = {
@@ -156,7 +251,12 @@ export const dahuaDriver: CameraDriver = {
description: "Dahua snapshot via CGI (HTTP Digest).", description: "Dahua snapshot via CGI (HTTP Digest).",
transports: ["tcp-ip"], transports: ["tcp-ip"],
configFields: cameraConfigFields, configFields: cameraConfigFields,
// Dahua channels are 0-based on the CGI; the admin enters 1-based. // Dahua channels are 0-based on the CGI; the admin enters 1-based. No stream in the
// path (the second arg is ignored — Dahua has no main/sub snapshot distinction here).
create: (c) => create: (c) =>
new HttpCamera("dahua", c, (ch) => `/cgi-bin/snapshot.cgi?channel=${Math.max(0, ch - 1)}`), new HttpCamera(
"dahua",
c,
(ch) => `/cgi-bin/snapshot.cgi?channel=${Math.max(0, ch - 1)}`,
),
}; };
+36
View File
@@ -0,0 +1,36 @@
import { execFile } from "node:child_process";
// Unprivileged ICMP liveness check for PUSH-only devices that expose no TCP port —
// e.g. the Dingtian DT-008 QR readers, which GET our backend on each scan but listen on
// nothing. For those a TCP connect probe (what cameras/printers use) has nothing to
// connect to; ICMP echo is the only honest "powered + on-network" signal.
//
// We shell to the system `ping` rather than open a raw socket: Node's `dgram` is
// UDP-only (no IPPROTO_ICMP), and a raw socket needs CAP_NET_RAW. `/bin/ping` in
// SOCK_DGRAM mode runs WITHOUT NET_RAW when the kernel's `net.ipv4.ping_group_range`
// includes the runtime user's gid — which the booth compose sets as a sysctl (see
// docker-compose.prod.yml). So: no native dep, no NET_RAW. A ping only proves the box
// answers ICMP (not that the scan head works) — but it correctly flips red when the
// reader is unplugged/dead, which the old hardcoded "ready" never did.
// See wiki/entities/dingtian-qr-reader.md / device-status-monitoring.md.
/**
* Send ONE ICMP echo to `host` and resolve true if it replied within `timeoutMs`.
* Never throws — any spawn/permission/timeout failure resolves false (treated as
* "not reachable"). Linux `ping` flags: `-n` numeric (no DNS), `-c 1` one packet,
* `-w`/`-W` deadline. We pass the host as a fixed arg (execFile, not a shell) so a
* crafted "host" can't inject a command.
*/
export function icmpPing(host: string, timeoutMs = 2000): Promise<boolean> {
const deadlineSec = Math.max(1, Math.ceil(timeoutMs / 1000));
return new Promise((resolve) => {
const child = execFile(
"ping",
["-n", "-c", "1", "-w", String(deadlineSec), "-W", String(deadlineSec), host],
{ timeout: timeoutMs + 500 },
(err) => resolve(err == null), // exit 0 = a reply; anything else = no reply
);
// If the binary is missing entirely, execFile emits 'error' (callback also fires).
child.on("error", () => resolve(false));
});
}
+3 -3
View File
@@ -7,7 +7,7 @@ import { stubAccessDriver } from "./access-stub.js";
import { dahuaDriver, hikvisionDriver } from "./camera.js"; import { dahuaDriver, hikvisionDriver } from "./camera.js";
import { cashinoDriver } from "./printer-cashino.js"; import { cashinoDriver } from "./printer-cashino.js";
import { rongtaDriver } from "./printer-rongta.js"; import { rongtaDriver } from "./printer-rongta.js";
import { geeQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js"; import { dingtianQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
let registered = false; let registered = false;
@@ -19,7 +19,7 @@ export function registerBuiltinDrivers(): void {
registry.register(stubAccessDriver); registry.register(stubAccessDriver);
registry.register(wiegandReaderDriver); registry.register(wiegandReaderDriver);
registry.register(tcpipReaderDriver); registry.register(tcpipReaderDriver);
registry.register(geeQrReaderDriver); registry.register(dingtianQrReaderDriver);
registry.register(hikvisionDriver); registry.register(hikvisionDriver);
registry.register(dahuaDriver); registry.register(dahuaDriver);
registry.register(rongtaDriver); registry.register(rongtaDriver);
@@ -31,7 +31,7 @@ export {
stubAccessDriver, stubAccessDriver,
wiegandReaderDriver, wiegandReaderDriver,
tcpipReaderDriver, tcpipReaderDriver,
geeQrReaderDriver, dingtianQrReaderDriver,
hikvisionDriver, hikvisionDriver,
dahuaDriver, dahuaDriver,
rongtaDriver, rongtaDriver,
@@ -0,0 +1,50 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { cashinoDriver } from "./printer-cashino.js";
import { renderTicket } from "./printer-escpos.js";
// End-to-end transport routing through the real driver: a USB-configured Cashino must
// resolve to the char-device transport and write the SAME ESC/POS bytes the TCP path
// would. (The TCP path is exercised by the routing/escpos suites and on hardware.)
describe("cashinoDriver — USB transport", () => {
let dir: string;
let devicePath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "cashino-usb-"));
devicePath = join(dir, "lp0");
// Stand in for an enumerated usblp node (the kernel creates it; we only open it).
writeFileSync(devicePath, "");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("prints a ticket to the configured USB device path", async () => {
const printer = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
const data = { ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" };
await printer.printTicket(data);
const written = readFileSync(devicePath);
expect(written.equals(renderTicket(data))).toBe(true);
});
it("healthCheck reports ready when the node exists, offline when it doesn't", async () => {
const present = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
expect((await present.healthCheck()).status).toBe("ready");
// An absent device node (printer unplugged / not enumerated) → offline.
const absent = cashinoDriver.create({
transport: "usb",
devicePath: join(dir, "absent-lp0"),
timeoutMs: 1000,
});
expect((await absent.healthCheck()).status).toBe("offline");
});
it("advertises both transports", () => {
expect(cashinoDriver.transports).toContain("usb");
expect(cashinoDriver.transports).toContain("tcp-ip");
});
});
+38 -31
View File
@@ -10,20 +10,31 @@ import type {
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js"; import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js"; import { hostField, portField, stubLog } from "./common.js";
import { import {
probe, devicePathField,
probeTo,
renderReceipt, renderReceipt,
renderReport, renderReport,
renderSubscriptionCard, renderSubscriptionCard,
renderTicket, renderTicket,
renderWindowChargeNotice, renderWindowChargeNotice,
sendRaw, sendTo,
transportField,
transportFromConfig,
type Transport,
} from "./printer-escpos.js"; } from "./printer-escpos.js";
// Cashino 80mm network thermal printer driver. The Cashino is an ESC/POS clone: // Cashino 80mm thermal printer driver (network OR USB). The Cashino is an ESC/POS
// it PRINTS identically to the Rongta (same byte stream — see ./printer-escpos.ts), // clone: it PRINTS identically to the Rongta (same byte stream — see
// so tickets, reports and subscription cards render the same. What it does NOT // ./printer-escpos.ts), so tickets, reports and subscription cards render the same,
// have is the Rongta board's decoded status web page (/prn_stat.htm). It cannot // over either transport. What it does NOT have is the Rongta board's decoded status
// report paper-out / cover-open / cutter faults in a form we trust. // web page (/prn_stat.htm). It cannot report paper-out / cover-open / cutter faults
// in a form we trust.
//
// TRANSPORT: a single `config.transport` ("tcp-ip" | "usb") picks the wire; the
// driver resolves it ONCE into a Transport and every print/probe stays transport-
// blind (see transportFromConfig/sendTo/probeTo). USB writes the same bytes to a
// local usblp char device (/dev/usb/lp0); TCP writes to the raw print socket. This
// clone is the natural USB candidate — reachability-only, no status page to lose.
// //
// Therefore this driver deliberately does NOT implement MonitorableDevice // Therefore this driver deliberately does NOT implement MonitorableDevice
// (no readStatus). The device monitor then falls back to the generic // (no readStatus). The device monitor then falls back to the generic
@@ -40,13 +51,11 @@ import {
class CashinoPrinter implements PrinterDevice { class CashinoPrinter implements PrinterDevice {
readonly driverId = "cashino"; readonly driverId = "cashino";
readonly #host: string; readonly #transport: Transport;
readonly #port: number;
readonly #timeout: number; readonly #timeout: number;
constructor(config: DeviceConfig) { constructor(config: DeviceConfig) {
this.#host = String(config.host); this.#transport = transportFromConfig(config);
this.#port = config.port ? Number(config.port) : 9100;
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000; this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
} }
@@ -59,15 +68,15 @@ class CashinoPrinter implements PrinterDevice {
} }
/** /**
* Reachability only — a TCP connect probe of the raw print socket. The Cashino * Reachability only — a connect probe (TCP) or char-device open probe (USB) of
* has no trustworthy status protocol, so this is the floor and the ceiling of * the print path. The Cashino has no trustworthy status protocol, so this is the
* what we report: reachable → ready, unreachable → offline. Deliberately NO * floor and the ceiling of what we report: reachable → ready, unreachable →
* readStatus(): the monitor uses this for the traffic-light, never a guessed * offline. Deliberately NO readStatus(): the monitor uses this for the
* paper/cover state. * traffic-light, never a guessed paper/cover state.
*/ */
async healthCheck(): Promise<DeviceHealth> { async healthCheck(): Promise<DeviceHealth> {
try { try {
await probe(this.#host, this.#port, this.#timeout); await probeTo(this.#transport, this.#timeout);
return { status: "ready" }; return { status: "ready" };
} catch (err) { } catch (err) {
return { status: "offline", detail: (err as Error).message }; return { status: "offline", detail: (err as Error).message };
@@ -75,12 +84,12 @@ class CashinoPrinter implements PrinterDevice {
} }
async printTicket(data: TicketData): Promise<void> { async printTicket(data: TicketData): Promise<void> {
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout); await sendTo(this.#transport, renderTicket(data), this.#timeout);
stubLog(this.driverId, `printed ticket ${data.ticketId}`); stubLog(this.driverId, `printed ticket ${data.ticketId}`);
} }
async printReport(report: PrintReport): Promise<void> { async printReport(report: PrintReport): Promise<void> {
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout); await sendTo(this.#transport, renderReport(report), this.#timeout);
stubLog( stubLog(
this.driverId, this.driverId,
`printed report "${report.title}" (${report.lines.length} lines)`, `printed report "${report.title}" (${report.lines.length} lines)`,
@@ -88,17 +97,12 @@ class CashinoPrinter implements PrinterDevice {
} }
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> { async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
await sendRaw( await sendTo(this.#transport, renderSubscriptionCard(data), this.#timeout);
this.#host,
this.#port,
renderSubscriptionCard(data),
this.#timeout,
);
stubLog(this.driverId, `printed subscription card ${data.code}`); stubLog(this.driverId, `printed subscription card ${data.code}`);
} }
async printReceipt(data: ReceiptData): Promise<void> { async printReceipt(data: ReceiptData): Promise<void> {
await sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout); await sendTo(this.#transport, renderReceipt(data), this.#timeout);
stubLog( stubLog(
this.driverId, this.driverId,
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`, `printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
@@ -106,7 +110,7 @@ class CashinoPrinter implements PrinterDevice {
} }
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> { async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
await sendRaw(this.#host, this.#port, renderWindowChargeNotice(data), this.#timeout); await sendTo(this.#transport, renderWindowChargeNotice(data), this.#timeout);
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`); stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
} }
} }
@@ -141,14 +145,17 @@ export const cashinoDriver: PrinterDriver = {
category: "printer", category: "printer",
label: "Cashino 80mm thermal printer", label: "Cashino 80mm thermal printer",
description: description:
"Cashino 80mm thermal printer (ESC/POS over raw TCP, port 9100). Prints like the Rongta but has no status page — monitored by reachability ping only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.", "Cashino 80mm thermal printer (ESC/POS over raw TCP port 9100, OR local USB /dev/usb/lp0). Prints like the Rongta but has no status page — monitored by reachability only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
transports: ["tcp-ip"], transports: ["tcp-ip", "usb"],
configFields: [ configFields: [
hostField, transportField,
devicePathField,
// host/port are TCP-only; not required because a USB printer needs neither.
{ ...hostField, required: false, help: `${hostField.help} Leave blank for a USB printer.` },
{ {
...portField(9100), ...portField(9100),
required: false, required: false,
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).", help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100). TCP only.",
}, },
roleField, roleField,
rankField, rankField,
@@ -1,9 +1,15 @@
import { describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { import {
renderTicket, renderTicket,
renderReceipt, renderReceipt,
renderWindowChargeNotice, renderWindowChargeNotice,
renderSubscriptionCard, renderSubscriptionCard,
probeUsb,
sendRawUsb,
transportFromConfig,
stamp, stamp,
} from "./printer-escpos.js"; } from "./printer-escpos.js";
@@ -103,6 +109,69 @@ describe("CP852 character mapping (the misprint fixes)", () => {
}); });
}); });
describe("USB transport (sendRawUsb / probeUsb / transportFromConfig)", () => {
// A regular file stands in for the usblp character device: open(O_WRONLY) + write
// is the same syscall path. This proves the transport is byte-blind — the EXACT
// ESC/POS stream renderTicket produces lands at the device path, with no transport
// touching a rendered byte (the whole point of the seam).
let dir: string;
let devicePath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "escpos-usb-"));
devicePath = join(dir, "lp0");
// A real usblp node already EXISTS (created by the kernel on enumeration); we open
// it O_WRONLY without O_CREAT, never create it. Pre-create the stand-in file so the
// test mirrors that — opening an ABSENT path means "printer not present" (offline).
writeFileSync(devicePath, "");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("writes the exact rendered ESC/POS bytes to the device path", async () => {
const payload = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" });
await sendRawUsb(devicePath, payload, 1000);
const written = readFileSync(devicePath);
expect(written.equals(payload)).toBe(true);
});
it("rejects when the device path can't be opened (printer not present)", async () => {
await expect(
sendRawUsb(join(dir, "absent-lp0"), Buffer.from([0x1b, 0x40]), 1000),
).rejects.toThrow();
});
it("probeUsb resolves for an existing node, rejects for a missing one", async () => {
await expect(probeUsb(devicePath, 1000)).resolves.toBeUndefined();
await expect(probeUsb(join(dir, "nope"), 1000)).rejects.toThrow();
});
it("transportFromConfig: transport=usb selects the char device (default /dev/usb/lp0)", () => {
expect(transportFromConfig({ transport: "usb", devicePath: "/dev/usb/lp1" })).toEqual({
kind: "usb",
devicePath: "/dev/usb/lp1",
});
expect(transportFromConfig({ transport: "usb" })).toEqual({
kind: "usb",
devicePath: "/dev/usb/lp0",
});
});
it("transportFromConfig: anything else is TCP (back-compat with host-only configs)", () => {
expect(transportFromConfig({ host: "10.0.0.9" })).toEqual({
kind: "tcp",
host: "10.0.0.9",
port: 9100,
});
expect(transportFromConfig({ host: "10.0.0.9", port: 9101 })).toEqual({
kind: "tcp",
host: "10.0.0.9",
port: 9101,
});
});
});
describe("stamp (Albanian date format)", () => { describe("stamp (Albanian date format)", () => {
it("formats an ISO time as '<day> <Month> <year> HH:MM:SS'", () => { it("formats an ISO time as '<day> <Month> <year> HH:MM:SS'", () => {
// Local-time dependent, so assert the structure + the Albanian month name. // Local-time dependent, so assert the structure + the Albanian month name.
@@ -1,4 +1,6 @@
import { Socket } from "node:net"; import { Socket } from "node:net";
import { open } from "node:fs/promises";
import { constants as FS } from "node:fs";
import type { import type {
PrintReport, PrintReport,
ReceiptData, ReceiptData,
@@ -567,7 +569,150 @@ export function probe(
}); });
} }
// --- USB transport (kernel usblp character device) ----------------------------
// An ESC/POS USB printer plugged into the appliance enumerates as a character
// device (e.g. /dev/usb/lp0) via the in-box `usblp` kernel driver. We deliver the
// SAME ESC/POS byte stream there as over TCP — only the transport differs, not a
// single rendered byte. No libusb / CUPS / native addon: a plain file write keeps
// the MIT-only + offline-first, minimal-deps appliance constraints, and the path is
// a LOCAL char device the booth operator (the threat model's adversary) can't reach
// over the network. Paper/cover is NOT sensed here — same honesty floor as the
// Cashino TCP probe. usblp + a udev rule granting the server write access to the
// node are a provisioning dependency. See wiki/concepts/printer-usb-transport.md.
/** Bound a promise with a timeout — a wedged USB printer can block a write (or even
* the open) indefinitely, and a stuck print must surface as a failure rather than
* hang the entry flow. The underlying handle leaks on timeout, but the process is
* the appliance server; a failed print is logged and retried/failed-over upstream. */
function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error(msg)), ms);
p.then(
(v) => {
clearTimeout(t);
resolve(v);
},
(e) => {
clearTimeout(t);
reject(e as Error);
},
);
});
}
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
* is a RAW character device: a single open + write delivers the job — there is no
* FIN/half-close dance (that was a TCP concern, where an early destroy() could
* truncate the stream). We always close the handle (even on a failed write). */
export async function sendRawUsb(
devicePath: string,
payload: Buffer,
timeoutMs: number,
): Promise<void> {
const handle = await withTimeout(
open(devicePath, FS.O_WRONLY | FS.O_NONBLOCK),
timeoutMs,
"usb open timeout",
);
try {
await withTimeout(handle.write(payload), timeoutMs, "usb write timeout");
} finally {
await handle.close();
}
}
/** Reachability for a USB printer: the floor is "does the char device exist and
* open writable". A present, openable /dev/usb/lp0 means usblp bound a powered,
* enumerated printer — the USB analogue of the TCP connect probe. (Like the Cashino
* TCP probe, this reports reachability only, never a guessed paper/cover state.) */
export async function probeUsb(devicePath: string, timeoutMs: number): Promise<void> {
const handle = await withTimeout(
open(devicePath, FS.O_WRONLY | FS.O_NONBLOCK),
timeoutMs,
"usb open timeout",
);
await handle.close();
}
// --- transport dispatch -------------------------------------------------------
// A discriminated transport so each driver resolves the wire ONCE (from config) and
// every print/probe call site stays transport-blind. Adding a transport = one more
// arm here + the render layer is untouched.
/** Where a printer's bytes go: a TCP raw-print socket, or a local USB char device. */
export type Transport =
| { kind: "tcp"; host: string; port: number }
| { kind: "usb"; devicePath: string };
/** Build a Transport from a driver's flat config. `transport: "usb"` selects the
* USB char device (`devicePath`, default /dev/usb/lp0); anything else is TCP
* (host + port, default 9100) — so existing network configs with no `transport`
* key keep working unchanged. */
export function transportFromConfig(config: {
transport?: unknown;
host?: unknown;
port?: unknown;
devicePath?: unknown;
}): Transport {
if (config.transport === "usb") {
return { kind: "usb", devicePath: String(config.devicePath ?? "/dev/usb/lp0") };
}
return {
kind: "tcp",
host: String(config.host),
port: config.port ? Number(config.port) : 9100,
};
}
/** Send an ESC/POS payload over whichever transport the printer is configured for. */
export function sendTo(t: Transport, payload: Buffer, timeoutMs: number): Promise<void> {
return t.kind === "usb"
? sendRawUsb(t.devicePath, payload, timeoutMs)
: sendRaw(t.host, t.port, payload, timeoutMs);
}
/** Reachability probe over whichever transport the printer is configured for. */
export function probeTo(t: Transport, timeoutMs: number): Promise<void> {
return t.kind === "usb"
? probeUsb(t.devicePath, timeoutMs)
: probe(t.host, t.port, timeoutMs);
}
/** Human label for a transport, for status detail / logs. */
export function transportLabel(t: Transport): string {
return t.kind === "usb" ? t.devicePath : `${t.host}:${t.port}`;
}
// --- shared driver config fields ---------------------------------------------- // --- shared driver config fields ----------------------------------------------
// Role + failover are identical across ESC/POS printers; defined here so each // Role + failover are identical across ESC/POS printers; defined here so each
// driver shares them. See wiki/concepts/printer-roles-failover.md. // driver shares them. See wiki/concepts/printer-roles-failover.md.
export type PrinterRole = "entry-dispenser" | "booth-receipt"; export type PrinterRole = "entry-dispenser" | "booth-receipt";
// --- shared printer config fields (transport) ---------------------------------
// TCP-or-USB is offered identically across the ESC/POS drivers; defined here so each
// shares the exact field set. The setup wizard renders these generically.
import type { ConfigField } from "../registry.js";
/** Connection-transport select: network (raw TCP 9100) or local USB char device. */
export const transportField: ConfigField = {
key: "transport",
label: "Connection",
type: "select",
required: true,
default: "tcp-ip",
options: [
{ value: "tcp-ip", label: "Network (raw TCP, port 9100)" },
{ value: "usb", label: "USB (local /dev/usb/lp0)" },
],
help: "USB drives a printer plugged into the appliance (usblp); Network drives one on the isolated device VLAN.",
};
/** USB character-device path; used only when transport=usb (ignored for TCP). */
export const devicePathField: ConfigField = {
key: "devicePath",
label: "USB device",
type: "string",
required: false,
default: "/dev/usb/lp0",
help: "Character device for a USB printer (usblp), e.g. /dev/usb/lp0. Only used when Connection is USB.",
};
+45 -28
View File
@@ -13,22 +13,28 @@ import type {
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js"; import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js"; import { hostField, portField, stubLog } from "./common.js";
import { import {
probe, devicePathField,
probeTo,
renderReceipt, renderReceipt,
renderReport, renderReport,
renderSubscriptionCard, renderSubscriptionCard,
renderTicket, renderTicket,
renderWindowChargeNotice, renderWindowChargeNotice,
sendRaw, sendTo,
transportField,
transportFromConfig,
type Transport,
} from "./printer-escpos.js"; } from "./printer-escpos.js";
// Rongta 80mm network thermal printer driver. Rongta RP-series printers (and the // Rongta 80mm thermal printer driver (network OR USB). Rongta RP-series printers
// many OEM clones that share their firmware) speak ESC/POS over a raw TCP socket // (and the many OEM clones that share their firmware) speak ESC/POS over a raw TCP
// on port 9100 — the JetDirect/RAW convention. The ESC/POS rendering + transport // socket on port 9100 — the JetDirect/RAW convention — or over a local USB usblp
// are shared with the other ESC/POS clones in ./printer-escpos.ts; what is unique // char device. The ESC/POS rendering + transport are shared with the other ESC/POS
// to Rongta — and lives here — is LIVE STATUS via the board's own status web page. // clones in ./printer-escpos.ts (config.transport picks the wire); what is unique to
// There is no auth on the print socket; like the other field devices it lives on // Rongta — and lives here — is LIVE STATUS via the board's own status web page. That
// the isolated device VLAN. // page is a NETWORK feature: a USB Rongta degrades to reachability-only monitoring
// (see readStatus). There is no auth on the print socket; like the other field
// devices a networked unit lives on the isolated device VLAN.
// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md. // See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md.
// //
// ROLES + FAILOVER: a lane has more than one printer. Each instance declares a // ROLES + FAILOVER: a lane has more than one printer. Each instance declares a
@@ -128,14 +134,15 @@ function parseStatusPage(html: string): StatusFlags {
class RongtaPrinter implements PrinterDevice, MonitorableDevice { class RongtaPrinter implements PrinterDevice, MonitorableDevice {
readonly driverId = "rongta"; readonly driverId = "rongta";
readonly #transport: Transport;
readonly #host: string; readonly #host: string;
readonly #port: number;
readonly #httpPort: number; readonly #httpPort: number;
readonly #timeout: number; readonly #timeout: number;
constructor(config: DeviceConfig) { constructor(config: DeviceConfig) {
this.#host = String(config.host); this.#transport = transportFromConfig(config);
this.#port = config.port ? Number(config.port) : 9100; // Kept for the HTTP status page (TCP only); empty on a USB printer.
this.#host = config.host ? String(config.host) : "";
this.#httpPort = config.httpPort ? Number(config.httpPort) : 80; this.#httpPort = config.httpPort ? Number(config.httpPort) : 80;
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000; this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
} }
@@ -150,7 +157,7 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
async healthCheck(): Promise<DeviceHealth> { async healthCheck(): Promise<DeviceHealth> {
try { try {
await probe(this.#host, this.#port, this.#timeout); await probeTo(this.#transport, this.#timeout);
return { status: "ready" }; return { status: "ready" };
} catch (err) { } catch (err) {
return { status: "offline", detail: (err as Error).message }; return { status: "offline", detail: (err as Error).message };
@@ -158,12 +165,12 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
} }
async printTicket(data: TicketData): Promise<void> { async printTicket(data: TicketData): Promise<void> {
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout); await sendTo(this.#transport, renderTicket(data), this.#timeout);
stubLog(this.driverId, `printed ticket ${data.ticketId}`); stubLog(this.driverId, `printed ticket ${data.ticketId}`);
} }
async printReport(report: PrintReport): Promise<void> { async printReport(report: PrintReport): Promise<void> {
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout); await sendTo(this.#transport, renderReport(report), this.#timeout);
stubLog( stubLog(
this.driverId, this.driverId,
`printed report "${report.title}" (${report.lines.length} lines)`, `printed report "${report.title}" (${report.lines.length} lines)`,
@@ -171,17 +178,12 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
} }
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> { async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
await sendRaw( await sendTo(this.#transport, renderSubscriptionCard(data), this.#timeout);
this.#host,
this.#port,
renderSubscriptionCard(data),
this.#timeout,
);
stubLog(this.driverId, `printed subscription card ${data.code}`); stubLog(this.driverId, `printed subscription card ${data.code}`);
} }
async printReceipt(data: import("../interfaces.js").ReceiptData): Promise<void> { async printReceipt(data: import("../interfaces.js").ReceiptData): Promise<void> {
await sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout); await sendTo(this.#transport, renderReceipt(data), this.#timeout);
stubLog( stubLog(
this.driverId, this.driverId,
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`, `printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
@@ -189,7 +191,7 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
} }
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> { async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
await sendRaw(this.#host, this.#port, renderWindowChargeNotice(data), this.#timeout); await sendTo(this.#transport, renderWindowChargeNotice(data), this.#timeout);
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`); stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
} }
@@ -206,6 +208,18 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
*/ */
async readStatus(): Promise<PrinterStatus> { async readStatus(): Promise<PrinterStatus> {
const checkedAt = new Date().toISOString(); const checkedAt = new Date().toISOString();
// The status page is an HTTP feature of the network board; a USB printer has no
// such page. Degrade to the reachability floor (open the char device) and report
// ready/offline only — never a guessed paper/cover state, same honesty rule as
// the Cashino. (A USB Rongta is effectively a Cashino for monitoring purposes.)
if (this.#transport.kind === "usb") {
try {
await probeTo(this.#transport, this.#timeout);
return { status: "ready", checkedAt };
} catch (err) {
return { status: "offline", detail: (err as Error).message, checkedAt };
}
}
let html: string; let html: string;
try { try {
html = await fetchStatusPage(this.#host, this.#httpPort, this.#timeout); html = await fetchStatusPage(this.#host, this.#httpPort, this.#timeout);
@@ -281,14 +295,17 @@ export const rongtaDriver: PrinterDriver = {
category: "printer", category: "printer",
label: "Rongta 80mm thermal printer", label: "Rongta 80mm thermal printer",
description: description:
"Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones that serve the /prn_stat.htm status page) over raw TCP (port 9100). No auth on the print socket — isolate the VLAN.", "Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones that serve the /prn_stat.htm status page) over raw TCP (port 9100), OR local USB /dev/usb/lp0. The decoded status page is a network feature — a USB Rongta is monitored by reachability only. No auth on the print socket — isolate the VLAN.",
transports: ["tcp-ip"], transports: ["tcp-ip", "usb"],
configFields: [ configFields: [
hostField, transportField,
devicePathField,
// host/port/status-page are TCP-only; not required for a USB printer.
{ ...hostField, required: false, help: `${hostField.help} Leave blank for a USB printer.` },
{ {
...portField(9100), ...portField(9100),
required: false, required: false,
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).", help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100). TCP only.",
}, },
{ {
key: "httpPort", key: "httpPort",
@@ -296,7 +313,7 @@ export const rongtaDriver: PrinterDriver = {
type: "port", type: "port",
required: false, required: false,
default: 80, default: 80,
help: "Device status page (/prn_stat.htm) port for live monitoring (default 80).", help: "Device status page (/prn_stat.htm) port for live monitoring (default 80). TCP only.",
}, },
roleField, roleField,
rankField, rankField,
@@ -0,0 +1,43 @@
import { afterEach, describe, expect, it, vi } from "vitest";
// Reader health: push-only QR readers expose no TCP port, so liveness is an ICMP
// ping of the (optional) configured IP. With no IP we must NOT claim "ready" (the old
// stub did, hiding offline readers behind a green dot) — we report degraded instead.
// icmpPing is mocked so the test is deterministic + offline.
const icmpPing = vi.fn<(host: string, timeoutMs?: number) => Promise<boolean>>();
vi.mock("./icmp.js", () => ({ icmpPing: (...a: [string, number?]) => icmpPing(...a) }));
const { dingtianQrReaderDriver } = await import("./reader.js");
afterEach(() => {
icmpPing.mockReset();
});
describe("QR reader healthCheck (ICMP liveness)", () => {
it("with an IP that replies → ready", async () => {
icmpPing.mockResolvedValue(true);
const r = dingtianQrReaderDriver.create({ serial: "H05M2AFA", host: "10.0.10.7" });
expect(await r.healthCheck()).toEqual({ status: "ready", detail: "ping 10.0.10.7" });
expect(icmpPing).toHaveBeenCalledWith("10.0.10.7");
});
it("with an IP that does NOT reply → offline (this is the bug fix)", async () => {
icmpPing.mockResolvedValue(false);
const r = dingtianQrReaderDriver.create({ serial: "H05M2AFA", host: "10.0.10.7" });
expect(await r.healthCheck()).toEqual({ status: "offline", detail: "no ping reply from 10.0.10.7" });
});
it("with NO IP → degraded (never a false 'ready')", async () => {
const r = dingtianQrReaderDriver.create({ serial: "H05M2AFA" });
const h = await r.healthCheck();
expect(h.status).toBe("degraded");
expect(icmpPing).not.toHaveBeenCalled(); // nothing to ping
});
it("exposes an optional host field for monitoring", () => {
const hostField = dingtianQrReaderDriver.configFields.find((f) => f.key === "host");
expect(hostField).toBeDefined();
expect(hostField!.required).toBe(false); // operation is push-by-serial; IP is monitor-only
});
});
+37 -12
View File
@@ -1,6 +1,7 @@
import type { DeviceHealth, ReaderDevice, ReaderEvent } from "../interfaces.js"; import type { DeviceHealth, ReaderDevice, ReaderEvent } from "../interfaces.js";
import type { DeviceConfig, ReaderDriver } from "../registry.js"; import type { DeviceConfig, ReaderDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js"; import { hostField, portField, stubLog } from "./common.js";
import { icmpPing } from "./icmp.js";
// Reader drivers (RF / optical). Two integration paths: Wiegand reads reach the // Reader drivers (RF / optical). Two integration paths: Wiegand reads reach the
// access controller directly (autonomous); TCP-IP readers are seen host-side. // access controller directly (autonomous); TCP-IP readers are seen host-side.
@@ -18,8 +19,23 @@ class StubReader implements ReaderDevice {
async disconnect(): Promise<void> { async disconnect(): Promise<void> {
stubLog(this.driverId, "disconnect"); stubLog(this.driverId, "disconnect");
} }
/**
* Liveness. These readers PUSH (scan → GET our backend) and expose no TCP port, so
* there's nothing to connect-probe. If the admin gave the reader's IP we ICMP-ping
* it (powered + on-network); a reply → ready, no reply → offline. With NO IP we
* report `degraded` ("set IP to monitor") rather than a false `ready` — a push
* device that's silent is indistinguishable from a dead one, so claiming `ready`
* unconditionally (the old behaviour) hid offline readers behind a green dot.
*/
async healthCheck(): Promise<DeviceHealth> { async healthCheck(): Promise<DeviceHealth> {
return { status: "ready", detail: "stub" }; const host = this.config.host ? String(this.config.host) : "";
if (!host) {
return { status: "degraded", detail: "push device — set IP to monitor" };
}
const alive = await icmpPing(host);
return alive
? { status: "ready", detail: `ping ${host}` }
: { status: "offline", detail: `no ping reply from ${host}` };
} }
onRead(cb: (r: ReaderEvent) => void): void { onRead(cb: (r: ReaderEvent) => void): void {
this.#cb = cb; this.#cb = cb;
@@ -59,18 +75,19 @@ export const tcpipReaderDriver: ReaderDriver = {
create: (c) => new StubReader("tcpip-reader", c), create: (c) => new StubReader("tcpip-reader", c),
}; };
// GEE/Fondvision QR access reader (e.g. GEE-QR-ER80). A PUSH device: on each scan // Dingtian DT-008 QR/RFID access reader (same vendor as the relay board). Scans QR/barcode
// it HTTP-GETs our backend (/qa/mcardsea.<ext>) carrying its serial (cjihao); the // + ID/IC/NFC cards. A PUSH device: on each scan it HTTP-GETs our backend (/qa/mcardsea.<ext>)
// backend resolves the lane by matching that serial to this device's `serial` // carrying its serial (cjihao); the backend resolves the lane by matching that serial to this
// config, decides, and replies the verdict (drives the beep). No host-side // device's `serial` config, decides, and replies the verdict (drives the beep). No host-side
// connection — the adapter is a stub; the real integration is the HTTP endpoint // connection — the adapter is a stub; the real integration is the HTTP endpoint (apps/server
// (apps/server routes/qr-reader.ts). See wiki/entities/gee-qr-er80.md. // routes/qr-reader.ts). Interfaces: Wiegand 26/34, TCP/IP, USB, RS485. See
export const geeQrReaderDriver: ReaderDriver = { // wiki/entities/dingtian-dt008-reader.md.
id: "gee-qr-reader", export const dingtianQrReaderDriver: ReaderDriver = {
id: "dingtian-qr-reader",
category: "reader", category: "reader",
label: "GEE/Fondvision QR reader (HTTP push)", label: "Dingtian DT-008 QR/RFID reader (HTTP push)",
description: description:
"QR/barcode access reader that HTTP-pushes each scan to the backend. Set its server IP/port to this host in the vendor tool; enter its serial here so scans resolve to this lane.", "Dingtian DT-008 QR/barcode/RFID access reader that HTTP-pushes each scan to the backend. Set its server IP/port to this host in the vendor tool; enter its serial here so scans resolve to this lane.",
transports: ["tcp-ip"], transports: ["tcp-ip"],
configFields: [ configFields: [
{ {
@@ -80,6 +97,14 @@ export const geeQrReaderDriver: ReaderDriver = {
required: true, required: true,
help: "The reader's serial as it reports in each scan (the `cjihao` field). Used to map scans to this lane.", help: "The reader's serial as it reports in each scan (the `cjihao` field). Used to map scans to this lane.",
}, },
{
// OPTIONAL: the reader pushes by serial (operation needs no IP), but giving its
// IP lets the status monitor ICMP-ping it for a real online/offline dot instead
// of an always-green stub. Leave blank to skip monitoring (shows "set IP").
...hostField,
required: false,
help: "Optional: the reader's IP, used ONLY to monitor it (ping). Scans still resolve by serial. Leave blank to skip liveness monitoring.",
},
], ],
create: (c) => new StubReader("gee-qr-reader", c), create: (c) => new StubReader("dingtian-qr-reader", c),
}; };
+1 -1
View File
@@ -14,7 +14,7 @@ export {
stubAccessDriver, stubAccessDriver,
wiegandReaderDriver, wiegandReaderDriver,
tcpipReaderDriver, tcpipReaderDriver,
geeQrReaderDriver, dingtianQrReaderDriver,
hikvisionDriver, hikvisionDriver,
dahuaDriver, dahuaDriver,
rongtaDriver, rongtaDriver,
+17
View File
@@ -35,6 +35,23 @@ export interface AccessControlDevice extends Device {
getDoorStatus(doorId: number): Promise<"open" | "closed">; getDoorStatus(doorId: number): Promise<"open" | "closed">;
} }
// --- Auxiliary outputs (non-barrier latched signals) ---------------------
// Optional capability for controllers with SPARE relays wired to something that
// is NOT a barrier — a button lamp, a "wait"/"go" sign. setAux LATCHES the output
// on or off and holds it (unlike pulseOpen, which is momentary). The
// barrier-not-a-door rule does NOT apply here: this output never gates a vehicle,
// so holding/blinking it is fine. Business logic drives indicators through THIS,
// never the driver's own relay methods. See wiki/concepts/button-light-indicator.md.
export interface AuxOutputDevice {
/** Latch an auxiliary output on/off. 1-based channel (a spare relay). */
setAux(channel: number, on: boolean): Promise<void>;
}
/** Feature-detect the aux-output capability on a built device adapter. */
export function hasAuxOutput(d: unknown): d is AuxOutputDevice {
return typeof (d as Partial<AuxOutputDevice>)?.setAux === "function";
}
// --- Inputs (buttons / dry contacts) ------------------------------------- // --- Inputs (buttons / dry contacts) -------------------------------------
// Optional capability for controllers that expose host-readable inputs SEPARATE // Optional capability for controllers that expose host-readable inputs SEPARATE
// from their relays — e.g. the Dingtian board. This is what enables host-in-the- // from their relays — e.g. the Dingtian board. This is what enables host-in-the-
+7
View File
@@ -28,6 +28,7 @@ export const RESOURCES = [
"report", // events feed, occupancy, future reports "report", // events feed, occupancy, future reports
"log", // application/diagnostic logs (app_logs) — view + retention "log", // application/diagnostic logs (app_logs) — view + retention
"recyclebin", // soft-deleted master data: view / restore / purge "recyclebin", // soft-deleted master data: view / restore / purge
"backup", // encrypted DB backups: configure target + trigger a manual run
] as const; ] as const;
export type Resource = (typeof RESOURCES)[number]; export type Resource = (typeof RESOURCES)[number];
@@ -60,6 +61,12 @@ export const PERMISSIONS: readonly Permission[] = [
// Recycle bin: read (list soft-deleted items), update (restore), delete (purge). These // Recycle bin: read (list soft-deleted items), update (restore), delete (purge). These
// are admin-grade — a restore can revive a privileged user/role, a purge is permanent. // are admin-grade — a restore can revive a privileged user/role, a purge is permanent.
"recyclebin:read", "recyclebin:update", "recyclebin:delete", "recyclebin:read", "recyclebin:update", "recyclebin:delete",
// Backup: read (view config + last-run status), update (set target/schedule), create
// (trigger a manual "back up now"). Admin-grade — a backup exposes the whole signed
// ledger off-box. RESTORE is deliberately NOT a permission: it's an out-of-band runbook
// action on a fresh appliance, never reachable from the running console. See
// wiki/concepts/backup-recovery.md.
"backup:read", "backup:update", "backup:create",
] as const; ] as const;
/** The protected built-in role: non-deletable, non-editable, always = ALL /** The protected built-in role: non-deletable, non-editable, always = ALL
+299
View File
@@ -66,6 +66,9 @@ importers:
fastify-plugin: fastify-plugin:
specifier: 6.0.0 specifier: 6.0.0
version: 6.0.0 version: 6.0.0
sharp:
specifier: ^0.35.2
version: 0.35.2
devDependencies: devDependencies:
'@types/bcrypt': '@types/bcrypt':
specifier: 6.0.0 specifier: 6.0.0
@@ -265,6 +268,9 @@ packages:
'@emnapi/runtime@1.10.0': '@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@emnapi/runtime@1.11.1':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
'@emnapi/wasi-threads@1.2.1': '@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
@@ -774,6 +780,152 @@ packages:
'@floating-ui/utils@0.2.11': '@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.35.2':
resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.35.2':
resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
'@img/sharp-freebsd-wasm32@0.35.2':
resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==}
engines: {node: '>=20.9.0'}
os: [freebsd]
'@img/sharp-libvips-darwin-arm64@1.3.1':
resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.3.1':
resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.3.1':
resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linux-arm@1.3.1':
resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==}
cpu: [arm]
os: [linux]
'@img/sharp-libvips-linux-ppc64@1.3.1':
resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==}
cpu: [ppc64]
os: [linux]
'@img/sharp-libvips-linux-riscv64@1.3.1':
resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==}
cpu: [riscv64]
os: [linux]
'@img/sharp-libvips-linux-s390x@1.3.1':
resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==}
cpu: [s390x]
os: [linux]
'@img/sharp-libvips-linux-x64@1.3.1':
resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==}
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linuxmusl-arm64@1.3.1':
resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linuxmusl-x64@1.3.1':
resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==}
cpu: [x64]
os: [linux]
'@img/sharp-linux-arm64@0.35.2':
resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
'@img/sharp-linux-arm@0.35.2':
resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==}
engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
'@img/sharp-linux-ppc64@0.35.2':
resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==}
engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
'@img/sharp-linux-riscv64@0.35.2':
resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==}
engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
'@img/sharp-linux-s390x@0.35.2':
resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==}
engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
'@img/sharp-linux-x64@0.35.2':
resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
'@img/sharp-linuxmusl-arm64@0.35.2':
resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
'@img/sharp-linuxmusl-x64@0.35.2':
resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
'@img/sharp-wasm32@0.35.2':
resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==}
engines: {node: '>=20.9.0'}
'@img/sharp-webcontainers-wasm32@0.35.2':
resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==}
engines: {node: '>=20.9.0'}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.35.2':
resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.35.2':
resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==}
engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.35.2':
resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -2576,6 +2728,10 @@ packages:
setprototypeof@1.2.0: setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
sharp@0.35.2:
resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==}
engines: {node: '>=20.9.0'}
siginfo@2.0.0: siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@@ -2953,6 +3109,11 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@emnapi/runtime@1.11.1':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.2.1': '@emnapi/wasi-threads@1.2.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -3276,6 +3437,112 @@ snapshots:
'@floating-ui/utils@0.2.11': {} '@floating-ui/utils@0.2.11': {}
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.35.2':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.3.1
optional: true
'@img/sharp-darwin-x64@0.35.2':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.3.1
optional: true
'@img/sharp-freebsd-wasm32@0.35.2':
dependencies:
'@img/sharp-wasm32': 0.35.2
optional: true
'@img/sharp-libvips-darwin-arm64@1.3.1':
optional: true
'@img/sharp-libvips-darwin-x64@1.3.1':
optional: true
'@img/sharp-libvips-linux-arm64@1.3.1':
optional: true
'@img/sharp-libvips-linux-arm@1.3.1':
optional: true
'@img/sharp-libvips-linux-ppc64@1.3.1':
optional: true
'@img/sharp-libvips-linux-riscv64@1.3.1':
optional: true
'@img/sharp-libvips-linux-s390x@1.3.1':
optional: true
'@img/sharp-libvips-linux-x64@1.3.1':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.3.1':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.3.1':
optional: true
'@img/sharp-linux-arm64@0.35.2':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.3.1
optional: true
'@img/sharp-linux-arm@0.35.2':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.3.1
optional: true
'@img/sharp-linux-ppc64@0.35.2':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.3.1
optional: true
'@img/sharp-linux-riscv64@0.35.2':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.3.1
optional: true
'@img/sharp-linux-s390x@0.35.2':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.3.1
optional: true
'@img/sharp-linux-x64@0.35.2':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.3.1
optional: true
'@img/sharp-linuxmusl-arm64@0.35.2':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.3.1
optional: true
'@img/sharp-linuxmusl-x64@0.35.2':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.3.1
optional: true
'@img/sharp-wasm32@0.35.2':
dependencies:
'@emnapi/runtime': 1.11.1
optional: true
'@img/sharp-webcontainers-wasm32@0.35.2':
dependencies:
'@img/sharp-wasm32': 0.35.2
optional: true
'@img/sharp-win32-arm64@0.35.2':
optional: true
'@img/sharp-win32-ia32@0.35.2':
optional: true
'@img/sharp-win32-x64@0.35.2':
optional: true
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -4910,6 +5177,38 @@ snapshots:
setprototypeof@1.2.0: {} setprototypeof@1.2.0: {}
sharp@0.35.2:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.35.2
'@img/sharp-darwin-x64': 0.35.2
'@img/sharp-freebsd-wasm32': 0.35.2
'@img/sharp-libvips-darwin-arm64': 1.3.1
'@img/sharp-libvips-darwin-x64': 1.3.1
'@img/sharp-libvips-linux-arm': 1.3.1
'@img/sharp-libvips-linux-arm64': 1.3.1
'@img/sharp-libvips-linux-ppc64': 1.3.1
'@img/sharp-libvips-linux-riscv64': 1.3.1
'@img/sharp-libvips-linux-s390x': 1.3.1
'@img/sharp-libvips-linux-x64': 1.3.1
'@img/sharp-libvips-linuxmusl-arm64': 1.3.1
'@img/sharp-libvips-linuxmusl-x64': 1.3.1
'@img/sharp-linux-arm': 0.35.2
'@img/sharp-linux-arm64': 0.35.2
'@img/sharp-linux-ppc64': 0.35.2
'@img/sharp-linux-riscv64': 0.35.2
'@img/sharp-linux-s390x': 0.35.2
'@img/sharp-linux-x64': 0.35.2
'@img/sharp-linuxmusl-arm64': 0.35.2
'@img/sharp-linuxmusl-x64': 0.35.2
'@img/sharp-webcontainers-wasm32': 0.35.2
'@img/sharp-win32-arm64': 0.35.2
'@img/sharp-win32-ia32': 0.35.2
'@img/sharp-win32-x64': 0.35.2
siginfo@2.0.0: {} siginfo@2.0.0: {}
simple-concat@1.0.1: {} simple-concat@1.0.1: {}
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
#
# booth.sh — operate the parking stack on the booth PC (Ubuntu).
#
# Wraps the three compose files (base + a dev/prod override) so the operator runs
# one command instead of a long `docker compose -f … -f … --env-file …` line.
#
# ./booth.sh up # start the stack (detached)
# ./booth.sh update # pull newer images + recreate (the "there are new
# # images" case) — see `update` below
# ./booth.sh down # stop the stack
# ./booth.sh restart # restart without pulling
# ./booth.sh status # what's running
# ./booth.sh logs # follow logs (Ctrl-C to stop)
# ./booth.sh ps|pull|config|exec …
#
# Runs from wherever it sits next to the compose files (the booth deploys them
# flat, e.g. /opt/parking_systems/) or from the repo at scripts/booth.sh.
#
# Environment is PROD by default (the booth runs prod: pull pinned registry images,
# Caddy on :80, fast_alpr). Override with ENV=dev for a local build/dev run:
# ENV=dev ./booth.sh up
#
# Config comes from an .env file next to the compose files (REGISTRY, TAG,
# JWT_SECRET, …). Copy .env.example → .env and fill it in. See
# wiki/decisions/container-deployment.md.
set -euo pipefail
# --- locate the compose files -------------------------------------------------
# The script must work in BOTH layouts: in the repo at <repo>/scripts/booth.sh
# (files one level up), AND deployed flat on the booth (booth.sh sits next to the
# compose files, e.g. /opt/parking_systems/). So we don't assume a `scripts/`
# parent — we look for docker-compose.yml in the script's own dir, then ../,
# then $PWD, and cd there. (An absolute SELF is also kept for usage()/sed.)
SELF="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/$(basename -- "${BASH_SOURCE[0]}")"
SCRIPT_DIR="$(dirname -- "$SELF")"
REPO_DIR=""
for d in "$SCRIPT_DIR" "$SCRIPT_DIR/.." "$PWD"; do
if [ -f "$d/docker-compose.yml" ]; then REPO_DIR="$(cd -- "$d" && pwd)"; break; fi
done
[ -n "$REPO_DIR" ] || {
printf 'ERROR: docker-compose.yml not found (looked in %s, its parent, and %s).\n' \
"$SCRIPT_DIR" "$PWD" >&2
exit 1
}
cd "$REPO_DIR"
# --- environment selection (prod by default; the booth is prod) ---------------
ENV="${ENV:-prod}"
case "$ENV" in
prod|production) ENV=prod; OVERRIDE="docker-compose.prod.yml" ;;
dev|development) ENV=dev; OVERRIDE="docker-compose.dev.yml" ;;
*) echo "ERROR: ENV must be 'prod' or 'dev' (got '$ENV')." >&2; exit 2 ;;
esac
BASE="docker-compose.yml"
ENV_FILE="${ENV_FILE:-.env}"
# --- colours (only when attached to a terminal) -------------------------------
if [ -t 1 ]; then
R="$(printf '\033[31m')"; G="$(printf '\033[32m')"; Y="$(printf '\033[33m')"
B="$(printf '\033[1m')"; N="$(printf '\033[0m')"
else
R=""; G=""; Y=""; B=""; N=""
fi
info() { printf '%s==>%s %s\n' "$B" "$N" "$*"; }
warn() { printf '%s!! %s%s\n' "$Y" "$*" "$N" >&2; }
die() { printf '%sERROR:%s %s\n' "$R" "$N" "$*" >&2; exit 1; }
usage() {
sed -n '3,26p' "$SELF" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
# --- preflight (only for commands that actually talk to Docker) ---------------
# Deferred into a function so `help`/usage works with no Docker and no .env.
ENV_ARGS=()
DC=()
preflight() {
command -v docker >/dev/null 2>&1 || die "docker is not installed or not on PATH."
# Prefer the v2 plugin (`docker compose`); fall back to legacy `docker-compose`.
if docker compose version >/dev/null 2>&1; then
DC=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
DC=(docker-compose)
else
die "Docker Compose v2 plugin not found ('docker compose'). Install docker-compose-plugin."
fi
[ -f "$BASE" ] || die "missing $BASE in $REPO_DIR"
[ -f "$OVERRIDE" ] || die "missing $OVERRIDE in $REPO_DIR"
# An .env is required for prod (JWT_SECRET et al. have no safe default); optional
# for dev (we inject a benign local secret below). Pass --env-file only when it
# exists so dev works without one.
if [ -f "$ENV_FILE" ]; then
ENV_ARGS=(--env-file "$ENV_FILE")
elif [ "$ENV" = "prod" ]; then
die "no $ENV_FILE found. Copy .env.example to $ENV_FILE and set JWT_SECRET/REGISTRY/TAG. (prod has no safe defaults.)"
else
# The BASE compose file makes JWT_SECRET shell-required (${JWT_SECRET:?}), which
# the dev override's service-level default can't satisfy. For a dev run with no
# .env, inject the same benign 32-char local secret the dev override documents so
# `up`/`config` work out of the box. NEVER do this for prod (the die above).
warn "no $ENV_FILE found — injecting the documented local-dev JWT_SECRET (dev only)."
: "${JWT_SECRET:=localdevsecret0123456789abcdef0123}"
export JWT_SECRET
fi
}
# The assembled compose invocation every subcommand builds on (runs preflight once).
compose() { "${DC[@]}" -f "$BASE" -f "$OVERRIDE" "${ENV_ARGS[@]}" "$@"; }
# --- subcommands --------------------------------------------------------------
cmd="${1:-}"; [ "$#" -gt 0 ] && shift || true
# Help/usage short-circuits before any Docker or .env requirement.
case "$cmd" in ""|-h|--help|help) usage 0 ;; esac
# Reject an unknown command up front (before preflight) so a typo gets a clear
# "unknown command" rather than a confusing "no .env" from the prod env check.
case "$cmd" in
up|start|update|upgrade|down|stop|restart|pull|status|ps|logs|config|exec) ;;
*) warn "unknown command: $cmd"; usage 1 ;;
esac
preflight
case "$cmd" in
up|start)
info "Starting the parking stack ($B$ENV$N) …"
compose up -d "$@"
info "Up. ${G}$(compose ps --services 2>/dev/null | tr '\n' ' ')${N}"
info "Booth UI: prod → http://<booth-ip>/ · dev → http://<booth-ip>:3000/"
;;
update|upgrade)
# The "I know there are new images" path: pull the moving branch tag, then
# recreate only what changed. Compose recreates a service whose image digest
# moved; unchanged services (and the named volumes — the SQLite DB!) are left
# alone. Old image layers are pruned afterwards to reclaim disk.
[ "$ENV" = "prod" ] || warn "update on ENV=$ENV: dev builds locally, so 'pull' may be a no-op. Use 'up --build' to rebuild dev."
info "Pulling newer images for the ${B}$ENV_FILE${N} TAG …"
compose pull
info "Recreating changed services (volumes/DB preserved) …"
compose up -d --remove-orphans
info "Pruning dangling image layers …"
docker image prune -f >/dev/null || true
info "${G}Update complete.${N} Running:"
compose ps
;;
down|stop)
info "Stopping the parking stack ($ENV) …"
# NOTE: never pass -v here — that would delete the parking-data volume (the
# signed event ledger). Volumes are intentionally preserved across down/up.
compose down "$@"
;;
restart)
info "Restarting (no pull) …"
compose restart "$@"
;;
pull)
info "Pulling images only (no recreate) …"
compose pull "$@"
;;
status|ps)
compose ps "$@"
;;
logs)
# Follow by default; pass a service name to scope, e.g. `logs server`.
compose logs -f --tail=200 "$@"
;;
config)
# Render the merged, variable-substituted compose config (debugging).
compose config "$@"
;;
exec)
[ "$#" -ge 1 ] || die "usage: $0 exec <service> [cmd…] (e.g. exec server sh)"
compose exec "$@"
;;
esac
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
import sys
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8099
class Sink(BaseHTTPRequestHandler):
def _log(self, method: str) -> None:
ts = datetime.now().strftime("%H:%M:%S")
src = self.client_address[0]
clen = int(self.headers.get("Content-Length", 0) or 0)
body = self.rfile.read(clen) if clen else b""
ctype = self.headers.get("Content-Type", "-")
print(f"\n=== {ts} {method} {self.path} from {src} ===", flush=True)
print(f" Content-Type: {ctype} ({clen} bytes)", flush=True)
text = body.decode("utf-8", "replace")
cut = text.find('Content-Type: image/jpeg')
if cut != -1:
text = text[:cut] + "\n [...JPEG image part omitted...]"
print(" body:\n" + text, flush=True)
self.send_response(200)
self.send_header("Content-Length", "2")
self.end_headers()
self.wfile.write(b"OK")
def do_POST(self):
self._log("POST")
def do_GET(self):
self._log("GET")
def do_PUT(self):
self._log("PUT")
def log_message(self, *args):
pass
if __name__ == "__main__":
print(f"camera-event post test listening on 0.0.0.0:{PORT}", flush=True)
print("Ctrl-C to stop.\n", flush=True)
ThreadingHTTPServer(("0.0.0.0", PORT), Sink).serve_forever()
+168
View File
@@ -0,0 +1,168 @@
---
type: concept
tags: [parking, durability, backup, recovery, security, crypto]
sources: []
updated: 2026-06-29
---
# Backup & Disaster Recovery
The appliance's [[sqlite]] DB **is** the signed [[append-only-event-chain]] — the whole
revenue/audit history. A disk failure or a stolen/destroyed PC currently means **total
loss** (this is [[open-questions]] #5). This page is the settled design for an on-site,
admin-driven backup that survives **total hardware loss** and restores to a fresh appliance
with the signed chain still verifying. (Designed 2026-06-29.)
## The recovery scenario it must satisfy
The driving scenario (the one that forces every decision below): **the PC is gone** — stolen
or destroyed. Its SSD is LUKS-encrypted and **TPM-sealed**, so the disk is unrecoverable *by
design* (a stolen disk won't unlock off its own TPM — see [[disk-os-hardening]], [[tpm]]). We do
**not** want the dead disk; we want to stand up a **new PC**, restore the backup, and continue
signing the **same** chain. For that to work, recovery must depend on **(a)** the backup file and
**(b) two keys held out-of-band** — never on the dead machine.
## Key custody — the load-bearing decision
This is the part the whole plan rests on, and it interacts with the secure-element question
([[open-questions]] #6). Three **independent** keys, three custodians:
| Key | Lives | Recoverable after PC loss? | Job |
| --- | --- | --- | --- |
| **`EVENT_SIGNING_KEY`** | [[fleet-deployment-komodo\|Komodo]] secret (`park_buzi_event_signing_key`), escrowed offsite | **Yes — by design** | Signs + verifies the ledger chain |
| **`park_buzi_backup_key`** *(new)* | Komodo secret, escrowed offsite, **separate** from the signing key | **Yes** | Encrypts/decrypts the backup file |
| **LUKS / TPM disk key** | The appliance's TPM only | **No — deliberately** | At-rest protection of the powered-off SSD |
- **The signing key is decoupled from the TPM** — kept an *extractable software HMAC secret*
([[append-only-event-chain]], `signer.ts`), held in Komodo and escrowed by the operator. This is a
**conscious trade**: a truly non-extractable TPM-sealed signing key (the #6 upgrade) would make the
ledger unforgeable even against a host-root attacker — but it would also make the **old ledger
permanently unverifiable after total hardware loss** (the sealed key dies with the machine;
`buildVerifier(keyId)` would return `undefined` forever). You cannot have *both* "key can never be
extracted" *and* "I can rescue the key after the machine dies" — they are the same property from two
sides. Against the [[threat-model|primary adversary]] (the **booth operator**, who has a UI login, not
host root) an escrowed software key is already tamper-evident, so the recoverable design is chosen
**today**; revisiting #6 means re-accepting the unverifiable-after-loss cost. See [[tpm]] "TPM vs.
ATECC608", [[fleet-deployment-komodo]] (the "EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius"
caveat is the same trade).
- **Backup key is separate from the signing key** even though Komodo holds both — so they can be
managed independently. Rationale: (1) the **signing key must almost never rotate** (every rotation
fractures the chain into a new `keyId` segment — old events stay pinned to the old key forever),
whereas the **backup key may want routine rotation** (a USB went home, a target was decommissioned);
coupling them drags the cheap op into the expensive one. (2) The backup key **travels to every backup
destination** (USB, NAS, SFTP); the signing key should travel *nowhere* but Komodo → process memory —
sharing one key means every backup target conceptually exposes the signing key. (3) Keeping them
separate keeps the **#6 TPM-migration door open** without re-wiring backups. Decided 2026-06-29
(the "one fewer secret to escrow" simplicity of a shared key is real, but weakest here because Komodo
already holds both).
> **The keys are never inside the backup they unlock.** A key can't decrypt the file it's locked in.
> Recovery = backup file **+** both escrowed keys, supplied out-of-band. The runbook must say this
> plainly so nobody "helpfully" stores the keys next to the backups.
## What a backup contains
**Full SQLite DB, snapshots included** — one self-contained, restore-to-identical-appliance file
(ledger + sessions + config + subscriptions + the [[entry-exit-points|snapshot]] BLOBs). Chosen for
completeness over size.
> **Size caveat (interacts with [[open-questions]] #10).** Snapshot BLOBs **dominate** DB size and
> bloat *every* backup. They are unsigned, advisory, and already disk-pressure-pruned
> ([[entry-exit-points]]). A future **"exclude snapshots" toggle** (ledger/sessions/config only — much
> smaller, signed chain still fully preserved) is the obvious knob if backup size becomes a problem; the
> default is the complete picture.
The backup is produced via SQLite **online-backup / `VACUUM INTO`** (a consistent snapshot of the
live WAL-mode DB — **never a raw file copy**, which can capture a torn WAL), then encrypted with
`park_buzi_backup_key`. **Acceptance test:** a restored copy must still pass `verifyChain` — the
signed chain is the thing being protected, so an unverifiable restore is a failed backup.
## Triggers
- **Manual** — an admin-only **"Back up now"** button runs immediately to the configured target.
- **Periodic** — an **in-process daily timer** (same pattern as the snapshot-retention prune,
[[entry-exit-points]] / `snapshot-retention.ts`): runs only if the configured target is
reachable/mounted; surfaces last-success / last-error in the UI. No OS cron — it lives inside the
Fastify process, works inside the [[container-deployment|Docker container]], and is configured in
one place. ([[offline-first]]: the periodic path must tolerate a missing/unmounted target without
failing the app.)
## Destinations (admin-configurable)
All three supported in the first cut; the manual button and the periodic timer share them:
- **Local / USB / SATA disk** — a mounted path on an attached disk. Simplest, fully offline, matches
the air-gapped appliance. The strong first target.
- **Network drive (SMB/NFS)** — a mounted share on the isolated LAN (a site NAS). Still
local-network, no internet ([[network-isolation]]).
- **SFTP** — push to an SFTP endpoint, useful for an offsite copy. **FTP is excluded** (plaintext
credentials + data); SFTP is the safe equivalent.
## Retention at the destination
**Keep last N + thinned dailies** (e.g. last 7 daily / last 4 weekly) — bounded disk use, and it
survives the "a bad/partial run clobbered the only good copy" failure. (A single rolling
overwrite-latest file was rejected for exactly that reason.)
## Threat-model fit — restore is the dangerous half
Writing a backup is benign; **restore is operator-adversary surface** ([[threat-model]]). A restored
DB *replaces* the live signed chain — so a malicious restore is a way to swap in a doctored history.
Therefore:
- **Restore is NOT a booth button.** It is an **admin-only, out-of-band runbook action** (new
appliance, deliberate provisioning step), not something reachable from the operator console.
- The backup **target configuration** and the **"Back up now"** action are admin-gated.
- Backups do **not** weaken the chain's tamper-evidence: a restored chain is re-verified with the
escrowed `EVENT_SIGNING_KEY`; a tampered restore fails `verifyChain` just as a tampered live DB
would. The backup is a **durability** control, not an integrity one — integrity stays with the
signed chain + [[reconciliation]].
## As-built (2026-06-29) — engine + local/mounted target
The first slice is **built and tested**: the backup **engine + a local/mounted target + the daily
timer + the manual route**. What landed:
- **`apps/server/src/backup.ts`** — the engine. Consistent online copy via better-sqlite3's native
`.backup()` (a transactionally-consistent snapshot of the live WAL DB — **not** a raw file copy),
then **AES-256-GCM** encryption with a **scrypt-derived** key from `BACKUP_KEY`. Self-describing
header (`magic | version | salt | iv | … | authTag`) so a restore tool needs only the key + the file
— **zero new dependencies** (Node `crypto`). The plaintext intermediate is written to **scratch**
(not the removable/network target) and **wiped in a `finally`**, success or fail. Retention =
**keep-last-N + one-per-day-within-N-days** (`pruneOldBackups`). Tested: round-trip decrypts to a
**byte-identical, queryable DB**; a flipped byte or wrong key **fails GCM auth**; short key rejected;
scratch plaintext always removed.
- **`backup-service.ts`** — the **target directory is admin-chosen** (`site_config.backup_target_dir`,
migration 0016) and read **fresh each run**, so changing it in the UI takes effect with no restart.
Only the **encryption key stays an env/Komodo secret** (`BACKUP_KEY`) — a key must never live in the
DB it backs up. Retention knobs (`BACKUP_KEEP_LAST`, `BACKUP_KEEP_DAILY_DAYS`) stay env. The service
**serializes** concurrent runs (single in-flight guard) and records last-success / last-error;
`status()` exposes `targetDir` + `keyPresent` so the UI distinguishes "no target" from "no key".
- **`routes/backup.ts`** — `GET /api/backup/status` (`backup:read`); `PUT /api/backup/config` to set/
clear the target (`backup:update`); `POST /api/backup/test` to probe a candidate path server-side —
exists / is-a-dir / writable (`backup:update`); `POST /api/backup/run` (`backup:create`), a clean
**409 `backup_not_configured`** when target+key aren't both set. New `backup` permission resource
(`backup:read/update/create`) in `@parking/shared`. **No restore route** — out-of-band by design.
- **`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
BACKUP_KEY** warning), a **Back up now** button, and the restore-is-out-of-band note. Full i18n
(sq + en).
- **`server.ts`** — an **unref'd daily timer** (`backupService.runScheduled`), a **no-op until
configured**, and **deliberately NOT run at startup** (a just-power-cut booth shouldn't write to a
possibly-unmounted disk; the daily cadence + the manual button cover it).
- Env documented in `apps/server/.env.example` (with the escrow + separate-key notes).
**SMB/NFS already work** — they're just a mounted path the admin enters as the target. **Deferred to
follow-up slices:** an **SFTP** target and a **restore runbook / CLI**.
## Status
Design settled 2026-06-29; **engine + admin-configured local/mounted target + admin UI BUILT
2026-06-29** (SFTP + restore tooling pending). The target directory is **admin-chosen in the UI**
(`site_config`, migration 0016), not an env var — the on-site admin picks where backups land; only
`BACKUP_KEY` stays a server secret. Resolves the *design* half of [[open-questions]] #5 and the first
build slices; records the key-custody stance that bears on #6 (signing stays decoupled from the TPM) and
#10 (snapshots bloat backups → future exclude toggle). See [[append-only-event-chain]],
[[disk-os-hardening]], [[tpm]], [[fleet-deployment-komodo]], [[reconciliation]].

Some files were not shown because too many files have changed in this diff Show More