diff --git a/opendbc_repo/opendbc/car/gm/carcontroller.py b/opendbc_repo/opendbc/car/gm/carcontroller.py index 153b54a78..9289f1cca 100644 --- a/opendbc_repo/opendbc/car/gm/carcontroller.py +++ b/opendbc_repo/opendbc/car/gm/carcontroller.py @@ -28,11 +28,11 @@ AUTO_HOLD_VOLT_CARS = { CAR.CHEVROLET_VOLT_ASCM, CAR.CHEVROLET_VOLT_CAMERA, } -AUTO_HOLD_DRIVE_GEARS = { +AUTO_HOLD_DRIVE_GEARS = ( GearShifter.drive, GearShifter.low, GearShifter.manumatic, -} +) AUTO_HOLD_MIN_BRAKE = 80 AUTO_HOLD_MAX_BRAKE = 240 AUTO_HOLD_MIN_DRIVE_TIME_S = 3.0 diff --git a/opendbc_repo/opendbc/car/gm/tests/test_carcontroller.py b/opendbc_repo/opendbc/car/gm/tests/test_carcontroller.py index 35f1a3d1c..88c3accc5 100644 --- a/opendbc_repo/opendbc/car/gm/tests/test_carcontroller.py +++ b/opendbc_repo/opendbc/car/gm/tests/test_carcontroller.py @@ -2,6 +2,8 @@ import sys import types from types import SimpleNamespace +from opendbc.car import structs + fake_interfaces = types.ModuleType("opendbc.car.interfaces") @@ -33,6 +35,7 @@ fake_testing_grounds.testing_ground = SimpleNamespace(use_1=False) sys.modules.setdefault("openpilot.starpilot.common.testing_grounds", fake_testing_grounds) from opendbc.car.gm.carcontroller import ( + AUTO_HOLD_DRIVE_GEARS, CarController, estimate_auto_hold_brake, get_adas_keepalive_step, @@ -220,6 +223,13 @@ def test_auto_hold_brake_estimate_uses_driver_or_op_brake_and_clamps(): assert estimate_auto_hold_brake(100.0, 400.0) == 240 +def test_auto_hold_drive_gears_accept_capnp_dynamic_enum_membership(): + msg = structs.CarState.new_message() + msg.gearShifter = structs.CarState.GearShifter.drive + + assert msg.gearShifter in AUTO_HOLD_DRIVE_GEARS + + def test_auto_hold_activation_allows_direct_entry_from_stopped_brake_press(): assert should_activate_auto_hold( True, diff --git a/selfdrive/controls/tests/test_conditional_experimental_mode.py b/selfdrive/controls/tests/test_conditional_experimental_mode.py index 58643db4b..81030502c 100644 --- a/selfdrive/controls/tests/test_conditional_experimental_mode.py +++ b/selfdrive/controls/tests/test_conditional_experimental_mode.py @@ -35,10 +35,18 @@ def make_cem(*, model_length: float, model_stopped: bool = False, tracking_lead: model_length=model_length, model_stopped=model_stopped, tracking_lead=tracking_lead, - starpilot_vcruise=SimpleNamespace(stop_sign_confirmed=stop_sign_confirmed, forcing_stop=forcing_stop), + starpilot_vcruise=SimpleNamespace( + stop_sign_confirmed=stop_sign_confirmed, + forcing_stop=forcing_stop, + slc=SimpleNamespace(experimental_mode=False), + ), starpilot_following=SimpleNamespace(slower_lead=False, following_lead=False), lead_one=SimpleNamespace(status=lead_status, dRel=lead_d_rel, vLead=lead_v_lead, modelProb=lead_model_prob, radar=lead_radar), + road_curvature_detected=False, + driving_in_curve=False, + lane_width_left=0.0, + lane_width_right=0.0, ) return ConditionalExperimentalMode(planner) @@ -368,6 +376,35 @@ def test_standstill_update_can_activate_exp_from_red_light_detection(monkeypatch assert cem.status_value == conditional_experimental_mode_module.CEStatus["STOP_LIGHT"] +def test_first_post_standstill_pullaway_frame_does_not_blip_into_exp(monkeypatch): + cem = make_cem( + model_length=70.0, + tracking_lead=True, + lead_status=True, + lead_d_rel=8.4, + lead_v_lead=2.7, + lead_model_prob=0.999, + ) + toggles = make_update_toggles() + toggles.conditional_lead = True + toggles.conditional_slower_lead = True + toggles.conditional_stopped_lead = True + standstill_sm = make_update_sm(standstill=True) + moving_sm = make_update_sm(standstill=False) + + now = [100.0] + monkeypatch.setattr(conditional_experimental_mode_module.time, "monotonic", lambda: now[0]) + + cem.update(0.0, standstill_sm, toggles) + assert not cem.experimental_mode + + now[0] = 100.1 + cem.update(0.12, moving_sm, toggles) + + assert not cem.experimental_mode + assert cem.status_value == conditional_experimental_mode_module.CEStatus["OFF"] + + def test_standstill_update_can_activate_exp_from_dashboard_stop_sign(monkeypatch): cem = make_cem(model_length=80.0, model_stopped=False) toggles = make_update_toggles() diff --git a/starpilot/controls/lib/conditional_experimental_mode.py b/starpilot/controls/lib/conditional_experimental_mode.py index b16327501..1bbbef871 100644 --- a/starpilot/controls/lib/conditional_experimental_mode.py +++ b/starpilot/controls/lib/conditional_experimental_mode.py @@ -120,8 +120,10 @@ class ConditionalExperimentalMode: if triggered: self.mode_hold_until = now + self.CEM_TRANSITION_GUARD_TIME self.mode_false_since = 0.0 - elif self.mode_false_since == 0.0: + elif self.prev_experimental_mode and self.mode_false_since == 0.0: self.mode_false_since = now + elif not self.prev_experimental_mode: + self.mode_false_since = 0.0 hold_active = now < self.mode_hold_until transition_buffer_active = self.mode_false_since != 0.0 and (now - self.mode_false_since) < self.CEM_TRANSITION_BUFFER_TIME diff --git a/starpilot/system/the_pond/assets/components/home/home.css b/starpilot/system/the_pond/assets/components/home/home.css index d612cb76d..7dbe9fc55 100644 --- a/starpilot/system/the_pond/assets/components/home/home.css +++ b/starpilot/system/the_pond/assets/components/home/home.css @@ -403,7 +403,7 @@ border-top: 1px solid rgba(255, 255, 255, 0.08); display: grid; gap: 1rem; - grid-template-columns: minmax(170px, 1.3fr) minmax(230px, 1fr) minmax(210px, 1fr) minmax(180px, 0.9fr); + grid-template-columns: minmax(170px, 1.35fr) minmax(170px, 0.8fr) minmax(210px, 1fr) minmax(180px, 0.9fr); min-width: 0; padding: 0.9rem 0; } @@ -412,6 +412,10 @@ border-top: 0; } +.dashboard-drive-row.is-pending { + color: var(--dashboard-muted); +} + .dashboard-drive-main, .dashboard-drive-details, .dashboard-attention, diff --git a/starpilot/system/the_pond/assets/components/home/home.js b/starpilot/system/the_pond/assets/components/home/home.js index 382847fb6..9b0d4c1f8 100644 --- a/starpilot/system/the_pond/assets/components/home/home.js +++ b/starpilot/system/the_pond/assets/components/home/home.js @@ -110,6 +110,7 @@ function fallbackDashboard(data, unit) { segmentCount: 0, distractedMoments: 0, unresponsiveMoments: 0, + attentionKnown: true, distanceUnit: unit, speedUnit: unit === "kilometers" ? "kph" : "mph", }, @@ -144,21 +145,28 @@ function fallbackDashboard(data, unit) { }; } +function driveStatsReady(drive) { + return drive?.attentionKnown !== false; +} + function renderLastDrive(drive) { + const ready = driveStatsReady(drive); return `
Last drive
${escapeHtml(formatDate(drive.date))}
-
${formatOneDecimal(drive.distance)}${escapeHtml(drive.distanceUnit || "miles")}
+
${ready ? formatOneDecimal(drive.distance) : "..."}${ready ? escapeHtml(drive.distanceUnit || "miles") : "analyzing"}
${formatDuration(drive.duration)}duration
-
${formatInt(drive.avgSpeed)}${escapeHtml(drive.speedUnit || "mph")} avg
-
${formatPercent(drive.engagedPercent)}engaged
+
${ready ? formatInt(drive.avgSpeed) : "..."}${ready ? `${escapeHtml(drive.speedUnit || "mph")} avg` : "speed"}
+
${ready ? formatPercent(drive.engagedPercent) : "..."}engaged
`; @@ -236,27 +244,30 @@ function renderRecentDrives(drives) { `; } - const rows = drives.map(drive => ` -
+ const rows = drives.map(drive => { + const ready = driveStatsReady(drive); + return ` +
${escapeHtml(formatDate(drive.date))} ${escapeHtml(drive.model || "Unknown model")}
- ${formatOneDecimal(drive.distance)} ${escapeHtml(drive.distanceUnit || "miles")} + ${ready ? `${formatOneDecimal(drive.distance)} ${escapeHtml(drive.distanceUnit || "miles")}` : "Analyzing stats"} ${formatDuration(drive.duration)} - ${formatInt(drive.segmentCount)} segments
- ${formatInt(drive.distractedMoments)} distracted - ${formatInt(drive.unresponsiveMoments)} unresponsive + ${ready + ? `${formatInt(drive.distractedMoments)} distracted${formatInt(drive.unresponsiveMoments)} unresponsive` + : `Waiting for full route analysis`}
-
- ${formatPercent(drive.engagedPercent)} engaged +
+ ${ready ? `${formatPercent(drive.engagedPercent)} engaged` : "Pending"}
- `).join(""); + `; + }).join(""); return `
diff --git a/starpilot/system/the_pond/tests/test_dashboard_stats.py b/starpilot/system/the_pond/tests/test_dashboard_stats.py index 0c6a9ee76..0b2b57a18 100644 --- a/starpilot/system/the_pond/tests/test_dashboard_stats.py +++ b/starpilot/system/the_pond/tests/test_dashboard_stats.py @@ -207,6 +207,11 @@ class FakeParams: self.values[key] = value +class FailingPutParams(FakeParams): + def put(self, key, value): + raise RuntimeError("unknown key") + + class FakeMessage: def __init__(self, kind, log_mono_time, payload): self._kind = kind @@ -331,6 +336,20 @@ def test_route_sampling_bounds_analyzed_segments(): assert [segment["num"] for segment in sampled["segments"]] == [0, 4, 9] +def test_route_listing_prefers_segment_candidates_with_logs(tmp_path): + hd_root = tmp_path / "realdata_HD" + standard_root = tmp_path / "realdata" + hd_segment = hd_root / "00000001--abcdef1234--0" + standard_segment = standard_root / "00000001--abcdef1234--0" + hd_segment.mkdir(parents=True) + standard_segment.mkdir(parents=True) + (standard_segment / "qlog.zst").write_bytes(b"") + + routes = utilities._list_dashboard_routes([hd_root, standard_root]) + + assert routes[0]["segments"][0]["path"] == standard_segment + + def test_top_models_are_ranked_from_persisted_usage_not_favorites(): params = FakeParams({ "AvailableModels": "orion,vega,atlas,nova", @@ -439,6 +458,29 @@ def test_persistent_loader_accepts_decoded_param_dict(): assert stats["routes"]["route-1"]["attentionKnown"] is False +def test_dashboard_persistent_stats_fallback_to_file_when_param_put_fails(tmp_path, monkeypatch): + monkeypatch.setattr(utilities, "DASHBOARD_PARAMS_DIR", tmp_path) + params = FailingPutParams() + drive = { + "name": "route-1", + "date": "2026-06-15T08:00:00", + "distanceMeters": 1000.0, + "duration": 60, + "engagedSeconds": 30.0, + "model": "Orion", + "routeModifiedAt": 100, + "attentionKnown": True, + "analysisComplete": True, + } + + utilities._update_dashboard_persistent_stats(params, [drive], wall_now=1000) + stats = utilities._load_dashboard_persistent_stats(params) + + assert (tmp_path / utilities.DASHBOARD_PERSISTENT_STATS_PARAM).is_file() + assert stats["routes"]["route-1"]["distanceMeters"] == 1000 + assert stats["routes"]["route-1"]["analysisComplete"] is True + + def test_lightweight_routes_surface_recent_drives_without_log_analysis(monkeypatch): utilities._invalidate_dashboard_cache() route_infos = [ @@ -513,6 +555,38 @@ def test_unknown_attention_rows_do_not_reset_persisted_clean_records(): assert records["cleanDriveStreak"]["value"] == "1 drive" +def test_lightweight_route_update_preserves_parsed_duration(): + params = FakeParams() + parsed_drive = { + "name": "route-1", + "date": "2026-06-15T08:00:00", + "distanceMeters": 10000.0, + "duration": 58, + "engagedSeconds": 30.0, + "model": "Orion", + "routeModifiedAt": 100, + "attentionKnown": True, + "analysisComplete": True, + } + shell_drive = { + "name": "route-1", + "date": "2026-06-15T08:00:00", + "distanceMeters": 0.0, + "duration": 60, + "engagedSeconds": 0.0, + "model": "Orion", + "routeModifiedAt": 100, + "attentionKnown": False, + "analysisComplete": False, + } + + utilities._update_dashboard_persistent_stats(params, [parsed_drive], wall_now=1000) + stats = utilities._update_dashboard_persistent_stats(params, [shell_drive], wall_now=1000) + + assert stats["routes"]["route-1"]["duration"] == 58 + assert stats["routes"]["route-1"]["analysisComplete"] is True + + def test_github_urls_accept_owner_repo_origin(): assert utilities.get_github_changelog_url("owner/repo", "main") == "https://github.com/owner/repo/commits/main/" assert utilities.get_github_changelog_url("github.com/owner/repo", "main") == "https://github.com/owner/repo/commits/main/" diff --git a/starpilot/system/the_pond/utilities.py b/starpilot/system/the_pond/utilities.py index 2f8e45f51..5af834c8b 100644 --- a/starpilot/system/the_pond/utilities.py +++ b/starpilot/system/the_pond/utilities.py @@ -61,12 +61,13 @@ DASHBOARD_CACHE_TTL_SECONDS = 5.0 DASHBOARD_ROUTE_SCAN_LIMIT = 24 DASHBOARD_ROUTE_ANALYSIS_LIMIT = 0 DASHBOARD_ANALYSIS_TIME_BUDGET_SECONDS = 0.0 -DASHBOARD_BACKGROUND_ROUTE_ANALYSIS_LIMIT = 24 +DASHBOARD_BACKGROUND_ROUTE_ANALYSIS_LIMIT = 5 DASHBOARD_RECENT_DRIVE_LIMIT = 5 DASHBOARD_ROUTE_SEGMENT_SAMPLE_LIMIT = 2 DASHBOARD_PERSISTED_ROUTE_LIMIT = 5000 DASHBOARD_PERSIST_MIN_ROUTE_AGE_SECONDS = 120 DASHBOARD_PERSISTENT_STATS_PARAM = "GalaxyDashboardStats" +DASHBOARD_PARAMS_DIR = Path("/data/params/d") DASHBOARD_ANALYZER_LOG_PATH = "/tmp/galaxy_dashboard_analyzer.log" DASHBOARD_TOP_MODEL_LIMIT = 3 DASHBOARD_EVENT_DISTRACTED = "promptDriverDistracted" @@ -633,14 +634,44 @@ def _params_get_bool(params_obj, key): return value.strip().lower() in ("1", "true", "yes", "on") +def _dashboard_param_file_path(key): + if key != DASHBOARD_PERSISTENT_STATS_PARAM: + return None + return DASHBOARD_PARAMS_DIR / key + + +def _read_dashboard_param_file(key): + path = _dashboard_param_file_path(key) + if path is None or not path.is_file(): + return None + try: + return path.read_text(encoding="utf-8") + except Exception: + return None + + +def _write_dashboard_param_file(key, value): + path = _dashboard_param_file_path(key) + if path is None: + return False + try: + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_name(f".{path.name}.{os.getpid()}.tmp") + tmp_path.write_text(str(value), encoding="utf-8") + os.replace(tmp_path, path) + return True + except Exception: + return False + + def _params_put_text(params_obj, key, value): if params_obj is None: - return False + return _write_dashboard_param_file(key, value) try: params_obj.put(key, value) return True except Exception: - return False + return _write_dashboard_param_file(key, value) def _split_csv(value): @@ -691,6 +722,19 @@ def _segment_mtime(segment_path): return 0.0 +def _segment_has_dashboard_log(segment_path): + path = Path(segment_path) + if not path.is_dir(): + return False + return any((path / candidate).is_file() for candidate in ROUTE_TIME_LOG_CANDIDATES) + + +def _select_dashboard_segment_candidate(candidates): + if not candidates: + return None + return next((candidate for candidate in candidates if _segment_has_dashboard_log(candidate)), candidates[0]) + + def _list_dashboard_routes(footage_paths, limit=DASHBOARD_ROUTE_SCAN_LIMIT): routes = {} @@ -726,7 +770,9 @@ def _list_dashboard_routes(footage_paths, limit=DASHBOARD_ROUTE_SCAN_LIMIT): for route in routes.values(): segments = [] for segment_num, candidates in sorted(route["segments_by_num"].items()): - segments.append({"num": segment_num, "path": candidates[0]}) + selected = _select_dashboard_segment_candidate(candidates) + if selected is not None: + segments.append({"num": segment_num, "path": selected}) if not segments: continue @@ -993,6 +1039,7 @@ def _analyze_route_messages(messages, route_info, model_names, is_metric, deadli "unresponsiveMoments": unresponsive_moments, "routeModifiedAt": _safe_float(route_info.get("modifiedAt", 0.0), 0.0), "attentionKnown": True, + "analysisComplete": analysis_segment_count >= segment_count, } @@ -1030,6 +1077,8 @@ def _empty_drive(is_metric): "segmentCount": 0, "distractedMoments": 0, "unresponsiveMoments": 0, + "attentionKnown": True, + "analysisComplete": False, "distanceUnit": "kilometers" if is_metric else "miles", "speedUnit": "kph" if is_metric else "mph", } @@ -1076,6 +1125,7 @@ def _route_shell_drive(route_info, params_obj, model_names, is_metric): "unresponsiveMoments": 0, "routeModifiedAt": _safe_float(route_info.get("modifiedAt", 0.0), 0.0), "attentionKnown": False, + "analysisComplete": False, } @@ -1100,6 +1150,7 @@ def _drive_from_persistent_route(route_name, entry, is_metric): "unresponsiveMoments": max(0, _safe_int(entry.get("unresponsiveMoments", 0), 0)), "routeModifiedAt": _safe_float(entry.get("modifiedAt", 0.0), 0.0), "attentionKnown": bool(entry.get("attentionKnown", True)), + "analysisComplete": bool(entry.get("analysisComplete", False)), } @@ -1147,7 +1198,7 @@ def _analysis_candidates(route_infos, persistent_stats): return True if _safe_float(entry.get("modifiedAt", 0.0), 0.0) < _safe_float(route_info.get("modifiedAt", 0.0), 0.0): return True - return not bool(entry.get("attentionKnown", 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)] return missing @@ -1180,9 +1231,10 @@ def warm_dashboard_stats(footage_paths=None): persistent_stats = _load_dashboard_persistent_stats(params_obj) candidates = _analysis_candidates(route_infos, persistent_stats)[:DASHBOARD_BACKGROUND_ROUTE_ANALYSIS_LIMIT] for route_info in candidates: - sampled_route_info = _sample_route_info(route_info) - messages = _iter_route_log_messages(sampled_route_info) - drive = _analyze_route_messages(messages, sampled_route_info, model_names, is_metric) + full_route_info = dict(route_info) + full_route_info["analysisSegmentCount"] = max(0, _safe_int(route_info.get("segmentCount", 0), 0)) + messages = _iter_route_log_messages(full_route_info) + drive = _analyze_route_messages(messages, full_route_info, model_names, is_metric) _update_dashboard_persistent_stats(params_obj, [drive], time.time()) @@ -1541,12 +1593,16 @@ def _normalize_persistent_routes(raw_routes): "segmentCount": max(0, _safe_int(entry.get("segmentCount", 0), 0)), "modifiedAt": _safe_float(entry.get("modifiedAt", 0.0), 0.0), "attentionKnown": bool(entry.get("attentionKnown", True)), + "analysisComplete": bool(entry.get("analysisComplete", False)), } return routes def _load_dashboard_persistent_stats(params_obj): - data = _decode_json_param(_params_get_value(params_obj, DASHBOARD_PERSISTENT_STATS_PARAM, None), {}) + raw_data = _read_dashboard_param_file(DASHBOARD_PERSISTENT_STATS_PARAM) + if raw_data is None: + raw_data = _params_get_value(params_obj, DASHBOARD_PERSISTENT_STATS_PARAM, None) + data = _decode_json_param(raw_data, {}) if not isinstance(data, dict): data = {} data["version"] = 1 @@ -1771,6 +1827,7 @@ def _update_dashboard_persistent_stats(params_obj, drives, wall_now): "segmentCount": max(0, _safe_int(drive.get("segmentCount", 0), 0)), "modifiedAt": _safe_float(drive.get("routeModifiedAt", 0.0), 0.0), "attentionKnown": attention_known, + "analysisComplete": bool(drive.get("analysisComplete", False)), } existing_entry = routes.get(route_name) if isinstance(existing_entry, dict): @@ -1782,12 +1839,15 @@ def _update_dashboard_persistent_stats(params_obj, drives, wall_now): next_entry["clean"] = bool(existing_entry.get("clean", False)) next_entry["undistracted"] = bool(existing_entry.get("undistracted", existing_entry.get("clean", False))) next_entry["attentionKnown"] = True + next_entry["analysisComplete"] = bool(existing_entry.get("analysisComplete", False)) if not attention_known and existing_current and existing_distance >= next_distance: next_entry["distanceMeters"] = existing_distance - next_entry["duration"] = max(_safe_int(existing_entry.get("duration", 0), 0), next_entry["duration"]) + existing_duration = _safe_int(existing_entry.get("duration", 0), 0) + next_entry["duration"] = existing_duration if existing_attention_known else max(existing_duration, next_entry["duration"]) next_entry["engagedSeconds"] = max(0.0, _safe_float(existing_entry.get("engagedSeconds", 0.0), 0.0)) next_entry["distractedMoments"] = max(0, _safe_int(existing_entry.get("distractedMoments", 0), 0)) next_entry["unresponsiveMoments"] = max(0, _safe_int(existing_entry.get("unresponsiveMoments", 0), 0)) + next_entry["analysisComplete"] = bool(existing_entry.get("analysisComplete", False)) if (not model_name or model_name == "Unknown model") and _clean_model_label(existing_entry.get("model", "")): next_entry["model"] = _clean_model_label(existing_entry.get("model", "")) next_entry["modelKey"] = canonical_model_key(existing_entry.get("modelKey", "")) or _model_usage_key(next_entry["model"])