From 4f7ff0a4d3bda886bfbcf938c9fc139ec91e30f6 Mon Sep 17 00:00:00 2001 From: Zeph Leggett Date: Fri, 16 Oct 2026 04:08:06 -0500 Subject: [PATCH] Jetlink adapter checks and guide jetlink's own kit checks the adapter module and modeld's hook order. Tests cover in_control with AOL armed and paused, the driving warp jetlink runs, and the catalog runner's history reset on tinygrad keeping its buffers. JETLINK.md describes the stay-engaged hand-back and the cable rule. --- CREDITS.md | 2 +- openpilot/starpilot/models/JETLINK.md | 14 +- openpilot/starpilot/models/README.md | 3 +- .../models/tests/test_jetlink_adapter.py | 156 +++++++++++++++++- 4 files changed, 166 insertions(+), 9 deletions(-) diff --git a/CREDITS.md b/CREDITS.md index 75ee4b24b2..fb01922527 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -2,7 +2,7 @@ ## Jetlink external inference -The comma-side Jetlink client and USB transport in `jetlink_repo` are by Zeph Leggett and the [Jetlink contributors](https://github.com/zoompilot/jetlink). They are vendored from revision `d24328d849b0e5154b55f149dc18c5dfe8bd79cc` under the MIT license, retained in [jetlink_repo/LICENSE](jetlink_repo/LICENSE). StarPilot owns the adapter to its model runner, settings and process lifecycle. Companion applications and inference servers remain distributed by Jetlink. +The comma-side Jetlink client and USB transport in `jetlink_repo` are by Zeph Leggett and the [Jetlink contributors](https://github.com/zoompilot/jetlink). They are vendored from revision `d24328d849b0e5154b55f149dc18c5dfe8bd79cc` under the MIT license, retained in [jetlink_repo/LICENSE](jetlink_repo/LICENSE). StarPilot's adapter (`openpilot/starpilot/models/jetlink_adapter.py`) builds on Jetlink's openpilot layer and owns its param names, Always On Lateral rule, model runner reset, settings and alerts. Companion applications and inference servers remain distributed by Jetlink. ## MRR35 Hyundai angle and radar diff --git a/openpilot/starpilot/models/JETLINK.md b/openpilot/starpilot/models/JETLINK.md index e9b90d717d..b3ddebeb45 100644 --- a/openpilot/starpilot/models/JETLINK.md +++ b/openpilot/starpilot/models/JETLINK.md @@ -69,10 +69,16 @@ prompt and benchmark sustained performance before use. ## Use and recovery Before changing the remote model, disengage cruise **and** any independent -Always On Lateral steering. Wait for Galaxy's fresh **Active · Jetlink remote model** -status before re-engaging. If the link stops or stalls, StarPilot falls back to -the local Small model; be ready to take control as behavior changes. Set Jetlink -to **Off** while parked to stop using the remote connection. +Always On Lateral steering, armed or paused. Wait for Galaxy's fresh **Active · Jetlink remote model** +status before re-engaging; engaging is refused for a second after the switch. If +the link stops or stalls while engaged, StarPilot stays engaged on the local Small +model and shows **Big Model Lost**; be ready to take control as behavior changes. +Jetlink reconnects by itself and switches back at the next full disengagement. Set +Jetlink to **Off** while parked to stop using the remote connection. + +With Jetlink on, the USB-C port belongs to its host: a downloaded Chestnut pick is +used only when a Chestnut is detected, never because a cable is plugged in. When +Jetlink is on but cannot run, an offroad alert says why. If a connection fails, check separate power, USB 3 support, host sleep, cooling, and the chosen StarPilot connection mode. An iPhone must stay unlocked with the diff --git a/openpilot/starpilot/models/README.md b/openpilot/starpilot/models/README.md index ad3f9163fe..34f8168747 100644 --- a/openpilot/starpilot/models/README.md +++ b/openpilot/starpilot/models/README.md @@ -31,4 +31,5 @@ charging over USB is optional and starts off. For a remote model switch, fully disengage cruise and any lateral control that stays on without cruise. Wait for Galaxy's fresh **Active · Jetlink remote model** status before engaging again. A lost or stalled link falls back to the local -Small model; remain ready to take over while its behavior changes. +Small model and stays engaged with a **Big Model Lost** warning; remain ready to +take over while its behavior changes. diff --git a/openpilot/starpilot/models/tests/test_jetlink_adapter.py b/openpilot/starpilot/models/tests/test_jetlink_adapter.py index 2fe6263936..f724b2fbd6 100644 --- a/openpilot/starpilot/models/tests/test_jetlink_adapter.py +++ b/openpilot/starpilot/models/tests/test_jetlink_adapter.py @@ -1,17 +1,41 @@ +import ast +import logging +from pathlib import Path from types import SimpleNamespace from unittest.mock import patch import numpy as np +import pytest -from jetlink.openpilot import Openpilot, conformance from jetlink.openpilot.hooks import Hooks, Off +from jetlink.openpilot.stock import Stock +from jetlink.openpilot.testing import FakeSubMaster, check_adapter, check_fallback, check_seam, install_guard from openpilot.starpilot.models import jetlink_adapter as adapter +from openpilot.starpilot.models.tests.jetlink_fixture import ARMED_PAUSED, AXES_IDLE, rule + +ROOT = Path(__file__).resolve().parents[4] +GUARD = install_guard() + + +@pytest.fixture(autouse=True) +def no_machine_files(): + # on a comma, a test that reaches the live params would clear JetlinkSpec + GUARD.start() + yield + assert GUARD.stop() == [] + + +def test_the_adapter_module(): + assert check_adapter(adapter, heavy=("openpilot.common.params", "openpilot.common.swaglog", "openpilot.cereal")) == [] + + +def test_the_modeld_seam(): + assert check_seam(ROOT / "openpilot/selfdrive/modeld/modeld.py") == [] def test_interface_keys(): - # the params directory is Stock's, held against openpilot's Params in test_jetlink_lifecycle + # conformance is check_adapter's; the params directory is Stock's, held against Params in test_jetlink_lifecycle op = adapter.adapter() - assert conformance(op, Openpilot) == [] assert adapter.owner_config().adapter == adapter.__name__ assert op.keys.link == "JetlinkMode" # nothing writes a big-model slot or catalog: jetlink's default model, so a fresh Jetson is provisioned @@ -38,6 +62,18 @@ def test_guard_answers_link_off_without_a_usable_jetlink(): adapter.hooks.cache_clear() +@pytest.mark.parametrize("mode", [None, "0", "1"]) +def test_the_fallback_answers_link_off(monkeypatch, tmp_path, mode): + # the hooks a missing jetlink reaches: modeld stops at prepare(), selfdrived builds no swap events + monkeypatch.setenv("PARAMS_ROOT", str(tmp_path)) + monkeypatch.setenv("OPENPILOT_PREFIX", "jetlink-test") + (tmp_path / "jetlink-test").mkdir() + if mode is not None: + (tmp_path / "jetlink-test/JetlinkMode").write_text(mode) + hooks = ("should_run", "status", "reason", "prepare", "shutdown_step", "runtime", "swap_events") + assert check_fallback(adapter._NoJetlink("jetlink is missing"), hooks, link_on=adapter.link_on) == [] + + def test_one_action_call_for_every_small_model(): from openpilot.selfdrive.modeld import modeld from openpilot.starpilot.models import runner @@ -60,3 +96,117 @@ def test_unsupported_device(): assert adapter.supported_device("mici") for device in ("tici", "pc", "unknown", "neo"): assert not adapter.supported_device(device) + + +@pytest.mark.parametrize("device,camera", [("tizi", (1928, 1208)), ("mici", (1344, 760))]) +def test_jetlink_runs_the_driving_warp_modeld_ships(device, camera): + op = adapter.adapter() + with patch("openpilot.common.hardware.HARDWARE.get_device_type", return_value=device): + geometry = op.camera() + assert geometry == (*camera, 512, 256) + assert op.warp_path(*geometry).is_file() + + +def test_chestnut_ids_are_openpilots(): + from openpilot.common.hardware.usb import CHESTNUT_ROM_USB_IDS, CHESTNUT_USB_IDS + assert adapter.adapter().CHESTNUT_IDS == frozenset(CHESTNUT_USB_IDS + CHESTNUT_ROM_USB_IDS) == Stock.CHESTNUT_IDS + + +DISARMED = SimpleNamespace(allowedLatch=False, lateralArmed=False) + + +def in_control(core=None, aol=None, intent=DISARMED): + with patch("openpilot.starpilot.aol.wire.decode_intent", return_value=intent): + return rule(core, aol).in_control() + + +# openpilot's own services are Stock's rule, tested in jetlink: one row here, then AOL's +@pytest.mark.parametrize("case,expected", [ + ({}, False), + ({"core": {"selfdriveState": SimpleNamespace(enabled=True, active=True)}}, True), + # AOL armed but paused: its axes are idle and only the intent says so + ({"aol": {"aolIntentWire": b"intent", "aolAxisState": AXES_IDLE}, "intent": ARMED_PAUSED}, True), + ({"aol": {"aolIntentWire": b"intent"}, "intent": SimpleNamespace(allowedLatch=True, lateralArmed=False)}, True), + ({"aol": {"aolIntentWire": b"intent"}, "intent": None}, True), + ({"aol": {"aolIntentWire": b"intent", "aolAxisState": SimpleNamespace(**{**vars(AXES_IDLE), "desiredLateral": True})}}, True), + ({"aol": {"aolIntentWire": b"intent", "aolAxisState": AXES_IDLE}}, False), +]) +def test_in_control(case, expected): + assert in_control(**case) is expected + + +def test_in_control_holds_the_swap_off_when_aol_cannot_be_read(): + op = rule(aol={"aolIntentWire": b"intent"}) + op._aol.alive["aolIntentWire"] = False + assert op.in_control() is True + op._aol = SimpleNamespace(update=lambda timeout: (_ for _ in ()).throw(RuntimeError("no msgq"))) + assert op.in_control() is True + + +def test_an_aol_intent_not_heard_from_yet_counts_as_armed(): + # the first frames after the AOL subscription starts have seen nothing yet + op = rule() + op._aol = None + with patch("openpilot.cereal.messaging.SubMaster", side_effect=lambda services: FakeSubMaster(services)), \ + patch("openpilot.starpilot.models.jetlink_adapter.time.monotonic", return_value=100.): + assert op.in_control() is True + with patch("openpilot.starpilot.models.jetlink_adapter.time.monotonic", return_value=100. + adapter.AOL_GRACE_S): + assert op.in_control() is False + + +def test_aols_reader_is_built_while_openpilot_is_engaged(): + # so it has heard AOL by the time openpilot disengages and an offer or a fall asks + op = rule(core={"selfdriveState": SimpleNamespace(enabled=True, active=True)}) + op._aol = None + with patch("openpilot.cereal.messaging.SubMaster", side_effect=lambda services: FakeSubMaster(services)) as built: + assert op.in_control() is True + assert op.in_control() is True + built.assert_called_once_with(list(adapter.AOL_SERVICES)) + assert op._aol.timeouts == [0, 0] + + +STATEFUL = {"input_shapes": {"new_img": (2, 6, 4, 8), "desire_pulse": (1, 8), "traffic_convention": (1, 2), "state_feat": (1, 3, 4)}, + "input_dtypes": {"state_feat": "float32"}, "state_pairs": ["state_feat"], "mapped_image_input": True} +VISION = {"img": (1, 12, 4, 8), "big_img": (1, 12, 4, 8)} +POLICY = {"features_buffer": (1, 3, 4), "desire_pulse": (1, 3, 8), "traffic_convention": (1, 2)} + + +@pytest.mark.parametrize("layout", ["split", "supercombo", "stateful"]) +def test_the_small_models_history_clears_in_place(layout): + # after the big model drove, the catalog runner starts from a zero history on the buffers its JIT captured + from tinygrad import Device, Tensor + from jetlink.openpilot import warp + from openpilot.starpilot.models import compile as catalog + from openpilot.starpilot.models.runner import CatalogModelState + + queues, npy = {"split": lambda: catalog.make_split_input_queues(VISION, POLICY, 2, Device.DEFAULT), + "supercombo": lambda: catalog.make_supercombo_input_queues({**VISION, **POLICY}, 2, Device.DEFAULT), + "stateful": lambda: catalog.make_stateful_input_queues(STATEFUL, Device.DEFAULT)}[layout]() + small = CatalogModelState.__new__(CatalogModelState) + small.input_queues, small.npy, small.prev_desire = queues, npy, np.ones(8, dtype=np.float32) + buffers = {name: q.uop.base.buffer for name, q in queues.items()} + warp.init_device(logging.getLogger(__name__)) # as modeld: the reset's kernels compile with no compile pool + reset = adapter.adapter().small_reset(small) + history = [name for name, q in queues.items() if q.device != "NPY" and name not in adapter.FRAME_INPUTS] + for _ in range(3): + for name in history: + queues[name].assign(Tensor.full(queues[name].shape, 7, dtype=queues[name].dtype, device=queues[name].device)).realize() + for array in npy.values(): + array.fill(3) + small.prev_desire.fill(1) + reset() + assert {name: q.uop.base.buffer for name, q in queues.items()} == buffers + for name in history: + assert not queues[name].numpy().any(), name + assert not any(array.any() for array in npy.values()) + assert not queues["packed_npy_inputs"].numpy().any() + assert not small.prev_desire.any() + + +def test_the_reset_reads_what_the_catalog_runner_keeps(): + # small_reset reads these; a rename would leave every fallback on a stale history + tree = ast.parse((ROOT / "openpilot/starpilot/models/runner.py").read_text()) + runner = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "CatalogModelState") + assigned = {n.attr for n in ast.walk(runner) if isinstance(n, ast.Attribute) and isinstance(n.ctx, ast.Store) + and isinstance(n.value, ast.Name) and n.value.id == "self"} + assert {"input_queues", "npy", "prev_desire"} <= assigned