Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
47fe9a7
Make retention disk-pressure quota-aware
RobXYZ May 28, 2026
0be72fb
Show live disk usage in Settings → Archive Retention
RobXYZ May 28, 2026
bf10b39
Add MQTT publishing with Home Assistant auto-discovery
RobXYZ May 28, 2026
71f5bba
Unified sync_status with four states (downloading/waiting/paused/error)
RobXYZ May 28, 2026
dda990a
Fix MQTT coalescer KeyError race and reduce progress publish load
RobXYZ May 28, 2026
b798914
Add session download speed + ETA (UI) and Home Assistant speed sensor
RobXYZ Jun 4, 2026
6d51b04
feat: optional alternative camera address with failover
RobXYZ Jun 4, 2026
344bfc8
fix(settings): expose DISK_CRITICAL_PCT in GET /api/settings projection
RobXYZ Jun 4, 2026
addc8c6
fix(retention): enforce archive caps on a periodic loop, not just on …
RobXYZ Jun 4, 2026
8791bca
feat(exports): sensible export filenames + download originals
RobXYZ Jun 4, 2026
8eb1d97
fix(ui): match sync-status badge height to the sync button
RobXYZ Jun 4, 2026
4bc6cff
Add retry failed button to web UI
RobXYZ Jun 4, 2026
bddefb1
feat(downloads): group the download list by hour
RobXYZ Jun 4, 2026
7023484
feat(exports): refresh the export jobs list UI
RobXYZ Jun 4, 2026
a37a10d
fix(exports): write absolute clip paths into the concat list
RobXYZ Jun 4, 2026
c472583
feat(import): manual SD-card / USB import
RobXYZ Jun 5, 2026
1cf8858
feat(logs): persistent application log with a Logs tab
RobXYZ Jun 5, 2026
12e16cd
fix(sync): treat pause/stop as cancellation, not a download failure
RobXYZ Jun 5, 2026
ed477ae
fix(ui): align header to content column and unify status styling
RobXYZ Jun 6, 2026
584c461
Update docs
RobXYZ Jun 6, 2026
a2bffdd
fix(ci): satisfy ruff and pin starlette below the 1.x line
RobXYZ Jun 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ config.json
.venv/
docs/*
CLAUDE.md

# Superpowers brainstorming companion (local mockups)
.superpowers/
47 changes: 46 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,50 @@
# Changelog

## v2.2 — 2026-06-06

### 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.

## v2.1 — 2026-05-16

### Fixed
Expand Down Expand Up @@ -48,4 +93,4 @@ boot. The old file is preserved as a one-shot rollback path.

## v1.x

- Cron-driven CLI version. See git history.
- Cron-driven CLI version. See git history.
16 changes: 0 additions & 16 deletions COPYING

This file was deleted.

2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ RUN apk add --no-cache \
esac && \
useradd -UMr dashcam

COPY COPYING /
COPY LICENSE /
COPY setuid.sh /setuid.sh
COPY entrypoint.sh /entrypoint.sh

Expand Down
6 changes: 5 additions & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2024 Rob Smith
Copyright (c) 2024-2026 Rob Smith

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand All @@ -19,3 +19,7 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

---

Inspired by BlackVueSync by Alessandro Colomba (https://github.com/acolomba).
80 changes: 71 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ Self-hosted web app for syncing, browsing, and exporting recordings from a Viofo

## Features

- **Archive browser** — view clips grouped by day, front/rear pairs, on-demand thumbnails, in-browser playback, kind filters (Driving / Parking / Read-only), GPS-maps toggle for low-bandwidth browsing.
- **GPS journeys** — Leaflet + OSM map per trip, automatic stop detection splits a day into journeys, reverse-geocoded start/end labels (e.g. *Whitegate → Sandiway*).
- **Exports** — select clip pairs, render joined front-only, rear-only, or picture-in-picture videos with ffmpeg. Hardware H.264 (videotoolbox / nvenc / qsv / vaapi) when available, software libx264 fallback.
- **Download manager** — live progress, reorderable queue, reachability badge, transient timeouts re-queue instead of burning retries.
- **Auto-delete from dashcam** *(optional)* — clears each clip from the device once it's downloaded and verified.
- **Settings page** — runtime settings hot-reload rather than Docker env vars; only `WEB_HOST`/`WEB_PORT` need a restart.
- **Archive browser** — clips grouped by day, paired front/rear, in-browser playback.
- **GPS journeys** — clickable map per trip with auto-split stops and reverse-geocoded place names.
- **Exports** — original clips, joined front/rear or picture-in-picture videos via ffmpeg.
- **Download manager** — live progress with session speed and ETA, a reorderable queue.
- **Auto-delete from dashcam** *(optional)* — frees SD card space once a clip is safely downloaded.
- **Settings page** — runtime settings hot-reload; no Docker env vars to fiddle with.
- **Home Assistant support** — auto-discovered sensors and buttons via MQTT.

## Hardware

Expand Down Expand Up @@ -48,15 +49,72 @@ After setup, every other setting lives on the **Settings** page in the UI.

The only Docker-level env vars are:


| Variable | Description | Default |
| --------------- | ------------------------------------------------ | ------------ |
| `PUID` / `PGID` | Owner of `/config` and `/recordings` on the host | host UID/GID |
| `TZ` | Timezone for log timestamps | UTC |


App-level settings (sync interval, dashcam IP, encoder, geocoding email, web port, retention, password, auto-delete, etc.) are editable on the **Settings** page. Advanced users can hand-edit `/config/config.json` between restarts; the schema lives in `[web/settings_schema.py](web/settings_schema.py)`.

### Importing without Wi-Fi

Use **Import manually** in the web UI to ingest clips you already have on disk. Two modes:

- **Upload** — pick a folder in your browser; clips upload one at a time and slot straight into the archive. On a quota-bound archive it makes room as it goes, evicting the oldest clips (never anything newer than what you're importing).
- **Folder** — copy clips into the `import` folder inside your recordings share, then **Scan** → **Ingest**. By default this is `recordings/import`; for a one-off import from a different path, type it in the Import dialog's Folder tab, or set a persistent default via the advanced `IMPORT_PATH` key in `/config/config.json`.

**From a USB drive / card reader:** bind-mount it into the container and set the import path to the mount, e.g.:

```bash
docker run ... -v /mnt/usb:/import robxyz/viofosync
# then type /import in the Import dialog, or set IMPORT_PATH=/import in /config/config.json
```

The source is only ever **read** — originals on the card/USB are never deleted. If you plug the drive in *after* the container starts, either restart the container or use shared mount propagation (`-v /mnt:/mnt:rshared`, with the host mount also shared) so the container sees it.

Imported clips are recognised by Viofo naming (`YYYY_MMDD_HHMMSS_NNNN[event][cam].MP4`); locked clips under an `RO/` folder keep their protected status. Non-matching files are left untouched.

## Alternative camera address

You can set an optional **Alternative address** (Settings → Dashcam) — a second IP/host for the **same** dashcam. It is **not** for a second camera.

This is for reaching one camera at more than one address depending on where the car is, for example:

- A Raspberry Pi running a VPN hotspot, so you can reach the dashcam remotely when the car is away from home.
- A site-to-site VPN to a second location the car is regularly parked at, where the camera sits on a different subnet/IP.

The alternative uses the same form as the primary (IP or hostname, plain `http`, port 80).

## Home Assistant via MQTT

viofosync can publish state and accept actions over MQTT, with full Home Assistant auto-discovery.

Enable on the Settings page → MQTT. You'll need:

- A reachable MQTT broker (Mosquitto, HA's built-in broker, EMQX, etc.).
- Broker host + port. Optional username, password, and TLS.
- A `Node ID` (default `viofosync`) — used as the topic prefix and as the `node_id` slot in HA discovery topics. Letters, digits, and `_` only. Set a distinct value per instance if you run more than one.

When MQTT is on, viofosync publishes:

- **Discovery configs** under `homeassistant/{component}/{node_id}/{object_id}/config` (retained) so HA picks them up immediately.
- **State** under `{node_id}/{object_id}/state` (retained, event-driven, no idle traffic).
- **Availability** to `{node_id}/availability` (`online` / `offline`), with LWT so HA marks every entity Unavailable within ~45s of an unclean disconnect.

### Sensors and buttons

Enabled by default in HA: dashcam connectivity, dashcam connection (`primary` / `alternative` / `offline`, with the live address as an `address` attribute), sync status (`downloading` / `waiting` / `paused` / `error`), queue pending, last downloaded clip, disk used, and six action buttons (start/pause/skip/refresh/retry-failed/rescan).

Disabled-by-default (still created — enable per-entity in HA): queue failed, queue downloading, current filename, current progress, total clips.

### Parameterised command

For "prioritize the last N hours", publish to `{node_id}/cmd/prioritize_recent` with payload `{"hours": 0.5}` (HA's `mqtt.publish` service works). `hours` must be in (0, 168].

### Security notes

- The MQTT password is stored in `config.json` in plaintext, alongside the bcrypt hash of the admin password and the session secret. The same access controls already apply to that file.

## Reverse geocoding

Journey and stop cards display their start/end as *"Street, Town"* via Nominatim (OpenStreetMap). Lookups are rate-limited to 1/second per [Nominatim's usage policy](https://operations.osmfoundation.org/policies/nominatim/) and cached in the `geocode_cache` table (coords rounded to 3 d.p., ≈110 m). Set **Nominatim email** in Settings → GPS & Geocoding to identify your install per OSM's terms; toggle the **GPS maps** filter off on the Archive page to skip the Leaflet + Nominatim machinery entirely for low-bandwidth browsing.
Expand Down Expand Up @@ -87,6 +145,10 @@ CONFIG_DIR=/path/to/config RECORDINGS=/path/to/archive \

`web.launcher` reads `WEB_HOST` / `WEB_PORT` from `config.json` (defaults `0.0.0.0:8080`) and re-execs into uvicorn. On first run, browse to `http://localhost:8080/setup`. `ffmpeg` must be on `$PATH` for thumbnails and exports.

## AI Code

This opensource project uses AI generated code and is intended for personal home use. It is not recommended that the server is exposed to the public internet.

## Credits

The GPX extraction logic uses the method described at [https://sergei.nz/extracting-gps-data-from-viofo-a119-and-other-novatek-powered-cameras/](https://sergei.nz/extracting-gps-data-from-viofo-a119-and-other-novatek-powered-cameras/).
Expand All @@ -95,4 +157,4 @@ This software is unaffiliated with Viofo or any other vendor.

## License

MIT — see [COPYING](COPYING).
MIT — see [LICENSE](LICENSE).
16 changes: 15 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
filterwarnings = ["error"]
filterwarnings = [
"error",
# amqtt broker thread leaves AF_UNIX sockets and its event loop
# unclosed on GC during session teardown — these are benign.
"ignore::ResourceWarning:amqtt",
"ignore:unclosed event loop:ResourceWarning",
"ignore:unclosed <socket.socket:ResourceWarning",
# When tests mutate settings, the SettingsProvider subscriber
# schedules MqttService.on_settings_changed via create_task.
# TestClient teardown can cancel the task before it ran, leaving
# the coroutine garbage-collected unawaited. Pure test-harness
# noise; the production loop awaits the task normally.
"ignore:.*on_settings_changed.*was never awaited:RuntimeWarning",
"ignore:Exception ignored in.*on_settings_changed:pytest.PytestUnraisableExceptionWarning",
]

[tool.ruff]
line-length = 100
Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ pytest>=8.0
pytest-asyncio>=0.23
httpx>=0.27
ruff>=0.4
amqtt>=0.11.0
4 changes: 4 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
fastapi>=0.110
# Pin below the starlette 1.x line: its TestClient deprecates httpx in favour
# of the httpx2 fork, which breaks our test suite under filterwarnings=error.
starlette<1.0
uvicorn[standard]>=0.29
bcrypt>=4.1
itsdangerous>=2.2
python-multipart>=0.0.9
pydantic>=2.6
httpx>=0.27
aiomqtt>=2.0,<3.0
59 changes: 59 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,62 @@ def tmp_recordings_dir(monkeypatch) -> Iterator[Path]:
yield d
finally:
shutil.rmtree(d, ignore_errors=True)


@pytest.fixture(scope="session")
def mqtt_broker():
"""Start an in-process amqtt broker on a random port for the session."""
import asyncio
import socket
import threading
import warnings

# Pick a free port
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]

cfg = {
"listeners": {
"default": {
"type": "tcp",
"bind": f"127.0.0.1:{port}",
"max_connections": 50,
},
},
"sys_interval": 0,
"auth": {"allow-anonymous": True},
"topic-check": {"enabled": False},
}

loop = asyncio.new_event_loop()
ready = threading.Event()
broker_holder: list = []

def _runner():
asyncio.set_event_loop(loop)
# Suppress amqtt deprecation warnings about old config keys.
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
from amqtt.broker import Broker
broker = Broker(cfg, loop=loop)
broker_holder.append(broker)
loop.run_until_complete(broker.start())
ready.set()
loop.run_forever()
loop.close()

t = threading.Thread(target=_runner, daemon=True, name="amqtt-broker")
t.start()
ready.wait(timeout=5.0)
try:
yield ("127.0.0.1", port)
finally:
broker = broker_holder[0]

async def _shutdown():
await broker.shutdown()

asyncio.run_coroutine_threadsafe(_shutdown(), loop).result(timeout=5.0)
loop.call_soon_threadsafe(loop.stop)
t.join(timeout=5.0)
Loading
Loading