From 9910651a9dda5d09b0659d51c6254985ae7cce32 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:38:56 -0500 Subject: [PATCH] Carl The Witch --- selfdrive/selfdrived/alert_sound.py | 19 +++ selfdrive/selfdrived/selfdrived.py | 9 +- .../selfdrived/tests/test_alert_sound.py | 52 +++++++ selfdrive/ui/mici/onroad/alert_renderer.py | 20 ++- selfdrive/ui/onroad/alert_renderer.py | 20 ++- selfdrive/ui/qt/onroad/alerts.cc | 3 +- selfdrive/ui/qt/onroad/alerts.h | 3 +- selfdrive/ui/qt/onroad/onroad_home.cc | 1 + .../assets/components/home/home.css | 46 +++++- .../the_galaxy/assets/components/home/home.js | 62 ++++++++- .../the_galaxy/tests/test_dashboard_stats.py | 131 +++++++++++++++++- starpilot/system/the_galaxy/the_galaxy.py | 42 ++++++ starpilot/system/the_galaxy/utilities.py | 105 ++++++++++++-- 13 files changed, 478 insertions(+), 35 deletions(-) create mode 100644 selfdrive/selfdrived/alert_sound.py create mode 100644 selfdrive/selfdrived/tests/test_alert_sound.py diff --git a/selfdrive/selfdrived/alert_sound.py b/selfdrive/selfdrived/alert_sound.py new file mode 100644 index 000000000..4ab094a28 --- /dev/null +++ b/selfdrive/selfdrived/alert_sound.py @@ -0,0 +1,19 @@ +from cereal import car + + +AudibleAlert = car.CarControl.HUDControl.AudibleAlert + + +def filter_forcing_stop_alert_sound(alert_type, audible_alert, forcing_stop, chime_played): + is_forcing_stop_alert = str(alert_type or "").startswith("forcingStop/") + + if not forcing_stop: + # Silence the alert manager's short holdover so the next force-stop cycle + # starts from a clean sound transition. + return (AudibleAlert.none if is_forcing_stop_alert else audible_alert), False + + if not is_forcing_stop_alert or audible_alert == AudibleAlert.none: + return audible_alert, chime_played + if chime_played: + return AudibleAlert.none, True + return audible_alert, True diff --git a/selfdrive/selfdrived/selfdrived.py b/selfdrive/selfdrived/selfdrived.py index 240896a24..e9db78ce4 100644 --- a/selfdrive/selfdrived/selfdrived.py +++ b/selfdrive/selfdrived/selfdrived.py @@ -25,6 +25,7 @@ from openpilot.selfdrive.selfdrived.events import Events, ET from openpilot.selfdrive.selfdrived.helpers import ExcessiveActuationCheck from openpilot.selfdrive.selfdrived.state import StateMachine from openpilot.selfdrive.selfdrived.alertmanager import AlertManager, set_offroad_alert +from openpilot.selfdrive.selfdrived.alert_sound import filter_forcing_stop_alert_sound from openpilot.system.version import get_build_metadata from openpilot.system.hardware import HARDWARE @@ -210,6 +211,7 @@ class SelfdriveD: self.display_timer = 0 self.last_below_steer_speed_alert_time = -float("inf") self.last_steer_saturated_alert_time = -float("inf") + self.forcing_stop_chime_played = False self.starpilot_events_prev = [] @@ -753,7 +755,12 @@ class SelfdriveD: fpss.alertSize = self.starpilot_AM.current_alert.alert_size fpss.alertStatus = self.starpilot_AM.current_alert.alert_status fpss.alertType = self.starpilot_AM.current_alert.alert_type - fpss.alertSound = self.starpilot_AM.current_alert.audible_alert + fpss.alertSound, self.forcing_stop_chime_played = filter_forcing_stop_alert_sound( + fpss.alertType, + self.starpilot_AM.current_alert.audible_alert, + bool(self.sm["starpilotPlan"].forcingStop), + self.forcing_stop_chime_played, + ) self.pm.send('starpilotSelfdriveState', fpss_msg) diff --git a/selfdrive/selfdrived/tests/test_alert_sound.py b/selfdrive/selfdrived/tests/test_alert_sound.py new file mode 100644 index 000000000..89da781b2 --- /dev/null +++ b/selfdrive/selfdrived/tests/test_alert_sound.py @@ -0,0 +1,52 @@ +from cereal import car + +from openpilot.selfdrive.selfdrived.alert_sound import filter_forcing_stop_alert_sound + + +AudibleAlert = car.CarControl.HUDControl.AudibleAlert + + +def test_forcing_stop_chimes_once_during_standstill_flicker(): + chime_played = False + sounds = [] + + for sound in (AudibleAlert.none, AudibleAlert.prompt, AudibleAlert.none, AudibleAlert.prompt): + filtered_sound, chime_played = filter_forcing_stop_alert_sound( + "forcingStop/warning", + sound, + forcing_stop=True, + chime_played=chime_played, + ) + sounds.append(filtered_sound) + + assert sounds == [AudibleAlert.none, AudibleAlert.prompt, AudibleAlert.none, AudibleAlert.none] + assert chime_played is True + + +def test_forcing_stop_chime_resets_after_cycle_ends(): + sound, chime_played = filter_forcing_stop_alert_sound( + "forcingStop/warning", AudibleAlert.prompt, forcing_stop=True, chime_played=False, + ) + assert sound == AudibleAlert.prompt + assert chime_played is True + + sound, chime_played = filter_forcing_stop_alert_sound( + "forcingStop/warning", AudibleAlert.prompt, forcing_stop=False, chime_played=chime_played, + ) + assert sound == AudibleAlert.none + assert chime_played is False + + sound, chime_played = filter_forcing_stop_alert_sound( + "forcingStop/warning", AudibleAlert.prompt, forcing_stop=True, chime_played=chime_played, + ) + assert sound == AudibleAlert.prompt + assert chime_played is True + + +def test_other_alert_sounds_are_not_suppressed(): + sound, chime_played = filter_forcing_stop_alert_sound( + "greenLight/permanent", AudibleAlert.prompt, forcing_stop=True, chime_played=True, + ) + + assert sound == AudibleAlert.prompt + assert chime_played is True diff --git a/selfdrive/ui/mici/onroad/alert_renderer.py b/selfdrive/ui/mici/onroad/alert_renderer.py index 7b006aaae..aab2b6666 100644 --- a/selfdrive/ui/mici/onroad/alert_renderer.py +++ b/selfdrive/ui/mici/onroad/alert_renderer.py @@ -5,7 +5,7 @@ import pyray as rl import random import string from dataclasses import dataclass -from cereal import messaging, log, car +from cereal import messaging, log, car, custom from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter from openpilot.system.hardware import TICI @@ -15,6 +15,7 @@ from openpilot.system.ui.widgets.label import UnifiedLabel AlertSize = log.SelfdriveState.AlertSize AlertStatus = log.SelfdriveState.AlertStatus +StarPilotAlertStatus = custom.StarPilotSelfdriveState.AlertStatus ALERT_MARGIN = 18 @@ -29,6 +30,7 @@ ALERT_COLORS = { AlertStatus.normal: rl.Color(0, 0, 0, 255), AlertStatus.userPrompt: rl.Color(255, 115, 0, 255), AlertStatus.critical: rl.Color(255, 0, 21, 255), + StarPilotAlertStatus.starpilot: rl.Color(23, 134, 68, 255), } TURN_SIGNAL_BLINK_PERIOD = 1 / (80 / 60) # Mazda heartbeat turn signal BPM @@ -138,13 +140,17 @@ class AlertRenderer(Widget): return ALERT_CRITICAL_TIMEOUT return ALERT_CRITICAL_REBOOT - # No alert if size is none - if ss.alertSize == 0: - return None + if ss.alertSize != AlertSize.none: + ret = Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize.raw, status=ss.alertStatus.raw, + visual_alert=ss.alertHudVisual, alert_type=ss.alertType) + else: + starpilot_ss = sm["starpilotSelfdriveState"] + if starpilot_ss.alertSize == custom.StarPilotSelfdriveState.AlertSize.none: + return None + ret = Alert(text1=starpilot_ss.alertText1, text2=starpilot_ss.alertText2, + size=starpilot_ss.alertSize.raw, status=starpilot_ss.alertStatus.raw, + alert_type=starpilot_ss.alertType) - # Return current alert - ret = Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize.raw, status=ss.alertStatus.raw, - visual_alert=ss.alertHudVisual, alert_type=ss.alertType) self._prev_alert = ret return ret diff --git a/selfdrive/ui/onroad/alert_renderer.py b/selfdrive/ui/onroad/alert_renderer.py index 2a297f7af..564990b34 100644 --- a/selfdrive/ui/onroad/alert_renderer.py +++ b/selfdrive/ui/onroad/alert_renderer.py @@ -1,7 +1,7 @@ import time import pyray as rl from dataclasses import dataclass -from cereal import messaging, log +from cereal import custom, messaging, log from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.hardware import TICI from openpilot.system.ui.lib.application import gui_app, FontWeight @@ -12,6 +12,7 @@ from openpilot.system.ui.widgets.label import Label AlertSize = log.SelfdriveState.AlertSize AlertStatus = log.SelfdriveState.AlertStatus +StarPilotAlertStatus = custom.StarPilotSelfdriveState.AlertStatus ALERT_MARGIN = 40 ALERT_PADDING = 60 @@ -35,6 +36,7 @@ ALERT_COLORS = { AlertStatus.normal: rl.Color(0x15, 0x15, 0x15, 0xF1), # #151515 with alpha 0xF1 AlertStatus.userPrompt: rl.Color(0xDA, 0x6F, 0x25, 0xF1), # #DA6F25 with alpha 0xF1 AlertStatus.critical: rl.Color(0xC9, 0x22, 0x31, 0xF1), # #C92231 with alpha 0xF1 + StarPilotAlertStatus.starpilot: rl.Color(0x17, 0x86, 0x44, 0xF1), } @@ -103,19 +105,23 @@ class AlertRenderer(Widget): return ALERT_CRITICAL_TIMEOUT return ALERT_CRITICAL_REBOOT - # No alert if size is none - if ss.alertSize == 0: - return None + if ss.alertSize != AlertSize.none: + alert = Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize.raw, status=ss.alertStatus.raw) + else: + starpilot_ss = sm["starpilotSelfdriveState"] + if starpilot_ss.alertSize == custom.StarPilotSelfdriveState.AlertSize.none: + return None + alert = Alert(text1=starpilot_ss.alertText1, text2=starpilot_ss.alertText2, + size=starpilot_ss.alertSize.raw, status=starpilot_ss.alertStatus.raw) - if ss.alertStatus.raw == AlertStatus.normal and ui_state.starpilot_toggles.get("hide_alerts", False): + if alert.status == AlertStatus.normal and ui_state.starpilot_toggles.get("hide_alerts", False): return None # Don't get old alert if recv_frame < ui_state.started_frame: return None - # Return current alert - return Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize.raw, status=ss.alertStatus.raw) + return alert def _render(self, rect: rl.Rectangle): alert = self.get_alert(ui_state.sm) diff --git a/selfdrive/ui/qt/onroad/alerts.cc b/selfdrive/ui/qt/onroad/alerts.cc index 0bfd65528..d8244f34a 100644 --- a/selfdrive/ui/qt/onroad/alerts.cc +++ b/selfdrive/ui/qt/onroad/alerts.cc @@ -32,6 +32,7 @@ OnroadAlerts::Alert OnroadAlerts::getAlert(const SubMaster &sm, const SubMaster const uint64_t selfdrive_frame = sm.rcv_frame("selfdriveState"); const cereal::StarPilotSelfdriveState::Reader &fpss = fpsm["starpilotSelfdriveState"].getStarpilotSelfdriveState(); + const bool starpilot_alert_received = fpsm.rcv_frame("starpilotSelfdriveState") > 0; Alert a = {}; static QString crash_log_path = "/data/error_logs/error.txt"; @@ -54,7 +55,7 @@ OnroadAlerts::Alert OnroadAlerts::getAlert(const SubMaster &sm, const SubMaster a = {ss.getAlertText1().cStr(), ss.getAlertText2().cStr(), ss.getAlertType().cStr(), ss.getAlertSize(), ss.getAlertStatus()}; - if (a.size == cereal::SelfdriveState::AlertSize::NONE) { + if (a.size == cereal::SelfdriveState::AlertSize::NONE && starpilot_alert_received) { a = {fpss.getAlertText1().cStr(), fpss.getAlertText2().cStr(), fpss.getAlertType().cStr(), static_cast(fpss.getAlertSize()), static_cast(fpss.getAlertStatus())}; } diff --git a/selfdrive/ui/qt/onroad/alerts.h b/selfdrive/ui/qt/onroad/alerts.h index 26ff06a16..16a9234af 100644 --- a/selfdrive/ui/qt/onroad/alerts.h +++ b/selfdrive/ui/qt/onroad/alerts.h @@ -25,7 +25,8 @@ protected: cereal::SelfdriveState::AlertStatus status; bool equal(const Alert &other) const { - return text1 == other.text1 && text2 == other.text2 && type == other.type; + return text1 == other.text1 && text2 == other.text2 && type == other.type && + size == other.size && status == other.status; } }; diff --git a/selfdrive/ui/qt/onroad/onroad_home.cc b/selfdrive/ui/qt/onroad/onroad_home.cc index d6493835a..711ae41c7 100644 --- a/selfdrive/ui/qt/onroad/onroad_home.cc +++ b/selfdrive/ui/qt/onroad/onroad_home.cc @@ -46,6 +46,7 @@ OnroadWindow::OnroadWindow(QWidget *parent) : QWidget(parent) { stacked_layout->addWidget(starpilot_onroad); starpilot_onroad->raise(); + alerts->raise(); nvg->starpilot_nvg = starpilot_nvg; } diff --git a/starpilot/system/the_galaxy/assets/components/home/home.css b/starpilot/system/the_galaxy/assets/components/home/home.css index 32eecbef4..1fa1dcc55 100644 --- a/starpilot/system/the_galaxy/assets/components/home/home.css +++ b/starpilot/system/the_galaxy/assets/components/home/home.css @@ -429,7 +429,7 @@ border-top: 1px solid rgba(255, 255, 255, 0.08); display: grid; gap: 1rem; - grid-template-columns: minmax(170px, 1.35fr) minmax(170px, 0.8fr) minmax(210px, 1fr) minmax(180px, 0.9fr); + grid-template-columns: minmax(170px, 1.35fr) minmax(150px, 0.75fr) minmax(190px, 1fr) minmax(170px, 0.85fr) auto; min-width: 0; padding: 0.9rem 0; } @@ -442,10 +442,15 @@ color: var(--dashboard-muted); } +.dashboard-drive-row.is-ignored { + opacity: 0.72; +} + .dashboard-drive-main, .dashboard-drive-details, .dashboard-attention, -.dashboard-engaged-cell { +.dashboard-engaged-cell, +.dashboard-drive-actions { min-width: 0; } @@ -483,6 +488,34 @@ grid-template-columns: 1fr auto; } +.dashboard-drive-actions { + display: flex; + justify-content: flex-end; +} + +.dashboard-drive-stats-action { + align-items: center; + background: transparent; + border: 1px solid var(--dashboard-border); + border-radius: 6px; + color: var(--dashboard-muted); + display: inline-flex; + font-size: 0.78rem; + gap: 0.4rem; + padding: 0.45rem 0.6rem; + white-space: nowrap; +} + +.dashboard-drive-stats-action:hover { + border-color: var(--dashboard-accent); + color: var(--dashboard-strong); +} + +.dashboard-drive-stats-action:disabled { + cursor: wait; + opacity: 0.5; +} + .dashboard-mini-bar, .dashboard-storage-track { background: var(--dashboard-track); @@ -633,6 +666,10 @@ .dashboard-drive-row { grid-template-columns: minmax(170px, 1fr) minmax(210px, 1fr); } + + .dashboard-drive-actions { + justify-content: flex-start; + } } @media only screen and (max-width: 900px) { @@ -690,6 +727,11 @@ .dashboard-drive-main span { white-space: normal; } + + .dashboard-drive-stats-action { + justify-content: center; + width: 100%; + } } @media only screen and (max-width: 560px) { diff --git a/starpilot/system/the_galaxy/assets/components/home/home.js b/starpilot/system/the_galaxy/assets/components/home/home.js index da5aca344..805fcc835 100644 --- a/starpilot/system/the_galaxy/assets/components/home/home.js +++ b/starpilot/system/the_galaxy/assets/components/home/home.js @@ -165,7 +165,7 @@ function fallbackDashboard(data, unit) { } function driveStatsReady(drive) { - return drive?.attentionKnown !== false; + return drive?.ignored === true || drive?.attentionKnown !== false; } function dashboardPendingDriveCount(dashboard) { @@ -309,26 +309,42 @@ function renderRecentDrives(drives) { } const rows = drives.map(drive => { + const ignored = drive?.ignored === true; const ready = driveStatsReady(drive); + const routeNames = Array.isArray(drive?.routeNames) ? drive.routeNames.filter(Boolean) : []; return ` -
+
${escapeHtml(formatDriveTimeRange(drive.date, drive.endDate))} ${escapeHtml(drive.model || "Unknown model")}
- ${ready ? `${formatOneDecimal(drive.distance)} ${escapeHtml(drive.distanceUnit || "miles")}` : "Analyzing stats"} + ${ignored && drive.attentionKnown === false ? "Stats excluded" : (ready ? `${formatOneDecimal(drive.distance)} ${escapeHtml(drive.distanceUnit || "miles")}` : "Analyzing stats")} ${formatDuration(drive.duration)}
- ${ready + ${ignored + ? ` Ignored from stats` + : ready ? `${formatInt(drive.distractedMoments)} distracted${formatInt(drive.unresponsiveMoments)} unresponsive` : `Waiting for full route analysis`}
-
- ${ready ? `${formatPercent(drive.engagedPercent)} engaged` : "Pending"} +
+ ${ignored ? "Excluded" : (ready ? `${formatPercent(drive.engagedPercent)} engaged` : "Pending")}
+ ${routeNames.length === 0 ? "" : ` +
+ +
+ `}
`; }).join(""); @@ -455,6 +471,40 @@ function bindDashboardActions() { if (refreshButton) { refreshButton.onclick = () => initializeHome(true); } + + document.querySelectorAll(".dashboard-drive-stats-action").forEach((button) => { + button.onclick = async () => { + let routeNames = []; + try { + routeNames = JSON.parse(button.dataset.routeNames || "[]"); + } catch { + routeNames = []; + } + if (!Array.isArray(routeNames) || routeNames.length === 0) return; + + const action = button.dataset.action === "include" ? "include" : "ignore"; + const confirmed = action === "include" || window.confirm( + "Ignore this drive's statistics?\n\n" + + "It will no longer affect local weekly totals, records, model usage, engagement, or attention streaks." + ); + if (!confirmed) return; + + button.disabled = true; + try { + const response = await fetch(`/api/stats/${action}_drive`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ routeNames }), + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || `Unable to ${action} drive statistics.`); + await initializeHome(false); + } catch (error) { + window.alert(error?.message || `Unable to ${action} drive statistics.`); + button.disabled = false; + } + }; + }); } function renderDashboard(state) { diff --git a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py index 0899b6d22..44a804921 100644 --- a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py +++ b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py @@ -545,6 +545,121 @@ def test_model_usage_ignores_pending_route_shells(): assert favorites[0]["drives"] == 1 +def test_ignored_drive_stays_recent_but_is_removed_from_local_stats(monkeypatch): + utilities._invalidate_dashboard_cache() + first_route = "00000001--abcdef0001" + shared_route = "00000002--abcdef0002" + third_route = "00000003--abcdef0003" + params = FakeParams({ + "AvailableModels": "orion", + "AvailableModelNames": "Orion", + }) + drives = [ + { + "name": first_route, + "date": "2026-06-15T08:00:00", + "endDate": "2026-06-15T08:20:00", + "distanceMeters": 10000.0, + "duration": 1200, + "engagedSeconds": 900.0, + "model": "Orion", + "distractedMoments": 0, + "unresponsiveMoments": 0, + "routeModifiedAt": 100, + "attentionKnown": True, + "analysisComplete": True, + }, + { + "name": shared_route, + "date": "2026-06-16T08:00:00", + "endDate": "2026-06-16T09:00:00", + "distanceMeters": 50000.0, + "duration": 3600, + "engagedSeconds": 100.0, + "model": "Orion", + "distractedMoments": 4, + "unresponsiveMoments": 1, + "routeModifiedAt": 100, + "attentionKnown": True, + "analysisComplete": True, + }, + { + "name": third_route, + "date": "2026-06-17T08:00:00", + "endDate": "2026-06-17T08:15:00", + "distanceMeters": 5000.0, + "duration": 900, + "engagedSeconds": 800.0, + "model": "Orion", + "distractedMoments": 0, + "unresponsiveMoments": 0, + "routeModifiedAt": 100, + "attentionKnown": True, + "analysisComplete": True, + }, + ] + utilities._update_dashboard_persistent_stats(params, drives, wall_now=1000) + + ignored = utilities.ignore_dashboard_routes(params, [shared_route]) + + assert ignored == [shared_route] + monkeypatch.setattr(utilities, "_list_dashboard_routes", lambda paths: []) + monkeypatch.setattr(utilities, "_start_dashboard_background_analysis", lambda *args: False) + monkeypatch.setattr(utilities, "_build_storage_summary", lambda paths: { + "freeBytes": 0, + "usedBytes": 0, + "totalBytes": 0, + "usedPercent": 0, + "segmentCounts": {}, + }) + + dashboard = utilities.get_dashboard_stats([], params, now=utilities.datetime(2026, 6, 18, 12, 0, 0)) + recent_by_name = {drive["name"]: drive for drive in dashboard["recentDrives"]} + + assert recent_by_name[shared_route]["ignored"] is True + assert recent_by_name[first_route]["ignored"] is False + assert dashboard["week"]["drives"] == 2 + assert dashboard["week"]["distance"] == 9.3 + assert dashboard["records"]["longestDrive"]["value"] == "6.2" + assert dashboard["records"]["cleanDriveStreak"]["value"] == "2 drives" + assert dashboard["favoriteModels"][0]["drives"] == 2 + + utilities.include_dashboard_routes(params, [shared_route]) + restored = utilities.get_dashboard_stats([], params, now=utilities.datetime(2026, 6, 18, 12, 0, 0)) + restored_by_name = {drive["name"]: drive for drive in restored["recentDrives"]} + assert restored_by_name[shared_route]["ignored"] is False + assert restored["week"]["drives"] == 3 + assert restored["records"]["longestDrive"]["value"] == "31.1" + assert restored["favoriteModels"][0]["drives"] == 3 + utilities._invalidate_dashboard_cache() + + +def test_ignored_route_is_not_queued_for_analysis(): + ignored_route = "00000001--abcdef0001" + active_route = "00000002--abcdef0002" + route_infos = [ + {"name": ignored_route, "modifiedAt": 100, "segmentCount": 1}, + {"name": active_route, "modifiedAt": 100, "segmentCount": 1}, + ] + stats = { + "routes": {}, + "ignoredRoutes": [ignored_route], + } + + assert utilities._analysis_candidates(route_infos, stats) == [route_infos[1]] + + +def test_ignore_dashboard_routes_rejects_arbitrary_names(): + params = FakeParams() + + try: + utilities.ignore_dashboard_routes(params, ["../../data/params"]) + except ValueError as exception: + assert "No valid dashboard routes" in str(exception) + else: + raise AssertionError("invalid route name was accepted") + + def test_cpu_temp_reader_uses_hardware_cpu_values(monkeypatch): hardware_module = _simple_module( "openpilot.system.hardware", @@ -1120,8 +1235,13 @@ def test_stats_endpoint_keeps_existing_keys_and_adds_dashboard(monkeypatch): monkeypatch.setattr(server.utilities, "get_disk_usage", lambda: [{"free": "1 GB", "size": "2 GB", "used": "1 GB", "usedPercentage": "50.00%"}]) monkeypatch.setattr(server.utilities, "get_drive_stats", lambda: {"all": {"drives": 0, "distance": 0, "hours": 0, "unit": "miles"}}) monkeypatch.setattr(server.utilities, "get_dashboard_stats", lambda footage_paths, params_obj: {"lastDrive": {}, "recentDrives": []}) + ignored_calls = [] + included_calls = [] + monkeypatch.setattr(server.utilities, "ignore_dashboard_routes", lambda params_obj, route_names: ignored_calls.extend(route_names) or route_names) + monkeypatch.setattr(server.utilities, "include_dashboard_routes", lambda params_obj, route_names: included_calls.extend(route_names) or route_names) - response = app.test_client().get("/api/stats") + client = app.test_client() + response = client.get("/api/stats") payload = response.get_json() assert response.status_code == 200 @@ -1130,3 +1250,12 @@ def test_stats_endpoint_keeps_existing_keys_and_adds_dashboard(monkeypatch): assert "softwareInfo" in payload assert payload["softwareInfo"]["buildEnvironment"] == "Experimental" assert payload["dashboard"]["recentDrives"] == [] + + route_name = "00000001--abcdef0001" + ignore_response = client.post("/api/stats/ignore_drive", json={"routeNames": [route_name]}) + include_response = client.post("/api/stats/include_drive", json={"routeNames": [route_name]}) + + assert ignore_response.status_code == 200 + assert include_response.status_code == 200 + assert ignored_calls == [route_name] + assert included_calls == [route_name] diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 204c64c12..af335ed37 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -5225,6 +5225,48 @@ def setup(app): }) return payload + @app.route("/api/stats/ignore_drive", methods=["POST"]) + def ignore_drive_stats(): + request_data = request.get_json() or {} + route_names = request_data.get("routeNames", []) + if not isinstance(route_names, list): + return jsonify({"error": "routeNames must be a list."}), 400 + + try: + ignored_routes = utilities.ignore_dashboard_routes(params, route_names) + except ValueError as exception: + return jsonify({"error": str(exception)}), 400 + + _STATS_RESPONSE_CACHE.update({ + "updated_at": 0.0, + "payload": None, + }) + return jsonify({ + "message": "Drive statistics ignored.", + "routeNames": ignored_routes, + }), 200 + + @app.route("/api/stats/include_drive", methods=["POST"]) + def include_drive_stats(): + request_data = request.get_json() or {} + route_names = request_data.get("routeNames", []) + if not isinstance(route_names, list): + return jsonify({"error": "routeNames must be a list."}), 400 + + try: + included_routes = utilities.include_dashboard_routes(params, route_names) + except ValueError as exception: + return jsonify({"error": str(exception)}), 400 + + _STATS_RESPONSE_CACHE.update({ + "updated_at": 0.0, + "payload": None, + }) + return jsonify({ + "message": "Drive statistics included.", + "routeNames": included_routes, + }), 200 + @app.route("/api/plots/live", methods=["GET"]) def get_live_plots(): _ensure_plots_worker() diff --git a/starpilot/system/the_galaxy/utilities.py b/starpilot/system/the_galaxy/utilities.py index 13ad3e29b..73b5164a1 100644 --- a/starpilot/system/the_galaxy/utilities.py +++ b/starpilot/system/the_galaxy/utilities.py @@ -51,6 +51,7 @@ ROUTE_TIME_LOG_CANDIDATES = [ ] SEGMENT_RE = re.compile(r"^[0-9a-fA-F]{8}--[0-9a-fA-F]{10}--\d+$") +ROUTE_RE = re.compile(r"^[0-9a-fA-F]{8}--[0-9a-fA-F]{10}$") TARGET_LOUDNESS = -15.0 METER_TO_MILE = 1.0 / 1609.344 @@ -1144,6 +1145,7 @@ def _analyze_route_messages(messages, route_info, model_names, is_metric, deadli return { "name": route_info.get("name", ""), + "routeNames": [route_info.get("name", "")], "date": start_date, "endDate": end_date, "distance": round(distance, 1), @@ -1189,6 +1191,8 @@ def _iter_route_log_messages(route_info, deadline=None): def _empty_drive(is_metric): return { "name": "", + "routeNames": [], + "ignored": False, "date": "", "endDate": "", "distance": 0, @@ -1250,6 +1254,8 @@ def _route_shell_drive(route_info, params_obj, model_names, is_metric): start_date, end_date = _route_time_range(route_info, duration_seconds) return { "name": route_info.get("name", ""), + "routeNames": [route_info.get("name", "")], + "ignored": False, "date": start_date, "endDate": end_date, "distance": 0, @@ -1278,6 +1284,8 @@ def _drive_from_persistent_route(route_name, entry, is_metric): avg_speed = (distance_m / duration) * (CV.MS_TO_KPH if is_metric else METER_PER_SECOND_TO_MPH) if duration > 0 else 0.0 return { "name": route_name, + "routeNames": [route_name], + "ignored": False, "date": entry.get("date", ""), "endDate": entry.get("endDate", ""), "distance": round(_distance_from_meters(distance_m, is_metric), 1), @@ -1302,11 +1310,15 @@ def _persistent_drives(stats, is_metric): routes = stats.get("routes", {}) if isinstance(stats, dict) else {} if not isinstance(routes, dict): return [] - return [ + ignored_routes = set(stats.get("ignoredRoutes", [])) if isinstance(stats.get("ignoredRoutes", []), list) else set() + drives = [ _drive_from_persistent_route(route_name, entry, is_metric) for route_name, entry in routes.items() if isinstance(entry, dict) and _dashboard_time_is_valid(entry.get("date", "")) ] + for drive in drives: + drive["ignored"] = drive["name"] in ignored_routes + return drives def _merge_dashboard_drives(*drive_lists): @@ -1365,7 +1377,15 @@ def _coalesced_drive_group(group, is_metric): primary["routeModifiedAt"] = max(_safe_float(drive.get("routeModifiedAt", 0.0), 0.0) for drive in ordered) primary["attentionKnown"] = any(bool(drive.get("attentionKnown", True)) for drive in ordered) primary["analysisComplete"] = all(bool(drive.get("analysisComplete", False)) for drive in ordered) - primary["name"] = ",".join(str(drive.get("name", "")).strip() for drive in ordered if str(drive.get("name", "")).strip()) + route_names = [] + for drive in ordered: + for route_name in drive.get("routeNames", [drive.get("name", "")]): + route_name = str(route_name or "").strip() + if route_name and route_name not in route_names: + route_names.append(route_name) + primary["routeNames"] = route_names + primary["name"] = ",".join(route_names) + primary["ignored"] = bool(route_names) and all(bool(drive.get("ignored", False)) for drive in ordered) return primary @@ -1413,6 +1433,7 @@ def _persistent_route_needs_time_refresh(entry): def _analysis_candidates(route_infos, persistent_stats): routes = persistent_stats.get("routes", {}) if isinstance(persistent_stats, dict) else {} routes = routes if isinstance(routes, dict) else {} + ignored_routes = set(persistent_stats.get("ignoredRoutes", [])) if isinstance(persistent_stats, dict) else set() def needs_analysis(route_info): route_name = route_info.get("name", "") @@ -1427,10 +1448,23 @@ def _analysis_candidates(route_infos, persistent_stats): return True return not bool(entry.get("attentionKnown", True)) or not bool(entry.get("analysisComplete", False)) - missing = [route_info for route_info in route_infos if needs_analysis(route_info)] + missing = [ + route_info for route_info in route_infos + if route_info.get("name", "") not in ignored_routes and needs_analysis(route_info) + ] return missing +def _mark_ignored_drives(drives, persistent_stats): + ignored_routes = set(persistent_stats.get("ignoredRoutes", [])) if isinstance(persistent_stats, dict) else set() + for drive in drives or []: + route_names = drive.get("routeNames", [drive.get("name", "")]) + route_names = [str(route_name or "").strip() for route_name in route_names if str(route_name or "").strip()] + drive["routeNames"] = route_names + drive["ignored"] = bool(route_names) and all(route_name in ignored_routes for route_name in route_names) + return drives + + def _invalidate_dashboard_cache(): _DASHBOARD_CACHE.update({ "key": None, @@ -1911,6 +1945,12 @@ def _load_dashboard_persistent_stats(params_obj): data = {} data["version"] = 1 data["routes"] = _normalize_persistent_routes(data.get("routes", {})) + ignored_routes = data.get("ignoredRoutes", []) + data["ignoredRoutes"] = sorted({ + str(route_name).strip() + for route_name in ignored_routes + if ROUTE_RE.fullmatch(str(route_name).strip()) + }) if isinstance(ignored_routes, list) else [] attention = data.get("attentionRecords", {}) data["attentionRecords"] = attention if isinstance(attention, dict) else {} personal_records = data.get("personalRecords", {}) @@ -2089,7 +2129,7 @@ def _merge_personal_records(current, previous, legacy_attention=None): return merged -def _recalculate_persistent_stats(stats): +def _recalculate_persistent_stats(stats, reset_personal_records=False): routes = stats.get("routes", {}) ordered_routes = sorted(routes.items(), key=_route_entry_sort_key) if len(ordered_routes) > DASHBOARD_PERSISTED_ROUTE_LIMIT: @@ -2097,9 +2137,20 @@ def _recalculate_persistent_stats(stats): routes = dict(ordered_routes) stats["routes"] = routes + ignored_routes = set(stats.get("ignoredRoutes", [])) + included_routes = { + route_name: entry + for route_name, entry in routes.items() + if route_name not in ignored_routes + } + included_ordered_routes = [ + (route_name, entry) + for route_name, entry in ordered_routes + if route_name not in ignored_routes + ] model_usage = {} - for _, entry in ordered_routes: + for _, entry in included_ordered_routes: if not _dashboard_time_is_valid(entry.get("date", "")): continue if not bool(entry.get("analysisComplete", False)): @@ -2119,8 +2170,11 @@ def _recalculate_persistent_stats(stats): usage["name"] = model_name previous_attention = stats.get("attentionRecords", {}) if isinstance(stats.get("attentionRecords", {}), dict) else {} - current_records = _build_personal_records_raw(routes) - stats["personalRecords"] = _merge_personal_records(current_records, stats.get("personalRecords", {}), previous_attention) + current_records = _build_personal_records_raw(included_routes) + if reset_personal_records: + stats["personalRecords"] = current_records + else: + stats["personalRecords"] = _merge_personal_records(current_records, stats.get("personalRecords", {}), previous_attention) stats["attentionRecords"] = { "longestUndistractedDrive": stats["personalRecords"]["longestUndistractedDrive"], @@ -2130,6 +2184,36 @@ def _recalculate_persistent_stats(stats): return stats +def set_dashboard_routes_ignored(params_obj, route_names, ignored): + normalized_names = { + str(route_name or "").strip() + for route_name in (route_names or []) + if ROUTE_RE.fullmatch(str(route_name or "").strip()) + } + if not normalized_names: + raise ValueError("No valid dashboard routes were provided.") + + stats = _load_dashboard_persistent_stats(params_obj) + ignored_routes = set(stats.get("ignoredRoutes", [])) + if ignored: + ignored_routes.update(normalized_names) + else: + ignored_routes.difference_update(normalized_names) + stats["ignoredRoutes"] = sorted(ignored_routes) + stats = _recalculate_persistent_stats(stats, reset_personal_records=True) + _params_put_text(params_obj, DASHBOARD_PERSISTENT_STATS_PARAM, json.dumps(stats, separators=(",", ":"))) + _invalidate_dashboard_cache() + return sorted(normalized_names) + + +def ignore_dashboard_routes(params_obj, route_names): + return set_dashboard_routes_ignored(params_obj, route_names, True) + + +def include_dashboard_routes(params_obj, route_names): + return set_dashboard_routes_ignored(params_obj, route_names, False) + + def _drive_stable_for_persistence(drive, wall_now): modified_at = _safe_float(drive.get("routeModifiedAt", 0.0), 0.0) return modified_at <= 0.0 or wall_now - modified_at >= DASHBOARD_PERSIST_MIN_ROUTE_AGE_SECONDS @@ -2432,10 +2516,13 @@ def get_dashboard_stats(footage_paths, params_obj=None, now=None): persisted_drives = _persistent_drives(persistent_stats, is_metric) combined_drives = _merge_dashboard_drives(shell_drives, persisted_drives, analyzed_drives) + _mark_ignored_drives(combined_drives, persistent_stats) display_drives = _coalesce_display_drives(combined_drives, is_metric) + included_display_drives = [drive for drive in display_drives if not bool(drive.get("ignored", False))] pending_candidates = _analysis_candidates(route_infos, persistent_stats) pending_route_names = {str(route.get("name", "")).strip() for route in pending_candidates} - week_drives = _week_summary_drives(combined_drives, pending_route_names) + included_drives = [drive for drive in combined_drives if not bool(drive.get("ignored", False))] + week_drives = _week_summary_drives(included_drives, pending_route_names) _start_dashboard_background_analysis(footage_paths, route_infos, persistent_stats, pending_candidates) analysis_status = _dashboard_analysis_status(pending_candidates) @@ -2444,7 +2531,7 @@ def get_dashboard_stats(footage_paths, params_obj=None, now=None): else: records = _display_personal_records(persistent_stats, is_metric) dashboard = { - "lastDrive": _public_drive(display_drives[0], is_metric), + "lastDrive": _public_drive(included_display_drives[0], is_metric) if included_display_drives else _empty_drive(is_metric), "recentDrives": [_public_drive(drive, is_metric) for drive in display_drives[:DASHBOARD_RECENT_DRIVE_LIMIT]], "week": _build_week_summary(week_drives, now, is_metric), "records": records,