Skip to content

v2.2 — 2026-06-06#14

Merged
RobXYZ merged 21 commits into
mainfrom
dev
Jun 6, 2026
Merged

v2.2 — 2026-06-06#14
RobXYZ merged 21 commits into
mainfrom
dev

Conversation

@RobXYZ

@RobXYZ RobXYZ commented Jun 6, 2026

Copy link
Copy Markdown
Owner

Added

Home Assistant MQTT Support

Auto-discovered sensors and action buttons over MQTT, set up from a new Settings panel.

Manual Import

Add clips to the archive without Wi-Fi sync — by browser upload or a folder/USB drop path.

Alternative Camera Address

An optional second address for the same camera, used automatically when the primary is unreachable (e.g. reaching the dashcam over a mobile VPN).

Quota-Bound Retention

Measure retention and disk thresholds against a declared quota (RECORDINGS_QUOTA_GB), for recordings on a NAS share or ZFS dataset.

Sync Error Reporting

Sync now surfaces a sticky error state — missing config, unwritable path, camera auth failure, or disk full — in both the UI and Home Assistant.

Download Manager Improvements

Session speed and ETA while syncing, one-click retry of failed downloads, and live disk usage in Settings.

Export Improvements

Meaningful download filenames, direct download of the original front/rear clips, and a new rear-main picture-in-picture variant.

Changed

  • Sync status simplified to four states (downloading / waiting / paused / error); update any Home Assistant automations that matched the old idle / stopped strings.
  • Export jobs panel redesigned.
  • Downloads are now grouped by hour.
  • UI polish: header alignment, unified status colours, and minor label tidy-ups.

Fixed

  • Archive retention caps now enforced on a periodic loop, not only after a download.
  • Join exports no longer fail when clip paths are stored relative.
  • Settings storage-usage card no longer renders near-invisible on the dark theme.

RobXYZ and others added 21 commits May 28, 2026 08:46
shutil.disk_usage reports the underlying volume, which on Synology
shares, ZFS datasets, NFS shares, and other quota-bound mounts can
be far larger than the slice viofosync is actually allowed to
consume. The disk-pressure rule then never fired: a 3 TB share inside
a 42 TB volume sat silently at 100% of its quota while the threshold
saw 86% of the volume.

Adds RECORDINGS_QUOTA_GB. When set, used % is measured against
bytes-under-recordings ÷ declared quota; a 60-second tree-walk cache
amortises the cost across both the retention sweep and any external
consumer that wants the same number. Bookkeeping decrement on each
delete keeps the inner sweep loop from triggering a fresh walk per
file. Leaving the quota at 0 preserves the legacy filesystem path.

The percentage and quota rules now trigger independently: each setting
works standalone, both-set uses OR semantics so cleanup runs whenever
either is breached.
A small card sitting between the section heading and the retention
fields now shows current usage as a percentage with absolute bytes,
and a coloured bar that turns red once usage crosses the cleanup
threshold. A vertical marker on the bar shows where the threshold
sits.

The card reports the same number the retention sweep evaluates
against — quota mode when RECORDINGS_QUOTA_GB is set, filesystem
mode otherwise. Both consumers share retention.py's 60-second
tree-walk cache, so opening the Settings page costs at most one
walk per minute.

* GET /api/storage/usage returns {mode, used_bytes, total_bytes,
  used_pct, threshold_pct, max_days} — session-authed, read-only.
* retention.disk_used_pct() is a new public helper that returns
  Optional[float] (None on error) so external consumers can show
  "unknown" rather than a misleading 0%.
* renderArchiveSection appends the card and kicks an initial
  refresh.
* CSS namespaced under .storage-usage* — no overlap with existing
  selectors.
Publish viofosync state and accept actions over MQTT, with full HA
discovery so sensors and buttons appear automatically with zero
HA-side YAML.

Surface:
- One device card "Viofosync" with 11 entities
  - binary_sensor.viofosync_dashcam (connectivity)
  - sensor.viofosync_sync_status (stopped/idle/paused/downloading)
  - sensor.viofosync_queue_pending
  - sensor.viofosync_last_downloaded_clip (timestamp)
  - sensor.viofosync_disk_used (% of the tighter of quota or
    filesystem — same number the Settings card shows)
  - disabled-by-default: queue_failed, queue_downloading,
    current_filename, current_progress, total_clips
- 6 action buttons: start_sync, pause_sync, skip_current,
  refresh_queue, retry_failed, rescan_archive
- 1 parameterised command topic: <node>/cmd/prioritize_recent
  {"hours": N}

Architecture:
- Single MqttService managed by the FastAPI lifespan; aiomqtt v2
  client in one asyncio task with exponential backoff reconnect
- Pure-data EntityDef catalog in mqtt_topology.py is the single
  source of truth for discovery payloads, state extractors, and
  command handlers
- PublishCoalescer suppresses unchanged payloads and rate-limits
  per topic so idle traffic is zero
- LWT + retained availability "online"/"offline" so HA marks all
  entities Unavailable within ~45s of an unclean disconnect
- Hot-reload of broker settings; node-rename publishes empty-payload
  discovery deletes so HA forgets the old topology cleanly
- Settings page gains an MQTT panel with broker host/port/auth,
  TLS, topic prefix, discovery prefix, QoS, a Test connection probe,
  and a live status dot

Tests: 60+ unit tests plus an end-to-end test against an in-process
amqtt broker covering connect, discovery republish, state
publication, and command dispatch.

Docs: README section + CHANGELOG entry.
Replaces the previous web UI "Dashcam online/offline" badge and the
Home Assistant `sync_status` sensor's `stopped`/`paused`/`downloading`/
`idle` vocabulary with a single four-state status that means the same
thing everywhere. Fixes two bugs from the old behaviour:

- HA `sync_status` sensor stuck at `downloading` when the dashcam went
  offline (it only checked sync_state.running, never dashcam_online).
- Web UI sync icon kept spinning while the dashcam was offline even
  though the badge correctly flipped to "Dashcam offline".

State model:

  downloading  green   spinning arrows  active sync in progress
  waiting      orange  static arrows    dashcam offline or queue empty
  paused       red     pause icon       user paused
  error        red+    warning icon     sticky condition needing action

Error conditions (with human-readable reason):
- Camera address not configured (ADDRESS unset)
- Recordings path not writable
- Camera authentication failure (HTTP 401/403)
- Filesystem usage above DISK_CRITICAL_PCT (new setting, default 95%)

The critical-disk trigger measures *filesystem* percentage, not the
retention-aware quota percentage — quota retention is designed to
keep the recordings dir at the quota, so the quota metric reads
~100% during normal operation.

Architecture:
- `web/services/sync_status.py` — pure `compute_sync_status()` is the
  single source of truth, called by both the MQTT sensor and the Hub.
- Hub stores `sync_status` and `sync_status_reason` in `last_state`,
  broadcasts a `sync_status` event on change so the web UI updates.
- Sync worker broadcasts `disk_pct` and stateful `sync_error` events
  that feed the compute function.
- MQTT `sync_status` sensor exposes a `reason` JSON attribute; new
  `attrs_fn` infrastructure on `EntityDef` adds `json_attributes_topic`
  to discovery payloads.
- Web UI badge shows "Error: <reason>" inline so users see the cause
  without having to hover.

Breaking change: HA `sensor.viofosync_sync_status` published values
change from {stopped, paused, downloading, idle} to
{downloading, waiting, paused, error}. Automations matching the old
strings need updating — see CHANGELOG.
Two production bugs both observable as the HA entity briefly going
Unavailable when the MqttService trips its reconnect cycle.

1. PublishCoalescer race (KeyError: 'viofosync/current_progress/state')

   flush_due() snapshots _pending, then awaits sink() per topic, then
   does `del self._pending[topic]`. During the sink await, a concurrent
   consider() from _drain_publishes can run; if its payload equals the
   not-yet-updated _last_payload[topic] it takes the first branch and
   pops the same entry. The trailing del then raises KeyError, crashes
   the tick task, and trips the reconnect cycle. A concurrent consider
   could also replace the entry with a NEWER pending which the bare del
   silently dropped — a latent data-loss bug paired with the KeyError.

   Fix: identity check after the await in both flush_due() and
   consider()'s immediate-publish branch. Only clear _pending[topic] if
   it still references the same object we just published; otherwise
   leave whatever a concurrent task installed in place.

2. aiomqtt publish timeout on high-frequency current_progress topic

   The `current_progress` entity fires on every `item_progress` event
   during a download. QoS=1 PUBACK waits stall the publisher under
   broker latency and grow the in-flight queue; aiomqtt eventually
   times out the publish and the connection drops. The retained value
   in HA means losing a single progress update is acceptable.

   Fix: optional per-entity qos override on EntityDef (defaults to
   the global MQTT_QOS setting). Set qos=0 on current_progress only.
   All four publish sites in MqttService now resolve to entity.qos when
   set, falling back to cfg["qos"]. Other entities (sync_status,
   dashcam, last_downloaded_clip, disk_used, queue_*) keep QoS=1 for
   reliable state delivery.

Tests:
- test_flush_due_survives_concurrent_consider_popping_pending —
  deterministic reproduction of the KeyError race using a blocking
  sink to interleave a payload-matches-last_payload consider().
- test_flush_due_preserves_newer_pending_added_during_await —
  deterministic reproduction of the data-loss companion bug.
- test_current_progress_uses_qos_0 / test_state_entities_default_to_global_qos
  structural assertions on the topology change.
When a sync session is downloading, the download manager now shows a
session-wide moving-average download speed and an estimated time to
complete, and a throttled Home Assistant sensor reports the speed.

- DownloadSession tracker (web/services/download_session.py): monotonic
  wire-byte accounting (clamps retry/file-boundary resets), a 30s
  windowed moving-average speed, and an ETA computed from the pending
  queue bytes plus the in-flight file remainder. Pure/injectable.
- queue.pending_bytes(): sums remote_size of pending rows for the ETA.
- Hub feeds the tracker from broadcast() and emits a deduped session_stats
  follow-up (whole-second elapsed in the key gives a ~1/s UI heartbeat);
  last_state["session"] rehydrates reconnecting clients.
- HA download_speed sensor (data_rate, MB/s, enabled by default):
  state_download_speed gates publishing until 30s into a session then
  the 60s min-interval caps it to once a minute; reports 0 when idle.
  Triggered by the source broadcast events (item_progress, etc.) so it
  reaches the MQTT bridge; the session idles on dashcam_offline.
- Web UI: live "Session avg X/s, ETA Y, Z this session" line.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Add an optional second IP/host for the SAME dashcam, used only when the
primary is unreachable (e.g. reaching the camera over a VPN — a Pi
hotspot or a site-to-site link to a second parking spot). Not for a
second camera.

- New ADDRESS_FALLBACK setting (validated like the primary), threaded
  through the Snapshot and the settings API.
- Sync worker selects the active address once per cycle, primary first,
  alternative only when the primary is down; held for the whole drain
  (no mid-download switching) and auto-returns to the primary next cycle.
- Hub retains the live source/address; new Home Assistant
  dashcam_connection sensor reports primary/alternative/offline with the
  live address as an attribute; web UI shows a "via alternative" chip.
- Settings → Dashcam gains the alternative-address field (with Test
  button and field-grouped help note); README + CHANGELOG updated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
DISK_CRITICAL_PCT was in EDITABLE_KEYS (so PUT accepted and persisted
it) but missing from the _editable_values() projection that GET
returns, so the Settings UI could neither display nor edit it. Add it
to the projection and surface the field in the Archive Retention pane.

Pre-existing bug, independent of the alternative-camera-address feature.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…download

Retention only ran once at startup (unconditional) and at the end of a
download cycle that actually downloaded something (gated behind did_any,
after the offline early-return). The quota/disk caps therefore went
unenforced whenever the camera was offline or had nothing new to
download, so the archive stayed over quota until the next restart.

Add a standalone retention loop to SyncWorker that sweeps on its own
cadence (RETENTION_INTERVAL_SECONDS = 5 min) independent of download
activity and camera reachability, tied to the worker lifecycle so it
runs while scheduled sync is enabled. The post-drain sweep still
provides immediate cleanup after downloads.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Joined/PiP export downloads now get friendly names derived from the
selected clips' date range, camera, and clip count
(e.g. 2024-03-15_1430-1502_front_4clips.mp4), falling back to the
legacy viofosync_export_{id}.mp4 when the source clips were pruned.

Originals can be downloaded directly as individual files (no ZIP, no
joining) via new Originals F/R buttons. The archive action bar is
regrouped into Originals / Joined / PIP sections, and a new rear-main
picture-in-picture variant (pip_rear) joins the existing front-main one.

- web/services/naming.py: build_basename / export_download_name / parse_clip_ids
- web/routers/exports.py: derive download filename; accept pip_rear
- web/services/exporter.py: parametrised PiP main lens; pip_rear job type
- web/static/: regrouped action bar, downloadOriginals helper, wiring
- tests: naming, pip_rear filter, end-to-end download filename + fallback

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Break each day's download list into a collapsible hour tier. Opening a
day now shows a list of hours (newest first), each with its clip count,
size, and state pills (downloading/pending/done/failed/gone); drilling
into an hour reveals the file table. A tri-state select-all cascade spans
day -> hour -> file, all backed by the existing flat filename selection
set. This keeps a busy day (hundreds of clips) scannable instead of
painting one giant table on expand.

Pure client-side change — rendering and selection only; the backend, API,
and SQL are untouched. Also ignores the local .superpowers/ brainstorm
scratch dir.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Rework the export jobs table to be clearer and tidier:

- Drop the ID and Created columns.
- Show export type as a human-readable badge (Join Front / Join
  Rear / PiP Fr / PiP Rf), echoing the toolbar buttons.
- Merge State + Progress into a single Status cell with a slim
  inline progress bar for running jobs.
- Add a Footage column showing the source clips' date range and
  clip count, captured onto the export_jobs row at enqueue time
  (new clip_start/clip_end columns) so it survives retention
  pruning of the underlying clips.
- Replace the text Download/Delete links with right-aligned icon
  buttons; the delete bin sits in a fixed rightmost column.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
ffmpeg's concat demuxer resolves relative entries in the list file
against the directory of the list file itself — which lives in the
system temp dir. When clip_index stores relative paths (a dev box
launched with a relative RECORDINGS), a join export fed ffmpeg a
relative path and it went looking under /tmp/.../recordings/...,
failing with "No such file or directory".

Resolve each clip path to an absolute path before writing it, so
the concat works regardless of how the path was stored or where
the temp list file happens to live. PiP exports were unaffected
(they list absolute mkdtemp segment paths).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Bring Viofo clips into the archive without Wi-Fi sync, via browser
folder upload (with folder drag-and-drop) or a configurable folder/USB
drop path. Imported clips land in the same recordings/<date>/ layout
with GPX, thumbnails, indexing, RO/parking classification and
retention, reusing the post-sync pipeline.

- IMPORT_PATH advanced setting (default <recordings>/import)
- importer service: scan_source, ingest_clip (dedup, cross-volume
  copy+verify keeping the source, same-volume restore-on-failure),
  run_folder_ingest with hub progress
- smart quota eviction that never deletes anything newer than the clip
  being imported; import staging excluded from the quota walk
- /api/import scan|ingest|upload endpoints (auth + CSRF, path-safe)
- RO/locked status survives rescans via a download_queue origin row
- Import modal (Upload + Folder tabs) with folder drag-and-drop,
  launched from the Download manager
- README + CHANGELOG

Squashed from the feature/sd-card-import branch. Also folds in the
v2.2 README/CHANGELOG release-notes edits that were in progress on the
branch.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Replace the ephemeral in-DOM "Event log" on the downloads page with a
durable, filterable log backed by a new app_log SQLite table.

- DBLogHandler on the root logger captures INFO+ from viofosync* loggers
  (WARNING+ from everything else). emit() only enqueues; an async drain
  task batch-inserts off-loop, prunes to a 50k-row cap, and broadcasts
  each row over the existing WebSocket hub as {"type":"log"}.
- GET /api/logs serves level/logger/search-filtered, before-cursor
  paginated history (auth-gated, fully parameterised queries).
- New "Logs" tab live-tails new rows and loads history with expandable
  tracebacks, level filter (default Warning), and a soft row cap; the
  old event-log panel is removed.
- Also folds in a small MQTT settings nav-label clarification.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Pausing mid-download set the cancel flag, which surfaced as a generic
UserWarning and was swallowed by the retry handler — so a deliberate
abort retried 3x and logged a misleading "Failed to download … after 3
attempts", while each pause also burned a queue attempt (mark_downloading
increments on every pickup) until the clip was marked failed.

- Add a dedicated DownloadCancelled exception; download_file now stops
  immediately on cancel, logs once at INFO, and consumes no retries.
- Add queue.mark_cancelled to requeue an interrupted download and refund
  the attempt mark_downloading consumed (mirrors reconcile_orphan_downloads).
- _download_one distinguishes cancellation from failure so a paused or
  stopped clip is never marked failed.

Logging audit (targeted):
- INFO logs for sync paused/resumed/started/stopped and skip/abort.
- dashcam online<->offline logged on transition only, not every cycle.
- a paused worker now skips the cycle entirely (no probe, no listing),
  ending the "Found N recordings" log spam while paused.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
- Constrain header content to the same 1400px column as <main> and
  centre it; the bar keeps its full-bleed background and bottom border,
  with the inset tracking main's padding at each breakpoint.
- Route the MQTT status dots and the storage-usage card through the
  OKLCH palette tokens. The card previously referenced undefined
  --bg-elev / --bar-track vars and a black threshold marker, all of
  which rendered near-invisible on the dark theme.
- Colour the sync button amber (--warn) in the waiting state so it
  matches the sync-status badge; fold the waiting badge's raw hex
  into the --warn token.
- Tidy labels: page title "Download manager" -> "Downloads", settings
  nav "Archives" -> "Storage", "New password (min 8)" ->
  "(min 8 characters)", and "skipped(quota)" -> "skipped (over quota)".

Co-Authored-By: Claude Opus 4.8 <[email protected]>
- Sort/hoist module-level imports to top of file (I001, E402)
- Remove unused imports and the unused `hub` local (F401, F841)
- Split semicolon statements onto separate lines (E702)
- Assert pydantic.ValidationError instead of blind Exception (B017)
- Pin starlette<1.0: its TestClient deprecates httpx for the httpx2 fork,
  which breaks the test suite under filterwarnings=error

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@RobXYZ RobXYZ merged commit 5f8f11a into main Jun 6, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant