From 3d49764855562fc2a3ec87b6d0fe7df3acdc4925 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Thu, 13 Aug 2026 02:02:45 -0400 Subject: [PATCH] feat: log OBD speed before GPS fix so rolling starts aren't lost - _log_loop: when GPS has no fix but OBD speed > 1, write speed-only rows (NULL coordinates) instead of dropping the motion - insert_tracking: accept None gps_x/gps_y -> NULL GPSX/GPSY - app_trip: keep NULL-coord rows through normalize; _segment returns 0 for missing coords; route_points drops them (no position to draw) - tests: speed-only rows build a trip with max speed + zero distance - docs: FEAT-12 + readme note; server already filters NULL-GPS safely --- FEATURES.md | 15 +++++++++++++++ modules/app_sql.py | 22 +++++++++++++++++++--- modules/app_trip.py | 14 ++++++++++++-- readme.md | 3 ++- tests/test_app_trip.py | 16 ++++++++++++++++ 5 files changed, 64 insertions(+), 6 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index e1a4334..c2d4271 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -31,6 +31,7 @@ dedicated PLAN.md phase (or fold it into the relevant existing work) and mark it | FEAT-09 | `WONTFIX` | mDNS (yaris.local) on phone hotspot | — | | FEAT-10 | `IDEA` | Home Assistant integration | unassigned | | FEAT-11 | `SHIPPED` | Hotspot SSID/password settings + 5GHz WiFi role swap | Phase 27 | +| FEAT-12 | `SHIPPED` | Log OBD speed before GPS fix (rolling-start capture) | Phase 27 | --- @@ -222,3 +223,17 @@ dedicated PLAN.md phase (or fold it into the relevant existing work) and mark it `app_network.py` (uplink on wlan0, AP on wlan1), `install_netwatch.sh` (rebinds profiles). +### FEAT-12 — Log OBD speed before GPS fix (rolling-start capture) +- **Status**: `SHIPPED` 2026-08-13 +- **Area**: `modules/app_sql.py` (`_log_loop`, `insert_tracking`), `modules/app_trip.py` + (`normalize_tracking_rows`, `_segment`, `route_points`) +- **What**: The tracking logger previously only wrote rows once GPS had a fix, so a rolling + start or mid-drive reboot lost the first ~1.5–2 min of movement (boot + GPS cold start). + Now, while GPS has no fix but OBD speed > 1 km/h, the logger writes speed-only rows with + NULL coordinates; trip building treats them as OBD-motion events (zero GPS distance). The + route polyline drops these rows (no position to draw). +- **Server parity note**: server `compute_trips_for_device` and `get_trip_route` already filter + `gps_lat IS NOT NULL`, so they safely ignore the NULL-coordinate rows — but the server's + recomputed trips don't yet include the speed-only leading motion (follow-up if needed). + + diff --git a/modules/app_sql.py b/modules/app_sql.py index 6a1d678..017600c 100644 --- a/modules/app_sql.py +++ b/modules/app_sql.py @@ -314,7 +314,11 @@ def count_active_dtcs(): def insert_tracking(gps_x, gps_y, speed, odometer=None): - """Insert a single trip-tracking snapshot row (GPS position + speed).""" + """Insert a single trip-tracking snapshot row (GPS position + speed). + + gps_x/gps_y may be None (logged before the first GPS fix) — the row then + stores NULL coordinates and relies on OBD speed for motion detection. + """ conn = get_conn() if conn is None: return False @@ -323,8 +327,14 @@ def insert_tracking(gps_x, gps_y, speed, odometer=None): VALUES (?, ?, ?, ?, ?, ?, ?)""" with _conn_lock: try: - latitude = float(str(gps_x).replace("\x00", "").strip()) - longitude = float(str(gps_y).replace("\x00", "").strip()) + latitude = ( + None if gps_x is None + else float(str(gps_x).replace("\x00", "").strip()) + ) + longitude = ( + None if gps_y is None + else float(str(gps_y).replace("\x00", "").strip()) + ) conn.execute( sql, (car.model, account.name, datetime.now(), latitude, longitude, speed, odometer), @@ -847,6 +857,12 @@ def _log_loop(): ) and insert_tracking(position[0], position[1], speed, app_obd.odometer): last_position = position last_logged_at = now + elif speed is not None and speed > 1: + # No GPS fix yet but the car is moving (OBD speed) — log a + # speed-only row so a rolling start / mid-drive reboot isn't lost. + # Coordinates stay NULL until the first fix; trip building treats + # these as OBD-motion events with zero GPS distance. + insert_tracking(None, None, speed, app_obd.odometer) time.sleep(LOG_INTERVAL) diff --git a/modules/app_trip.py b/modules/app_trip.py index 3c70960..6e91da6 100644 --- a/modules/app_trip.py +++ b/modules/app_trip.py @@ -47,9 +47,11 @@ def normalize_tracking_rows(rows): 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: + if timestamp is None: continue - if not (-90 <= latitude <= 90 and -180 <= longitude <= 180): + if latitude is not None and not (-90 <= latitude <= 90): + continue + if longitude is not None and not (-180 <= longitude <= 180): continue normalized.append({ "id": int(row[0]), @@ -66,6 +68,11 @@ def _segment(row, previous): seconds = (row["datetime"] - previous["datetime"]).total_seconds() if seconds <= 0: return 0, 0 + if ( + previous["latitude"] is None or previous["longitude"] is None + or row["latitude"] is None or row["longitude"] is None + ): + return 0, 0 distance_km = haversine_km( (previous["latitude"], previous["longitude"]), (row["latitude"], row["longitude"]), @@ -218,6 +225,9 @@ def build_trip_summaries(raw_rows): def route_points(raw_rows, max_points=2000, odometer_baseline=0.0, prior_km=0.0): rows = normalize_tracking_rows(raw_rows) + # The route polyline needs coordinates — drop speed-only rows logged before + # the first GPS fix (no position to draw; they add 0 distance anyway). + rows = [r for r in rows if r["latitude"] is not None and r["longitude"] is not None] filtered_rows = [] for row in rows: if filtered_rows: diff --git a/readme.md b/readme.md index e8383c0..bedf3ab 100644 --- a/readme.md +++ b/readme.md @@ -50,7 +50,8 @@ Dashboard — mobile (390 px) and desktop (1280 px): (create/delete/rotate keys), and server-side device identity sync that hot-reloads on the Pi. - **Trip history and replay** — motion-aware detection (OBD wheel speed primary, GPS displacement fallback). Metric summaries, offline route polyline, play/pause timeline with moving marker and - live reverse-geocoded location names. Tap-to-replay on mobile. + live reverse-geocoded location names. Tap-to-replay on mobile. Captures OBD speed before the + first GPS fix so rolling starts aren't lost. - **Status page** — Pi health (temp, RAM, disk, uptime, throttling), service states, uplink/server/ GPS/OBD diagnostics, and quick actions (test server, sync now, reconnect uplink, restart app). - **Offline-first alerts** — notification bell on every page for check-engine, storage pressure, diff --git a/tests/test_app_trip.py b/tests/test_app_trip.py index 2db8bbc..76cb5b9 100644 --- a/tests/test_app_trip.py +++ b/tests/test_app_trip.py @@ -115,6 +115,22 @@ class TripBuilderTest(unittest.TestCase): self.assertEqual(trips[0]["start_tracking_id"], 1) self.assertEqual(trips[0]["end_tracking_id"], 5) + def test_speed_only_rows_without_gps_build_trip(self): + started = datetime(2026, 1, 1, 8) + rows = [ + (index + 1, started + timedelta(seconds=index * 5), None, None, 30) + for index in range(4) + ] + + trips = app_trip.build_trip_summaries(rows) + + self.assertEqual(len(trips), 1) + self.assertEqual(trips[0]["start_tracking_id"], 1) + self.assertEqual(trips[0]["end_tracking_id"], 4) + self.assertEqual(trips[0]["distance_km"], 0) + self.assertEqual(trips[0]["max_speed_kmh"], 30) + self.assertEqual(trips[0]["point_count"], 4) + def test_clock_rollback_does_not_merge_motion_episodes(self): first = self.rows_for_route( [(45.5, -73.5), (45.5, -73.5)],