feat: odometer in trips/overview, trips upload toggle, fix video count

- OBD odometer now logged to tracking rows on every GPS snapshot
- trips table: START_ODOMETER/END_ODOMETER columns (migration)
- build_trip_summaries: picks odometer from first/last tracking row
- list_trips: uses real OBD odo, falls back to cumulative distance
- overview stats: odometer_km (max seen from OBD), video_count fixed
- history page: Odometer column shows start→end km per trip
- app_upload: TRIPS_UPLOAD_SETTING_KEY, is_trips_upload_enabled()
- _upload_tracking_batch: skips when trips upload is off
- settings: separate 'Upload trips & GPS' toggle from dashcam toggle

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 02:07:31 -04:00
co-authored by Copilot
parent 414fb78543
commit afce229310
9 changed files with 80 additions and 21 deletions
+5
View File
@@ -37,6 +37,7 @@ def _number_value(setting_key, converter):
def get_settings():
return {
"dashcam_upload_enabled": app_upload.is_dashcam_upload_enabled(),
"trips_upload_enabled": app_upload.is_trips_upload_enabled(),
"local_video_retention_days": app_video.retention_days(),
"video_convert_enabled": app_video.is_convert_enabled(),
"cellular_enabled": _setting_bool(
@@ -99,6 +100,7 @@ def update_settings(body):
values = {}
for field in (
"dashcam_upload_enabled",
"trips_upload_enabled",
"video_convert_enabled",
"cellular_enabled",
"ntfy_enabled",
@@ -184,6 +186,9 @@ def update_settings(body):
app_upload.DASHCAM_UPLOAD_SETTING_KEY: (
"1" if values["dashcam_upload_enabled"] else "0"
),
app_upload.TRIPS_UPLOAD_SETTING_KEY: (
"1" if values["trips_upload_enabled"] else "0"
),
app_video.RETENTION_SETTING: str(values["local_video_retention_days"]),
app_video.CONVERT_SETTING: "1" if values["video_convert_enabled"] else "0",
"cellular_enabled": "1" if values["cellular_enabled"] else "0",
+38 -9
View File
@@ -90,7 +90,9 @@ SQL_CREATE_TRIPS_TABLE = """CREATE TABLE IF NOT EXISTS trips (
DURATION_SECONDS integer NOT NULL,
POINT_COUNT integer NOT NULL,
AVERAGE_SPEED_KMH float NOT NULL DEFAULT 0,
MAX_SPEED_KMH float NOT NULL DEFAULT 0
MAX_SPEED_KMH float NOT NULL DEFAULT 0,
START_ODOMETER float,
END_ODOMETER float
);"""
SQL_CREATE_TRIP_BUILD_STATE_TABLE = """CREATE TABLE IF NOT EXISTS trip_build_state (
@@ -180,6 +182,8 @@ def _migrate_trips_table(conn):
additions = {
"AVERAGE_SPEED_KMH": "float NOT NULL DEFAULT 0",
"MAX_SPEED_KMH": "float NOT NULL DEFAULT 0",
"START_ODOMETER": "float",
"END_ODOMETER": "float",
}
for name, definition in additions.items():
if name not in existing_columns:
@@ -271,7 +275,7 @@ def refresh_trips(force=False):
if intersecting_trip[0] is not None:
rebuild_start_id = min(rebuild_start_id, intersecting_trip[0])
rows = conn.execute(
"""SELECT id, DATETIME, GPSX, GPSY, SPEED
"""SELECT id, DATETIME, GPSX, GPSY, SPEED, ODOMETER
FROM tracking WHERE id >= ? ORDER BY id ASC""",
(rebuild_start_id,),
).fetchall()
@@ -289,13 +293,15 @@ def refresh_trips(force=False):
START_DATETIME, END_DATETIME,
START_GPSX, START_GPSY, END_GPSX, END_GPSY,
DISTANCE_KM, DURATION_SECONDS, POINT_COUNT,
AVERAGE_SPEED_KMH, MAX_SPEED_KMH
AVERAGE_SPEED_KMH, MAX_SPEED_KMH,
START_ODOMETER, END_ODOMETER
) VALUES (
:id, :start_tracking_id, :end_tracking_id,
:start_datetime, :end_datetime,
:start_gpsx, :start_gpsy, :end_gpsx, :end_gpsy,
:distance_km, :duration_seconds, :point_count,
:average_speed_kmh, :max_speed_kmh
:average_speed_kmh, :max_speed_kmh,
:start_odometer, :end_odometer
)""",
trips,
)
@@ -325,15 +331,25 @@ def get_overview_stats():
COALESCE(SUM(DURATION_SECONDS),0), COALESCE(MAX(MAX_SPEED_KMH),0)
FROM trips
""").fetchone()
odo_row = conn.execute("""
SELECT MAX(ODOMETER) FROM tracking WHERE ODOMETER IS NOT NULL AND ODOMETER > 0
""").fetchone()
dtc_row = conn.execute("""
SELECT COUNT(*) FROM dtc_codes WHERE cleared_at IS NULL
""").fetchone() if _table_exists(conn, "dtc_codes") else (0,)
from modules import app_video as _av
try:
video_count = sum(1 for _ in _av.list_local_videos().get("files", []))
except Exception:
video_count = 0
return {
"trip_count": row[0],
"total_km": row[1],
"total_duration_s": row[2],
"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,
}
@@ -353,10 +369,19 @@ def list_trips():
"""SELECT id, START_DATETIME, END_DATETIME,
START_GPSX, START_GPSY, END_GPSX, END_GPSY,
DISTANCE_KM, DURATION_SECONDS, POINT_COUNT,
AVERAGE_SPEED_KMH, MAX_SPEED_KMH
FROM trips ORDER BY START_DATETIME DESC"""
AVERAGE_SPEED_KMH, MAX_SPEED_KMH,
START_ODOMETER, END_ODOMETER
FROM trips ORDER BY START_DATETIME ASC"""
).fetchall()
return [{
# If real OBD odometer is available use it; otherwise fall back to cumulative distance
result = []
cumulative_km = 0.0
for row in rows:
dist = row[7] or 0
start_odo = row[12] if row[12] is not None else round(cumulative_km, 1)
end_odo = row[13] if row[13] is not None else round(cumulative_km + dist, 1)
cumulative_km += dist
result.append({
"id": row[0],
"start_datetime": row[1],
"end_datetime": row[2],
@@ -367,7 +392,11 @@ def list_trips():
"point_count": row[9],
"average_speed_kmh": row[10],
"max_speed_kmh": row[11],
} for row in rows]
"odo_start_km": start_odo,
"odo_end_km": end_odo,
})
result.reverse() # newest first for display
return result
def get_trip_route(trip_id, max_points=2000):
@@ -674,7 +703,7 @@ def _log_loop():
)
if should_log_tracking(
position, speed, last_position, seconds_since_log
) and insert_tracking(position[0], position[1], speed):
) and insert_tracking(position[0], position[1], speed, app_obd.odometer):
last_position = position
last_logged_at = now
+4
View File
@@ -46,6 +46,7 @@ def normalize_tracking_rows(rows):
latitude = _number(row[2])
longitude = _number(row[3])
speed = _number(row[4]) or 0
odometer = _number(row[5]) if len(row) > 5 else None
if timestamp is None or latitude is None or longitude is None:
continue
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
@@ -56,6 +57,7 @@ def normalize_tracking_rows(rows):
"latitude": latitude,
"longitude": longitude,
"speed": max(0, speed),
"odometer": odometer,
})
return normalized
@@ -202,6 +204,8 @@ def build_trip_summaries(raw_rows):
"max_speed_kmh": round(
max(obd_speeds or segment_speeds or [0]), 1
),
"start_odometer": next((r["odometer"] for r in trip_rows if r.get("odometer")), None),
"end_odometer": next((r["odometer"] for r in reversed(trip_rows) if r.get("odometer")), None),
})
return trips
+8
View File
@@ -66,6 +66,7 @@ BACKOFF_MAX = 300
DASHCAM_CHUNK_SIZE = 40 * 1024 * 1024
DASHCAM_UPLOAD_SETTING_KEY = "dashcam_upload_enabled"
TRIPS_UPLOAD_SETTING_KEY = "trips_upload_enabled"
_upload_thread = None
_last_error = None
@@ -90,6 +91,11 @@ def set_dashcam_upload_enabled(enabled):
app_sql.set_setting(DASHCAM_UPLOAD_SETTING_KEY, "1" if enabled else "0")
def is_trips_upload_enabled():
"""Defaults to enabled."""
return app_sql.get_setting(TRIPS_UPLOAD_SETTING_KEY, "1") == "1"
def get_status():
"""Snapshot used by the /api/upload/status endpoint for the web UI."""
return {
@@ -138,6 +144,8 @@ def _upload_tracking_batch():
modem's AT-command HTTP client before giving up.
"""
global _last_network_path
if not is_trips_upload_enabled():
return 0
rows = app_sql.get_unsent_tracking_rows(limit=TRACKING_BATCH_SIZE)
if not rows:
+1
View File
@@ -156,6 +156,7 @@
<td>${formatDistance(trip.distance_km)}</td>
<td>${formatDuration(trip.duration_seconds)}</td>
<td>${formatSpeed(trip.max_speed_kmh)}</td>
<td class="text-muted small">${trip.odo_start_km.toFixed(0)} → ${trip.odo_end_km.toFixed(0)} km</td>
<td class="text-end">
<button class="btn btn-sm btn-primary" type="button" data-trip-id="${trip.id}">
Replay
+3
View File
@@ -13,6 +13,7 @@ const lastPhoneAddressLink = document.getElementById('last-phone-address-link');
let initialUplinkStatusLoaded = false;
const fields = {
tripsUpload: document.getElementById('trips-upload-enabled'),
dashcamUpload: document.getElementById('dashcam-upload-enabled'),
retention: document.getElementById('retention-days'),
videoConvert: document.getElementById('video-convert-enabled'),
@@ -79,6 +80,7 @@ function applyErrors(errors) {
function populate(settings) {
const thresholds = settings.alert_thresholds;
fields.tripsUpload.checked = settings.trips_upload_enabled;
fields.dashcamUpload.checked = settings.dashcam_upload_enabled;
fields.retention.value = settings.local_video_retention_days;
fields.videoConvert.checked = settings.video_convert_enabled;
@@ -105,6 +107,7 @@ function populate(settings) {
function payload() {
return {
trips_upload_enabled: fields.tripsUpload.checked,
dashcam_upload_enabled: fields.dashcamUpload.checked,
local_video_retention_days: Number(fields.retention.value),
video_convert_enabled: fields.videoConvert.checked,
+1
View File
@@ -31,6 +31,7 @@
{"key": "distance", "label": "km", "sortable": true},
{"key": "duration", "label": "Time", "sortable": true},
{"key": "maxSpeed", "label": "Max", "sortable": true},
{"key": "odometer", "label": "Odometer", "sortable": false},
{"key": "action", "label": "", "sortable": false}
],
"No trips detected",
+5
View File
@@ -136,6 +136,10 @@
<div class="text-xs text-muted fw-bold text-uppercase mb-1">Top speed ever</div>
<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="fw-bold fs-5 text-dark" id="stat-odometer">—</div>
</div>
<div class="col-6 col-lg-12">
<div class="text-xs text-muted fw-bold text-uppercase mb-1">Videos on Pi</div>
<div class="fw-bold fs-5 text-dark" id="stat-videos">—</div>
@@ -218,6 +222,7 @@
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' : '—';
document.getElementById('stat-videos').textContent = d.video_count;
const dtcEl = document.getElementById('stat-dtc');
dtcEl.textContent = d.active_dtc;
+5 -2
View File
@@ -25,10 +25,13 @@
</div>
<div class="card-body py-3">
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" role="switch" id="trips-upload-enabled">
<label class="form-check-label" for="trips-upload-enabled">Upload trips &amp; GPS logs to server</label>
</div>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" role="switch" id="dashcam-upload-enabled">
<label class="form-check-label" for="dashcam-upload-enabled">Upload dashcam videos to server</label>
<label class="form-check-label" for="dashcam-upload-enabled">Upload dashcam videos to server (WiFi only)</label>
</div>
<p class="text-muted small mb-2">Trip/GPS always uploads. Dashcam video only over WiFi/Ethernet.</p>
<p id="upload-health" class="small text-muted mb-3">Checking…</p>
<div class="d-flex align-items-center gap-2">
<label class="form-label mb-0 text-nowrap" for="retention-days">Delete after</label>