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. first, add pairing only if provisioning still feels annoying.
### FEAT-03 — Manual start odometer fallback when OBD odometer is unavailable ### FEAT-03 — Manual start odometer fallback when OBD odometer is unavailable
- **Status**: `IDEA` - **Status**: `SHIPPED` 2026-08-11
- **Area**: Settings page / odometer display (Pi `app_settings`, `/api/stats`, trip odometer) - **Area**: Settings page, Dashboard, `modules/app_settings.py`, `modules/app_sql.py`
- **Phase**: Phase 27 - **Phase**: Phase 27
- **Requested**: owner — the 2013 Yaris's ECU is (as expected) NOT exposing OBD PID 0xA6 - **What**: Settings page now has an "Odometer baseline (km)" field. The app stores
odometer (see Phase 24.6), so the odometer reads nothing. this value and computes `odometer_km = baseline + cumulative trip distance`. Dashboard
- **Idea**: allow the owner to enter the vehicle's current odometer reading once in the Settings shows "(manual)" label when using baseline. When baseline is 0, falls back to live
page; the app then computes a rolling odometer by adding subsequent trip distances OBD reading (the original behavior). Also appears in `/api/stats` response.
(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.
### FEAT-04 — Replace SIM868 2G GSM hat with 4G LTE modem (Huawei E3372h-153) ### FEAT-04 — Replace SIM868 2G GSM hat with 4G LTE modem (Huawei E3372h-153)
- **Status**: `IDEA` - **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) ### Phase 27: Feature additions — 🚧 IN PROGRESS (1 shipped, 6 candidates)
**Started 2026-08-11.** Phase 26 is complete with all 12 bugs closed. Select a feature **Started 2026-08-11.** Phase 26 is complete.
from [FEATURES.md](./FEATURES.md) to implement one at a time.
**Candidates:** **Shipped:**
1. **FEAT-01** — Parallel/concurrent chunked dashcam uploads (medium effort) - **FEAT-03 — Manual odometer fallback** (2026-08-11): Settings page has an "Odometer
2. **FEAT-02** — QR / claim-code device pairing flow (medium effort) baseline (km)" field. `odometer_km = baseline + cumulative trip distance`. Dashboard
3. **FEAT-03** — Manual start odometer fallback (small effort) label shows "(manual)" when using baseline. Falls back to live OBD when baseline is 0.
4. **FEAT-04** — Replace SIM868 2G GSM with 4G LTE modem (Huawei E3372h-153) (large effort) Changed files: `modules/app_settings.py`, `modules/app_sql.py`, `templates/settings.html`,
5. **FEAT-05** — DTC history synced to remote server with code descriptions (medium effort) `templates/index.html`, `static/js/settings.js`, `FEATURES.md`.
6. **FEAT-08** — Predictable IP on phone hotspot / DHCP reservation (small effort)
7. **FEAT-09** — mDNS (yaris.local) on phone hotspot (small effort)
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) ### 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.** **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" return app_sql.get_setting(key, fallback) == "1"
def _number_value(setting_key, converter): def _number_value(setting_key, converter, fallback=None):
default = app_alerts.SETTING_DEFAULTS[setting_key] if fallback is not None:
default = fallback
else:
default = app_alerts.SETTING_DEFAULTS[setting_key]
try: try:
return converter(app_sql.get_setting(setting_key, default)) return converter(app_sql.get_setting(setting_key, default))
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -53,6 +56,7 @@ def get_settings():
"ntfy_token_configured": bool( "ntfy_token_configured": bool(
app_sql.get_setting("alert_ntfy_token", "") app_sql.get_setting("alert_ntfy_token", "")
), ),
"odometer_baseline": _number_value("odometer_baseline", float, 0),
"alert_thresholds": { "alert_thresholds": {
field: _number_value(setting_key, converter) field: _number_value(setting_key, converter)
for field, ( for field, (
@@ -132,6 +136,10 @@ def update_settings(body):
if values["cellular_enabled"] and not apn: if values["cellular_enabled"] and not apn:
errors["cellular_apn"] = "An APN is required when cellular is enabled." 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_url = body.get("ntfy_url")
ntfy_token = body.get("ntfy_token") ntfy_token = body.get("ntfy_token")
if not isinstance(ntfy_url, str): if not isinstance(ntfy_url, str):
@@ -199,6 +207,9 @@ def update_settings(body):
for setting_key, _converter, _minimum, _maximum in _NUMBER_FIELDS.values(): for setting_key, _converter, _minimum, _maximum in _NUMBER_FIELDS.values():
settings[setting_key] = str(values[setting_key]) 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"]: if values["clear_ntfy_url"]:
settings["alert_ntfy_url"] = "" settings["alert_ntfy_url"] = ""
elif 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", [])) video_count = sum(1 for _ in _av.list_local_videos().get("files", []))
except Exception: except Exception:
video_count = 0 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 { return {
"trip_count": row[0], "trip_count": row[0],
"total_km": row[1], "total_km": row[1],
@@ -464,7 +468,8 @@ def get_overview_stats():
"all_time_max_speed": row[3], "all_time_max_speed": row[3],
"active_dtc": dtc_row[0] if dtc_row else 0, "active_dtc": dtc_row[0] if dtc_row else 0,
"video_count": video_count, "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'), deviceCarModel: document.getElementById('device-car-model'),
deviceAvatarPath: document.getElementById('device-avatar-path'), deviceAvatarPath: document.getElementById('device-avatar-path'),
deviceName: document.getElementById('device-name'), deviceName: document.getElementById('device-name'),
odometerBaseline: document.getElementById('odometer-baseline'),
remoteServerUrl: document.getElementById('remote-server-url'), remoteServerUrl: document.getElementById('remote-server-url'),
remoteDeviceName: document.getElementById('remote-device-name'), remoteDeviceName: document.getElementById('remote-device-name'),
remoteApiKey: document.getElementById('remote-api-key'), remoteApiKey: document.getElementById('remote-api-key'),
@@ -91,6 +92,7 @@ function applyErrors(errors) {
car_model: fields.deviceCarModel, car_model: fields.deviceCarModel,
avatar_path: fields.deviceAvatarPath, avatar_path: fields.deviceAvatarPath,
device_name: fields.deviceName, device_name: fields.deviceName,
odometer_baseline: Number(fields.odometerBaseline.value) || 0,
server_url: fields.remoteServerUrl, server_url: fields.remoteServerUrl,
remote_device_name: fields.remoteDeviceName, remote_device_name: fields.remoteDeviceName,
api_key: fields.remoteApiKey, api_key: fields.remoteApiKey,
@@ -128,6 +130,7 @@ function populate(settings) {
fields.ntfyToken.value = ''; fields.ntfyToken.value = '';
fields.clearNtfyUrl.checked = false; fields.clearNtfyUrl.checked = false;
fields.clearNtfyToken.checked = false; fields.clearNtfyToken.checked = false;
fields.odometerBaseline.value = (settings.odometer_baseline || 0).toFixed(1);
} }
function populateDeviceConfig(deviceConfig) { function populateDeviceConfig(deviceConfig) {
@@ -136,6 +139,7 @@ function populateDeviceConfig(deviceConfig) {
fields.deviceCarModel.value = deviceConfig.car_model; fields.deviceCarModel.value = deviceConfig.car_model;
fields.deviceAvatarPath.value = deviceConfig.avatar_path; fields.deviceAvatarPath.value = deviceConfig.avatar_path;
fields.deviceName.value = deviceConfig.device_name || ''; fields.deviceName.value = deviceConfig.device_name || '';
fields.odometerBaseline.value = (deviceConfig.odometer_baseline || 0).toFixed(1);
updateDeviceConfigFieldsState(); updateDeviceConfigFieldsState();
} }
@@ -178,6 +182,7 @@ function payload() {
gps_loss_seconds: Number(fields.gpsLoss.value), gps_loss_seconds: Number(fields.gpsLoss.value),
repeat_seconds: Number(fields.repeat.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 class="fw-bold fs-5 text-dark" id="stat-topspeed">—</div>
</div> </div>
<div class="col-6 col-lg-12"> <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 class="fw-bold fs-5 text-dark" id="stat-odometer">—</div>
</div> </div>
<div class="col-6 col-lg-12"> <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-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-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-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; document.getElementById('stat-videos').textContent = d.video_count;
const dtcEl = document.getElementById('stat-dtc'); const dtcEl = document.getElementById('stat-dtc');
dtcEl.textContent = d.active_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="invalid-feedback" data-error-for="device_name"></div>
<div class="form-text">Reserved for future server portal display.</div> <div class="form-text">Reserved for future server portal display.</div>
</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> </div>
<p class="text-muted small mb-0">Edit your device identity here. If server sync is enabled, these fields are read-only.</p> <p class="text-muted small mb-0">Edit your device identity here. If server sync is enabled, these fields are read-only.</p>
</div> </div>