552d87d75ba4a8e1831e63df1748b52d01da896b
87 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
552d87d75b |
feat(devices): K200L printer driver — live cover/paper status from the J-Speed LAN board
The park-buzi printer is a K200L (Xprinter/ICS XP-K200L; its LAN board and USB descriptor call it "POS-80"). Its board serves the Rongta's five-row status table under /prt_status.htm — but the reply carries no HTTP status line or headers, which node:http rejects, so the Rongta driver could never read it and the unit was filed in July as "no status page → generic driver" (reachability only). New `k200l` driver (printer-k200l.ts): prints through the generic ESC/POS device (same bytes, TCP 9100 or usblp) and reads the page over a raw socket, tolerant of both the headerless and a proper HTTP reply. Mapping mirrors the Rongta: board unreachable → offline; page not understood → degraded, never ready; any fault → degraded naming it; USB → reachability floor. The Rongta driver is untouched. Tests replay the captured headerless page (devices suite 76). Live against the lab unit: ready; with the cover open the board reports cover open, paper out, off-line. Wiki: new k200l-printer entity (names, network setup from factory 192.168.123.100, board quirks, status page, what it means for park-buzi — over USB the app never saw cover/paper state at all), cross-links on the Rongta, status-monitoring, USB-transport and WSL-networking pages (parking-net pinned to eth1 while the LAN NIC is eth0), index. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
4fd175e0e4 |
docs(wiki): subscription recurring billing + party ledger — design only
Subscription page: the one-window model can't express prepaid/postpaid, calendar or anniversary anchoring, grace or an expiry notice; and renewal is OFF-BOOK today (a PUT that appends no payment — the same hole closed for the first sale on 2026-06-20). Designed: plan billing rule, per-day pricing so both anchors share one formula, open- ended agreement, subscription_periods where each period is a ledger charge and a renewal = paying the next period, one subscriptionAccess() gate function, expiry notice derived not stored. New decision page party-ledger: a counterparty sub-ledger for who-owes-whom across modules — parties + signed charge / settlement / write_off events, balance derived never stored, aging + statements + CSV; lands postpaid subscriptions, hotel guest-nights, fleet washes on account, supplier/utility bills. Sub-ledger only: no bookkeeping, a statement is not a fiscal invoice, parties per appliance. validation-sponsorship's sponsor table marked superseded; open-questions #17; index. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
f7a262ac9a |
feat(trainer): phase-B body-type classifier — trainer job on the collector host + the classifier stage on the booth
Build & push images / images (push) Successful in 6m31s
apps/trainer (parking-trainer): inspect / train / evaluate / publish. Reads the wash collector's SQLite + crops read-only off its volume; time split (validation = newest slice); thin classes dropped; damped class weights; `features` mode (frozen ImageNet backbone, on-disk feature cache, seconds to retrain) and `finetune` mode (light augmentation). CPU-only torch from PyTorch's wheel index. ONNX export checked against the torch model; NO model file below the validation floor (exit 3, report still written); exit 2 = not enough labels. `evaluate` scores a shipped model on labels reviewed after training + the unlabelled pile; `publish` PUTs a version folder to a Gitea generic package. Light core deps; the `train` extra is heavy — CI syncs without it, torch tests skip. apps/vision: BodyTypeClassifier (bodytype.onnx + sidecar = the preprocessing contract: crop margin, input size, RGB 0-255, normalisation inside the graph) and RefinedVehicleDetector over YOLOX — refines only `car` or a class the classifier trained on, min-confidence, `detector_class` on the result; path set but no file = phase B off without an error; a broken file is a health detail. models/bodytype.version (tracked, empty) pins the published version the Dockerfile fetches at build (BuildKit secret; a pin that cannot be fetched fails the build). Verified: a trainer model gives identical probabilities inside the vision service; both images built and smoke-tested. Delivery: parking-trainer image in build-images.yml, the `trainer` compose profile on the collector stack (CPU, read-only data, TRAINER_OUT), commented TRAINER_OUT/PUBLISH_TOKEN in the wash-collector stack, .dockerignore for both Python contexts, trainer deps synced in CI. Wiki: bodytype-classifier-training rewritten as built (+ one fleet model not per site, secrets/access, where the crops live), opencv-anpr-service §Phase B, vision-review-outbox, vision-service-packaging, fleet-deployment-komodo, index, log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
1a0fe59488 |
docs(wiki): phase B training path and hardware decisions — recorded, not built
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
ec44547122 |
docs(wiki): close the session's loose threads — bay printer question, vocabulary-in-code decision, training note, guard family, OQ #16 settled
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
20a3cb3e80 |
feat(vision): vehicle stage, phase A — YOLOX-S (Apache-2.0 ONNX) beside the plate recognizer
Fills /analyze vehicle.body_type + confidence (car / motorcycle / bus / truck from COCO, mapped to the shared vocabulary) for the Car Wash desk's category suggestion (venue-modules.md §Vehicle category from vision). Advisory: the operator decides, a confident downgrade is flagged, nothing is gated on it. - vision_service/vehicle.py: pure numpy/cv2 letterbox (pad 114, raw BGR), stride-grid decode, class-agnostic NMS, one vehicle per frame (the box holding the plate's centre, else the largest); YoloxVehicleDetector on onnxruntime CPU, 2 intra-op threads. - recognizer.py: WithVehicle composes the stage over any plate recognizer (stub included); a failing stage yields vehicle=null + a "vehicle: …" note in /health.detail — never costs the plate read. model_version reads "<plate>+yolox:yolox_s.onnx@640". - settings: VISION_VEHICLE_MODEL_PATH (unset = off), _INPUT_SIZE (640), _MIN_CONFIDENCE (0.4, the detector's floor; the flag threshold is site config). - Dockerfile bakes yolox_s.onnx (best-effort curl at build; no network → stage off) and sets the path; compose forwards it (empty = off); .env.example documents it. - Measured on four real dev entry frames (DS-2CD1047G3H, 2560×1440): car at 0.83–0.88 in ~240–330 ms; empty lane with a person → none. - tests/test_vehicle.py: decode/NMS/pick/letterbox on synthetic tensors, the composition, and a missing-model /health. Wiki: opencv-anpr-service, venue-modules, log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
5e1395db18 |
feat(carwash): advisory vehicle category from the entry camera — mapping, pre-select, downgrade flag
The app plumbing for venue-modules.md §"Vehicle category from vision"; the model is the open half (no bundled recognizer emits body_type yet, so the desk shows nothing until phase A lands in the vision service). - Shared: VEHICLE_CLASSES vocabulary, VehicleRead, CARWASH_VISION_THRESHOLD_DEFAULT, reason code carwash.categoryDowngrade; settings/order/lookup views carry the read. - Vision contract: /analyze vehicle.body_type + confidence (service schema); the Node client normalises to the vocabulary and drops the rest. - Record: snapshot.ts stores the read in the plate's device_events row (or its own when the plate was unreadable); vehicleForIdentity() resolves it like the plate. - Car wash: carwash_categories.vision_classes (site mapping "car, sedan → Vetura"), carwash_config.vision_threshold (signed config_change when it moves), four vision columns on orders — migration 0030. Lookup returns vision + suggestedCategoryId. - Desk pre-selects the mapped category and shows the read + snapshot thumbnail; Setup offers class chips per category and the threshold. Operator decides. - Flag: a read at/above the threshold whose mapped category prices HIGHER than the chosen one signs one `anomaly` (both categories/prices, operator, snapshot) and stores its id on the order. Equal/upgrade/unsure/unmapped → nothing. Recorded only, never blocks, no reason prompt (user, 2026-09-06). Tests in carwash.test.ts; wiki venue-modules (As built), opencv-anpr-service, log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
50c18405b6 |
feat(roles): roles remember the jobs they follow (re-appliable), every role edit is signed
Closes the permissions-matrix loose ends (venue-modules.md §Permissions matrix): - `role_jobs` (migration 0029): a role stores the manifest jobs it was composed from (chips on at save + any bundle fully present). `jobById` / `jobsBehind` in @parking/shared surface a followed job whose bundle grew past the role in a later release; the roles list shows a "behind <job>" badge with a one-click "Update to job" (the union, nothing removed) and the editor lints it. Never a runtime union: the grid stays the explicit enforcement layer and an update never widens a role without a click. - Every role create/update/delete appends a `config_change` (`role.<id>`, prev/value = name + sorted permissions + jobs, operator); a no-op resave signs nothing. roleRoutes now takes the ledger. - booth-supervisor already carries subscription:*; the stale open note is closed. Tests: routes/roles.test.ts. Wiki: venue-modules status, local-jwt-auth, log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
55d6242c7d |
feat(permissions): per-desk till guards, jobs in the role composer, permission-scoped live feed; role reassignment applies without re-login
Permissions matrix rethink (wiki/decisions/venue-modules.md §"Permissions matrix", open-questions #16) — the grid stays the enforcement layer: - Move 1: each desk's money is guarded by that desk's own permissions. Manifest tillGuards {read, shift, cash}: booth = shift:read / shift:create / drawer:create (unchanged), carwash = carwash:read / carwash:cash (new). Shift + drawer routes resolve the guard FROM THE TILL (requireTill); a wash role holds no shift:* and cannot touch the booth by construction. Replaces the session:read borrowing (tillPermission). /api/shift/tills lists the role's readable tills with canWork; history/movements without a till filter return the union of readable tills. - Move 2: jobs — manifest permission bundles (booth-operator, booth-supervisor, merchant, wash-operator) as one-click chips in Setup → Roles, with "mixes desks" and "partial job" lints (warnings, never blocks). - Move 3: the live WebSocket admits any watch permission (event/session/device read or a module's feedPermission) and filters every push per role; report:read is the reports screen only. Auth: the token's roleId is only a hint — refreshRole() after every jwtVerify resolves the user's CURRENT role (cached, bumped on role/user writes), so reassigning a user's role applies on the next request and a deleted user's session ends with 401. Tests: till guards + look-only role, feed rules, every job's permissions exist, role reassignment without re-login. 353/353. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
3a176c5cc8 |
docs(wiki): DS-2CD1047G3H-LIU main-stream ISAPI snapshot is a firmware bug — camera line to be replaced
Full live investigation of the persistent 503 "deviceBusy" on main-stream ISAPI snapshots (10.0.10.13): ruled out config (byte-identical to a working sibling model), ruled out firmware age (reproduced on both the original V5.8.11 and current V5.11.0 builds, ~15 months apart), and ruled out real resource contention (a full channel-ID sweep shows every ID fails identically except the one hardcoded working value, including nonexistent channels) — pointing at a broken/incomplete ISAPI snapshot handler that mislabels itself as "busy," not a real encoder ceiling. RTSP main-stream frame-grab was confirmed as a working route around it, but given the bug and the sub-stream's real-world plate-read accuracy problems, the owner decided to replace the DS-2CD1047G3H-LIU units rather than carry an ffmpeg/RTSP dependency to work around vendor firmware. Ingested the vendor datasheet as a source page along the way. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
5287be5278 |
docs(wiki): catch-up sweep — five pages lagging the log
rongta-printer still named the cashino driver id (→ escpos + migration 0023 note); tariff-time-tiers listed the composer price preview as deferred (→ delivered by the lab fee breakdown); tariff.md lab section gained the breakdown + composer increment-guard paragraph; i18n.md now records the "25 Qer 14:30" date standard + never-toLocaleString-for- dates rule; fleet-deployment-komodo gained the park-lab stack + tier table (the park-lab addition had also slipped the log — both fixed). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
6ceaadfbf2 |
feat(devices): camera clock sync via ISAPI — heal the 1970 power-cut reset
park-buzi field observation: after a power cut the Hikvision cameras reboot at the 1970 epoch (no/dead RTC battery, no NTP) and stay there until a human logs into the web UI (which silently pushes the browser clock) — corrupting the snapshot OSD timestamps (the evidence trail) and ANPR push times meanwhile. The host is the site's time authority (offline-first, no NTP infra): - Device monitor triggers a sync at each camera's offline→ready edge — exactly the power-restored moment — plus a 24h backstop; the attempt is stamped before the async call so a failing camera retries at backstop cadence, never every poll. - HikvisionCamera.syncClock: GET /ISAPI/System/time; drift ≤60s → leave alone; beyond (or unparseable = infinite drift) → PUT timeMode=manual with the site wall-clock now WITH explicit utc offset (localIsoWithOffset), echoing the camera's timeZone verbatim — correct the clock, never fight its tz/DST config. - Jumps >1h (the power-cut signature) log warn (persisted to app_logs); small corrections info. Capability-guarded (isClockSyncable) — hikvision only; dahua's CGI has no such endpoint. - http-digest generalised to digestRequest (GET/PUT/POST + body); the handshake was already method-aware. digestGet delegates unchanged. 8 new tests: in-sync no-op, 1970 PUT shape (manual + host instant + echoed tz), unparseable→sync, failed-set surfaces, dahua non-capability, DST-both-sides pins on the offset formatter. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
43c1f45e29 |
feat(reader): channel tagging (clone defense) + structural filter for phantom scans
Two reader-hardening changes born from the park-buzi phantom-scan investigation
(empty pre-opening site, exit reader pushing sun-decoded garbage codes).
1. CHANNEL TAGGING — closes the printed-card-clone hole. The DT-008 push is
channel-blind (one opaque cardid from either engine) and SubscriptionFlow
matched by value only, so printing an RF card's UID (often written on the
card face, e.g. 86A158) as a barcode cloned the card. Now:
- Vendor tool sets output prefixes (QRCode "Q:", Card "K:"; server env
overrides READER_QR_PREFIX / READER_CARD_PREFIX).
- routes/qr-reader.ts strips the prefix and tags the read's confirmed
channel (DeviceReadEvent.channel optical|rf; kind qr|card). Enrollment
capture stores the BARE value. READ log lines carry ch=… (permanent
phantom attribution).
- SubscriptionFlow.match requires channel agreement: an optical decode may
not claim an rf credential (and vice versa) — refused + signed
sub.refused.channelMismatch anomaly (a clone attempt is a fraud signal).
- Unprefixed reads keep the legacy untagged shape and match as before, so
enforcement only bites where prefixes are deployed. Deploy server FIRST,
then set prefixes in the vendor tool.
2. STRUCTURAL FILTER — phantom decodes out of the signed feed (operator-
requested, reverses the earlier "record every probe" position — red
"who is exiting?" rows for NOBODY train the operator to ignore the feed).
read-dispatch.ts drops a no-match reader value that cannot possibly be a
credential we issue (no ticket Luhn shape, no SUB-/SUBSESS- prefix, not
confirmed-RF, not a plate) to UNSIGNED device_events telemetry
(unrecognizedRead:true). Deliberately WIDE plausibility: forged ticket
shapes, unknown physical cards, unknown SUB- codes all still sign the
normal refusal anomaly; enrolled credentials match before the filter and
can never be hidden. Works for legacy unprefixed reads too — the feed
cleans up on deploy, before any vendor-tool change.
Wiki: dingtian-dt008-reader.md records the clone hole + fix, the filter (as a
recorded position reversal), and the two device-side settings now part of the
credential contract (output prefixes + Card Input format, moving 6H→8H at the
next vendor-tool session; both live ON the device — re-apply after any
factory reset/swap).
Tests: qr-reader-channel.test.ts (prefix split, route tagging, bare-value
capture), subscription-channel.test.ts (channel agreement matrix + anomaly),
read-dispatch-filter.test.ts (filter boundary: phantoms dropped, probes kept,
enrolled never hidden). Suite 278 green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
35e593ab63 |
docs(wiki): DT-008 phantom-scan diagnosis + backfill bypass/relay-test concept pages
Two independent wiki updates bundled (all docs):
1. dingtian-dt008-reader.md: phantom optical decodes on the park-buzi EXIT
reader (empty pre-opening site, low-sun afternoons). Chain of evidence:
READ log lines carry the reader's own serial (H05MA5B0) → physical device,
not a network source; snapshot shows nobody present; code shapes are the
giveaway (6-digit numerics = checksum-less Interleaved 2-of-5, lone "C" =
Code39/Codabar artifact) → 1D engine decoding sun-made stripe patterns
(striped arm, fence shadows, glare). No fraud exposure (11-digit Luhn ids
can't match); noise only. Fix on the entity page: vendor-tool symbology cut
to QR+Code128 + min decode length, BOTH readers; config lives ON the device
→ re-apply after any factory reset/swap. Deliberately NOT filtering
impossible codes server-side — probe recording is the anomaly path's job.
2. Backfilled two shipped-but-undocumented features (six code files already
linked the first page as if it existed):
- concepts/entry-presence-bypass.md — admin drops a FAULTY presence signal
(granular radar/camera by decision, not a master switch); every flip is a
signed config_change; persists till off; tickets stamped presenceBypassed;
radar-bypass cooldown tradeoff; "the admin is not the adversary, but
trusted never means invisible".
- concepts/setup-relay-test.md — admin-only commissioning pulse, signed
barrier_open_command BEFORE the fire so a test open never reads as the
out-of-band-open fraud signal; saved controllers/declared relays only;
radarAlert lamps excluded; pulseOpen only.
Cross-linked from operator-issued-entry.md, cataloged in index.md, logged.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
f486dcbbfc |
docs(wiki): vision-service hardening backlog + boot-migration data-seed note
Two unrelated leftover wiki edits from earlier sessions:
- NEW concepts/vision-service-hardening.md: the prioritised to-do list from the
2026-07-02 code + security reviews of apps/vision/ (DoS gaps, unauthenticated/
operator-writable model weights, 0.0.0.0 default bind). Cross-linked from
opencv-anpr-service.md ("consult before touching this service").
- container-deployment.md: note that a boot-time migration can be a DATA SEED
(e.g. an RBAC permission granted to the operator role via INSERT OR IGNORE),
and that a built-in-role grant does not auto-apply to a custom role.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
c142166972 |
docs(wiki): ATECC608 is upcoming — retag ledger signing to the on-host reality
No secure element is on-site: event signing runs on the software HMAC (EVENT_SIGNING_KEY, an env var on the host disk), so the ledger is tamper-EVIDENT but forgeable by anyone who owns the host. Several pages overstated it as present-tense "ATECC608-signed / unforgeable"; correct them. - NEW concepts/hardware-signer-options.md: four options for a non-extractable signing key (USB HSM / YubiKey / reuse the TPM / plain-dongle trap) + the recommendation (TPM interim → USB-HSM target; ATECC608 stays for the embedded ESP32, wrong part for a PC host). - entities/atecc608.md: UPCOMING-not-present status banner + PC-vs-embedded. - disk-os-hardening.md: fix the live-USB row (BIOS boot-order password is load-bearing, not Secure Boot — a signed live USB runs); add a physical-tamper chain (Dell 7070 CMOS-reset → live-USB → PCR-7 same-signer unseal) + accepted risks (that unseal, unsigned-initramfs evil-maid, operator-USB read TODO). - open-questions #6 reframed; standing-decisions / overview / threat-model / index de-overstated; log query entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
f6e35bbebf |
fix(reader): correct the QR reader's identity — Dingtian DT-008, not "GEE"
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 |
||
|
|
96acd6b662 |
feat(snapshot): re-encode captures + disk-pressure retention
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 |
||
|
|
4418594af0 |
refactor(setup): unify controller I/O — event-driven relays[] + generic inputs[]
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
8129b63a8c |
feat(profile): self-service name/email/password + desktop installers in CI
Self-service profile: any signed-in user edits their OWN fullName/email and changes their OWN password (proving the current one), without any user:* permission. New routes PUT /api/auth/profile + /api/auth/password act only on req.user.sub (cannot touch username/role), CSRF-guarded; SPA screen at /profile reachable from the header username chip. email added to the session view + SessionUser. 7 tests (routes/profile.test.ts); 148 server tests green. Desktop in CI: new .gitea/workflows/build-desktop.yml builds .deb + .AppImage on every push to dev/main and uploads them as unsigned workflow artifacts (per-commit test build). Signed/versioned release stays on release.yml (tag v*). Wiki: local-jwt-auth (self-service routes), desktop-shell-tauri (two-workflow CI split), log entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
65328b8c11 |
feat(anpr): subscriber-entry bridge + admin disable toggle
CI / check (push) Failing after 15s
Wire the lane camera's vehicle event into the gated subscription flow: on a vehicle/active push from an opt-in (config.anpr) camera, AnprBridge pulls a fresh snapshot, runs ANPR, applies a stricter entry confidence floor, debounces, and — matching the plate to a subscription BEFORE emitting — emits a kind:"plate" read. The existing ReadDispatcher -> SubscriptionFlow then signs the entry/exit and opens the barrier. A plate is never the sole authority: it routes through the same gate (active/window/blocklist/car-count) as any credential. Fail-soft, fire-and-forget, subscriber-only by construction. Field-verified end to end (plate AA504LX opened the entry barrier and appended a signed vehicle_entry). Add an admin master switch (site_config.anpr_entry_enabled, default ON) in Site Settings that disables ONLY the barrier-driving bridge; advisory snapshot-ANPR and lane busy/free are unaffected. Read live per event, so toggling takes effect with no restart. Migration 0013 (additive ALTER ADD COLUMN, default 1). - New: apps/server/src/anpr-entry.ts (AnprBridge) + tests (9) - hikvision-alarm.ts hands vehicle detections to the bridge (fire-and-forget) + wiring tests (3) - server.ts reorders the read flows above the hik-alarm registration - snapshot.ts exports buildCamera for reuse - env: VISION_ENTRY_MIN_CONFIDENCE (0.85), ANPR_DEBOUNCE_MS (12000) - site route + SiteSettings checkbox + i18n (sq/en parity) - wiki: lane-presence-and-anpr-entry / lpr-camera / index / log -> BUILT Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
411572511d |
docs(camera): lane presence + ANPR subscriber-entry bridge design
Captures this session's back-and-forth as a new concept page
[[lane-presence-and-anpr-entry]] and cross-links it:
- BUILT: advisory lane busy/free booth lights (LaneStatus + WS), with the
measured camera limits behind the 30s timeout (no leave signal; movement-
driven re-fire; notificationRecurrence locked to "beginning" — ISAPI flip
silently reverts).
- PLANNED: the ANPR "bridge" — explicitly a small apps/server HANDLER (~40
lines), NOT a new service/container. On a camera vehicle event: snapshot ->
ANPR -> high-confidence match -> debounce -> emitRead{kind:"plate"}, then
the existing subscription match/dispatch/gate admits the subscriber. Both
directions, opt-in (config.anpr), plate never the sole authority.
- Records the decisions (high confidence floor, debounce-for-correctness)
and the REJECTED ideas (continuous livestream / per-car queue tracking /
make-model) with why, plus the open hardware question (booth-PC test).
Updates subscription.md (plate matching is built; the live source is this
bridge) and lpr-camera.md (the two consumers of the vehicle event).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
df6a1ca63a |
docs(camera): correct the "dead camera" conclusion — root cause was undrawn detection area
The Hik DS-2CD1043G2-LIU was NOT defective. An earlier wiki entry wrongly concluded it needed RMA (dead event engine) based on a silent alertStream + diskfull/EventScribe:except + dead RTC surviving a full factory reset. Real cause: no detection AREA was drawn on the frame. With no region, the camera detects nothing -> generates no event -> posts nothing. The instant an area was drawn, the first vehicle produced a clean POST. - Flag "draw the detection area" as the FIRST thing to check. - Document the confirmed real payload: multipart/form-data (MoveDetection.xml), EventNotificationAlert with eventType=VMD, eventState=active, targetType=vehicle (vehicle/human classified on-device), targetRect bbox. Note the dateTime is garbage (dead RTC) -> use our own receive time. - Reframe the SSH diagnostics: diskfull/EventScribe/RTC are RED HERRINGS, not proof of a dead camera; don't escalate to hardware fault while a basic config precondition is unmet. - Append a log correction (append-only) superseding the earlier conclusion. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
547061edf9 |
docs(camera): Hik event-push gotchas + dead-camera diagnostic method
Captures the hard-won findings from the field session: the WSL source-IP rewrite + skipSourceIpCheck fix, the boolean-as-string setup bug, the unreliable "Test" button, the latching httpBroken flag, and Notify- Surveillance-Center vs HTTP-Alarm-Server. Adds a "diagnose a non-pushing camera from its OWN state" runbook (alertStream heartbeat silence, SSH showStatus EventScribe:except, dmesg RTC/UBIFS, netstat outbound watch) and documents the verified-dead DS-2CD1043G2-LIU unit (defective event engine, survives factory reset -> RMA), with the pull+vision fallback. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
6133923094 |
feat(camera): Hikvision Alarm Server event-push ingress (discovery-first)
Newer Hik firmware can PUSH events to us: Event -> Smart/VCA with "Detection Target: Human/Vehicle" + Notify Surveillance Center + Alarm Settings -> Alarm Server makes the camera HTTP-POST an EventNotificationAlert on each detection. - New POST /api/devices/hikvision/:deviceId/event (routes/hikvision-alarm.ts): same machine-push pattern as the Dingtian Input Link — source-IP guarded + optional HTTP Digest, not behind the SPA cookie/CSRF. - Discovery-first / permissive: a wildcard content-type parser accepts ANY body as raw bytes (event XML, multipart+JPEG, or JSON — Hik varies by firmware), records it verbatim as a kind:"alarm" device_event, and best-effort extracts eventType/target/plate/dateTime/channelID for the summary + a loud log line. The point is to SEE exactly what a camera sends before wiring it further. - hikvision driver gains alarmPushEnabled + pushUser/pushPassword config and pushesToBackend:true (setup offers the backend push IP). - NOT yet a barrier trigger / DeviceReadEvent — records only. A plate read is advisory, never the sole reason a barrier opens; the read-bus/ANPR wiring is a deliberate next step once the real payload is known. Tests: hikvision-alarm.test.ts (6: vehicle XML summary, ANPR plate, raw JSON, wrong-IP 404, disabled 404, unknown-device 404). server 109/109; build+lint 14/14. Wiki: lpr-camera.md + log. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
7680d9a0ed |
feat(recycle-bin): soft delete + restore for master data
Accidental admin deletes of users/roles/subscriptions/plans/tariffs were hard and unrecoverable. Now they soft-delete into a recycle bin. Schema (migration 0012): nullable deleted_at + deleted_by on users, roles, subscriptions, subscription_plans, tariffs. Additive ADD COLUMN; verified against a copy of the live DB. Backend: each resource's DELETE route STAMPS instead of removing; every catalog list filters deleted_at IS NULL. New recycle-bin module + routes (GET /api/recycle-bin, POST .../restore, DELETE .../:id purge) gated on a new recyclebin:read/update/delete permission. A 6-hourly + startup sweep auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 = forever). Invariants: soft-deleted users can't log in (login rejects deleted_at; no-lockout counts live admins only); a soft-deleted subscription doesn't open the barrier; plans are versioned so a delete stamps all versions of the plan_id (bin shows one item); username/role-name UNIQUE spans deleted rows so reuse returns a clear 409 pointing at the bin; restore doesn't auto-cascade a dangling role (guard resolves missing role to empty perms). The signed append-only ledger is OUT of scope (no delete path). Web: a Recycle bin tab under Setup (RecycleBin.tsx) with Restore/Purge + purge confirm; api client + i18n (sq + en parity). Tests: recycle-bin.test.ts (9 unit) + recycle-bin-routes.test.ts (4 integration: delete -> can't-login -> restore -> login, purge, gating, 409 reuse). server 103/103; build+lint+test 19/19. Wiki: new concepts/soft-delete.md; local-jwt-auth + index + log updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
66c1291578 |
docs(deploy): COOKIE_SECURE=0 runbook for the plain-HTTP appliance
Documents the deploy-time requirement that the cookie fail-safe fix (
|
||
|
|
78d1f6808a |
feat(subs): admin can correct a subscription's plan VERSION
A subscription froze its planVersionId at sale (reproducible pricing). There was no way to move a sold sub onto a different VERSION of the SAME plan — needed when an admin publishes v2 with different timeframes (e.g. mujor-naten-cdo-dite v1 "every day" → v2 "weekdays only") and wants an existing subscriber on it, or back on v1. Backend (PUT /api/subscriptions/:id): - accept planVersionId; honored only with the subscription:plan permission (stronger than subscription:update — a plan-management action). Non-privileged caller sending a change → 403, not silently dropped. - validated to belong to the sub's EXISTING planId (a different plan = a different price basis = a re-sale → 400). - price/currency/period/planId stay frozen; only planVersionId moves. The swap is server-logged for audit (the row is mutable master data, not on the ledger). Past signed entry/exit events keep their own windowTariffVersionId, so history reprices identically — only future access uses the new version's windows. Frontend (SubscriptionManager): - pass the session user through the route (like RolesManager). - admin-only "Versioni" picker in the edit modal: lists every version of the sub's plan by effective date + a timeframe summary (days + window, or 24/7), current pre-selected. The plan itself stays read-only. Sends planVersionId only when it changed. - i18n: subs.version/versionHint/versionCurrent/versionOnlyOne/everyDay/allDay in both sq + en. Verified on a writable DB copy: version changed, price + planId frozen, cross-plan version rejected. Live DB untouched. build+lint 14/14. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
8acef0464c |
fix(subs): price out-of-window charge from minutes actually parked, not a fixed entry stamp
An out-of-window subscriber entry stamped a FIXED windowOwedMinor = the whole gap to window-open (e.g. 800 ALL for a 13:21 arrival to a 20:00 window) and deferred it to exit. That over-charged anyone who left before the window opened — a 1-hour visit was billed as 6.5 hours. The amount isn't knowable at entry: a subscriber may enter early, leave after an hour, come and go several times before the window opens, and linger past window-close. They should pay only for the minutes actually parked outside the window (capped at the window edges) — exactly what minutesOutsideWindow already computes. So the entry now stamps a MARKER only (outOfWindow: true + windowTariffVersionId for reproducible pricing), no fixed amount. The exit gate and booth quote price it live via windowOwedBetween(entry → settle-time), which already caps at the window edges (early entry stops accruing at window-open; the in-window portion of a crossing stay is free; the late-exit tail keeps accruing until payment). Both already called that one function, so they agree. - subscription-flow: entry stamps outOfWindow marker; the advisory slip is now a scannable out-of-window TICKET (Code128 + QR of the occurrence id). - shared LedgerPayload: add outOfWindow; mark windowOwedMinor/windowGap*/ windowCurrency deprecated read-only (historic signed events still type-check). - BoothScreen: window-charge badge keys on outOfWindow (or the old stamp). - ActiveSessions: drop the always-on "Open barrier" for subscribers — the assist-open / window-charge payment live in the pay modal, so the list can't one-click past an unpaid out-of-window charge. Verified the live model on a DB copy: 13:21→14:30 = 200 ALL; 19:55(in grace)→ 23:00 = 0; 19:00→21:30 (crosses into window) = 100 ALL. Existing signed occurrences left untouched (immutable). build+lint 14/14, shared 87/87. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
d0536da3d7 |
feat(desktop): Tauri v2 kiosk shell — maximized window, prod right-click block, auto-update + code-signing
Add apps/desktop, a thin Tauri v2 shell wrapping the SAME @parking/web SPA so the desktop and browser UIs never drift: dev loads the Vite dev server (HMR), prod bundles the web app's dist/. No business logic in the shell (device/auth/ ledger stay in @parking/server); deny-by-default capabilities. apps/web (single UI source of truth): - lib/origin.ts: centralize the backend origin (API_BASE/apiUrl/wsUrl from VITE_API_BASE); no-op in the browser, lets the desktop build target Fastify. - lib/kiosk.ts: block the right-click context menu in PROD only (dev keeps it + devtools). - lib/desktop-updater.ts: prompt-on-update auto-update (no-op in browser/offline) → downloadAndInstall + relaunch; i18n update.* keys (sq+en). - .env.production: VITE_API_BASE wired to the Fastify origin for the bundle. Desktop: - window starts maximized (not fullscreen — operator keeps OS access). - auto-update via tauri-plugin-updater + -process; self-hosted endpoint is a PLACEHOLDER to fill in. Updater keypair: pubkey embedded in tauri.conf.json; private key + password kept OUTSIDE the repo (~/.parking-updater-keys) and as TAURI_SIGNING_* build secrets. - Turbo build is a no-op; the real signed bundle is `pnpm --filter @parking/desktop bundle` (verified → .deb/.rpm/.AppImage + .sig signatures). Verified: cargo check clean; turbo run build lint 14/14 green; i18n parity holds; no key/sig/bundle artifacts in the repo. Wiki (security + desktop analysis recorded alongside): - new concepts/tpm.md (TPM 2.0: how it works, sealed-LUKS auto-unlock + non- extractable signing key, limits — live-root, bus-sniff — TPM-vs-ATECC608 by platform). - new decisions/desktop-shell-tauri.md (Tauri v2 over Electron; best-case Ubuntu 26.04 LTS, worst-case Windows+WSL → kiosk browser; full as-built). - pull-the-disk attack trace on append-only-event-chain; ATECC608 not-in-a-PC caveat; cross-links from disk-os-hardening / threat-model. - open-questions #11 (appliance WebKitGTK), #12 (TPM hardening impl), #13 (startup verifyChain self-check); index/overview/log/standing-decisions. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
f2734641b2 |
feat(subs): print an advisory "out-of-window" slip at early entry
A subscriber entering outside their plan's window owes a deferred charge, but
nothing printed — they had no paper proof a fee was pending. Print a best-effort
ADVISORY slip at entry ("PARKIM — JASHTË ORARIT"): holder, entry time, "entered
out-of-window (window opens HH:MM)", and the key line "⚠ fee computed at exit"
+ the occurrence number. It is NOT a payable ticket and carries NO amount — the
total is computed at the booth on settlement, combining early-entry AND any
late-exit time into one number (windowOwedBetween over the whole stay).
Best-effort like the Z-report / subscription card: printed AFTER the barrier
opens and fully swallowed, so a missing/failed printer never blocks entry. New
printWindowChargeNotice (booth-print.ts) via the generic printReport; wired into
the subscription entry flow when an out-of-window entry charge applies.
(The "both charges at the booth" requirement was already satisfied by the
windowOwedBetween fix — verified: early-entry + late-exit minutes combine in one
calc at lookup/exit. This commit only adds the entry paper trail.) Build+lint 12/12.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
e0e218fa61 |
refactor: plan timeframes use a per-day-of-week picker (like the V2 tariff)
The timeframes model was a coarse weekday/weekend split, which couldn't express
"open Saturdays" or different rules on a specific day — and it didn't match the
V2 tariff, which already has a proper per-day-of-week picker (Hën–Die).
Replace PlanTimeframes { weekday, weekend } with { days[], fromMin, toMin }: the
allowed window applies only on the selected days (0=Sun..6=Sat; empty = every
day); on unselected days the subscriber parks free. A "night plan, free
weekends" is just days [Mon..Fri] with a 20:00→08:00 window — the exact case
from before, now expressible alongside any other day combination.
outOfWindowGap reworked to the days model (per-day membership test instead of
the weekend helper); the plans editor reuses the tariff composer's Mon-first
checkbox row and the shared tariff.dow0..6 labels. No production plans carry
timeframes yet (feature shipped today), so the shape changed directly with no
migration. Unit tests updated + extended (Saturday-only, every-day, weekday
night); 81 shared tests pass. Build+lint 12/12.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
53e1e7b25c |
feat: subscription v2 — quantity pricing, plan timeframes (tariff bridge), reserved spots
Three subscriber enhancements driven by real scenarios (migration 0011, all
additive columns — backward-compatible).
1. QUANTITY. One subscription covers N cars (a family pays once for two). Sale
amount = span price × quantity; maxConcurrent defaults to the quantity so all
N cars can be inside. Quantity rides in the payment payload.
2. PLAN TIMEFRAMES → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may
park (e.g. weekday 20:00→08:00, weekend all-day). A scan outside the window is
NOT refused — the out-of-window minutes are charged at the normal TRANSIENT
tariff (the subscriber is a transient for that time):
- early entry: arrival → window-open, DEFERRED (signed as windowOwedMinor on
the vehicle_entry payload), collected at exit;
- late exit: window-close → departure, and exit is GATED
(sub.refused.unpaidWindow) until paid at the booth.
Pure, tz-aware outOfWindowGap in @parking/shared (12 unit tests); pricing
reuses computeFee + the active tariff version
(apps/server/src/subscription-window.ts). The exit refusal is a host-ONLINE
business gate — the fail-open rule still governs the offline path.
3. RESERVED SPOTS. Site toggle reserve_subscriber_spots: occupancy holds
max(0, quantity − itsCarsInside) per active subscription, so transients see
"full" sooner; effectiveFree = capacity − count − reserved. Subscribers are
never gated by full.
UI: quantity field + ×N quote (SubscriptionManager); timeframes editor
(SubscriptionPlansManager); reserve checkbox (SiteSettings); booth pay modal
shows an "OUT-OF-WINDOW" charge and takes payment to clear the exit gate.
Verified on a copy of the live DB: qty 2 = 2× price; a night-plan 19:30 entry →
30min/15,000 ALL owed, stamped + paid → gate clears, chain verifies; the reserve
toggle holds a qty-2 sub's 2 spots. Build+lint 12/12; 80 shared tests pass.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
fd4608a8f1 |
feat: subscription plan catalog — config-defined pricing, dated spans, no typed amounts
Re-model subscription pricing from per-row, operator-typed prices into an admin-composed, versioned PLAN CATALOG (the tariff pattern). The operator now SELLS by picking a plan over a date span; the price is LOOKED UP, never typed — removing the fat-finger risk on a money field — and day/week/month periods make the hotel "guest stays 1–N days" case a daily plan over a check-in→check-out span. - Schema/migration 0010: new `subscription_plans` (immutable, effective-dated, keyed by a stable planId; period day/week/month + per-period price + active flag). `subscriptions` gains planId/planVersionId; period enum widened. Seeds a "Monthly" plan from the existing site default price (no data loss). - Pricing (pure, unit-tested in @parking/shared): periods = ceil(span / period), amount = periods × per-period price. Ceil = any started period is full (hotel practice). `resolvePlanVersion` picks the latest active version ≤ sale instant. - Backend: new admin-only plan CRUD (`subscription:plan` permission); reworked sell path derives the amount from the plan; `POST /api/subscriptions/quote` returns a server-computed quote so the operator can't override it. The signed-payment sale fix is unchanged — only the amount SOURCE moved; payload now carries planId/planVersionId/periods. Updates never re-sell (price frozen). - Frontend: SubscriptionManager sell form swaps the price field for a plan picker + start/end dates + a live quote line. New SubscriptionPlansManager (Setup tab) for the admin catalog. i18n (sq+en) for both. Verified on a copy of the live DB: 0010 applies (existing subs intact), a 3-night hotel sale prices to 2,400 ALL, appends one signed payment with planVersionId, chain verifies. Build+lint 12/12; 68 shared tests pass. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
a20400c2c5 |
fix: record subscription sale as a signed payment (close off-book hole)
Creating a priced subscription wrote only the mutable `subscriptions`
master row and appended NOTHING to the signed ledger — so the cash an
operator collected showed in the live feed, drawer, and shift Z-report
nowhere, leaving no signed trace. A booth operator could sell
subscriptions and pocket the money untraceably — the exact
operator-as-adversary path the append-only signed ledger exists to close.
Found live: 3 priced subscriptions (27,000 ALL) had zero payment events.
Selling a priced subscription now appends a signed `payment` event at
create time: amount = priceMinor x months (full multi-month prepay),
operator-chosen tender (cash->drawer / card->bank), payload
{ subscriptionSale: true, permitId, operator, months }. Folds into the
shift Z-report/drawer with no new summing logic; the feed badges it
"subscription sale" and resolves the holder name. The create response
returns the recorded { sale }; subscriptionRoutes now takes the EventLog
and ShiftService.
Not hard-gated on an open shift (a sale can happen outside the booth money
path) — it warns instead. The 3 historical off-book sales are not
back-fillable (append-only forbids forging dated events) — reconcile via
cash_movement or a Z-report note.
Verified against a copy of the live DB with the real signing modules:
signed payment appended, hash-chain still verifies, lands in shift cash
totals. Build + lint 12/12.
Wiki: subscription "Collecting the fee" deferred -> BUILT (+ the off-book
hole and why); shift sale-folds-in; threat-model worked example
("store the price != account for the sale").
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
9ec644811a |
feat(vision): surface the recognized plate in the booth UI
The ANPR plate was saved (device_events kind:"read") but had no UI. Extend GET /api/snapshots/by-identity/:identity to also return plates[] (plate, confidence, region, direction, snapshotId, at) for that session, and render each as a cyan "Plate: AA558EE 100%" chip in the SnapshotStrip — so it shows in both the booth event-detail modal and the pay modal, beside the evidence photo, no separate screen. Deduped by plate+direction; session:read gated; i18n sq+en. Verified: by-identity returns plates[] for a seeded read (200, AA558EE 0.999 Albania entry). Build + lint green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
ecaaefd899 |
refactor(vision): ANPR rides the entry/exit snapshot, drop polling reader
Rework the ANPR trigger to the real design: when a transient presses the button or a
subscriber passes QR/RFID, the entry/exit fires and takes its evidence snapshot — that
is the moment to recognize. snapshotAsync now takes the VisionClient and, after storing
each snapshot from an opt-in (config.anpr) camera, runs ANPR on the SAME image and
records the plate against the SAME session identity (device_events kind:"read" with
plate/confidence/region/snapshotId/source:"entry-exit-snapshot"). One image serves both
evidence and plate extraction; recognition fires only on a real entry/exit — no polling.
The entry/exit/subscription flows take an optional VisionClient and pass it through;
server.ts wires it. Removed the polling VisionReader and VISION_POLL_MS/VISION_DEDUPE_MS.
Advisory + fire-and-forget: a low-confidence/no-plate result records nothing, a vision
failure never delays or changes the open, and the plate does not feed the access
decision. Verified e2e: a simulated entry snapshot on an anpr camera (live fast_alpr)
stored the snapshot for the session and recorded {identity, plate:AA558EE, 0.999,
region:Albania, snapshotId}. Build + lint green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
4af8b56dda |
feat(vision): configurability — SetupWizard ANPR toggle, footer health chip, env docs
Make the vision service genuinely configurable (was env-only). - SetupWizard: an "ANPR" checkbox on the camera form (writes config.anpr; persisted only when on; sq+en) — opt-in is no longer raw JSON. - DeviceMonitor optionally takes the VisionClient and probes /health each tick, emitting a "vision" pseudo-device → a Vision chip (ready/degraded/offline + recognizer) in the booth footer when VISION_ENABLED, no chip when off. Widened the DeviceStatus category union (server + web) + footer maps + devices.catVision. Verified: ready/fast_alpr when up, 0 chips when disabled. - apps/vision/.env.example (Python service) + a VISION_* block in apps/server/.env.example (Node side) + a Configuration section in opencv-anpr-service.md covering all four layers and the caveats: the two processes share the VISION_ prefix but need SEPARATE .env files; bind /analyze to 127.0.0.1; cache model weights at deploy; an unbound anpr camera recognizes but every read is refused. Build + lint green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
540b333b06 |
feat(vision): persist every recognized plate + snapshot as telemetry (non-blocking)
Answers "is a recognized plate saved?" — now yes, for both transient and subscriber, as
an ANPR audit trail independent of whether it matched anything.
VisionReader now stores the snapshot bytes in `snapshots` keyed by identity=PLATE — the
same identity the flow signs its anomaly/event with — so GET /api/snapshots/by-identity/:plate
(the booth event-detail modal's snapshot strip) shows the car's photo against that
anomaly with no UI changes. It also records an unsigned device_events{kind:"read"}
breadcrumb (plate, confidence, region, model, snapshotId, and the dispatch outcome) as a
queryable recognition log. Switched from emitRead to calling ReadDispatcher.dispatch
directly (like qr-reader) to capture that outcome.
Non-blocking: a refused read (no session / unpaid / unknown plate) just returns
rejected — no barrier hold — and is logged with its snapshot for investigation. Plate
stays advisory (exit demands payment; subscription matches only a bound plate).
Verified e2e: a recognized AL plate with no open session signed exit.refused.noSession
(identity=plate), stored a 555KB snapshot under that plate, recorded the read breadcrumb
(accepted:false, reason "no open session"), and by-identity returned the image — the
refused read is fully investigable with its picture. Build + lint green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
7e086ff0d7 |
feat(vision): wire ANPR into the read bus via VisionReader
A VisionReader polls each opt-in camera (config.anpr===true, off by default) every
VISION_POLL_MS, captures a snapshot, recognizes via VisionClient, and on a confident
plate emits deviceEvents.emitRead({kind:"plate", value}) — the same event a physical
plate reader sends, so the existing ReadDispatcher routes it to the subscription/exit
flow unchanged (no flow rewrite).
The plate stays advisory by construction: the exit flow still demands a covering
payment, the subscription flow only matches a bound plate. Guards: low-confidence reads
dropped; debounce (VISION_DEDUPE_MS) so a parked car doesn't re-fire; per-camera
in-flight guard; idle when vision is off or no camera opts in. #recognizeOn is public
for a future on-demand (loop-edge/API) trigger.
Verified end-to-end: an in-memory anpr camera (AL plate image) + live fast_alpr service
→ VisionReader emitted exactly one {kind:"plate",value:"AA558EE"} onto the bus; debounce
held it to 1 emit over 7 polls. Build + lint green. Updates opencv-anpr-service
(trigger-wiring + per-camera opt-in marked done).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
236cbfecab |
feat(vision): add VisionClient Node adapter (advisory, fail-soft, opt-in)
Node-side adapter to the apps/vision ANPR microservice (localhost HTTP: POST /analyze
with snapshot bytes, GET /health), returning a normalised VisionResult or null. Enforces
"advisory, never sole authority" at the boundary: opt-in (VISION_ENABLED, default off),
fail-soft (any error/timeout/unreachable → null, never throws into the lane → ticket
fallback), and re-applies the confidence floor (VISION_MIN_CONFIDENCE) on top of the
service's own low_confidence flag. Per-request AbortController timeout so a slow call
can't hang the barrier. Constructed in server.ts.
Verified: fail-soft (disabled/unreachable → null, no throw) and live end-to-end (Node
client → running fast_alpr service → AA558EE 0.999, region=Albania). NOT yet wired into
the read bus — the opt-in snapshot→DeviceReadEvent{kind:"plate"} trigger is the next
step. Build + lint green. Updates opencv-anpr-service (adapter gap marked done).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
17fdf3d482 |
docs(wiki): vision fitness assessment for entry/exit flows
Record the verdict: the ANPR service is worthy to consume NOW as an advisory plate IDENTITY source (Job 1) — the flows already treat a kind:"plate" read as first-class (exit signs source:"lpr"; subscription matches read plate vs bound plates), so it feeds an existing input with no flow rewrite. It is NOT worthy as the sole authority to open a transient barrier (a plate is not a payment; spoofing needs Job 2 vehicle verification, unbuilt) — gated by the confidence floor with ticket/manual fallback. Lists the four gaps before consumption (VisionClient adapter, opt-in trigger, field accuracy, weight-provenance). Next step is the adapter, not more model work. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
4833b4373d |
docs(wiki): Albanian-plate OCR benchmark — keep cct-xs-v2-global default
Benchmarked fast-alpr's four fast-plate-ocr models via the full pipeline on real AL plates (AA558EE, AA687KE), CPU. All four read both correctly; the default cct-xs-v2-global-model wins on confidence (0.999/1.000) AND speed (33-39ms) and returns region=Albania. The "European 40+country" model is WORSE here (~0.77 confidence, one synthetic misread) — overturning the "EU model better for AL" assumption from the prior research. Decision: no config change. Resolves the AL-accuracy-benchmark open item (results table + finding added to opencv-anpr-service); weight-provenance remains the one open recognizer item. Re-benchmark on real on-site captures once cameras installed. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
ee28b7302f |
docs(wiki): decide vision service packaging — apps/vision/ in the monorepo
Settle WHERE the host-side ANPR service lives and how it joins the build: in this monorepo at apps/vision/ (not a separate repo), still a separate OS process called over localhost HTTP, wired into the Turbo graph via a thin package.json shim whose scripts shell to Python tooling (uv/uvicorn/ruff/pytest). Co-located source honors the vision-service runtime+license isolation decision (AGPL reach is a linking boundary, not a folder); the fast-alpr MIT baseline removes most of the split-repo pressure anyway. New page vision-service-packaging; updates vision-service, opencv-anpr-service, the CLAUDE.md layout, index, log. Not built yet — packaging decision only. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
e4827c9651 |
docs(wiki): record fast-alpr as the evaluated ANPR recognizer baseline
Research note from the recognizer-options query. fast-alpr v0.4.0 (MIT) — a swappable YOLOv9-detector + CCT-OCR pipeline on ONNX Runtime, CPU-only and offline — fits the decided vision-service architecture and is MIT end-to-end (code + published weights), so the ANPR path may not need the scoped AGPL exception. Flags the open caveats: verify model-weight provenance, and benchmark AL-plate accuracy (default global vs. the 40+ country EU model). fast-alpr is plate-only, so the vehicle-verification job stays ours to build. Decision kept open. Updates opencv-anpr-service (new "Recognizer evaluation" section + licensing nuance), vision-service (open/next), index, log. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |