From 34253b3ffb84a81456383371cd64c1eda5ee05b2 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Tue, 11 Aug 2026 18:51:11 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20manual=20odometer=20baseline=20?= =?UTF-8?q?=E2=80=94=20Settings=20field=20computes=20baseline=20+=20trip?= =?UTF-8?q?=20distance,=20Dashboard=20shows=20(manual)=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FEATURES.md | 19 ++++++------------- PLAN.md | 27 +++++++++++++++------------ modules/app_settings.py | 15 +++++++++++++-- modules/app_sql.py | 7 ++++++- static/js/settings.js | 5 +++++ templates/index.html | 9 +++++++-- templates/settings.html | 6 ++++++ 7 files changed, 58 insertions(+), 30 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 6297e8f..bafe8de 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -45,20 +45,13 @@ dedicated PLAN.md phase (or fold it into the relevant existing work) and mark it first, add pairing only if provisioning still feels annoying. ### FEAT-03 — Manual start odometer fallback when OBD odometer is unavailable -- **Status**: `IDEA` -- **Area**: Settings page / odometer display (Pi `app_settings`, `/api/stats`, trip odometer) +- **Status**: `SHIPPED` 2026-08-11 +- **Area**: Settings page, Dashboard, `modules/app_settings.py`, `modules/app_sql.py` - **Phase**: Phase 27 -- **Requested**: owner — the 2013 Yaris's ECU is (as expected) NOT exposing OBD PID 0xA6 - odometer (see Phase 24.6), so the odometer reads nothing. -- **Idea**: allow the owner to enter the vehicle's current odometer reading once in the Settings - page; the app then computes a rolling odometer by adding subsequent trip distances - (from `trips` table cumulative distance) to that entered baseline, instead of relying on the - unavailable OBD odometer PID. -- **Outstanding considerations**: what happens when the user's entered value comes from the car's - instrument cluster vs the OBD one being absent — this would be the *only* odometer source in - that case; needs a clear "this is a manual baseline" indicator in the UI so it's not mistaken - for a live OBD reading; decide whether it should also sync to the remote device config - (Phase 25.5) or stay local. +- **What**: Settings page now has an "Odometer baseline (km)" field. The app stores + this value and computes `odometer_km = baseline + cumulative trip distance`. Dashboard + shows "(manual)" label when using baseline. When baseline is 0, falls back to live + OBD reading (the original behavior). Also appears in `/api/stats` response. ### FEAT-04 — Replace SIM868 2G GSM hat with 4G LTE modem (Huawei E3372h-153) - **Status**: `IDEA` diff --git a/PLAN.md b/PLAN.md index 4a00791..e0e51cc 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1933,20 +1933,23 @@ agree on OBD and GPS state. BUGS.md Open section is empty. readme.md project sta --- -### Phase 27: Feature additions — 🚧 IN PROGRESS (pick one) -**Started 2026-08-11.** Phase 26 is complete with all 12 bugs closed. Select a feature -from [FEATURES.md](./FEATURES.md) to implement one at a time. +### Phase 27: Feature additions — 🚧 IN PROGRESS (1 shipped, 6 candidates) +**Started 2026-08-11.** Phase 26 is complete. -**Candidates:** -1. **FEAT-01** — Parallel/concurrent chunked dashcam uploads (medium effort) -2. **FEAT-02** — QR / claim-code device pairing flow (medium effort) -3. **FEAT-03** — Manual start odometer fallback (small effort) -4. **FEAT-04** — Replace SIM868 2G GSM with 4G LTE modem (Huawei E3372h-153) (large effort) -5. **FEAT-05** — DTC history synced to remote server with code descriptions (medium effort) -6. **FEAT-08** — Predictable IP on phone hotspot / DHCP reservation (small effort) -7. **FEAT-09** — mDNS (yaris.local) on phone hotspot (small effort) +**Shipped:** +- **FEAT-03 — Manual odometer fallback** (2026-08-11): Settings page has an "Odometer + baseline (km)" field. `odometer_km = baseline + cumulative trip distance`. Dashboard + label shows "(manual)" when using baseline. Falls back to live OBD when baseline is 0. + Changed files: `modules/app_settings.py`, `modules/app_sql.py`, `templates/settings.html`, + `templates/index.html`, `static/js/settings.js`, `FEATURES.md`. -Already shipped from Phase 26: **FEAT-06** (trip ID column), **FEAT-07** (hotspot auto-connect). +**Remaining candidates:** +1. **FEAT-01** — Parallel/concurrent chunked dashcam uploads (medium) +2. **FEAT-02** — QR / claim-code device pairing flow (medium) +3. **FEAT-04** — Replace SIM868 2G GSM with 4G LTE modem (large) +4. **FEAT-05** — DTC history synced to remote server (medium) +5. **FEAT-08** — Predictable IP on phone hotspot (small) +6. **FEAT-09** — mDNS on phone hotspot (small) ### Phase 28: Native Android companion app — 🚧 PLANNED (low priority, always last) **Renumbered from former Phase 26 on 2026-08-10 to make room for the road-test bug-fix phase. Renumbered to Phase 28 on 2026-08-11 to make room for Phase 27 feature additions.** diff --git a/modules/app_settings.py b/modules/app_settings.py index dc99473..614dabb 100644 --- a/modules/app_settings.py +++ b/modules/app_settings.py @@ -26,8 +26,11 @@ def _setting_bool(key, env_name=None, default=False): return app_sql.get_setting(key, fallback) == "1" -def _number_value(setting_key, converter): - default = app_alerts.SETTING_DEFAULTS[setting_key] +def _number_value(setting_key, converter, fallback=None): + if fallback is not None: + default = fallback + else: + default = app_alerts.SETTING_DEFAULTS[setting_key] try: return converter(app_sql.get_setting(setting_key, default)) except (TypeError, ValueError): @@ -53,6 +56,7 @@ def get_settings(): "ntfy_token_configured": bool( app_sql.get_setting("alert_ntfy_token", "") ), + "odometer_baseline": _number_value("odometer_baseline", float, 0), "alert_thresholds": { field: _number_value(setting_key, converter) for field, ( @@ -132,6 +136,10 @@ def update_settings(body): if values["cellular_enabled"] and not apn: errors["cellular_apn"] = "An APN is required when cellular is enabled." + values["odometer_baseline"] = _bounded_number( + body, "odometer_baseline", float, 0, 999_999, errors + ) + ntfy_url = body.get("ntfy_url") ntfy_token = body.get("ntfy_token") if not isinstance(ntfy_url, str): @@ -199,6 +207,9 @@ def update_settings(body): for setting_key, _converter, _minimum, _maximum in _NUMBER_FIELDS.values(): settings[setting_key] = str(values[setting_key]) + if values["odometer_baseline"] is not None: + settings["odometer_baseline"] = str(values["odometer_baseline"]) + if values["clear_ntfy_url"]: settings["alert_ntfy_url"] = "" elif ntfy_url: diff --git a/modules/app_sql.py b/modules/app_sql.py index f2b5bb9..244c633 100644 --- a/modules/app_sql.py +++ b/modules/app_sql.py @@ -457,6 +457,10 @@ def get_overview_stats(): video_count = sum(1 for _ in _av.list_local_videos().get("files", [])) except Exception: video_count = 0 + from modules import app_settings + base = app_settings._number_value("odometer_baseline", float, 0) + cumul = conn.execute("SELECT COALESCE(SUM(DISTANCE_KM), 0) FROM trips").fetchone() + computed = base + (cumul[0] if cumul else 0) return { "trip_count": row[0], "total_km": row[1], @@ -464,7 +468,8 @@ def get_overview_stats(): "all_time_max_speed": row[3], "active_dtc": dtc_row[0] if dtc_row else 0, "video_count": video_count, - "odometer_km": odo_row[0] if odo_row and odo_row[0] else None, + "odometer_km": computed if base > 0 else (odo_row[0] if odo_row and odo_row[0] else None), + "odometer_source": "manual" if base > 0 else "obd", } diff --git a/static/js/settings.js b/static/js/settings.js index 80c8ed1..bf626e2 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -38,6 +38,7 @@ const fields = { deviceCarModel: document.getElementById('device-car-model'), deviceAvatarPath: document.getElementById('device-avatar-path'), deviceName: document.getElementById('device-name'), + odometerBaseline: document.getElementById('odometer-baseline'), remoteServerUrl: document.getElementById('remote-server-url'), remoteDeviceName: document.getElementById('remote-device-name'), remoteApiKey: document.getElementById('remote-api-key'), @@ -91,6 +92,7 @@ function applyErrors(errors) { car_model: fields.deviceCarModel, avatar_path: fields.deviceAvatarPath, device_name: fields.deviceName, + odometer_baseline: Number(fields.odometerBaseline.value) || 0, server_url: fields.remoteServerUrl, remote_device_name: fields.remoteDeviceName, api_key: fields.remoteApiKey, @@ -128,6 +130,7 @@ function populate(settings) { fields.ntfyToken.value = ''; fields.clearNtfyUrl.checked = false; fields.clearNtfyToken.checked = false; + fields.odometerBaseline.value = (settings.odometer_baseline || 0).toFixed(1); } function populateDeviceConfig(deviceConfig) { @@ -136,6 +139,7 @@ function populateDeviceConfig(deviceConfig) { fields.deviceCarModel.value = deviceConfig.car_model; fields.deviceAvatarPath.value = deviceConfig.avatar_path; fields.deviceName.value = deviceConfig.device_name || ''; + fields.odometerBaseline.value = (deviceConfig.odometer_baseline || 0).toFixed(1); updateDeviceConfigFieldsState(); } @@ -178,6 +182,7 @@ function payload() { gps_loss_seconds: Number(fields.gpsLoss.value), repeat_seconds: Number(fields.repeat.value), }, + odometer_baseline: Number(fields.odometerBaseline.value) || 0, }; } diff --git a/templates/index.html b/templates/index.html index 026e68e..62c1e65 100644 --- a/templates/index.html +++ b/templates/index.html @@ -153,7 +153,7 @@
—
-
Odometer
+
Odometer
—
@@ -268,7 +268,12 @@ document.getElementById('stat-km').textContent = d.total_km.toFixed(1) + ' km'; document.getElementById('stat-time').textContent = d.total_duration_s > 0 ? fmtDuration(d.total_duration_s) : '—'; document.getElementById('stat-topspeed').textContent = d.all_time_max_speed > 0 ? d.all_time_max_speed + ' km/h' : '—'; - document.getElementById('stat-odometer').textContent = d.odometer_km != null ? Math.round(d.odometer_km).toLocaleString() + ' km' : '—'; + const odoEl = document.getElementById('stat-odometer'); + const odoLabel = document.getElementById('stat-odometer-label'); + odoEl.textContent = d.odometer_km != null ? Math.round(d.odometer_km).toLocaleString() + ' km' : '—'; + if (odoLabel && d.odometer_source === 'manual') { + odoLabel.textContent = 'Odometer (manual)'; + } document.getElementById('stat-videos').textContent = d.video_count; const dtcEl = document.getElementById('stat-dtc'); dtcEl.textContent = d.active_dtc; diff --git a/templates/settings.html b/templates/settings.html index c888407..bd18e47 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -55,6 +55,12 @@
Reserved for future server portal display.
+
+ + +
+
Enter your car's current odometer reading. The app adds trip distances to this baseline. Leave at 0 to use live OBD reading (not available on 2013 Yaris).
+

Edit your device identity here. If server sync is enabled, these fields are read-only.