From 0ffe5859ffefe81d3ebdcda5ae023e5e1d6b3c4b Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:05:50 -0600 Subject: [PATCH] Show Laboratory model pairs and selection status --- .../starpilot/galaxy/tests/test_models.mjs | 28 +++++++++++++ openpilot/starpilot/galaxy/web/js/models.js | 35 ++++++++++++---- openpilot/starpilot/models/manager.py | 6 ++- openpilot/starpilot/models/runtime.py | 14 ++++++- .../models/tests/test_pair_presentation.py | 33 +++++++++++++++ openpilot/starpilot/ui/home.py | 8 ++++ openpilot/starpilot/ui/models_state.py | 12 +++++- openpilot/starpilot/ui/runtime_app.py | 4 +- .../ui/tests/test_laboratory_home.py | 40 +++++++++++++++++++ prebuilt.json | 2 +- 10 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 openpilot/starpilot/models/tests/test_pair_presentation.py create mode 100644 openpilot/starpilot/ui/tests/test_laboratory_home.py diff --git a/openpilot/starpilot/galaxy/tests/test_models.mjs b/openpilot/starpilot/galaxy/tests/test_models.mjs index 72b0c71b57..1fc50b3c6d 100644 --- a/openpilot/starpilot/galaxy/tests/test_models.mjs +++ b/openpilot/starpilot/galaxy/tests/test_models.mjs @@ -343,3 +343,31 @@ for (const label of ["Polaris R3 A ⚠️🧪", "Polaris R3 B ⚠️🧪", "Pola } assert.deepEqual(ModelsPage.methods.polarisTitleParts({value: "spz_polaris_r3a"}), ["spz_polaris_r3a", ""]) + +// Laboratory overrides normal selections until the next model start. +const labConfig = { enabled: true, lateralModel: 'small', longitudinalModel: 'big' } +const labView = { ...managerPayload, laboratory: labConfig, capabilities: { ...managerPayload.capabilities, configureLaboratory: true } } +assert.equal(modelActionAllowed(labView, 'disable-laboratory'), true) +assert.equal(modelActionAllowed({ ...labView, isOnroad: true }, 'disable-laboratory'), false) +assert.equal(modelActionAllowed({ ...labView, downloading: true }, 'disable-laboratory'), false) +assert.equal(modelActionAllowed({ ...labView, laboratory: { ...labConfig, enabled: false } }, 'disable-laboratory'), false) +const labManager = managerFixture() +labManager.manager.start() +await labManager.reply(0, labView) +const disabledLab = labManager.manager.action('disable-laboratory') +assert.equal(labManager.requests[1].url, './api/models/laboratory') +assert.deepEqual(JSON.parse(labManager.requests[1].options.body), { ...labConfig, enabled: false }) +await labManager.reply(1, { message: 'Model Laboratory configuration saved' }) +await labManager.reply(2, { ...labView, laboratory: { ...labConfig, enabled: false } }) +await disabledLab +assert.equal(labManager.manager.data.laboratory.enabled, false) +labManager.manager.stop() +const pairData = { catalog: [{ id: 'small', name: 'Small model' }, { id: 'big', name: 'Big model' }], + laboratory: { active: true, lateralModel: 'small', longitudinalModel: 'big' } } +const pairPage = { selectionUncertain: false, status: {}, runtime: { status: 'ready', data: pairData } } +assert.equal(ModelsPage.computed.runningModelLabel.call(pairPage), 'Small model/Big model') +assert.equal(ModelsPage.computed.runningModelLabel.call({ ...pairPage, runtime: { status: 'stale', data: pairData } }), 'Status unavailable') +assert.equal(ModelsPage.computed.runningModelLabel.call({ ...pairPage, runtime: { status: 'ready', data: { ...pairData, laboratory: { ...pairData.laboratory, active: false } } } }), 'Laboratory output unavailable') +assert.match(ModelsPage.template, /Turn off Laboratory/) +assert.match(ModelsPage.template, /href="#\/model_laboratory"/) +assert.match(ModelsPage.template, /fallback choices/) diff --git a/openpilot/starpilot/galaxy/web/js/models.js b/openpilot/starpilot/galaxy/web/js/models.js index b5ac4ccdc3..06c486c8dc 100644 --- a/openpilot/starpilot/galaxy/web/js/models.js +++ b/openpilot/starpilot/galaxy/web/js/models.js @@ -151,10 +151,10 @@ function fileSizeText(model) { const actionPaths = { "select-small": "active", "select-big": "active", favorite: "preferences", unfavorite: "preferences", "enable-randomizer": "preferences", "disable-randomizer": "preferences", exclude: "preferences", include: "preferences", - download: "download", downloadAll: "download_all", cancel: "cancel", delete: "delete", refresh: "refresh_manifest", jetlink: "jetlink" } + download: "download", downloadAll: "download_all", cancel: "cancel", delete: "delete", refresh: "refresh_manifest", jetlink: "jetlink", "disable-laboratory": "laboratory" } const actionCapabilities = { "select-small": "select", "select-big": "select", favorite: "favorites", unfavorite: "favorites", "enable-randomizer": "randomizer", "disable-randomizer": "randomizer", exclude: "exclusions", include: "exclusions", - download: "download", downloadAll: "downloadAll", cancel: "cancel", delete: "delete", refresh: "refresh", jetlink: "jetlink" } + download: "download", downloadAll: "downloadAll", cancel: "cancel", delete: "delete", refresh: "refresh", jetlink: "jetlink", "disable-laboratory": "configureLaboratory" } export function fitsModelProfile(model, profile) { return Array.isArray(model?.profiles) ? model.profiles.includes(profile) : profile === "big" ? model?.requiresGpu === true : model?.requiresGpu === false @@ -163,6 +163,7 @@ export function fitsModelProfile(model, profile) { export function modelActionAllowed(data, action, model = null) { if (!data || data.capabilities?.[actionCapabilities[action]] !== true) return false if (data.isOnroad !== false && !["favorite", "unfavorite", "cancel", "select-small", "select-big"].includes(action)) return false + if (action === "disable-laboratory") return data.laboratory?.enabled === true && !data.downloading if (action === "jetlink") return data.jetlink !== undefined if (["select-small", "select-big"].includes(action)) { if (action === "select-big" && data.jetlinkBig && data.isOnroad !== false) return false @@ -294,6 +295,7 @@ export class ModelManagerFeed { const data = this.data const key = model?.value || "" let payload = action === "jetlink" ? extra : {} + if (action === "disable-laboratory") payload = { ...data.laboratory, enabled: false } if (action.startsWith("select-")) payload = { profile: action === "select-big" ? "big" : "small", model: key } else if (["favorite", "unfavorite"].includes(action)) { const userFavorites = data.models.filter(m => m.userFavorite && m.value !== key).map(m => m.value) @@ -306,7 +308,7 @@ export class ModelManagerFeed { } else if (["enable-randomizer", "disable-randomizer"].includes(action)) payload = { randomizer: action === "enable-randomizer" } else if (["download", "delete"].includes(action)) payload = { model: key } if (["download", "downloadAll"].includes(action)) payload.allowGpuWithoutGpu = extra.allowGpuWithoutGpu === true - if (typeof data.view === "string") payload.view = data.view + if (typeof data.view === "string" && action !== "disable-laboratory") payload.view = data.view this.saving = true this.data = null const result = await this.requestJson(actionPaths[action], payload) @@ -337,10 +339,15 @@ export const ModelsPage = { if (this.runtime.status === "unavailable" || this.runtime.status === "stale") return "Status unavailable" if (this.runtime.status !== "ready" || !this.runtime.data) return "Checking…" const model = this.runtime.data + const pair = model.laboratory + if (pair?.active) return [pair.lateralModel, pair.longitudinalModel].map(id => model.catalog.find(m => m.id === id)?.name || id).join("/") + if (pair?.lateralModel) return "Laboratory output unavailable" if (model.health === "unavailable") return "No running model" if (!model.loadedId) return model.health === "loading" ? "Loading…" : "Identity unverified" return model.catalog.find(item => item.id === model.loadedId)?.name || model.loadedId }, + laboratoryEnabled() { return !this.selectionUncertain && this.status.laboratory?.enabled === true }, + laboratoryRunning() { return this.runtime.status === "ready" && !!this.runtime.data?.laboratory?.lateralModel }, installedSmallModels() { return this.models.filter(m => (m.value === this.activeSmallModel || m.selectable) && fitsModelProfile(m, "small")) }, installedBigModels() { return this.models.filter(m => (m.value === this.activeBigModel || m.selectable) && fitsModelProfile(m, "big")) }, // the manager words it: under Jetlink, the Jetlink app's choice @@ -469,8 +476,8 @@ export const ModelsPage = { const result = await (["enable-randomizer", "disable-randomizer", "include", "exclude"].includes(action) ? reportSave(perform) : perform()) if (!this.disposed && result) { this.trackingProgress = ["download", "downloadAll", "refresh"].includes(action) - this.message = (this.trackingProgress && this.status.progress) || result.message || - (action.startsWith("select-") ? "Selection saved for the next onroad cycle." : "Saved.") + this.message = (action === "disable-laboratory" ? "Laboratory is off for the next start. Choose your Small and Big models below." : "") || (this.trackingProgress && this.status.progress) || result.message || + (action === "disable-laboratory" ? "Laboratory is off for the next start. Choose your Small and Big models below." : action.startsWith("select-") ? "Selection saved for the next onroad cycle." : "Saved.") } } finally { if (!this.disposed) { this.pendingChoice = null; this.busy = "" } } }, @@ -488,11 +495,25 @@ export const ModelsPage = { {{ error }} Selections apply next onroad cycle. Park before downloading models or changing Jetlink. {{ status.progress || 'Keep the device connected until the download finishes.' }} +
+
Model Laboratory
+
+

Laboratory is on. Its lateral/longitudinal pair overrides the Small/Big selections below at the next model start.

+

Laboratory is off for the next start. The currently loaded pair stays in use until then.

+

Loaded pair: {{ runningModelLabel }} · saved changes pending next start

+

{{ runtime.status === 'ready' && runtime.data?.laboratory?.health === 'fallback' ? runtime.data.laboratory.error : 'The pair is not currently verified as running.' }}

+
+ Open Laboratory + +
+

Park to turn off Laboratory. Changes apply at the next model start.

+
+
Models - Selected: {{ currentLabel }} + {{ laboratoryEnabled ? 'Normal selection:' : 'Selected:' }} {{ currentLabel }}
@@ -528,7 +549,7 @@ export const ModelsPage = { Controls
-

Selections take effect the next time the driving model starts.

+

{{ laboratoryEnabled ? 'Small/Big selections are saved as fallback choices. Turn off Laboratory to use them normally at the next start.' : 'Selections take effect the next time the driving model starts.' }}

diff --git a/openpilot/starpilot/models/manager.py b/openpilot/starpilot/models/manager.py index b71cf749cb..0b9a8b9df9 100644 --- a/openpilot/starpilot/models/manager.py +++ b/openpilot/starpilot/models/manager.py @@ -573,6 +573,7 @@ class ModelManager: def snapshot(self) -> dict: with self.lock: self._refresh_if_needed() + from openpilot.starpilot.models.laboratory import configuration entries, prefs = catalog(self.root), preferences(self.root) parked, gpu = self.parked(), self.gpu_present() job = self._job_status() @@ -599,7 +600,7 @@ class ModelManager: "Download required" if published else f"{GENERATION} download not published"}) count = sum(bool(row["installed"]) for row in models) return {"schemaVersion": 1, "models": models, "activeSmallModel": prefs["small"], "activeBigModel": active_big, - "jetlinkBig": jetlink_big, "bigNoneLabel": big_none, + "jetlinkBig": jetlink_big, "bigNoneLabel": big_none, "laboratory": configuration(self.root), "currentModel": prefs["big"] if gpu and prefs["big"] else prefs["small"], "userFavorites": prefs["userFavorites"], "sortMode": prefs["sortMode"], "randomizer": prefs["randomizer"], "blacklistedModels": prefs["blacklistedModels"], "summary": {"total": len(models), "installed": count, "missing": len(models)-count}, @@ -608,7 +609,8 @@ class ModelManager: "modelToDownload": job.get("model", ""), "downloadAll": job.get("downloadAll", False), "downloading": job["downloading"], "refreshing": job["refreshing"], "progress": job.get("progress", ""), "isOnroad": not parked, "gpuAvailable": gpu, "jetlink": jetlink, - "capabilities": {"select": True, "download": parked, "downloadAll": parked, "cancel": True, + "capabilities": {"configureLaboratory": parked and not job["downloading"], "select": True, "download": parked, + "downloadAll": parked, "cancel": True, "jetlink": parked, "refresh": parked, "favorites": True, "delete": parked, "randomizer": parked, "exclusions": parked}} def _require_parked(self) -> None: diff --git a/openpilot/starpilot/models/runtime.py b/openpilot/starpilot/models/runtime.py index 207ba22d77..acbd5e35c3 100644 --- a/openpilot/starpilot/models/runtime.py +++ b/openpilot/starpilot/models/runtime.py @@ -89,7 +89,19 @@ class ModelStatusSource: "requestedId": status.requested_id, "loadedId": status.loaded_id, "variant": status.variant.value if status.variant is not None else None, "health": status.health.value, "fallbackReason": status.fallback_reason, - "artifactSha256": status.artifact_sha256, "pendingNextStart": status.pending_next_start} + "artifactSha256": status.artifact_sha256, "pendingNextStart": status.pending_next_start, + "laboratory": self.laboratory_json(self.laboratory_configuration())} + + @staticmethod + def laboratory_configuration() -> dict: + from openpilot.starpilot.models.laboratory import configuration + from openpilot.starpilot.models.manager import ROOT + return configuration(ROOT) + + def home_label(self, commit: str = "") -> str: + from openpilot.starpilot.ui.models_state import home_model_label + status = self.snapshot() + return home_model_label(status, commit, self.laboratory_json(self.laboratory_configuration())) def laboratory_json(self, config: dict) -> dict: self.sm.update(0) diff --git a/openpilot/starpilot/models/tests/test_pair_presentation.py b/openpilot/starpilot/models/tests/test_pair_presentation.py new file mode 100644 index 0000000000..e55db484e8 --- /dev/null +++ b/openpilot/starpilot/models/tests/test_pair_presentation.py @@ -0,0 +1,33 @@ +from dataclasses import replace +from unittest.mock import Mock, patch + +from openpilot.starpilot.models.catalog import BUNDLED_CURRENT, model_entries +from openpilot.starpilot.models.runtime import ModelStatusSource +from openpilot.starpilot.models.status import ModelStatus, ModelHealth, LaboratoryLoad +from openpilot.starpilot.models.tests.test_runtime import FakeSubMaster, FakeParams + + +def test_actual_pair_status_reaches_home_and_galaxy_and_expires(): + sm = FakeSubMaster() + sm.update = Mock() + sm.messages['modelV2'].big = True + source = ModelStatusSource(FakeParams(True), sm, lambda: 2_000_000_000) + ids = list(model_entries())[:2] + config = {"enabled": True, "lateralModel": ids[1], "longitudinalModel": ids[0]} + load = LaboratoryLoad(421, 54321, 1_800_000_000, ids[0], 'v16', 'a'*64, ids[1], 'v16', 'b'*64, 'c'*64) + normal = ModelStatus(BUNDLED_CURRENT, None, None, ModelHealth.IDENTITY_UNAVAILABLE, False, None, None) + with patch.object(source, 'snapshot', return_value=normal), \ + patch.object(source, 'laboratory_configuration', return_value=config), \ + patch('openpilot.starpilot.models.runtime.process_start_ticks', return_value=54321), \ + patch('openpilot.starpilot.models.runtime.read_pair_receipt', return_value=load) as receipt: + status = source.json()['laboratory'] + assert status['active'] and status['pendingNextStart'] + assert (status['lateralModel'], status['longitudinalModel']) == tuple(ids) + assert ' / ' in source.home_label() + sm.valid['drivingModelData'] = False + assert source.json()['laboratory']['health'] == 'stale' + assert source.home_label() == 'Laboratory output unavailable' + sm.valid['drivingModelData'] = True + receipt.return_value = replace(load, process_start_ticks=99999) + assert not source.json()['laboratory']['active'] + assert ' / ' not in source.home_label() diff --git a/openpilot/starpilot/ui/home.py b/openpilot/starpilot/ui/home.py index a10e5a895a..9a900270bf 100644 --- a/openpilot/starpilot/ui/home.py +++ b/openpilot/starpilot/ui/home.py @@ -232,6 +232,14 @@ class HomeView: size = 30 while size > 22 and self._measure(text, FontRole.ROMAN, size).width > width: size -= 1 + if y == 140 and " / " in text: + parts = text.split(" / ", 1) + budget = (width - self._measure(" / ", FontRole.ROMAN, size).width) / 2 + for i, part in enumerate(parts): + while part and self._measure(part, FontRole.ROMAN, size).width > budget: + part = part[:-2].rstrip('…') + '…' + parts[i] = part + text = " / ".join(parts) while text and self._measure(text, FontRole.ROMAN, size).width > width: text = text[:-2].rstrip('…') + '…' self._text(text, FontRole.ROMAN, size, 6, y, color) diff --git a/openpilot/starpilot/ui/models_state.py b/openpilot/starpilot/ui/models_state.py index 6e1a722ba1..e7d4280bfd 100644 --- a/openpilot/starpilot/ui/models_state.py +++ b/openpilot/starpilot/ui/models_state.py @@ -136,7 +136,17 @@ def model_display_text(value: str) -> str: unicodedata.category(char) != "So" and char not in "\ufe0e\ufe0f\u200d").split()) -def home_model_label(status: ModelStatus, commit: str = "") -> str: +def home_model_label(status: ModelStatus, commit: str = "", laboratory: dict | None = None) -> str: + if laboratory: + if laboratory.get("active") is True: + entries = model_entries() + names = [entries[mid].name if mid in entries else mid + for mid in (laboratory["lateralModel"], laboratory["longitudinalModel"])] + return model_display_text(" / ".join(names)) + if laboratory.get("lateralModel"): + return "Laboratory output unavailable" + if laboratory.get("requested") and laboratory.get("health") != "fallback": + return "Model Laboratory · next start" if laboratory.get("health") == "unavailable" else "Model Laboratory · starting" entry = model_entries().get(status.loaded_id or status.requested_id) if entry is None: return "Driving model unavailable" diff --git a/openpilot/starpilot/ui/runtime_app.py b/openpilot/starpilot/ui/runtime_app.py index d55a62e6e1..6e799174c5 100644 --- a/openpilot/starpilot/ui/runtime_app.py +++ b/openpilot/starpilot/ui/runtime_app.py @@ -57,7 +57,7 @@ from openpilot.starpilot.ui.feature_settings_state import ( from openpilot.starpilot.ui.lane_change_feature import KEYS as LANE_CHANGE_KEYS, RESET as LANE_CHANGE_RESET from openpilot.starpilot.models.runtime import ModelStatusSource from openpilot.starpilot.models.status import project_status -from openpilot.starpilot.ui.models_state import model_page, model_profile_choices, model_profile_request, home_model_label +from openpilot.starpilot.ui.models_state import model_page, model_profile_choices, model_profile_request from openpilot.starpilot.ui.maps_state import MapStatusSource from openpilot.starpilot.galaxy.access import GalaxyAccessOwner, default_owner from openpilot.starpilot.ui.galaxy_access import GalaxyAccessFlow @@ -1319,7 +1319,7 @@ class StarShellSession: try: if self.model_source is None: self.model_source = ModelStatusSource(self.adapter.ui_state.params) - label = home_model_label(self.model_source.snapshot(), snapshot.home.commit if self.profile == Profile.COMPACT else "") + label = self.model_source.home_label(snapshot.home.commit if self.profile == Profile.COMPACT else "") except (OSError, RuntimeError, ValueError): label = "Driving model unavailable" self._home_model = (now, label) diff --git a/openpilot/starpilot/ui/tests/test_laboratory_home.py b/openpilot/starpilot/ui/tests/test_laboratory_home.py new file mode 100644 index 0000000000..c79db3692c --- /dev/null +++ b/openpilot/starpilot/ui/tests/test_laboratory_home.py @@ -0,0 +1,40 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +from openpilot.starpilot.models.catalog import model_entries, BUNDLED_CURRENT +from openpilot.starpilot.models.status import ModelStatus, ModelHealth +from openpilot.starpilot.ui.home import HomeView +from openpilot.starpilot.ui.models_state import home_model_label, model_display_text + + +def test_home_pair_uses_verified_roles_and_never_saved_choices(): + ids = list(model_entries())[:2] + normal = ModelStatus(BUNDLED_CURRENT, None, None, ModelHealth.UNAVAILABLE, False, None, None) + pair = {"active": True, "lateralModel": ids[0], "longitudinalModel": ids[1], "requested": False, "pendingNextStart": True} + names = [model_display_text(model_entries()[mid].name) for mid in ids] + assert home_model_label(normal, "abcdef123", pair) == " / ".join(names) + assert home_model_label(normal, laboratory=pair | {"active": False}) == "Laboratory output unavailable" + assert home_model_label(normal, laboratory={"requested": True, "health": "unavailable"}) == "Model Laboratory · next start" + assert home_model_label(normal, laboratory={"requested": True, "health": "fallback"}) == home_model_label(normal) + + +def test_compact_keeps_both_names_inside_the_model_row(): + view = HomeView.__new__(HomeView) + view._text = Mock() + view._icon = Mock() + view._measure = lambda text, role, size: SimpleNamespace(width=len(text) * size * .6) + state = SimpleNamespace(version="7", branch="Dom", commit_date="", model_label="A" * 60 + " / " + "B" * 60, + network="none", network_strength=0) + # Stop after rendering the header; later sections need unrelated home state. + class EndHeader(Exception): + pass + view._icon.side_effect = EndHeader + try: + view._compact(state) + except EndHeader: + pass + drawn = next(call.args for call in view._text.call_args_list if len(call.args) > 4 and call.args[4] == 140) + text, role, size, x, y, color = drawn + assert text.startswith("A") and " / B" in text + assert "Lateral" not in text and "Longitudinal" not in text + assert view._measure(text, role, size).width <= 520 diff --git a/prebuilt.json b/prebuilt.json index be92a000af..6736796985 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "1afefe80e3fd4ee15afd2a8b1c1108543dfd0547089bb508caf646d6c2a8b40b", "schema_version": 1} +{"protected_inventory_sha256": "26ffbe8157fb1f59f9cdf5141380ff040e251450435acd02ac0dfe08c5bffe4f", "schema_version": 1}