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.
|
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`
|
||||||
|
|||||||
@@ -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.**
|
||||||
|
|||||||
+12
-1
@@ -26,7 +26,10 @@ 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):
|
||||||
|
if fallback is not None:
|
||||||
|
default = fallback
|
||||||
|
else:
|
||||||
default = app_alerts.SETTING_DEFAULTS[setting_key]
|
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))
|
||||||
@@ -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
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user