{{ runtime.status === 'ready' && runtime.data?.laboratory?.health === 'fallback' ? runtime.data.laboratory.error : 'The pair is not currently verified as running.' }}
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}