Carl The Witch

This commit is contained in:
firestar5683
2026-06-20 14:38:56 -05:00
parent 42a23508b7
commit 9910651a9d
13 changed files with 478 additions and 35 deletions
+19
View File
@@ -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
+8 -1
View File
@@ -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
+13 -7
View File
@@ -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
+13 -7
View File
@@ -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)
+2 -1
View File
@@ -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())};
}
+2 -1
View File
@@ -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;
}
};
+1
View File
@@ -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]
+42
View File
@@ -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()
+96 -9
View File
@@ -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,