feat: manual odometer baseline — Settings field computes baseline + trip distance, Dashboard shows (manual) label

This commit is contained in:
Mathieu Moran
2026-08-11 18:51:11 -04:00
parent 887a001723
commit 34253b3ffb
7 changed files with 58 additions and 30 deletions
+6 -13
View File
@@ -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`
+15 -12
View File
@@ -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.**
+13 -2
View File
@@ -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:
+6 -1
View File
@@ -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",
}
+5
View File
@@ -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,
};
}
+7 -2
View File
@@ -153,7 +153,7 @@
<div class="fw-bold fs-5 text-dark" id="stat-topspeed">—</div>
</div>
<div class="col-6 col-lg-12">
<div class="text-xs text-muted fw-bold text-uppercase mb-1">Odometer</div>
<div class="text-xs text-muted fw-bold text-uppercase mb-1" id="stat-odometer-label">Odometer</div>
<div class="fw-bold fs-5 text-dark" id="stat-odometer">—</div>
</div>
<div class="col-6 col-lg-12">
@@ -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;
+6
View File
@@ -55,6 +55,12 @@
<div class="invalid-feedback" data-error-for="device_name"></div>
<div class="form-text">Reserved for future server portal display.</div>
</div>
<div class="col-md-6">
<label class="form-label form-label-sm" for="odometer-baseline">Odometer baseline (km)</label>
<input class="form-control form-control-sm" id="odometer-baseline" type="number" inputmode="decimal" min="0" max="999999" step="0.1">
<div class="invalid-feedback" data-error-for="odometer_baseline"></div>
<div class="form-text">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).</div>
</div>
</div>
<p class="text-muted small mb-0">Edit your device identity here. If server sync is enabled, these fields are read-only.</p>
</div>