Connect large Home dashboard to cached drive history

This commit is contained in:
firestar5683
2026-11-02 09:40:11 -06:00
parent 91c1fee59a
commit f1e5ee7312
7 changed files with 256 additions and 5 deletions
+3 -1
View File
@@ -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,
@@ -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)
@@ -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)
+7 -1
View File
@@ -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()
+6 -3
View File
@@ -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,
@@ -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()
@@ -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")