diff --git a/selfdrive/ui/tests/test_drive_stats_widget.py b/selfdrive/ui/tests/test_drive_stats_widget.py index 5f51d6a56..082c7304b 100644 --- a/selfdrive/ui/tests/test_drive_stats_widget.py +++ b/selfdrive/ui/tests/test_drive_stats_widget.py @@ -113,6 +113,60 @@ def test_drive_stats_falls_back_to_local_totals_and_computed_records(): assert data.records[5].value == "2 drives" +def test_drive_stats_repairs_touched_filesystem_routes_without_moving_them_to_today(): + params = FakeParams({ + "ApiCache_DriveStats": { + "week": {"distance": 302.9, "routes": 31, "minutes": 467}, + }, + "GalaxyDashboardStats": { + "routes": { + "000011e4--2ed59ee965": { + "date": "2026-07-18T08:59:31", + "distanceMeters": 1609.344, + "duration": 600, + "timeSource": "log", + }, + "000011e5--92dc4759b2": { + "date": "2026-07-20T11:33:49", + "distanceMeters": 16093.44, + "duration": 1800, + "timeSource": "filesystem", + }, + "000011e6--8b54c54356": { + "date": "2026-07-19T07:03:36", + "distanceMeters": 3218.688, + "duration": 900, + "timeSource": "log", + }, + "000011e8--09c2203d2e": { + "date": "2026-07-19T18:17:44", + "distanceMeters": 1609.344, + "duration": 600, + "timeSource": "log", + }, + "000011e9--a3a4dcd6ef": { + "date": "2026-07-20T11:38:53", + "distanceMeters": 32186.88, + "duration": 1800, + "timeSource": "filesystem", + }, + "000011ea--00b28940d5": { + "date": "2026-07-20T02:18:17", + "distanceMeters": 8046.72, + "duration": 900, + "timeSource": "log", + }, + }, + }, + }) + + data = load_drive_stats_data(params, now=datetime(2026, 7, 20, 12, 0, 0)) + + assert data.past_week.distance == 302.9 + assert round(data.this_week.distance, 1) == 25.0 + assert round(data.daily_distance[0].distance, 1) == 25.0 + + def test_drive_stats_uses_clean_empty_record_labels(): data = load_drive_stats_data(FakeParams({}), now=datetime(2026, 7, 20, 12, 0, 0)) diff --git a/selfdrive/ui/widgets/drive_stats.py b/selfdrive/ui/widgets/drive_stats.py index 033412e9c..12e51c5ec 100644 --- a/selfdrive/ui/widgets/drive_stats.py +++ b/selfdrive/ui/widgets/drive_stats.py @@ -63,6 +63,7 @@ class DriveStatsData: @dataclass class _RouteStat: + name: str date: datetime distance_meters: float duration: int @@ -70,6 +71,7 @@ class _RouteStat: attention_known: bool clean: bool undistracted: bool + time_source: str def _json_object(value: Any) -> dict[str, Any]: @@ -128,6 +130,66 @@ def _summary_has_data(summary: DriveSummary) -> bool: return summary.drives > 0 or summary.distance > 0.0 or summary.hours > 0.0 +def _route_counter(route_name: str) -> int | None: + prefix = route_name.split("--", 1)[0] + try: + return int(prefix, 16) if len(prefix) == 8 else None + except ValueError: + return None + + +def _repair_filesystem_route_dates(routes: list[_RouteStat]) -> None: + sequenced = [ + (counter, route) + for route in routes + if (counter := _route_counter(route.name)) is not None + ] + sequenced.sort(key=lambda item: item[0]) + if sum(route.time_source == "log" for _, route in sequenced) < 2: + return + + previous_anchors = [] + previous_anchor = None + for counter, route in sequenced: + previous_anchors.append(previous_anchor) + if route.time_source == "log": + previous_anchor = (counter, route) + + following_anchors = [None] * len(sequenced) + following_anchor = None + for index in range(len(sequenced) - 1, -1, -1): + counter, route = sequenced[index] + following_anchors[index] = following_anchor + if route.time_source == "log": + following_anchor = (counter, route) + + for index, (counter, route) in enumerate(sequenced): + if route.time_source != "filesystem": + continue + + previous = previous_anchors[index] + following = following_anchors[index] + if previous is None or following is None: + continue + + previous_counter, previous_route = previous + following_counter, following_route = following + if previous_route.date > following_route.date or previous_route.date <= route.date <= following_route.date: + continue + + if following_counter == counter + 1: + inferred = following_route.date - timedelta(seconds=route.duration + 5 * 60) + elif previous_counter == counter - 1: + inferred = previous_route.date + timedelta(seconds=previous_route.duration + 5 * 60) + else: + progress = (counter - previous_counter) / (following_counter - previous_counter) + inferred = previous_route.date + (following_route.date - previous_route.date) * progress + + lower_bound = previous_route.date + timedelta(seconds=1) + upper_bound = following_route.date - timedelta(seconds=1) + route.date = min(max(inferred, lower_bound), upper_bound) + + def _load_routes(galaxy_stats: dict[str, Any]) -> list[_RouteStat]: raw_routes = galaxy_stats.get("routes", {}) if not isinstance(raw_routes, dict): @@ -147,6 +209,7 @@ def _load_routes(galaxy_stats: dict[str, Any]) -> list[_RouteStat]: distracted = max(0, int(_number(entry.get("distractedMoments", 0)))) unresponsive = max(0, int(_number(entry.get("unresponsiveMoments", 0)))) routes.append(_RouteStat( + name=str(route_name), date=route_date, distance_meters=max(0.0, _number(entry.get("distanceMeters", 0.0))), duration=max(0, int(_number(entry.get("duration", 0)))), @@ -154,7 +217,9 @@ def _load_routes(galaxy_stats: dict[str, Any]) -> list[_RouteStat]: attention_known=attention_known, clean=bool(entry.get("clean", unresponsive == 0)), undistracted=bool(entry.get("undistracted", distracted == 0)), + time_source=str(entry.get("timeSource", "") or ""), )) + _repair_filesystem_route_dates(routes) return sorted(routes, key=lambda route: route.date) @@ -499,44 +564,45 @@ class DriveStatsDashboard: cx = rect.x + rect.width / 2 cy = rect.y + rect.height / 2 color = PURPLE - thickness = 2.5 + scale = min(rect.width, rect.height) / 64.0 + thickness = 2.5 * scale def line(x1: float, y1: float, x2: float, y2: float) -> None: rl.draw_line_ex(rl.Vector2(x1, y1), rl.Vector2(x2, y2), thickness, color) if index == 0: - line(cx - 13, cy, cx + 12, cy) - line(cx + 12, cy, cx + 5, cy - 7) - line(cx + 12, cy, cx + 5, cy + 7) + line(cx - 13 * scale, cy, cx + 12 * scale, cy) + line(cx + 12 * scale, cy, cx + 5 * scale, cy - 7 * scale) + line(cx + 12 * scale, cy, cx + 5 * scale, cy + 7 * scale) elif index == 1: - rl.draw_circle_lines(int(cx), int(cy), 13, color) - rl.draw_circle_lines(int(cx), int(cy), 12, color) - line(cx - 7, cy, cx - 2, cy + 5) - line(cx - 2, cy + 5, cx + 8, cy - 7) + rl.draw_circle_lines(int(cx), int(cy), 13 * scale, color) + rl.draw_circle_lines(int(cx), int(cy), 12 * scale, color) + line(cx - 7 * scale, cy, cx - 2 * scale, cy + 5 * scale) + line(cx - 2 * scale, cy + 5 * scale, cx + 8 * scale, cy - 7 * scale) elif index == 2: - line(cx - 13, cy - 12, cx - 13, cy + 12) - line(cx - 13, cy + 12, cx + 13, cy + 12) - line(cx - 9, cy + 6, cx - 2, cy - 2) - line(cx - 2, cy - 2, cx + 4, cy + 3) - line(cx + 4, cy + 3, cx + 13, cy - 8) + line(cx - 13 * scale, cy - 12 * scale, cx - 13 * scale, cy + 12 * scale) + line(cx - 13 * scale, cy + 12 * scale, cx + 13 * scale, cy + 12 * scale) + line(cx - 9 * scale, cy + 6 * scale, cx - 2 * scale, cy - 2 * scale) + line(cx - 2 * scale, cy - 2 * scale, cx + 4 * scale, cy + 3 * scale) + line(cx + 4 * scale, cy + 3 * scale, cx + 13 * scale, cy - 8 * scale) elif index == 3: points = ( - (cx + 2, cy - 15), (cx - 10, cy + 2), (cx - 2, cy + 2), - (cx - 5, cy + 15), (cx + 11, cy - 5), (cx + 3, cy - 5), + (cx + 2 * scale, cy - 15 * scale), (cx - 10 * scale, cy + 2 * scale), (cx - 2 * scale, cy + 2 * scale), + (cx - 5 * scale, cy + 15 * scale), (cx + 11 * scale, cy - 5 * scale), (cx + 3 * scale, cy - 5 * scale), ) for point_index, point in enumerate(points): next_point = points[(point_index + 1) % len(points)] line(point[0], point[1], next_point[0], next_point[1]) elif index == 4: points = ( - (cx, cy - 14), (cx + 12, cy - 9), (cx + 10, cy + 3), - (cx, cy + 14), (cx - 10, cy + 3), (cx - 12, cy - 9), + (cx, cy - 14 * scale), (cx + 12 * scale, cy - 9 * scale), (cx + 10 * scale, cy + 3 * scale), + (cx, cy + 14 * scale), (cx - 10 * scale, cy + 3 * scale), (cx - 12 * scale, cy - 9 * scale), ) for point_index, point in enumerate(points): next_point = points[(point_index + 1) % len(points)] line(point[0], point[1], next_point[0], next_point[1]) - line(cx - 5, cy, cx - 1, cy + 4) - line(cx - 1, cy + 4, cx + 6, cy - 4) + line(cx - 5 * scale, cy, cx - scale, cy + 4 * scale) + line(cx - scale, cy + 4 * scale, cx + 6 * scale, cy - 4 * scale) else: def sparkle(x: float, y: float, radius: float) -> None: inner = radius * 0.22 @@ -550,9 +616,9 @@ class DriveStatsDashboard: next_point = points[(point_index + 1) % len(points)] line(point[0], point[1], next_point[0], next_point[1]) - sparkle(cx + 3, cy + 2, 10) - sparkle(cx - 9, cy - 9, 5) - sparkle(cx + 12, cy - 10, 4) + sparkle(cx + 3 * scale, cy + 2 * scale, 10 * scale) + sparkle(cx - 9 * scale, cy - 9 * scale, 5 * scale) + sparkle(cx + 12 * scale, cy - 10 * scale, 4 * scale) def _draw_fitted_centered(self, text: str, rect: rl.Rectangle, font_size: int, minimum_size: int, color: rl.Color) -> None: size = font_size @@ -685,19 +751,24 @@ class DriveStatsDashboard: if row_index > 0: rl.draw_line(int(rect.x + 24), int(row_y), int(rect.x + rect.width - 24), int(row_y), TRACK_COLOR) - icon_size = min(84.0, row_height - 44) + icon_size = min(120.0, row_height - 34) icon_rect = rl.Rectangle(rect.x + 28, row_y + (row_height - icon_size) / 2, icon_size, icon_size) self._draw_record_icon(record_index, icon_rect) - text_x = rect.x + 132 + text_x = rect.x + 170 content_center_y = row_y + row_height / 2 - title_pos = rl.Vector2(text_x, content_center_y - 56) - rl.draw_text_ex(self._font_medium, record.title, title_pos, 31, 0, MUTED_COLOR) + title_pos = rl.Vector2(text_x, content_center_y - 66) + rl.draw_text_ex(self._font_medium, record.title, title_pos, 38, 0, MUTED_COLOR) - value_pos = rl.Vector2(text_x, content_center_y - 8) - rl.draw_text_ex(self._font_bold, record.value, value_pos, 44, 0, TEXT_COLOR) + value_pos = rl.Vector2(text_x, content_center_y - 10) + rl.draw_text_ex(self._font_bold, record.value, value_pos, 56, 0, TEXT_COLOR) - detail_size = measure_text_cached(self._font_medium, record.detail, 27) - detail_x = max(text_x + 240, rect.x + rect.width - detail_size.x - 30) - detail_pos = rl.Vector2(detail_x, content_center_y - detail_size.y / 2 + 12) - rl.draw_text_ex(self._font_medium, record.detail, detail_pos, 27, 0, MUTED_COLOR) + detail_font_size = 34 + detail_min_x = text_x + 250 + detail_width = rect.x + rect.width - detail_min_x - 30 + while detail_font_size > 29 and measure_text_cached(self._font_medium, record.detail, detail_font_size).x > detail_width: + detail_font_size -= 1 + detail_size = measure_text_cached(self._font_medium, record.detail, detail_font_size) + detail_x = rect.x + rect.width - detail_size.x - 30 + detail_pos = rl.Vector2(detail_x, content_center_y + 52) + rl.draw_text_ex(self._font_medium, record.detail, detail_pos, detail_font_size, 0, MUTED_COLOR) diff --git a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py index bd1e8c274..caf44672b 100644 --- a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py +++ b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py @@ -1,5 +1,6 @@ import importlib.util import json +import os import sys from pathlib import Path @@ -457,6 +458,27 @@ def test_route_listing_prefers_segment_candidates_with_logs(tmp_path): assert routes[0]["segments"][0]["path"] == standard_segment +def test_route_listing_uses_all_segment_times_when_segment_zero_was_touched(tmp_path): + route_start = utilities.datetime(2026, 7, 18, 7, 19, 0) + route_name = "000011e3--6e01289631" + for segment_num in range(2): + segment = tmp_path / f"{route_name}--{segment_num}" + segment.mkdir() + segment_end = route_start.timestamp() + (segment_num + 1) * 60 + os.utime(segment, (segment_end, segment_end)) + + touched_segment = tmp_path / f"{route_name}--0" + touched_time = utilities.datetime(2026, 7, 20, 12, 8, 38).timestamp() + os.utime(touched_segment, (touched_time, touched_time)) + + routes = utilities._list_dashboard_routes([tmp_path]) + + assert routes[0]["startedAt"] == route_start + start, end = utilities._route_time_range(routes[0], 180) + assert start == "2026-07-18T07:19:00" + assert end == "2026-07-18T07:22:00" + + def test_top_models_are_ranked_from_persisted_usage_not_favorites(): params = FakeParams({ "AvailableModels": "orion,vega,atlas,nova", @@ -1337,6 +1359,52 @@ def test_invalid_filesystem_time_requests_reanalysis(): assert utilities._analysis_candidates([route], stats) == [route] +def test_corrected_filesystem_time_replaces_touched_persisted_time_without_losing_stats(): + params = FakeParams({ + utilities.DASHBOARD_PERSISTENT_STATS_PARAM: { + "routes": { + "000011e5--92dc4759b2": { + "date": "2026-07-20T11:33:49", + "endDate": "2026-07-20T12:08:38", + "distanceMeters": 45919.2, + "duration": 2089, + "engagedSeconds": 1200.0, + "model": "Pop Model V2", + "modifiedAt": 100.0, + "timeSource": utilities.DASHBOARD_TIME_SOURCE_FILESYSTEM, + "attentionKnown": True, + "analysisComplete": True, + "analysisVersion": utilities.DASHBOARD_ROUTE_ANALYSIS_VERSION, + }, + }, + }, + }) + corrected_shell = { + "name": "000011e5--92dc4759b2", + "date": "2026-07-19T04:55:02", + "endDate": "2026-07-19T05:30:02", + "distanceMeters": 0.0, + "duration": 2100, + "engagedSeconds": 0.0, + "model": "Unknown model", + "segmentCount": 35, + "routeModifiedAt": 100.0, + "timeSource": utilities.DASHBOARD_TIME_SOURCE_FILESYSTEM, + "attentionKnown": False, + "analysisComplete": False, + "analysisVersion": 0, + } + + stats = utilities._update_dashboard_persistent_stats(params, [corrected_shell], wall_now=1000.0) + corrected = stats["routes"]["000011e5--92dc4759b2"] + + assert corrected["date"] == "2026-07-19T04:55:02" + assert corrected["endDate"] == "2026-07-19T05:29:51" + assert corrected["distanceMeters"] == 45919.2 + assert corrected["duration"] == 2089 + assert corrected["model"] == "Pop Model V2" + + def test_github_urls_accept_owner_repo_origin(): assert utilities.get_github_changelog_url("owner/repo", "main") == "https://github.com/owner/repo/commits/main/" assert utilities.get_github_changelog_url("github.com/owner/repo", "main") == "https://github.com/owner/repo/commits/main/" diff --git a/starpilot/system/the_galaxy/utilities.py b/starpilot/system/the_galaxy/utilities.py index 9dd92d0ec..6cb040e3a 100644 --- a/starpilot/system/the_galaxy/utilities.py +++ b/starpilot/system/the_galaxy/utilities.py @@ -87,6 +87,7 @@ DASHBOARD_TIME_SOURCE_FILESYSTEM = "filesystem" DASHBOARD_MIN_VALID_ROUTE_TIME = datetime(2026, 1, 1) DASHBOARD_ROUTE_FUTURE_GRACE_SECONDS = 6 * 60 * 60 DASHBOARD_LOCAL_ROUTE_MAX_AGE_SECONDS = 45 * 24 * 60 * 60 +DASHBOARD_ROUTE_TIME_REPAIR_THRESHOLD_SECONDS = 5 * 60 XOR_KEY = "s8#pL3*Xj!aZ@dWq" @@ -992,6 +993,19 @@ def _select_dashboard_segment_candidate(candidates): return next((candidate for candidate in candidates if _segment_has_dashboard_log(candidate)), candidates[0]) +def _estimate_route_started_at(segments): + estimates = [] + for segment in segments: + segment_num = max(0, _safe_int(segment.get("num", 0), 0)) + # Segment directory mtimes normally land at the end of their one-minute segment. + estimate = _segment_mtime(segment.get("path")) - (segment_num + 1) * 60 + parsed = _timestamp_to_dashboard_time(estimate, require_recent=True) + if parsed is not None: + estimates.append(parsed.timestamp()) + # Dashboard analysis can touch a segment directory later, but cannot make it older. + return datetime.fromtimestamp(min(estimates)) if estimates else None + + def _list_dashboard_routes(footage_paths, limit=DASHBOARD_ROUTE_SCAN_LIMIT): routes = {} @@ -1034,11 +1048,7 @@ def _list_dashboard_routes(footage_paths, limit=DASHBOARD_ROUTE_SCAN_LIMIT): if not segments: continue - first_segment = next((segment for segment in segments if segment["num"] == 0), segments[0]) - try: - started_at = _timestamp_to_dashboard_time(first_segment["path"].stat().st_mtime, require_recent=True) - except OSError: - started_at = None + started_at = _estimate_route_started_at(segments) route_infos.append({ "name": route["name"], @@ -1418,14 +1428,16 @@ def _public_drive(drive, is_metric): def _route_time_range(route_info, duration_seconds): modified_at = _safe_float(route_info.get("modifiedAt", 0.0), 0.0) duration_seconds = max(0.0, _safe_float(duration_seconds, 0.0)) + started_at = route_info.get("startedAt") + if _dashboard_time_is_valid(started_at, require_recent=True): + end_time = started_at + timedelta(seconds=duration_seconds) if duration_seconds > 0.0 else None + return _jsonable_time(started_at), _jsonable_time(end_time) + modified_time = _timestamp_to_dashboard_time(modified_at, require_recent=True) if modified_time is not None and duration_seconds > 0.0: end_time = modified_time start_time = end_time - timedelta(seconds=duration_seconds) return _jsonable_time(start_time), _jsonable_time(end_time) - started_at = route_info.get("startedAt") - if _dashboard_time_is_valid(started_at, require_recent=True): - return _jsonable_time(started_at), _jsonable_time(modified_time) if modified_time is not None else "" return "", "" @@ -2414,6 +2426,19 @@ def _drive_time_reliable_for_persistence(drive): return bool(date_text) +def _filesystem_route_time_changed(existing_entry, next_entry): + if ( + str(existing_entry.get("timeSource", "") or "") != DASHBOARD_TIME_SOURCE_FILESYSTEM + or str(next_entry.get("timeSource", "") or "") != DASHBOARD_TIME_SOURCE_FILESYSTEM + ): + return False + existing_date = _coerce_dashboard_time(existing_entry.get("date", "")) + next_date = _coerce_dashboard_time(next_entry.get("date", "")) + if existing_date is None or next_date is None: + return False + return abs((next_date - existing_date).total_seconds()) > DASHBOARD_ROUTE_TIME_REPAIR_THRESHOLD_SECONDS + + def _update_dashboard_persistent_stats(params_obj, drives, wall_now): stats = _load_dashboard_persistent_stats(params_obj) before = json.dumps(stats, sort_keys=True, separators=(",", ":")) @@ -2453,6 +2478,7 @@ def _update_dashboard_persistent_stats(params_obj, drives, wall_now): next_entry["analysisVersion"] = DASHBOARD_ROUTE_ANALYSIS_VERSION existing_entry = routes.get(route_name) if isinstance(existing_entry, dict): + replace_filesystem_time = _filesystem_route_time_changed(existing_entry, next_entry) existing_distance = max(0.0, _safe_float(existing_entry.get("distanceMeters", 0.0), 0.0)) next_distance = max(0.0, _safe_float(next_entry.get("distanceMeters", 0.0), 0.0)) existing_current = _safe_float(existing_entry.get("modifiedAt", 0.0), 0.0) >= _safe_float(next_entry.get("modifiedAt", 0.0), 0.0) @@ -2471,11 +2497,14 @@ def _update_dashboard_persistent_stats(params_obj, drives, wall_now): next_entry["distanceMeters"] = existing_distance existing_duration = _safe_int(existing_entry.get("duration", 0), 0) next_entry["duration"] = existing_duration if existing_attention_known else max(existing_duration, next_entry["duration"]) - if existing_attention_known and str(existing_entry.get("date", "")).strip(): + if existing_attention_known and str(existing_entry.get("date", "")).strip() and not replace_filesystem_time: next_entry["date"] = str(existing_entry.get("date", "")).strip() next_entry["timeSource"] = str(existing_entry.get("timeSource", "") or next_entry["timeSource"]) - if str(existing_entry.get("endDate", "")).strip(): + if str(existing_entry.get("endDate", "")).strip() and not replace_filesystem_time: next_entry["endDate"] = str(existing_entry.get("endDate", "")).strip() + elif replace_filesystem_time: + corrected_start = _coerce_dashboard_time(next_entry.get("date", "")) + next_entry["endDate"] = _jsonable_time(corrected_start + timedelta(seconds=next_entry["duration"])) if corrected_start else "" next_entry["engagedSeconds"] = max(0.0, _safe_float(existing_entry.get("engagedSeconds", 0.0), 0.0)) next_entry["distractedMoments"] = max(0, _safe_int(existing_entry.get("distractedMoments", 0), 0)) next_entry["unresponsiveMoments"] = max(0, _safe_int(existing_entry.get("unresponsiveMoments", 0), 0))