mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-08-21 08:14:00 +08:00
Carl The Witch
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<cereal::SelfdriveState::AlertSize>(fpss.getAlertSize()), static_cast<cereal::SelfdriveState::AlertStatus>(fpss.getAlertStatus())};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 `
|
||||
<div class="dashboard-drive-row ${ready ? "" : "is-pending"}">
|
||||
<div class="dashboard-drive-row ${ready ? "" : "is-pending"} ${ignored ? "is-ignored" : ""}">
|
||||
<div class="dashboard-drive-main">
|
||||
<strong>${escapeHtml(formatDriveTimeRange(drive.date, drive.endDate))}</strong>
|
||||
<span>${escapeHtml(drive.model || "Unknown model")}</span>
|
||||
</div>
|
||||
<div class="dashboard-drive-details">
|
||||
<span>${ready ? `${formatOneDecimal(drive.distance)} ${escapeHtml(drive.distanceUnit || "miles")}` : "Analyzing stats"}</span>
|
||||
<span>${ignored && drive.attentionKnown === false ? "Stats excluded" : (ready ? `${formatOneDecimal(drive.distance)} ${escapeHtml(drive.distanceUnit || "miles")}` : "Analyzing stats")}</span>
|
||||
<span>${formatDuration(drive.duration)}</span>
|
||||
</div>
|
||||
<div class="dashboard-attention">
|
||||
${ready
|
||||
${ignored
|
||||
? `<span><i class="bi bi-eye-slash"></i> Ignored from stats</span>`
|
||||
: ready
|
||||
? `<span>${formatInt(drive.distractedMoments)} distracted</span><span>${formatInt(drive.unresponsiveMoments)} unresponsive</span>`
|
||||
: `<span>Waiting for full route analysis</span>`}
|
||||
</div>
|
||||
<div class="dashboard-engaged-cell">
|
||||
<div class="dashboard-mini-bar"><span style="width:${ready ? Math.max(0, Math.min(100, numberValue(drive.engagedPercent))) : 0}%"></span></div>
|
||||
<strong>${ready ? `${formatPercent(drive.engagedPercent)} engaged` : "Pending"}</strong>
|
||||
<div class="dashboard-mini-bar"><span style="width:${ready && !ignored ? Math.max(0, Math.min(100, numberValue(drive.engagedPercent))) : 0}%"></span></div>
|
||||
<strong>${ignored ? "Excluded" : (ready ? `${formatPercent(drive.engagedPercent)} engaged` : "Pending")}</strong>
|
||||
</div>
|
||||
${routeNames.length === 0 ? "" : `
|
||||
<div class="dashboard-drive-actions">
|
||||
<button
|
||||
class="dashboard-drive-stats-action"
|
||||
data-action="${ignored ? "include" : "ignore"}"
|
||||
data-route-names="${escapeHtml(JSON.stringify(routeNames))}"
|
||||
title="${ignored ? "Include this drive in local dashboard statistics" : "Exclude this drive from local dashboard statistics"}">
|
||||
<i class="bi ${ignored ? "bi-arrow-counterclockwise" : "bi-eye-slash"}"></i>
|
||||
<span>${ignored ? "Include drive stats" : "Ignore drive stats"}</span>
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}).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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user