diff --git a/openpilot/starpilot/galaxy/drive_stats.py b/openpilot/starpilot/galaxy/drive_stats.py index b091514415..214cc48c60 100644 --- a/openpilot/starpilot/galaxy/drive_stats.py +++ b/openpilot/starpilot/galaxy/drive_stats.py @@ -496,6 +496,8 @@ class DriveStatsOwner: 'aolPercent': round(100 * seconds / duration, 1) if duration else None, 'aolDurationSeconds': duration, 'aolDrives': len(observed)} totals = sums(complete) + past_week = sums([r for r in complete if day - timedelta(days=6) <= + datetime.fromtimestamp(r['startTime'], zone).date() <= day]) totals.update(aol_sums(complete)) week_totals = sums(week) week_result = {'days': [{'date': date, 'distanceMeters': daily[date]} for date in dates], @@ -532,7 +534,7 @@ class DriveStatsOwner: self.clock() - f['firstAt'] >= MAX_RETRY_AGE_SECONDS} for key, f in self._failures.items()], 'scanIncomplete': self._scan_incomplete}, - 'totals': totals, 'week': week_result, 'lastDrive': public(included_recent[0]) if included_recent else None, + 'totals': totals, 'pastWeek': past_week, 'week': week_result, 'lastDrive': public(included_recent[0]) if included_recent else None, 'recentDrives': [public(r) for r in recent[:RECENT_LIMIT]], 'records': records, 'models': models, 'coverage': {'scope': 'analyzedLocalRoutes', 'timezone': timezone, 'partialHistory': True, 'scanIncomplete': self._scan_incomplete, 'scanned': self._scanned, diff --git a/openpilot/starpilot/galaxy/tests/test_drive_stats.py b/openpilot/starpilot/galaxy/tests/test_drive_stats.py index 82a146e376..07e9b35d48 100644 --- a/openpilot/starpilot/galaxy/tests/test_drive_stats.py +++ b/openpilot/starpilot/galaxy/tests/test_drive_stats.py @@ -42,6 +42,22 @@ def analyzed(root, selected, permitted): class DriveStatsTest(unittest.TestCase): + def test_past_week_is_seven_calendar_dates_and_excludes_old_future_ignored_and_incomplete(self): + self.now[0] = datetime(2026, 11, 2, 12, tzinfo=UTC).timestamp() + owner = self.owner(permitted=lambda: False) + for identifier, offset in (('today', 0), ('boundary', 6), ('old', 7), ('future', -1)): + value = analyzed(self.root, route(identifier), lambda: True) + value.update(startTime=self.now[0] - offset * 86400, endTime=self.now[0] - offset * 86400 + 60, + ignored=False, sourceSignature='fixture') + owner._records[identifier] = value + owner._records['ignored'] = dict(owner._records['today'], routeId='ignored', ignored=True) + owner._records['incomplete'] = dict(owner._records['today'], routeId='incomplete', complete=False) + snapshot = owner.snapshot() + self.assertEqual(snapshot['pastWeek']['drives'], 2) + self.assertEqual(snapshot['pastWeek']['distanceMeters'], 2000.) + self.assertEqual(snapshot['pastWeek']['durationSeconds'], 120.) + self.assertEqual(snapshot['totals']['drives'], 4) + def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) diff --git a/openpilot/starpilot/ui/home_stats_source.py b/openpilot/starpilot/ui/home_stats_source.py new file mode 100644 index 0000000000..7263d67860 --- /dev/null +++ b/openpilot/starpilot/ui/home_stats_source.py @@ -0,0 +1,96 @@ +"""Cached local driving history for the large Home dashboard.""" + +from collections.abc import Callable +from concurrent.futures import Future, ThreadPoolExecutor +from datetime import UTC, datetime +import os +from pathlib import Path +import time +from zoneinfo import ZoneInfo + +from openpilot.common.hardware import PC +from openpilot.common.realtime import drop_realtime, set_core_affinity +from openpilot.starpilot.ui.home_state import DailyDistance, DriveStatsData, DriveSummary, PersonalRecord + + +def read_home_stats(metric: bool, *, store: Path | None = None, clock: Callable[[], float] = lambda: datetime.now(UTC).timestamp(), + timezone: str = 'UTC') -> DriveStatsData | None: + from openpilot.starpilot.galaxy.drive_stats import DriveStatsOwner + from openpilot.starpilot.storage import starpilot_storage_root + + path = store if store is not None else starpilot_storage_root() / 'galaxy/drive-stats.json' + if not path.is_file(): + return None + owner = DriveStatsOwner(store=path, permitted=lambda: False, clock=clock, metric=lambda: metric) + try: + data = owner.snapshot(timezone) + finally: + owner.close() + if data['coverage']['cacheCorrupt']: + return None + scale, unit = (1000., 'kilometers') if metric else (1609.344, 'miles') + def summary(row): + return DriveSummary(row['drives'], row['distanceMeters'] / scale, row['durationSeconds'] / 3600., unit) + today = datetime.fromtimestamp(clock(), ZoneInfo(timezone)).date() + days = tuple(DailyDistance(datetime.fromisoformat(row['date']).strftime('%a'), row['distanceMeters'] / scale, + row['date'] == today.isoformat(), row['date'] > today.isoformat()) for row in data['week']['days']) + records = [] + for row in data['records']: + value, detail = row['value'], row['unit'] + if value is None: + text = '—' + elif detail == 'm': + text, detail = f'{value / scale:,.1f}', 'km' if metric else 'miles' + elif detail == 's': + text, detail = f'{value / 3600.:,.1f}', 'hours' + elif detail == '%': + text = f'{value:g}' + else: + text = f'{value:,}' + records.append(PersonalRecord(row['label'], text, detail)) + return DriveStatsData(summary(data['totals']), summary(data['pastWeek']), summary(data['week']), days, tuple(records)) + + +def _background_priority() -> None: + if not PC: + drop_realtime() + set_core_affinity([0, 1, 2, 3]) + os.setpriority(os.PRIO_PROCESS, 0, 19) + + +class HomeStatsSource: + REFRESH_SECONDS = 30. + + def __init__(self, request: Callable[[bool], DriveStatsData | None] = read_home_stats, + clock: Callable[[], float] = time.monotonic): + self.request, self.clock = request, clock + self.executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix='home-history', initializer=_background_priority) + self.pending: Future | None = None + self.pending_metric = None + self.data: DriveStatsData | None = None + self.data_metric = None + self.next_read = 0. + self.closed = False + + def snapshot(self, metric: bool) -> DriveStatsData | None: + if self.closed: + return None + now = self.clock() + if self.pending is not None and self.pending.done(): + try: + self.data = self.pending.result() + except (OSError, RuntimeError, ValueError, KeyError, TypeError): + self.data = None + self.data_metric = self.pending_metric + self.pending = None + if now < self.next_read - self.REFRESH_SECONDS: + self.next_read = 0. + if self.pending is None and (now >= self.next_read or metric != self.pending_metric): + self.pending_metric = metric + self.pending = self.executor.submit(self.request, metric) + self.next_read = now + self.REFRESH_SECONDS + return self.data if self.data_metric == metric else None + + def close(self) -> None: + self.closed = True + self.executor.shutdown(wait=False, cancel_futures=True) diff --git a/openpilot/starpilot/ui/runtime_app.py b/openpilot/starpilot/ui/runtime_app.py index d9f35462ea..f77647e051 100644 --- a/openpilot/starpilot/ui/runtime_app.py +++ b/openpilot/starpilot/ui/runtime_app.py @@ -195,10 +195,13 @@ class StarShellSession: except Exception: self.fonts.close() raise + from openpilot.starpilot.ui.home_stats_source import HomeStatsSource + self.home_stats_source = HomeStatsSource() if profile == Profile.LARGE else None self.galaxy_access = _galaxy_access_owner() self.bluetooth_source = BluetoothStatusSource() if profile == Profile.COMPACT else None self.adapter = RuntimeSnapshotAdapter(ui_state, self.galaxy_access, - self.bluetooth_source.snapshot if self.bluetooth_source is not None else None) + self.bluetooth_source.snapshot if self.bluetooth_source is not None else None, + home_stats=self.home_stats_source.snapshot if self.home_stats_source is not None else None) self.galaxy_flow = GalaxyAccessFlow(self.galaxy_access, self.confirmed_offroad) self.feature_owner = FeatureSettingsOwner(ui_state.params, self._feature_authority, vehicle_fingerprint=lambda: getattr(ui_state.CP, "carFingerprint", None), @@ -1736,6 +1739,9 @@ class StarShellSession: self._aol_save_closed = True if pending := getattr(self, "_aol_save", None): pending.cancel.set() + if source := getattr(self, "home_stats_source", None): + source.close() + self.home_stats_source = None self.drive_state.physical.close() self.slc_actions = None self.galaxy_flow.close() diff --git a/openpilot/starpilot/ui/runtime_snapshot.py b/openpilot/starpilot/ui/runtime_snapshot.py index 6c3beb72fe..14489c24c6 100644 --- a/openpilot/starpilot/ui/runtime_snapshot.py +++ b/openpilot/starpilot/ui/runtime_snapshot.py @@ -40,7 +40,7 @@ from openpilot.cereal.services import SERVICE_LIST from openpilot.tools.replay.display_clock import BOOT_TIME_SERVICES from opendbc.car.structs import car as car_schema from openpilot.starpilot.ui.device_state import DeviceRequest, DeviceState -from openpilot.starpilot.ui.home_state import HomeMode, HomeState +from openpilot.starpilot.ui.home_state import DriveStatsData, HomeMode, HomeState from openpilot.starpilot.models.jetlink_status import JetlinkStatusFeed from openpilot.starpilot.ui.onroad_state import conditional_lead_from_message from openpilot.starpilot.ui.onroad_state import (AlertSize, BorderSignals, ObservationKind, OnroadAlert, @@ -302,11 +302,13 @@ class RuntimeSnapshotAdapter: def __init__(self, ui_state: Any, galaxy_access: GalaxyAccessOwner | None = None, bluetooth_powered: Callable[[], str | bool] | None = None, - *, mono_clock: Callable[[], int] = time.monotonic_ns, + *, home_stats: Callable[[bool], DriveStatsData | None] | None = None, + mono_clock: Callable[[], int] = time.monotonic_ns, boot_clock: Callable[[], int] = _boot_time_ns): self.ui_state = ui_state self.galaxy_access = galaxy_access self.bluetooth_powered = bluetooth_powered + self.home_stats = home_stats self._customization_value = default_document() self._appearance_value = OnroadAppearance() self._appearance_read_ns: int | None = None @@ -764,7 +766,8 @@ class RuntimeSnapshotAdapter: HomeMode.CONDITIONAL_CHILL if self._conditional_choice is ModeChoice.CCM else HomeMode.EXPERIMENTAL if experimental else HomeMode.CHILL), experimental_enabled=experimental, experimental_available=bool(ui.has_longitudinal_control), - stats=None, paired=bool(ui.prime_state.is_paired()), network=network, network_strength=strength, + stats=self.home_stats(bool(ui.is_metric)) if mode == ShellMode.HOME and self.home_stats is not None else None, + paired=bool(ui.prime_state.is_paired()), network=network, network_strength=strength, temperature_c=device.maxTempC if device is not None else None, metric=bool(ui.is_metric), temperature_high=device is not None and device.thermalStatus != log.DeviceState.ThermalStatus.ok, vehicle_online=panda_connected, connection=connection, diff --git a/openpilot/starpilot/ui/tests/test_home_stats_source.py b/openpilot/starpilot/ui/tests/test_home_stats_source.py new file mode 100644 index 0000000000..85cee56745 --- /dev/null +++ b/openpilot/starpilot/ui/tests/test_home_stats_source.py @@ -0,0 +1,111 @@ +"""Home displays the validated Galaxy cache without running route analysis.""" + +from concurrent.futures import Future +from datetime import UTC, datetime +import json +from pathlib import Path +from tempfile import TemporaryDirectory +import threading +import unittest +from unittest.mock import patch + +from openpilot.starpilot.ui.home_stats_source import HomeStatsSource, read_home_stats + +NOW = datetime(2026, 11, 2, 12, tzinfo=UTC).timestamp() + + +def record(identifier, start): + return {'routeId': identifier, 'startTime': start, 'endTime': start + 3600, 'distanceMeters': 1609.344, + 'durationSeconds': 3600., 'engagedSeconds': 2700., 'complete': True, 'ignored': False, + 'segmentCount': 1, 'sourceSignature': 'fixture'} + + +class TestHomeStatsSource(unittest.TestCase): + def test_validated_cache_populates_both_units_periods_records_and_days_without_analysis(self): + with TemporaryDirectory() as temp: + path = Path(temp) / 'drive-stats.json' + rows = [record('today', NOW - 3600), record('lastweek', NOW - 5 * 86400), record('older', NOW - 8 * 86400), + {**record('ignored', NOW - 7200), 'ignored': True}, + {**record('incomplete', NOW - 7200), 'complete': False}] + path.write_text(json.dumps({'schemaVersion': 1, 'scanned': True, 'routes': {row['routeId']: row for row in rows}})) + original = path.read_bytes() + with patch('openpilot.starpilot.galaxy.drive_stats.DriveHistory.snapshot', side_effect=AssertionError('No log scans')): + imperial = read_home_stats(False, store=path, clock=lambda: NOW) + metric = read_home_stats(True, store=path, clock=lambda: NOW) + self.assertEqual((imperial.all_time.drives, imperial.all_time.distance, imperial.all_time.hours), (3, 3., 3.)) + self.assertEqual((imperial.past_week.drives, imperial.this_week.drives), (2, 1)) + self.assertAlmostEqual(metric.all_time.distance, 4.828032) + self.assertEqual(metric.all_time.unit, 'kilometers') + self.assertEqual(imperial.all_time.unit, 'miles') + self.assertEqual(len(imperial.daily_distance), 7) + self.assertTrue(imperial.daily_distance[0].is_today) + self.assertEqual(imperial.daily_distance[0].distance, 1.) + self.assertTrue(imperial.daily_distance[1].is_future) + self.assertEqual(imperial.records[0].value, '1.0') + self.assertEqual(imperial.records[0].detail, 'miles') + self.assertEqual(path.read_bytes(), original) + + def test_missing_corrupt_oversized_and_invalid_records_are_unavailable(self): + from openpilot.starpilot.galaxy.drive_stats import MAX_CACHE_BYTES + with TemporaryDirectory() as temp: + path = Path(temp) / 'drive-stats.json' + self.assertIsNone(read_home_stats(False, store=path)) + for raw in ('bad JSON', 'x' * (MAX_CACHE_BYTES + 1), json.dumps({'schemaVersion': 1, 'routes': {'bad': {}}})): + path.write_text(raw) + self.assertIsNone(read_home_stats(False, store=path)) + + def test_future_read_never_blocks_render_and_changes_units_without_mixing_results(self): + pool = unittest.mock.Mock() + first, second = Future(), Future() + pool.submit.side_effect = [first, second] + with patch('openpilot.starpilot.ui.home_stats_source.ThreadPoolExecutor', return_value=pool): + source = HomeStatsSource(clock=lambda: 10.) + self.addCleanup(source.close) + self.assertIsNone(source.snapshot(False)) + for _ in range(100): + self.assertIsNone(source.snapshot(False)) + self.assertEqual(pool.submit.call_count, 1) + first.set_result('imperial') + self.assertEqual(source.snapshot(False), 'imperial') + self.assertIsNone(source.snapshot(True)) + second.set_result('metric') + self.assertEqual(source.snapshot(True), 'metric') + source.close() + self.assertIsNone(source.snapshot(False)) + pool.shutdown.assert_called_with(wait=False, cancel_futures=True) + + def test_failed_read_retries_and_recovers_instead_of_sticking_unavailable(self): + now = [10.] + pool = unittest.mock.Mock() + failed, recovered = Future(), Future() + failed.set_exception(OSError('cache temporarily unavailable')) + recovered.set_result('recovered') + pool.submit.side_effect = [failed, recovered] + with patch('openpilot.starpilot.ui.home_stats_source.ThreadPoolExecutor', return_value=pool): + source = HomeStatsSource(clock=lambda: now[0]) + self.addCleanup(source.close) + self.assertIsNone(source.snapshot(False)) + self.assertIsNone(source.snapshot(False)) + self.assertEqual(pool.submit.call_count, 1) + now[0] += source.REFRESH_SECONDS + self.assertIsNone(source.snapshot(False)) + self.assertEqual(source.snapshot(False), 'recovered') + self.assertEqual(pool.submit.call_count, 2) + + def test_actual_read_runs_on_background_thread(self): + entered, release = threading.Event(), threading.Event() + threads = [] + def read(metric): + threads.append(threading.get_ident()) + entered.set() + release.wait(2.) + return None + source = HomeStatsSource(request=read) + try: + self.assertIsNone(source.snapshot(False)) + self.assertTrue(entered.wait(2.)) + self.assertNotEqual(threads, [threading.get_ident()]) + self.assertIsNone(source.snapshot(False)) + finally: + release.set() + source.close() diff --git a/openpilot/starpilot/ui/tests/test_runtime_snapshot.py b/openpilot/starpilot/ui/tests/test_runtime_snapshot.py index 1128dbedaa..b19727ae64 100644 --- a/openpilot/starpilot/ui/tests/test_runtime_snapshot.py +++ b/openpilot/starpilot/ui/tests/test_runtime_snapshot.py @@ -102,6 +102,23 @@ def ui_fake(): class TestRuntimeSnapshot(unittest.TestCase): + def test_home_uses_shared_statistics_source_without_onroad_polling(self): + from openpilot.starpilot.ui.home_state import DriveStatsData, DriveSummary + data = DriveStatsData(DriveSummary(3, 10., 1.), DriveSummary(), DriveSummary(), (), ()) + source = Mock(return_value=data) + ui = ui_fake() + adapter = RuntimeSnapshotAdapter(ui, home_stats=source) + self.assertIs(adapter.build(ShellMode.HOME, now_ns=NOW).home.stats, data) + source.assert_called_once_with(False) + source.reset_mock() + adapter.build(ShellMode.ONROAD, now_ns=NOW + 1) + adapter.build(ShellMode.SETTINGS, now_ns=NOW + 2) + source.assert_not_called() + ui.is_metric = True + adapter.build(ShellMode.HOME, now_ns=NOW + 3) + source.assert_called_once_with(True) + + def test_onroad_saved_display_flags_share_the_settings_refresh(self): ui = ui_fake() ui.params.values.update(NavigationEnabled="1", ShowCSCStatus="1")