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
This commit is contained in:
+15
@@ -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-09 | `WONTFIX` | mDNS (yaris.local) on phone hotspot | — |
|
||||||
| FEAT-10 | `IDEA` | Home Assistant integration | unassigned |
|
| FEAT-10 | `IDEA` | Home Assistant integration | unassigned |
|
||||||
| FEAT-11 | `SHIPPED` | Hotspot SSID/password settings + 5GHz WiFi role swap | Phase 27 |
|
| 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).
|
`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).
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+19
-3
@@ -314,7 +314,11 @@ def count_active_dtcs():
|
|||||||
|
|
||||||
|
|
||||||
def insert_tracking(gps_x, gps_y, speed, odometer=None):
|
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()
|
conn = get_conn()
|
||||||
if conn is None:
|
if conn is None:
|
||||||
return False
|
return False
|
||||||
@@ -323,8 +327,14 @@ def insert_tracking(gps_x, gps_y, speed, odometer=None):
|
|||||||
VALUES (?, ?, ?, ?, ?, ?, ?)"""
|
VALUES (?, ?, ?, ?, ?, ?, ?)"""
|
||||||
with _conn_lock:
|
with _conn_lock:
|
||||||
try:
|
try:
|
||||||
latitude = float(str(gps_x).replace("\x00", "").strip())
|
latitude = (
|
||||||
longitude = float(str(gps_y).replace("\x00", "").strip())
|
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(
|
conn.execute(
|
||||||
sql,
|
sql,
|
||||||
(car.model, account.name, datetime.now(), latitude, longitude, speed, odometer),
|
(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):
|
) and insert_tracking(position[0], position[1], speed, app_obd.odometer):
|
||||||
last_position = position
|
last_position = position
|
||||||
last_logged_at = now
|
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)
|
time.sleep(LOG_INTERVAL)
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -47,9 +47,11 @@ def normalize_tracking_rows(rows):
|
|||||||
longitude = _number(row[3])
|
longitude = _number(row[3])
|
||||||
speed = _number(row[4]) or 0
|
speed = _number(row[4]) or 0
|
||||||
odometer = _number(row[5]) if len(row) > 5 else None
|
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
|
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
|
continue
|
||||||
normalized.append({
|
normalized.append({
|
||||||
"id": int(row[0]),
|
"id": int(row[0]),
|
||||||
@@ -66,6 +68,11 @@ def _segment(row, previous):
|
|||||||
seconds = (row["datetime"] - previous["datetime"]).total_seconds()
|
seconds = (row["datetime"] - previous["datetime"]).total_seconds()
|
||||||
if seconds <= 0:
|
if seconds <= 0:
|
||||||
return 0, 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(
|
distance_km = haversine_km(
|
||||||
(previous["latitude"], previous["longitude"]),
|
(previous["latitude"], previous["longitude"]),
|
||||||
(row["latitude"], row["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):
|
def route_points(raw_rows, max_points=2000, odometer_baseline=0.0, prior_km=0.0):
|
||||||
rows = normalize_tracking_rows(raw_rows)
|
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 = []
|
filtered_rows = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if filtered_rows:
|
if filtered_rows:
|
||||||
|
|||||||
@@ -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.
|
(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
|
- **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
|
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/
|
- **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).
|
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,
|
- **Offline-first alerts** — notification bell on every page for check-engine, storage pressure,
|
||||||
|
|||||||
@@ -115,6 +115,22 @@ class TripBuilderTest(unittest.TestCase):
|
|||||||
self.assertEqual(trips[0]["start_tracking_id"], 1)
|
self.assertEqual(trips[0]["start_tracking_id"], 1)
|
||||||
self.assertEqual(trips[0]["end_tracking_id"], 5)
|
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):
|
def test_clock_rollback_does_not_merge_motion_episodes(self):
|
||||||
first = self.rows_for_route(
|
first = self.rows_for_route(
|
||||||
[(45.5, -73.5), (45.5, -73.5)],
|
[(45.5, -73.5), (45.5, -73.5)],
|
||||||
|
|||||||
Reference in New Issue
Block a user