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.
This commit is contained in:
Zeph Leggett
2026-10-16 04:08:06 -05:00
parent acde83a0a8
commit 4f7ff0a4d3
4 changed files with 166 additions and 9 deletions
+1 -1
View File
@@ -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
+10 -4
View File
@@ -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
+2 -1
View File
@@ -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.
@@ -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