feat: manual odometer baseline — Settings field computes baseline + trip distance, Dashboard shows (manual) label
This commit is contained in:
+6
-13
@@ -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`
|
||||
|
||||
@@ -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.**
|
||||
|
||||
+12
-1
@@ -26,7 +26,10 @@ def _setting_bool(key, env_name=None, default=False):
|
||||
return app_sql.get_setting(key, fallback) == "1"
|
||||
|
||||
|
||||
def _number_value(setting_key, converter):
|
||||
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))
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user