mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-04 19:43:49 +08:00
Connect large Home dashboard to cached drive history
This commit is contained in:
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user