diff --git a/selfdrive/controls/tests/test_conditional_experimental_mode.py b/selfdrive/controls/tests/test_conditional_experimental_mode.py new file mode 100644 index 000000000..34c93cce2 --- /dev/null +++ b/selfdrive/controls/tests/test_conditional_experimental_mode.py @@ -0,0 +1,41 @@ +from types import SimpleNamespace + +from openpilot.common.constants import CV +from openpilot.starpilot.controls.lib.conditional_experimental_mode import ConditionalExperimentalMode + + +def make_cem(*, model_length: float, model_stopped: bool = False, tracking_lead: bool = False): + planner = SimpleNamespace( + params=None, + params_memory=None, + model_length=model_length, + model_stopped=model_stopped, + tracking_lead=tracking_lead, + ) + return ConditionalExperimentalMode(planner) + + +def make_sm(traffic_mode_enabled: bool = False): + return { + "starpilotCarState": SimpleNamespace(trafficModeEnabled=traffic_mode_enabled), + } + + +def test_low_speed_cruise_does_not_trigger_stop_light_from_model_stopped(): + v_ego = 10 * CV.MPH_TO_MS + model_length = v_ego * 10.0 + + cem = make_cem(model_length=model_length, model_stopped=True) + cem.stop_sign_and_light(v_ego, make_sm(), model_time=7.0) + + assert not cem.stop_light_detected + + +def test_predicted_stop_within_threshold_triggers_stop_light(): + v_ego = 30 * CV.MPH_TO_MS + model_length = v_ego * 4.0 + + cem = make_cem(model_length=model_length) + cem.stop_sign_and_light(v_ego, make_sm(), model_time=7.0) + + assert cem.stop_light_detected diff --git a/starpilot/controls/lib/conditional_experimental_mode.py b/starpilot/controls/lib/conditional_experimental_mode.py index ff58833f5..78d51e32a 100644 --- a/starpilot/controls/lib/conditional_experimental_mode.py +++ b/starpilot/controls/lib/conditional_experimental_mode.py @@ -199,7 +199,12 @@ class ConditionalExperimentalMode: model_stopping = self.starpilot_planner.model_length < v_ego * adjusted_model_time - self.stop_light_filter.update(self.starpilot_planner.model_stopped or model_stopping) + # `model_stopped` is a coarse horizon-length check (< 50 m with current constants) + # used elsewhere for force-stop/green-light behavior. Reusing it here causes + # ordinary low-speed cruising to look like a stop prediction and can latch the + # STOP_LIGHT CEM trigger. For the CEM detector, key strictly off the configured + # "predicted stop within N seconds" threshold. + self.stop_light_filter.update(model_stopping) self.stop_light_detected = bool(self.stop_light_filter.x >= THRESHOLD**2 and not self.starpilot_planner.tracking_lead) else: self.stop_light_filter.x = 0 diff --git a/starpilot/system/the_pond/assets/components/tools/toggles.css b/starpilot/system/the_pond/assets/components/tools/toggles.css index 8218cb497..dbe9c6335 100644 --- a/starpilot/system/the_pond/assets/components/tools/toggles.css +++ b/starpilot/system/the_pond/assets/components/tools/toggles.css @@ -109,6 +109,57 @@ width: 100%; } +.toggle-control-status { + background-color: rgba(10, 12, 18, 0.35); + border: 1px solid rgba(94, 200, 200, 0.2); + border-radius: var(--border-radius-lg); + margin-top: var(--padding-base); + padding: var(--padding-base); +} + +.toggle-control-status.error { + border-color: rgba(224, 85, 119, 0.4); +} + +.toggle-control-status-title { + color: var(--text-color); + font-size: var(--font-size-base); + font-weight: var(--font-weight-bold); +} + +.toggle-control-status-line { + color: var(--text-color); + font-size: var(--font-size-sm); + margin: var(--margin-sm) 0 0; +} + +.toggle-control-status-line.error { + color: var(--danger-fg); +} + +.toggle-control-status-track { + background: var(--track-color); + border-radius: var(--border-radius-sm); + height: 10px; + margin-top: var(--margin-sm); + overflow: hidden; +} + +.toggle-control-status-track.error { + border: 1px solid var(--danger-bg); +} + +.toggle-control-status-fill { + background: linear-gradient(90deg, #5ec8c8 0%, #8b6cc5 100%); + border-radius: var(--border-radius-sm); + height: 100%; + transition: width var(--transition-base); +} + +.toggle-control-status-fill.error { + background: linear-gradient(90deg, #b43a3a 0%, #de5656 100%); +} + @media only screen and (max-width: 768px) and (orientation: portrait) { .toggle-control-wrapper { flex-direction: column; diff --git a/starpilot/system/the_pond/assets/components/tools/toggles.js b/starpilot/system/the_pond/assets/components/tools/toggles.js index 4f2097a56..79d075fe5 100644 --- a/starpilot/system/the_pond/assets/components/tools/toggles.js +++ b/starpilot/system/the_pond/assets/components/tools/toggles.js @@ -2,20 +2,141 @@ import { html, reactive } from "/assets/vendor/arrow-core.js" import { Modal } from "/assets/components/modal.js" import { TailscaleControl } from "/assets/components/tailscale/tailscale.js" -export function ToggleControl() { - const state = reactive({ - showResetDefaultModal: false, - showResetStockModal: false, - showSaveMeModal: false, - factoryResetBusy: false, - }); +const FACTORY_RESET_STATUS_POLL_INTERVAL_MS = 1000 - const fileInput = document.createElement("input") +const state = reactive({ + showResetDefaultModal: false, + showResetStockModal: false, + showSaveMeModal: false, + factoryResetBusy: false, + factoryResetStatus: null, +}) + +let initialized = false +let fileInput = null +let factoryResetPollHandle = null + +function isToggleRouteActive() { + return window.location.pathname === "/manage_toggles" +} + +function isFactoryResetStatusRelevant(payload) { + return String(payload?.lastMode || "").trim() === "factory-reset" +} + +function toPercent(value) { + const n = Number(value) + if (!Number.isFinite(n)) return 0 + return Math.max(0, Math.min(100, n)) +} + +function shouldContinueFactoryResetPolling() { + return !!state.factoryResetStatus && ( + !!state.factoryResetStatus.running + || state.factoryResetStatus.stage === "factory-resetting" + || state.factoryResetStatus.stage === "rebooting" + || state.factoryResetStatus.stage === "starting" + ) +} + +function stopFactoryResetPolling() { + if (!factoryResetPollHandle) return + clearTimeout(factoryResetPollHandle) + factoryResetPollHandle = null +} + +function ensureFactoryResetPolling() { + if (factoryResetPollHandle) return + + const poll = async () => { + if (!isToggleRouteActive()) { + factoryResetPollHandle = null + return + } + + await fetchFactoryResetStatus() + if (shouldContinueFactoryResetPolling()) { + factoryResetPollHandle = setTimeout(poll, FACTORY_RESET_STATUS_POLL_INTERVAL_MS) + } else { + factoryResetPollHandle = null + } + } + + factoryResetPollHandle = setTimeout(poll, FACTORY_RESET_STATUS_POLL_INTERVAL_MS) +} + +async function fetchFactoryResetStatus() { + try { + const response = await fetch("/api/update/fast/status") + const payload = await response.json() + if (!response.ok) { + throw new Error(payload.error || response.statusText || "Failed to load factory reset status") + } + + state.factoryResetStatus = isFactoryResetStatusRelevant(payload) ? { + running: !!payload.running, + stage: String(payload.stage || "idle"), + message: String(payload.message || ""), + lastError: String(payload.lastError || ""), + progressLabel: String(payload.progressLabel || ""), + progressDetail: String(payload.progressDetail || ""), + progressPercent: toPercent(payload.progressPercent), + progressStep: Number(payload.progressStep || 0), + progressTotalSteps: Number(payload.progressTotalSteps || 5), + } : null + } catch (error) { + if (!shouldContinueFactoryResetPolling()) { + state.factoryResetStatus = null + } + } finally { + if (shouldContinueFactoryResetPolling()) { + ensureFactoryResetPolling() + } else { + stopFactoryResetPolling() + } + } +} + +async function restoreToggles(event) { + const uploadedFile = event.target.files[0] + if (uploadedFile) { + const fileContents = await uploadedFile.text() + const toggleData = JSON.parse(fileContents) + + const response = await fetch("/api/toggles/restore", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(toggleData), + }) + + const result = await response.json() + showSnackbar(result.message || "Toggles restored!") + + event.target.value = "" + } +} + +function initializeFileInput() { + if (fileInput) return + + fileInput = document.createElement("input") fileInput.type = "file" fileInput.accept = ".json" fileInput.style.display = "none" fileInput.addEventListener("change", restoreToggles) document.body.appendChild(fileInput) +} + +function initialize() { + if (initialized) return + initialized = true + + initializeFileInput() +} + +export function ToggleControl() { + initialize() + fetchFactoryResetStatus() async function backupToggles() { const response = await fetch("/api/toggles/backup", { method: "POST" }) @@ -29,25 +150,6 @@ export function ToggleControl() { URL.revokeObjectURL(downloadUrl) } - async function restoreToggles(event) { - const uploadedFile = event.target.files[0] - if (uploadedFile) { - const fileContents = await uploadedFile.text() - const toggleData = JSON.parse(fileContents) - - const response = await fetch("/api/toggles/restore", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(toggleData) - }) - - const result = await response.json() - showSnackbar(result.message || "Toggles restored!") - - event.target.value = "" - } - } - function confirmResetDefault() { state.showResetDefaultModal = true; } @@ -94,7 +196,20 @@ export function ToggleControl() { throw new Error(payload.error || response.statusText || "Failed to start factory reset") } + state.factoryResetStatus = { + running: true, + stage: "starting", + message: payload.message || "Factory reset started. Device will reboot when complete.", + lastError: "", + progressLabel: "Preparing factory reset", + progressDetail: "Initializing factory reset...", + progressPercent: 0, + progressStep: 1, + progressTotalSteps: 5, + } + ensureFactoryResetPolling() showSnackbar(payload.message || "Factory reset started.") + fetchFactoryResetStatus() } catch (error) { showSnackbar(error?.message || "Failed to start factory reset", "error") } finally { @@ -135,6 +250,22 @@ export function ToggleControl() { disabled="${() => state.factoryResetBusy}"> ${() => state.factoryResetBusy ? "Starting..." : "SAVE ME"} + ${() => state.factoryResetStatus ? html` +
+
Factory Reset Status
+

+ Step: + ${state.factoryResetStatus.progressStep}/${state.factoryResetStatus.progressTotalSteps} + ${state.factoryResetStatus.progressLabel ? `- ${state.factoryResetStatus.progressLabel}` : ""} +

+
+
+
+ ${() => state.factoryResetStatus.message ? html`

${state.factoryResetStatus.message}

` : ""} + ${() => state.factoryResetStatus.progressDetail ? html`

${state.factoryResetStatus.progressDetail}

` : ""} + ${() => state.factoryResetStatus.lastError ? html`

Last Error: ${state.factoryResetStatus.lastError}

` : ""} +
+ ` : ""} diff --git a/starpilot/system/the_pond/assets/components/tools/update_manager.js b/starpilot/system/the_pond/assets/components/tools/update_manager.js index 470854081..663ed960e 100644 --- a/starpilot/system/the_pond/assets/components/tools/update_manager.js +++ b/starpilot/system/the_pond/assets/components/tools/update_manager.js @@ -110,6 +110,10 @@ function shouldShowPrimaryUpdateAction() { return !!state.checkedForUpdates && !!state.status?.updateAvailable } +function isFactoryResetStatusActive() { + return String(state.status?.lastMode || "").trim() === "factory-reset" +} + function shouldContinuePolling() { return !!state.status?.running || state.status?.stage === "rebooting" || reconnectPending } @@ -503,19 +507,21 @@ export function UpdateManager() {

Onroad: ${state.status?.isOnroad ? "Yes" : "No"}

-
-
- - Step ${state.status?.progressStep || 0}/${state.status?.progressTotalSteps || 5}: - ${state.status?.progressLabel || "Idle"} - - ${Math.round(toPercent(state.status?.progressPercent))}% + ${() => !isFactoryResetStatusActive() ? html` +
+
+ + Step ${state.status?.progressStep || 0}/${state.status?.progressTotalSteps || 5}: + ${state.status?.progressLabel || "Idle"} + + ${Math.round(toPercent(state.status?.progressPercent))}% +
+
+
+
+ ${() => state.status?.progressDetail ? html`

${state.status.progressDetail}

` : ""}
-
-
-
- ${() => state.status?.progressDetail ? html`

${state.status.progressDetail}

` : ""} -
+ ` : html`

Factory reset status and errors are shown on Backup/Restore.

`} ${() => state.status?.isOnroad ? html`

Onroad: actions disabled

` : ""} @@ -573,9 +579,9 @@ export function UpdateManager() {
` : ""} - ${() => state.status?.message && state.status?.stage !== "rebooting" ? html`

${state.status.message}

` : ""} + ${() => !isFactoryResetStatusActive() && state.status?.message && state.status?.stage !== "rebooting" ? html`

${state.status.message}

` : ""} ${() => state.status?.remoteError ? html`

Remote Check: ${state.status.remoteError}

` : ""} - ${() => state.status?.lastError ? html`

Last Error: ${state.status.lastError}

` : ""} + ${() => !isFactoryResetStatusActive() && state.status?.lastError ? html`

Last Error: ${state.status.lastError}

` : ""} ${() => state.error ? html`

Error: ${state.error}

` : ""}
diff --git a/system/athena/athenad.py b/system/athena/athenad.py index 9b5f5dcbf..ed167c417 100755 --- a/system/athena/athenad.py +++ b/system/athena/athenad.py @@ -37,6 +37,7 @@ from openpilot.common.realtime import set_core_affinity from openpilot.system.hardware import HARDWARE, PC from openpilot.system.loggerd.xattr_cache import getxattr, setxattr from openpilot.common.swaglog import cloudlog +from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID from openpilot.system.version import get_build_metadata from openpilot.system.hardware.hw import Paths @@ -812,6 +813,20 @@ def backoff(retries: int) -> int: return random.randrange(0, min(128, int(2 ** retries))) +def get_athena_dongle_id(params: Params) -> str | None: + dongle_id = params.get("DongleId", encoding="utf-8") + if dongle_id in (None, "", UNREGISTERED_DONGLE_ID): + return None + return dongle_id + + +def wait_for_exit(exit_event: threading.Event | None, timeout: float) -> bool: + if exit_event is None: + time.sleep(timeout) + return False + return exit_event.wait(timeout) + + def main(exit_event: threading.Event = None): try: set_core_affinity([0, 1, 2, 3]) @@ -819,15 +834,28 @@ def main(exit_event: threading.Event = None): cloudlog.exception("failed to set core affinity") params = Params() - dongle_id = params.get("DongleId") UploadQueueCache.initialize(upload_queue) - ws_uri = ATHENA_HOST + "/ws/v2/" + dongle_id - api = Api(dongle_id) - conn_start = None conn_retries = 0 + waiting_for_dongle_id = False while exit_event is None or not exit_event.is_set(): + dongle_id = get_athena_dongle_id(params) + if dongle_id is None: + if not waiting_for_dongle_id: + cloudlog.warning("athenad.main.missing_dongle_id") + waiting_for_dongle_id = True + conn_start = None + conn_retries = 0 + params.remove("LastAthenaPingTime") + if wait_for_exit(exit_event, 5): + break + continue + + waiting_for_dongle_id = False + ws_uri = ATHENA_HOST + "/ws/v2/" + dongle_id + api = Api(dongle_id) + try: if conn_start is None: conn_start = time.monotonic() diff --git a/system/athena/tests/test_athenad_main.py b/system/athena/tests/test_athenad_main.py new file mode 100644 index 000000000..cd9ebdb0d --- /dev/null +++ b/system/athena/tests/test_athenad_main.py @@ -0,0 +1,35 @@ +import threading +import time + +from openpilot.common.params import Params +from openpilot.system.athena import athenad + + +class TestAthenadMain: + def setup_method(self): + self.default_params = { + "DongleId": "0000000000000000", + "AthenadUploadQueue": [], + } + + self.params = Params() + for k, v in self.default_params.items(): + self.params.put(k, v) + + def test_main_waits_for_dongle_id(self, mocker): + self.params.remove("DongleId") + + mock_create_connection = mocker.patch("openpilot.system.athena.athenad.create_connection") + exit_event = threading.Event() + thread = threading.Thread(target=athenad.main, args=(exit_event,)) + thread.start() + + try: + time.sleep(0.2) + mock_create_connection.assert_not_called() + assert thread.is_alive() + finally: + exit_event.set() + thread.join(5) + + assert not thread.is_alive()