Skip to content
Merged

v2.4 #23

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ff8b963
feat: camera control — read and adjust dashcam settings over Wi-Fi
droomurray Jun 16, 2026
f5bccb3
docs: add Camera tab screenshot
droomurray Jun 16, 2026
add4ed0
fix(logs): show date, reposition expand caret, and fix mobile readabi…
RobXYZ Jun 16, 2026
a0fa8c5
feat(sync): persist the pause toggle across restarts
RobXYZ Jun 16, 2026
67c7db0
style: fix ruff import ordering in test_control
droomurray Jun 16, 2026
b08c261
feat(derive): derive_queue table + state machine
RobXYZ Jun 16, 2026
7a62545
feat(derive): unified derive step + DeriveWorker consumer
RobXYZ Jun 16, 2026
e6bf095
feat(derive): producers, lifespan wiring, retention; drop legacy sweeps
RobXYZ Jun 16, 2026
b0dab7b
feat(derive): eager-derive settings + Thumbnails UI
RobXYZ Jun 16, 2026
32e63dd
Merge PR #21 (camera control, @droomurray) into dev
RobXYZ Jun 16, 2026
82817e5
feat(ui): camera-settings UX polish, app-wide tooltip, and settings t…
RobXYZ Jun 16, 2026
de1209c
feat(export): composite per-segment PiP into timeline exports
RobXYZ Jun 17, 2026
f448412
feat(timeline): per-segment PiP in the editor
RobXYZ Jun 17, 2026
e340bc2
feat(queue): skip/un-skip downloads
RobXYZ Jun 17, 2026
c930a38
feat(downloads): skip/un-skip UI via an Actions dropdown
RobXYZ Jun 17, 2026
d915932
feat(archive): animate clip filmstrip on hover via shared scrub primi…
RobXYZ Jun 17, 2026
e7f9b13
Update docs
RobXYZ Jun 17, 2026
24200f9
style(tests): fix ruff I001 import formatting in test_timeline_pip
RobXYZ Jun 17, 2026
40c7ec2
fix(web): mobile polish — responsive camera settings rows, stop zoom-…
RobXYZ Jun 17, 2026
933a82b
fix(timeline): buffer auto-journey start/end past the GPS stop radius
RobXYZ Jun 18, 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
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
# Changelog

## v2.4 — 2026-06-18

### Added

#### Camera Control

A new **Camera** tab reads the dashcam's current settings and lets you adjust the safely-changeable ones over Wi-Fi — on/off toggles, drop-downs, and a recording indicator — each validated against the camera's own option list and read back to confirm it stuck. Destructive commands (format SD, factory reset, firmware update, delete, reboot) are hard-blocked and never shown, and the few record-only settings auto-pause then resume recording. Settings for 29 Viofo models are mapped from the official app's command database; the A329S is validated on hardware. Contributed by [@droomurray](https://github.com/droomurray) (#21).

#### Three-Camera Support (Telephoto / Interior)

Telephoto (`T`) and interior/cabin (`I`) clips are now first-class alongside front and rear. They sync, index, and pair into the same capture group, so a three-camera day shows a third thumbnail in the archive and a third track on the timeline. New exports cover them: **Join Tele** / **Join Interior**, plus picture-in-picture with the third camera fullscreen and the front camera as the inset (the front clip stays the audio source, so the microphone track is preserved). The clip viewer's camera key cycles through every camera present at a timestamp. Two-camera setups are visually unchanged. Contributed by [@jusii](https://github.com/jusii) (#18).

#### Background Thumbnails & Filmstrips

Thumbnails and timeline filmstrips are now produced by a background worker as clips download — and existing clips are backfilled — so the archive and timeline populate as soon as footage arrives instead of after a sync cycle finishes. A new **Thumbnails** settings section controls it: thumbnail pre-generation is on by default, while the heavier filmstrip pre-generation is opt-in and otherwise falls back to generating on demand the first time a clip is viewed.

#### Per-Segment Picture-in-Picture in the Editor

The timeline editor's switched-camera cut can now carry a picture-in-picture inset on a per-segment basis. With a segment selected, press the PiP button (or **P**) to cycle the inset through your other cameras — it skips the segment's own camera — and a green placeholder shows where it will sit. The choice is remembered per segment and composited into the export, in the corner set by the global picture-in-picture position setting. A segment whose chosen camera has no overlapping footage simply exports without the inset.

#### Skip Downloads

You can now skip clips you don't want to sync. Select them in the download queue and choose **Skip** from the **Actions** menu; skipped clips get their own badge and are never downloaded. **Clear skip** returns them to the queue with a fresh set of retry attempts. Queue selection now spans pending, failed, and skipped clips, so one Actions menu — Download next / Skip / Clear skip / Retry failed — drives the whole list.

### Changed

- On/off controls across the app are now toggle switches, with one tooltip style used app-wide that works on hover, keyboard focus, and tap.
- The sync **pause** state is remembered across restarts instead of resetting to running.
- The download queue's per-action buttons are now a single **Actions** menu with **Apply**; selection-based **Retry failed** replaces the old retry-all button, while **Download recent hours next** is unchanged.
- Archive thumbnails animate on hover, scrubbing the clip's filmstrip — the same preview the Export Jobs list already offered. They fall back to the static thumbnail when no filmstrip is available, and respect reduced-motion.

### Fixed

- Locking a clip on the dashcam between sync cycles moves it into the camera's `/RO` folder; the download queue now refreshes the clip's source path when the camera re-reports it there, instead of exhausting its retry budget against the stale path and never syncing the clip. The dashcam-delete lock guard benefits too, since it keys off the same refreshed `/RO` path. Contributed by [@jusii](https://github.com/jusii) (#17).
- The Logs view now shows the date alongside the time and stays readable on a phone — long lines wrap rather than being clipped off-screen.
- Tapping a drop-down or text field no longer zooms the page in on mobile, and the interface no longer pinch-zooms — it behaves like a fixed app viewport.
- Auto-detected journeys on the timeline no longer miss the start of a drive or cut short on arrival — the journey window is padded past the GPS stop radius to take in the pull-away and pull-in clips, bounded by the surrounding parking footage.

## v2.3 — 2026-06-10

### Added
Expand Down
45 changes: 43 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ Self-hosted web app for syncing, browsing, and exporting recordings from a Viofo
## Features

- **Automatic Wi-Fi sync** - clips copy from the dashcam in your car when it joins your home wi-fi.
- **Archive browser** - clips grouped by day, played in your browser. Nothing to install on your phone or laptop.
- **Download control** - skip clips you don't want, retry failed ones, and prioritise recent footage, all from the download queue.
- **Archive browser** - clips grouped by day, played in your browser; hover a clip to scrub a quick preview. Nothing to install on your phone or laptop.
- **Journey maps** - automatic journey detection with each trip shown on a map with stops detected and place names looked up automatically.
- **Video editor** - trim clips and cut between the front and rear cameras, then export a single video.
- **Video editor** - trim clips, cut between cameras, and add a picture-in-picture inset to any segment, then export a single video.
- **Flexible exports** - original clips, joined front/rear, picture-in-picture, or edited cuts; hardware-accelerated where your system supports it.
- **Storage management** - set a size or age limit and the oldest footage is pruned to fit; optional auto-delete clears the camera's SD card once a clip is safely saved.
- **Camera control** - read and adjust the dashcam's own settings (resolution-adjacent options, parking mode, watermarks, HDR, LEDs, GPS…) from a Camera tab, with destructive actions hard-blocked.
- **Easy browser-based setup** - a first-run wizard, then a settings page.
- **Home Assistant support** - over MQTT, with sync status, alerts, and action buttons.

Expand All @@ -25,6 +27,7 @@ Self-hosted web app for syncing, browsing, and exporting recordings from a Viofo
- [Getting started](#getting-started)
- [Configuration](#configuration)
- [Home Assistant](#home-assistant-via-mqtt)
- [Camera control](#camera-control)
- [Reference](#reference)
- [About](#about)

Expand All @@ -41,6 +44,17 @@ Self-hosted web app for syncing, browsing, and exporting recordings from a Viofo
> - **NAS or always-on host** with large storage that can run Docker
> - **Optional: hardware video encoder + fast LAN** - recommended for the video editing features

### Tested cameras

viofosync targets any Viofo Wi-Fi dashcam that uses the standard `…F` / `…R` / `…T` / `…I` recording filenames, so models beyond those listed below should work — reports of other cameras are welcome.

| Camera | Channels | Tested by |
| --- | --- | --- |
| Viofo A229 Pro | Front + Rear | maintainer |
| Viofo A329 | Front + Rear + Telephoto | [@jusii](https://github.com/jusii) |

Three-camera models are supported with either a telephoto (`T`) or interior/cabin (`I`) third lens. The telephoto channel was validated live on the A329; the interior channel was validated against real cabin-cam footage contributed alongside that work.

### Quick start

```bash
Expand Down Expand Up @@ -154,6 +168,29 @@ For "prioritize the last N hours", publish to `{node_id}/cmd/prioritize_recent`

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

## Camera control

The **Camera** tab reads the dashcam's current settings and lets you change them over Wi-Fi — parking mode, watermarks, HDR, LEDs, GPS, beeps, time/date, loop length, bitrate, and so on. On/off settings are toggles; multi-choice settings are drop-downs populated with the camera's own option labels. Each change is validated, sent, and read back to confirm it applied.

![The Camera tab](screenshots/camera_control.webp)

This drives the undocumented Novatek **netapp** HTTP interface (`http://<cam>/?custom=1&cmd=<id>&par=<value>`). Because that protocol has no schema, the option labels and value enumerations come from a derived per-model command map (`viofosync_lib/data/command_map.json`); see [Command map data](#command-map-data).

**Safety model.** On this protocol a bare command is *not* always a harmless read — some ids are destructive actions. The control layer:

- **Hard-blocks destructive ids** (format SD, factory reset, firmware update, delete file, reboot, restart-Wi-Fi, SSD format/delete) — they are refused before any request is built and are never shown in the UI.
- **Allow-lists writes** to enumerated settings only, validates the value against the camera's option list, and **verifies** by reading the setting back.
- **Is gentle**: one request at a time with a short timeout, so it doesn't overrun the camera's single-threaded daemon.
- **Auto-pauses recording** for the few settings the camera only accepts when stopped (loop length, bitrate), then resumes — flagged in the UI as "paused recording".

**What it won't change.** Settings the camera refuses over Wi-Fi are shown read-only with the reason — e.g. *recording resolution* (changeable on the camera, not in station mode) and *exposure*. Settings for a lens that isn't attached (rear/interior HDR, video-merge, …) are read-only with "needs the rear/interior camera" and light up automatically once that lens is connected (detected via the live sensor count).

Only the **A329S** has been validated against real hardware; other models are mapped from the app data but untested.

### Command map data

`viofosync_lib/data/command_map.json` is a plain reformatting of the *factual API data* (command ids, English keys, descriptions, and option enumerations) found in the official VIOFO Android app's `device-cmd-manager.db` asset. No app code or resources are included, and the original `.db` is not redistributed. Regenerate or extend it with `scripts/build_command_map.py` (see the script header for how to pull the asset from the APK).

## Reference

### Reverse geocoding
Expand Down Expand Up @@ -194,6 +231,10 @@ viofosync is an open-source project built with substantial AI assistance, intend

### Credits

Camera control — reading and safely adjusting dashcam settings over Wi-Fi — was contributed by [@droomurray](https://github.com/droomurray) (#21).

Three-camera support (telephoto and interior lenses), the single-source camera registry and other improvements were contributed by [@jusii](https://github.com/jusii) (#17, #18, #20).

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/).

This software is unaffiliated with Viofo or any other vendor.
Expand Down
Binary file added screenshots/camera_control.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
88 changes: 88 additions & 0 deletions scripts/build_command_map.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Build ``viofosync_lib/data/command_map.json`` from the VIOFO app's
``device-cmd-manager.db``.

The Camera-control feature needs a semantic map for the Novatek netapp HTTP
protocol (command id -> key/options), which the bare protocol doesn't provide.
That mapping is derived from the official VIOFO Android app, which ships a
SQLite asset ``assets/device-cmd-manager.db``.

This script reformats only the *factual API data* (command ids, English keys,
descriptions and option enumerations) into a plain JSON file we can ship and
diff. It does not copy any app code or resources. We do not redistribute the
``.db`` itself.

To regenerate (e.g. for a newer app version, or to add a model):

# obtain the app's base APK, then:
unzip -o com.viofo.dashcam.apk 'assets/device-cmd-manager.db' -d /tmp/viofo
python3 scripts/build_command_map.py \
--db /tmp/viofo/assets/device-cmd-manager.db \
--out viofosync_lib/data/command_map.json

Only the A329S has been validated against real hardware; other models are
reformatted as-is and untested.
"""
from __future__ import annotations

import argparse
import json
import os
import sqlite3


def build(db_path: str) -> dict:
db = sqlite3.connect(db_path)
db.row_factory = sqlite3.Row
models = [r[0] for r in db.execute(
"SELECT DISTINCT DEVICE_MODEL FROM CMD_DEVICE_MANAGER ORDER BY DEVICE_MODEL")]
out: dict = {
"_provenance": (
"Command ids, keys and option enumerations reformatted from the "
"VIOFO dashcam Android app asset (device-cmd-manager.db). Factual "
"API data only. A329S validated against hardware; other models "
"reformatted as-is and untested. See scripts/build_command_map.py."
),
"models": {},
}
for m in models:
commands: dict = {}
rows = db.execute(
"SELECT m.CMD cmd, m.CMD_KEY key, m.DESCRIPTION descr "
"FROM CMD_MANAGER m JOIN CMD_DEVICE_MANAGER d ON d.CMD_ID=m._ID "
"WHERE d.DEVICE_MODEL=? GROUP BY m.CMD, m.CMD_KEY "
"ORDER BY CAST(m.CMD AS INTEGER)", (m,))
for r in rows:
opts = [
{"index": int(o["_INDEX"]), "value": o["_VALUE"],
"camera_tag": o["CAMERA_TAG"]}
for o in db.execute(
"SELECT o._INDEX, o._VALUE, o.CAMERA_TAG "
"FROM DASHCAM_MENU_OPTION_INFO o "
"JOIN CMD_MANAGER m ON o.CMD_ID=m._ID "
"WHERE o.DEVICE_MODEL=? AND m.CMD=? "
"ORDER BY CAST(o._INDEX AS INTEGER)", (m, r["cmd"]))
if str(o["_INDEX"]).lstrip("-").isdigit()]
commands.setdefault(str(r["cmd"]), {
"key": r["key"], "description": r["descr"], "options": opts})
out["models"][m] = {"commands": commands}
return out


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--db", required=True, help="path to device-cmd-manager.db")
ap.add_argument("--out", default=os.path.join(
os.path.dirname(__file__), "..", "viofosync_lib", "data",
"command_map.json"))
args = ap.parse_args()
data = build(args.db)
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
with open(args.out, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=1)
print(f"wrote {args.out}: {len(data['models'])} models")
return 0


if __name__ == "__main__":
raise SystemExit(main())
124 changes: 124 additions & 0 deletions tests/test_control.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""Unit tests for the camera-control layer (viofosync_lib._control).

All hardware I/O is monkeypatched, so these run without a camera. They cover
the parts that protect the device: the destructive denylist, value validation,
the support classification (lens / Wi-Fi limits), the read-back verification,
and the record-gated retry.
"""
from __future__ import annotations

import pytest

from viofosync_lib import _control as control

# --------------------------------------------------------------------------- #
# Pure data layer (uses the shipped command_map.json; no camera)
# --------------------------------------------------------------------------- #

def test_detect_model_longest_match():
assert control.detect_model("VIOFO_A329S_V2.0_260313") == "A329S"
# 'A329S' must beat the shorter 'A329'
assert control.detect_model("A329") == "A329"
assert control.detect_model(None) == control.DEFAULT_MODEL


def test_resolve_cmd_by_key_and_number():
assert control._resolve_cmd("A329S", "CMD_GPS_SWITCH")[0] == 8208
assert control._resolve_cmd("A329S", 8208)[1] == "CMD_GPS_SWITCH"


def test_resolve_cmd_unknown_raises():
with pytest.raises(control.ValidationError):
control._resolve_cmd("A329S", "CMD_NOPE")
with pytest.raises(control.ValidationError):
control._resolve_cmd("A329S", 999999)


def test_options_have_unique_sorted_indices():
opts = control._options("A329S", 8222, "F")
idx = [int(o["index"]) for o in opts]
assert idx == sorted(idx)
assert len(idx) == len(set(idx))


def test_support_station_locked_and_lenses():
# Resolution is not changeable over Wi-Fi.
ok, reason = control._support(8222, "F")
assert not ok and "Wi-Fi" in reason
# Rear-HDR needs the rear lens: blocked on 'F', allowed on 'F+R'.
assert control._support(9319, "F") == (False, "Needs the rear camera")
assert control._support(9319, "F+R") == (True, None)
# A plain setting is supported.
assert control._support(8208, "F") == (True, None)


# --------------------------------------------------------------------------- #
# Transport helpers
# --------------------------------------------------------------------------- #

def test_base_url_normalisation():
assert control.base_url("192.168.1.254") == "http://192.168.1.254"
assert control.base_url("http://cam/") == "http://cam"


def test_parser_reads_pairs_and_leaf_tags():
xml = ('<?xml version="1.0"?><Function><Cmd>3012</Cmd><Status>0</Status>'
'<String>VIOFO_A329S_V2.0</String></Function>')
assert control._flat_tags(xml)["String"] == "VIOFO_A329S_V2.0"
dump = "<Cmd>2001</Cmd><Status>1</Status><Cmd>8208</Cmd><Status>0</Status>"
pairs = control._PAIR_RE.findall(dump)
assert [(int(c), int(s)) for c, s in pairs] == [(2001, 1), (8208, 0)]


# --------------------------------------------------------------------------- #
# Write path (monkeypatched transport)
# --------------------------------------------------------------------------- #

def test_set_setting_refuses_destructive():
with pytest.raises(control.DestructiveCommandError):
control.set_setting("cam", 3010, 0, model="A329S") # format SD


def test_set_setting_validates_value():
with pytest.raises(control.ValidationError):
control.set_setting("cam", "CMD_GPS_SWITCH", "banana", model="A329S")


def test_set_setting_happy_path(monkeypatch):
sent = {}

def fake_send(address, cmd, par=None, s=None):
sent["cmd"], sent["par"] = cmd, par
return {"Cmd": str(cmd), "Status": "0"}

# Read-back reports the value we just wrote -> verified.
monkeypatch.setattr(control, "_send", fake_send)
monkeypatch.setattr(control, "read_status_pairs",
lambda addr: [(8208, 1)])
monkeypatch.setattr(control, "VERIFY_SETTLE", 0)

r = control.set_setting("cam", "CMD_GPS_SWITCH", "1", model="A329S")
assert sent == {"cmd": 8208, "par": 1}
assert r["ok"] and r["verified"] is True and r["applied_index"] == 1
assert r["record_cycled"] is False


def test_record_gated_retry_cycles_recording(monkeypatch):
calls = []

def fake_send(address, cmd, par=None, s=None):
calls.append((cmd, par))
return {"Status": "0"}

# First verify fails, second (after stopping recording) succeeds.
results = iter([(True, False, 3, "0"), (True, True, 2, "0")])
monkeypatch.setattr(control, "_send", fake_send)
monkeypatch.setattr(control, "_apply_and_verify",
lambda a, c, i: next(results))
monkeypatch.setattr(control, "_record_state", lambda a: 1)
monkeypatch.setattr(control, "RECORD_SETTLE", 0)

r = control.set_setting("cam", 8222, 2, model="A329S") # resolution (gated)
assert r["record_cycled"] is True and r["verified"] is True
# Recording was stopped (par=0) and restarted (par=1).
assert (control.RECORD_CMD, 0) in calls and (control.RECORD_CMD, 1) in calls
Loading
Loading