From 9a7f3d417453da8faf504c2ef9ecf68ce55abc5c Mon Sep 17 00:00:00 2001 From: Zeph Leggett Date: Fri, 16 Oct 2026 03:54:44 -0500 Subject: [PATCH] Jetlink offroad alert and optional owner - jetlinkd is ancillary: a dead owner sends the drive back to the small model and no longer blocks engaging - a crashed owner now restarts, as porting.md asks - Offroad_JetlinkUnavailable says why a Jetlink the user turned on cannot run (jetlink's reason()) - JetlinkBigModel, JetlinkCatalog and JetlinkRuntime are no longer used params_keys.h changed: libparams_c.so, libcommon.a and the prebuilt manifest need regenerating with the prebuilt build. Until then the tracked params library does not know Offroad_JetlinkUnavailable, so hardwared skips that alert (logged once) and it stays inert. --- openpilot/common/params_keys.h | 4 +- .../selfdrive/selfdrived/alerts_offroad.json | 4 ++ .../selfdrived/tests/test_process_health.py | 2 +- openpilot/system/hardware/hardwared.py | 21 ++++++++- .../hardware/tests/test_jetlink_alert.py | 47 +++++++++++++++++++ openpilot/system/manager/process_config.py | 3 +- openpilot/system/manager/process_health.py | 3 +- .../manager/test/test_process_recovery.py | 15 ++++++ 8 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 openpilot/system/hardware/tests/test_jetlink_alert.py diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 00d8f11720..a91c578c67 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -257,6 +257,7 @@ inline static std::unordered_map keys = { {"Offroad_ConnectivityNeeded", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_ConnectivityNeededPrompt", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_ExcessiveActuation", {PERSISTENT, JSON}}, + {"Offroad_JetlinkUnavailable", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_NoFirmware", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_Pairing", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_Recalibration", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, @@ -347,11 +348,8 @@ inline static std::unordered_map keys = { {"ChestnutLoading", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, {"JetlinkMode", {PERSISTENT, INT, "0"}}, {"JetlinkChargePhone", {PERSISTENT, BOOL, "0"}}, - {"JetlinkBigModel", {PERSISTENT | DONT_LOG, JSON}}, - {"JetlinkCatalog", {PERSISTENT | DONT_LOG, JSON}}, {"JetlinkSpec", {PERSISTENT | DONT_LOG, JSON}}, {"JetlinkPointers", {PERSISTENT | DONT_LOG, JSON}}, {"JetlinkProgress", {CLEAR_ON_MANAGER_START | DONT_LOG, JSON}}, - {"JetlinkRuntime", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | DONT_LOG, JSON}}, {"Version", {PERSISTENT, STRING}}, }; diff --git a/openpilot/selfdrive/selfdrived/alerts_offroad.json b/openpilot/selfdrive/selfdrived/alerts_offroad.json index 68cc6e24b0..2024aec407 100644 --- a/openpilot/selfdrive/selfdrived/alerts_offroad.json +++ b/openpilot/selfdrive/selfdrived/alerts_offroad.json @@ -49,6 +49,10 @@ "text": "Chestnut USB link is slow. Check the USB cable. The current speed is %1.", "severity": 0 }, + "Offroad_JetlinkUnavailable": { + "text": "Jetlink unavailable: %1", + "severity": 0 + }, "Offroad_UnregisteredHardware": { "text": "Failed to register with comma.ai backend. It will not connect or upload to comma.ai servers, and receives no support from comma.ai. If this is a device purchased at comma.ai/shop, open a ticket at https://comma.ai/support.", "severity": 1 diff --git a/openpilot/selfdrive/selfdrived/tests/test_process_health.py b/openpilot/selfdrive/selfdrived/tests/test_process_health.py index 19150a7b84..2524003385 100644 --- a/openpilot/selfdrive/selfdrived/tests/test_process_health.py +++ b/openpilot/selfdrive/selfdrived/tests/test_process_health.py @@ -25,7 +25,7 @@ def process_health(processes): return drive -@pytest.mark.parametrize('name', ['android_autod', 'galaxy_hotspot']) +@pytest.mark.parametrize('name', ['android_autod', 'galaxy_hotspot', 'jetlinkd']) @pytest.mark.parametrize('running,requested', [(False, False), (False, True), (True, True)]) def test_optional_lifecycle_does_not_block_engagement(name, running, requested): drive = process_health([(name, running, requested)]) diff --git a/openpilot/system/hardware/hardwared.py b/openpilot/system/hardware/hardwared.py index ac8cdfb569..1c43322641 100755 --- a/openpilot/system/hardware/hardwared.py +++ b/openpilot/system/hardware/hardwared.py @@ -17,7 +17,7 @@ from openpilot.cereal import log from openpilot.cereal.services import SERVICE_LIST from openpilot.common.utils import strip_deprecated_keys from openpilot.common.filter_simple import FirstOrderFilter -from openpilot.common.params import Params +from openpilot.common.params import Params, UnknownKeyName from openpilot.common.realtime import DT_HW from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert from openpilot.common.hardware import HARDWARE, COMMA_HARDWARE, PC @@ -33,6 +33,7 @@ from openpilot.system.hardware.chestnut.monitoring import chestnut_state_thread from openpilot.system.hardware.chestnut.status import ChestnutStatus from openpilot.common.version import terms_version, training_version from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID +from openpilot.starpilot.models import jetlink_adapter ThermalStatus = log.DeviceState.ThermalStatus NetworkType = log.DeviceState.NetworkType @@ -123,6 +124,23 @@ def set_offroad_alert_if_changed(offroad_alert: str, show_alert: bool, extra_tex prev_offroad_states[offroad_alert] = (show_alert, extra_text) set_offroad_alert(offroad_alert, show_alert, extra_text) +# A prebuilt params library not yet regenerated lacks Offroad_JetlinkUnavailable: the alert is skipped rather than +# taking hardwared down. The guard can go once the library knows the key. +jetlink_alert_usable = True + + +def update_jetlink_alert() -> None: + """Why the Jetlink the user turned on cannot run. Reads files only; with the link off, jetlink binds nothing.""" + global jetlink_alert_usable + if not jetlink_alert_usable: + return + reason = jetlink_adapter.hooks().reason() + try: + set_offroad_alert_if_changed("Offroad_JetlinkUnavailable", reason is not None, extra_text=reason) + except UnknownKeyName: + jetlink_alert_usable = False + cloudlog.warning("Offroad_JetlinkUnavailable is not in this params library: Jetlink's offroad alert is off") + def touch_thread(end_event): count = 0 @@ -394,6 +412,7 @@ def hardware_thread(end_event, hw_queue) -> None: extra_text = f"{offroad_comp_temp:.1f}C" show_alert = (not onroad_conditions["device_temp_good"] or not startup_conditions["device_temp_engageable"]) and onroad_conditions["ignition"] set_offroad_alert_if_changed("Offroad_TemperatureTooHigh", show_alert, extra_text=extra_text) + update_jetlink_alert() if show_alert: msg.deviceState.fanSpeedPercentDesired = 100 diff --git a/openpilot/system/hardware/tests/test_jetlink_alert.py b/openpilot/system/hardware/tests/test_jetlink_alert.py new file mode 100644 index 0000000000..1270abff87 --- /dev/null +++ b/openpilot/system/hardware/tests/test_jetlink_alert.py @@ -0,0 +1,47 @@ +"""hardwared's Jetlink offroad alert says why the link cannot run, and never takes hardwared down.""" +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch + +from openpilot.common.params import UnknownKeyName +from openpilot.system.hardware import hardwared + + +class TestJetlinkAlert(unittest.TestCase): + def setUp(self): + hardwared.jetlink_alert_usable = True + self.addCleanup(setattr, hardwared, 'jetlink_alert_usable', True) + self.reason = Mock(return_value='no usable gadget') + self.link = SimpleNamespace(hooks=lambda: SimpleNamespace(reason=self.reason)) + self.set_alert = Mock() + for name, value in (('jetlink_adapter', self.link), ('set_offroad_alert_if_changed', self.set_alert), ('cloudlog', Mock())): + patcher = patch.object(hardwared, name, value) + patcher.start() + self.addCleanup(patcher.stop) + + def test_the_alert_follows_jetlinks_reason(self): + hardwared.update_jetlink_alert() + self.set_alert.assert_called_with('Offroad_JetlinkUnavailable', True, extra_text='no usable gadget') + self.reason.return_value = None + hardwared.update_jetlink_alert() + self.set_alert.assert_called_with('Offroad_JetlinkUnavailable', False, extra_text=None) + + def test_with_the_link_off_jetlink_is_not_bound(self): + from jetlink.openpilot.hooks import Hooks + from openpilot.starpilot.models.jetlink_adapter import API + factory = Mock() + self.link.hooks = lambda: Hooks(API, factory, link_on=lambda: False) + hardwared.update_jetlink_alert() + factory.assert_not_called() + self.set_alert.assert_called_once_with('Offroad_JetlinkUnavailable', False, extra_text=None) + + def test_a_params_library_without_the_key_turns_the_alert_off_once(self): + self.set_alert.side_effect = UnknownKeyName(b'Offroad_JetlinkUnavailable') + hardwared.update_jetlink_alert() + hardwared.update_jetlink_alert() + self.set_alert.assert_called_once() + hardwared.cloudlog.warning.assert_called_once() + + +if __name__ == '__main__': + unittest.main() diff --git a/openpilot/system/manager/process_config.py b/openpilot/system/manager/process_config.py index b69338fefe..4e35c846e7 100644 --- a/openpilot/system/manager/process_config.py +++ b/openpilot/system/manager/process_config.py @@ -137,7 +137,8 @@ procs = [ enabled=platform.system() == "Linux", restart_on_exit=True), PythonProcess("galaxy_hotspot", "openpilot.starpilot.galaxy.hotspot_daemon", galaxy_local, enabled=COMMA_HARDWARE and not os.environ.get('OPENPILOT_PREFIX'), restart_on_exit=True), - JetlinkProcess("jetlinkd", "openpilot.starpilot.models.jetlink_adapter", jetlink_enabled, enabled=COMMA_HARDWARE), + JetlinkProcess("jetlinkd", "openpilot.starpilot.models.jetlink_adapter", jetlink_enabled, enabled=COMMA_HARDWARE, + restart_on_exit=True), PythonProcess("android_autod", "openpilot.starpilot.system.android_auto.daemon", android_auto_enabled, enabled=COMMA_HARDWARE, restart_on_exit=True), # boot can race bluetoothd (NoSuchUnit) # Boot can race the audio device; selfdrived blocks engagement while soundd is down. diff --git a/openpilot/system/manager/process_health.py b/openpilot/system/manager/process_health.py index e5694a10a5..99bc06e84b 100644 --- a/openpilot/system/manager/process_health.py +++ b/openpilot/system/manager/process_health.py @@ -2,7 +2,8 @@ # Projection and local phone access are optional even when requested. # Their lifecycles remain visible in managerState and their own status. -ANCILLARY_PROCESSES = frozenset({'android_autod', 'galaxy_hotspot'}) +# A dead Jetlink owner sends the drive back to the small model. +ANCILLARY_PROCESSES = frozenset({'android_autod', 'galaxy_hotspot', 'jetlinkd'}) def driving_process_failures(processes) -> set[str]: diff --git a/openpilot/system/manager/test/test_process_recovery.py b/openpilot/system/manager/test/test_process_recovery.py index 4e65a32ceb..e850a0ae58 100644 --- a/openpilot/system/manager/test/test_process_recovery.py +++ b/openpilot/system/manager/test/test_process_recovery.py @@ -102,6 +102,21 @@ class TestProcessRecovery(unittest.TestCase): self.assertEqual(p.restart_failures, 0) self.assertEqual(p.restart_at, 0) + def test_a_crashed_jetlink_owner_restarts(self): + from openpilot.system.manager.process_config import managed_processes + configured = managed_processes["jetlinkd"] + self.assertTrue(configured.restart_on_exit) + p = type(configured)("jetlinkd", "unused", lambda started, params, cp: True, restart_on_exit=configured.restart_on_exit) + self.step(p) + old = p.proc + old.exitcode = 1 + self.step(p) + self.assertIs(p.proc, old) + self.now += 1.01 + self.step(p) + self.assertIsNot(p.proc, old) + self.assertTrue(old.joined) + def test_boot_racing_processes_restart(self): from openpilot.system.manager.process_config import procs restarting = {p.name for p in procs if getattr(p, "restart_on_exit", False)}