Jetlink: Active Big chooses Jetlink's model by its ONNX

This commit is contained in:
Zeph Leggett
2026-10-29 16:38:54 -05:00
committed by firestar5683
25 changed files with 557 additions and 133 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 `bf4dc940e98f5d3ed9b7163adb021439010d634f` 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.
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 `a863c3801da6fd925d14eb1f5b1e250f8efcc649` 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
+1 -1
View File
@@ -10,4 +10,4 @@ jetlink: run openpilot's large driving models on an attached Jetson.
# so does the release check (macos/scripts/check-version.sh). The comma runs
# jetlink from a checkout, not an install, so this is also all it has. The Swift
# reads it from Pinned.swift: run JetlinkKit/Scripts/make_pins.py after a bump.
__version__ = '0.8.9'
__version__ = '0.8.12'
+20 -6
View File
@@ -224,12 +224,16 @@ class JetlinkClient:
# -- handshake ------------------------------------------------------------
def hello(self, timeout: float = 5.0) -> dict:
def hello(self, timeout: float = 5.0, pick: dict | None = None) -> dict:
"""Introduce this client. The server starts its session over on a hello,
so this is also how a new owner of the gadget takes over one the server
never saw end; see Session.greet in JetlinkServer."""
never saw end; see Session.greet in JetlinkServer. `pick` is the comma's
own big-model pick and its age, {ref, sha256, nbytes, name, age_s}; the
answer's `pick` is the newer of it and the server's (PickStore)."""
seq = self._next_seq()
client = {'nonce': self.nonce, 'name': self.name}
if pick is not None:
client['pick'] = pick
try:
link = self.t.link_info()
except Exception:
@@ -301,7 +305,8 @@ class JetlinkClient:
frame_skip: int = DEFAULT_FRAME_SKIP,
progress: ProgressFn | None = None,
build_timeout: float = 900.0,
should_stop: StopFn | None = None) -> ModelSpec:
should_stop: StopFn | None = None,
fetch: bool = False, ref: str | None = None) -> ModelSpec:
"""Make the server ready to run this model, uploading and building if needed.
Blocks until ready or the build fails, and returns the spec the server
@@ -309,8 +314,12 @@ class JetlinkClient:
parser rejects an export (tinygrad and the org.tinygrad domain) still runs.
`onnx_path` None means the caller cannot upload (modeld) and a missing
engine is EngineMissing. `progress(stage, frac, msg)` has stage in
upload/patch/parse/build/load. `should_stop` is polled during long waits.
engine is EngineMissing. `fetch` asks a server that lacks the model to
download it itself (state downloading, stage download) rather than answer
need_upload; one that cannot, or is too old to, answers need_upload as
before. `ref` is the catalog ref the model is known by.
`progress(stage, frac, msg)` has stage in
download/upload/patch/parse/build/load. `should_stop` is polled during long waits.
"""
self.progress_cb = progress
self._should_stop = should_stop or (lambda: False)
@@ -318,7 +327,12 @@ class JetlinkClient:
self._engine_state = None
seq = self._next_seq()
self.t.send_json(P.Msg.ENGINE_REQ, seq, {'sha256': sha256, 'nbytes': nbytes, 'frame_skip': frame_skip})
request = {'sha256': sha256, 'nbytes': nbytes, 'frame_skip': frame_skip}
if fetch:
request['fetch'] = True
if ref:
request['ref'] = ref
self.t.send_json(P.Msg.ENGINE_REQ, seq, request)
resp = json.loads(bytes(self._expect(P.Msg.ENGINE_RESP, seq, 60.0).payload))
self._engine_state = resp
log.info("server engine state: %s (%s)", resp['state'], resp.get('detail', ''))
+1 -1
View File
@@ -55,7 +55,7 @@ POLL = 0.5
# what a borrower passes on of the server's hello (Loan.note_server): the owner
# never speaks the protocol, and sleep_after is how it knows whether letting
# go of the gadget lets the Jetson sleep. The rest is for its status record
SERVER_FIELDS = ('protocol', 'device', 'backend', 'runtime_version', 'trt_version', 'sleep_after')
SERVER_FIELDS = ('protocol', 'device', 'backend', 'runtime_version', 'trt_version', 'sleep_after', 'pick', 'fetches')
def _send(conn: socket.socket, msg: dict) -> None:
+10 -2
View File
@@ -45,15 +45,23 @@ class Keys:
offroad: str # BOOL: is the car parked (manager's IsOffroad)
progress: str # JSON {stage, frac, msg, drops}: provisioning and join progress
spec: str # JSON: the built model's spec and whether its engine is built
pointers: str # JSON: catalog ref -> {oid, size}
pointers: str # JSON: catalog ref, or a hash-named pick's sha256 -> {oid, size}
# sunnypilot's model manager: the big-model pick, JSON {ref, displayName},
# and the big-model catalog, JSON {bundles}. Without one both are None, and
# jetlink runs its own default big model
# jetlink runs its own default big model, or the one picked on the server.
# A fork with a picker of its own and no catalog (StarPilot) declares only
# big_model, and may name its pick by the ONNX's hash, {sha256,
# displayName}; a newer pick made on the server is written into it
big_model: str | None = None
catalog: str | None = None
# BOOL: an iPhone on a direct cable is asked to charge from the comma
# (comma.port). Off when unset, and without the key
charge_phone: str | None = None
# STRING: where the model manager takes a pick by its ref and stores it in
# big_model (sunnypilot's ModelManager_DownloadRef). A newer pick made on
# the server is written there, so the comma's own list shows it; without
# the key it drives all the same
select: str | None = None
@dataclass(frozen=True)
+25 -11
View File
@@ -43,6 +43,8 @@ PRESENT_TIMEOUT = 5.0
# hardware; the ceiling is only there so a server that has stopped answering
# does not hold the caller for the rest of the day.
BUILD_TIMEOUT = 1800.0
# and on top of that, a server downloading the model itself: 1.75 GB at 1 MB/s
FETCH_TIMEOUT = 1800.0
class ModelMissing(RuntimeError):
@@ -226,12 +228,15 @@ def identity(parts, entry: dict) -> tuple[str, int]:
"""The picked model's sha256 and byte count, from the catalog model's LFS
pointer, so the comma can name the model without holding or hashing the
ONNX. Looked up once per model ever and kept in a param: the one part of
provisioning that needs the internet."""
provisioning that needs the internet. A model picked by its hash alone is
only sized."""
sha256, nbytes = entry.get('oid'), entry.get('size')
if sha256 and nbytes:
return sha256, int(nbytes)
parts.progress.report('connect', 0.0, 'finding model', code='finding_model')
return parts.models.resolve_pointer(entry['ref'])
if entry.get('ref'):
return parts.models.resolve_pointer(entry['ref'])
return parts.models.resolve_size(sha256)
# What has already been hashed in this process, keyed on the file as it was
@@ -278,21 +283,27 @@ def verified_upload(log, model_path: Path | None, sha256: str, nbytes: int) -> P
def ensure(parts, client, sha256: str, nbytes: int, model_path: Path | None, *,
progress=None, should_stop=None, build_timeout: float = BUILD_TIMEOUT):
progress=None, should_stop=None, build_timeout: float = BUILD_TIMEOUT, ref: str | None = None,
fetch: bool = True):
"""Make the server ready for this model and remember what it answered.
Asks without the file first: the server answers from the sha alone when it
has the model, which is every parked poll and every join. EngineMissing means
the Jetson has neither the plan nor the bytes, and neither has the caller."""
has the model, which is every parked poll and every join. Without the file
here either, the server is asked to download the model itself, which only
the Jetson needs. EngineMissing means the Jetson has neither the plan nor
the bytes and could not get them, and neither has the caller. `fetch` False
never asks for the download: a stand-in is a model the server has. `ref`,
the catalog's name for the model, goes with a download."""
from jetlink.client import EngineMissing
ask = functools.partial(client.ensure_engine, sha256, nbytes, progress=progress,
build_timeout=build_timeout, should_stop=should_stop)
fetch = fetch and model_path is None
ask = functools.partial(client.ensure_engine, sha256, nbytes, progress=progress, should_stop=should_stop)
try:
spec = ask(onnx_path=None)
spec = ask(onnx_path=None, fetch=fetch, ref=ref if fetch else None,
build_timeout=build_timeout + (FETCH_TIMEOUT if fetch else 0.0))
except EngineMissing:
upload = verified_upload(parts.log, model_path, sha256, nbytes)
if upload is None:
raise
spec = ask(onnx_path=upload)
spec = ask(onnx_path=upload, build_timeout=build_timeout)
parts.spec.store(spec)
return spec
@@ -346,11 +357,14 @@ def open_link(parts, link: Link, should_stop=None):
# booting; both resolve on their own
client = connect_patiently(link)
try:
hello = client.hello(timeout=10.0)
hello = parts.models.heard(client.hello(timeout=10.0, pick=parts.models.hello_pick()))
parts.log.warning("jetlink: %s trt %s, engine %s, loaded %s",
hello.get('device'), hello.get('trt_version'),
hello.get('engine_state'), str(hello.get('loaded'))[:16])
link.note_server(hello)
# a newer pick on the server drives, and is ours from now on
parts.models.adopt()
selected = parts.models.selected_model() or selected
sha256, nbytes = identity(parts, selected)
path = parts.models.shipped_model_path()
built = parts.spec.ready_spec()
@@ -370,7 +384,7 @@ def open_link(parts, link: Link, should_stop=None):
# server that restarted reloads from the plan cache, 13 to 25 s; one
# that has never seen this model builds it, 102 to 294 s
spec = ensure(parts, client, sha256, nbytes, path, progress=parts.progress.report_with_eta,
should_stop=should_stop)
should_stop=should_stop, ref=selected.get('ref'), fetch=standin is None)
client.deadline = INFERENCE_TIMEOUT
return client, spec
except EngineMissing as e:
+196 -28
View File
@@ -16,16 +16,25 @@ export, and jetlink's registry follows that to comma's model repo; see
jetlink.registry.lfs.
Without a model manager (Keys.big_model and Keys.catalog None) there is no
pick and no catalog, and the large model is jetlink's own default, named by its
ref: its pointer resolves from the ref as any pick's does.
pick and no catalog, and the large model is the one picked on the server, else
jetlink's own default, named by its ref: its pointer resolves from the ref as
any pick's does.
A fork with a picker of its own but no catalog jetlink reads (Keys.big_model
without Keys.catalog, StarPilot's) may name its pick by the ONNX's sha256
instead of a ref, which is all its catalog knows of a model. Its size is looked
up from comma's LFS servers once (resolve_size). With no catalog no model
manager owns the slot either, so jetlink writes a newer pick from the server
into it itself, and the fork's picker shows what drives.
"""
from __future__ import annotations
import os
import re
import time
from pathlib import Path
from jetlink.registry.catalog import DEFAULT_BIG_MODEL_NAME, DEFAULT_BIG_MODEL_REF
from jetlink.registry.catalog import DEFAULT_BIG_MODEL_NAME, DEFAULT_BIG_MODEL_REF, is_ref, is_sha256
POINTER_TIMEOUT = 10.0
_REF = re.compile(r'[0-9a-f]{40}')
@@ -39,13 +48,32 @@ INDEX_TTL = 2.0
# on from the .part the last left, so a stall costs the wait, not the bytes
# (lfs.lfs_download)
DOWNLOAD_RETRY_DELAYS = (5.0, 15.0, 30.0, 60.0, 120.0)
# server_pick() before this process has heard a hello
_UNHEARD = object()
def _dated(pick) -> dict | None:
"""A server's pick as its hello carries it, dated by this clock:
{ref, sha256, nbytes, name, at}. None for none, or one naming no model."""
if not isinstance(pick, dict) or not isinstance(pick.get('age_s'), (int, float)):
return None
ref = pick['ref'] if is_ref(pick.get('ref')) else None
sha256 = pick['sha256'] if is_sha256(pick.get('sha256')) else None
if ref is None and sha256 is None:
return None
return {'ref': ref, 'sha256': sha256, 'nbytes': pick.get('nbytes'), 'name': pick.get('name'),
'at': time.time() - float(pick['age_s'])}
class Models:
def __init__(self, op):
self.op = op
self._index_cache: tuple[float, list[dict], dict[str, dict]] | None = None
self._slot_cache: tuple[float, dict | None] | None = None
self._slot_cache: tuple[float, dict | None, float | None] | None = None
# the pick of the last hello this process heard, and the owner's record
# of the last hello any process heard
self._heard: dict | None | object = _UNHEARD
self._record_cache: tuple[float, dict | None] | None = None
def catalog(self) -> list[dict]:
"""sunnypilot's big-model bundles as {name, ref}, newest first, as the model
@@ -77,13 +105,28 @@ class Models:
def resolve_pointer(self, ref: str) -> tuple[str, int]:
"""The oid and size behind a catalog model, fetched the first time and kept for good."""
return self._resolve(ref, self.fetch_pointer)
def fetch_size(self, sha256: str) -> tuple[str, int]:
"""The ONNX with this hash and its byte count, from the LFS servers a
download asks."""
from jetlink.registry.lfs import fetch_size as registry_fetch_size
pointer = registry_fetch_size(sha256, self.lfs_endpoints(), timeout=POINTER_TIMEOUT)
return pointer.oid, pointer.size
def resolve_size(self, sha256: str) -> tuple[str, int]:
"""A model picked by its hash, sized the first time and kept for good with
the catalog's pointers, under the hash."""
return self._resolve(sha256, self.fetch_size)
def _resolve(self, key: str, fetch) -> tuple[str, int]:
known = self.pointers()
if ref in known:
return known[ref]['oid'], int(known[ref]['size'])
oid, size = self.fetch_pointer(ref)
known[ref] = {'oid': oid, 'size': size}
if key in known:
return known[key]['oid'], int(known[key]['size'])
oid, size = fetch(key)
known[key] = {'oid': oid, 'size': size}
self._keep_pointers(known)
self.op.log.warning("jetlink: %s is %s, %d MB", ref[:10], oid[:16], size >> 20)
self.op.log.warning("jetlink: %s is %s, %d MB", key[:10], oid[:16], size >> 20)
return oid, size
def _keep_pointers(self, known: dict[str, dict]) -> None:
@@ -92,9 +135,11 @@ class Models:
self._index_cache = None
@staticmethod
def _row(name: str, ref: str, known: dict[str, dict]) -> dict:
p = known.get(ref) or {}
return {'name': name, 'ref': ref, 'oid': p.get('oid'), 'size': int(p['size']) if p.get('size') else None}
def _row(name: str, ref: str | None, known: dict[str, dict], sha256: str | None = None) -> dict:
"""A model as a catalog row, by its ref's pointer, else by its hash, sized
once resolve_size has looked it up."""
p = known.get(ref) or known.get(sha256) or {}
return {'name': name, 'ref': ref, 'oid': p.get('oid') or sha256, 'size': int(p['size']) if p.get('size') else None}
def _index(self) -> tuple[list[dict], dict[str, dict]]:
"""The catalog's rows and the pointers behind them, read together once per INDEX_TTL."""
@@ -110,18 +155,27 @@ class Models:
return self._index()[0]
def selected_slot(self) -> dict | None:
"""The big-model slot's pick as {name, ref}, from the raw param rather than a
parsed bundle, and memoised: the UI asks every frame."""
"""The big-model slot's pick as {name, ref, sha256}, from the raw param
rather than a parsed bundle, and memoised with when it was made
(picked_at): the UI asks every frame. It names its model by the catalog's
ref, by the ONNX's sha256 (a fork whose catalog knows only that), or by
both; the one it lacks is None."""
now = time.monotonic()
if self._slot_cache is not None and now - self._slot_cache[0] < INDEX_TTL:
return self._slot_cache[1]
key = self.op.keys.big_model
slot = self.op.get(key) if key else None
ref = slot.get('ref') if isinstance(slot, dict) else None
pick = None
if isinstance(ref, str) and ref:
pick = {'name': str(slot.get('displayName') or ref[:10]), 'ref': ref}
self._slot_cache = (now, pick)
slot = slot if isinstance(slot, dict) else {}
ref = slot['ref'] if isinstance(slot.get('ref'), str) and slot['ref'] else None
sha256 = slot['sha256'] if is_sha256(slot.get('sha256')) else None
pick, at = None, None
if ref or sha256:
pick = {'name': str(slot.get('displayName') or (ref or sha256)[:10]), 'ref': ref, 'sha256': sha256}
try:
at = os.stat(Path(self.op.params_dir()) / key).st_mtime
except OSError:
pass
self._slot_cache = (now, pick, at)
return pick
def default_model(self) -> dict | None:
@@ -142,12 +196,129 @@ class Models:
return _TRAILING_DATE.sub('', model['name']) if model else None
def selected_model(self) -> dict | None:
"""The slot's pick, listed in the catalog or not: a ref is enough to find its
pointer. With no pick, default_model()."""
if (pick := self.selected_slot()) is not None:
return self._row(pick['name'], pick['ref'], self._index()[1])
"""The model picked last, here or on the server: the slot's pick, listed
in the catalog or not (a ref is enough to find its pointer), unless the
server's last hello named a newer one. With neither, default_model()."""
if (theirs := self._newer_server_pick()) is not None:
return self._server_row(theirs)
if (own := self.selected_slot()) is not None:
return self._own_row(own)
return self.default_model()
def _own_row(self, own: dict) -> dict:
return self._row(own['name'], own['ref'], self._index()[1], own['sha256'])
# -- picks on both ends -----------------------------------------------------
# A pick is made on either end: in the comma's list, or on the server (its
# web page, an app's Use Model). Each hello carries the comma's own pick and
# how long ago it was made, and the server answers with the newer of that
# and its own (PickStore in JetlinkServer). The answer is kept with the rest
# of the hello (gadget.SERVER, through the owner), dated by this clock, and
# selected_model() takes whichever pick is newer; so a model picked on the
# server drives without being picked here too, and one picked here goes to
# the server at the next hello.
def picked_at(self) -> float | None:
"""When the slot was last written, by this clock: when the pick was made.
None with no pick of our own, or no date for it."""
return self._slot_cache[2] if self.selected_slot() is not None and self._slot_cache else None
def hello_pick(self) -> dict | None:
"""Our own pick, for the hello: {ref, sha256, nbytes, name, age_s}, the
pointer where it is resolved already (no network here). None with nothing
picked here, or no date for it: the server's pick, if any, stands."""
own, at = self.selected_slot(), self.picked_at()
if own is None or at is None:
return None
row = self._own_row(own)
return {'ref': row['ref'], 'sha256': row['oid'], 'nbytes': row['size'], 'name': row['name'],
'age_s': round(max(0.0, time.time() - at), 1)}
def heard(self, hello: dict) -> dict:
"""The server's hello with its `pick` dated by this clock (_dated), which
is how the owner keeps it (lending.SERVER_FIELDS) and server_pick() reads
it. This process goes by it at once, whatever the owner does with it."""
self._heard = _dated(hello.get('pick'))
return {**hello, 'pick': self._heard} if self._heard is not None else hello
def server_pick(self) -> dict | None:
"""The pick the server last answered a hello with, {ref, sha256, nbytes,
name, at}, or None: a server too old to say, or one with no pick. What
this process heard last, else the owner's record of the last hello: the
UI says no hello, and reads it every frame."""
if self._heard is not _UNHEARD:
return self._heard
now = time.monotonic()
if self._record_cache is None or now - self._record_cache[0] >= INDEX_TTL:
from jetlink.comma import gadget
pick = (gadget.read_record(gadget.SERVER) or {}).get('pick')
self._record_cache = (now, pick if isinstance(pick, dict) and isinstance(pick.get('at'), (int, float)) else None)
return self._record_cache[1]
def _newer_server_pick(self) -> dict | None:
"""The server's last pick, when it is another model than ours and was
picked after it, or we have none."""
theirs, own = self.server_pick(), self.selected_slot()
if theirs is None or (own is not None and (self._same(theirs, self._own_row(own)) or
theirs['at'] <= (self.picked_at() or 0.0))):
return None
return theirs
@staticmethod
def _same(pick: dict, row: dict) -> bool:
"""One model: by the catalog ref where both have one, since two refs can
share one export, else by the hash. The server's PickStore.sameModel."""
if pick['ref'] and row['ref']:
return pick['ref'] == row['ref']
return bool(pick['sha256']) and pick['sha256'] == row['oid']
def _server_row(self, pick: dict) -> dict:
"""The server's pick as a catalog row: ours where we list it, with the
server's oid and size, which name the bytes it has."""
listed = next((m for m in self.model_index() if pick['ref'] and m['ref'] == pick['ref']), None)
row = self._row(listed['name'] if listed else pick.get('name') or (pick['ref'] or pick['sha256'])[:16],
pick['ref'], self._index()[1], pick['sha256'])
if pick['sha256'] and pick.get('nbytes'):
row['oid'], row['size'] = pick['sha256'], int(pick['nbytes'])
return row
def adopt(self) -> None:
"""Keep the server's pointer for its pick, by ref and by hash, so naming
the model here needs no lookup; and make a newer pick from the server
ours. With a catalog, ask the model manager for it (Keys.select), so the
comma's own list shows what drives: only a model the catalog lists, which
is all the model manager takes; any other drives all the same
(selected_model). Without one, no model manager owns the slot, so the
pick is written into it, whatever the model. Never raises."""
try:
if (theirs := self.server_pick()) is None:
return
known = self.pointers()
if theirs['sha256'] and theirs.get('nbytes'):
pointer = {'oid': theirs['sha256'], 'size': int(theirs['nbytes'])}
missing = {k: pointer for k in (theirs['ref'], theirs['sha256']) if k and k not in known}
if missing:
self._keep_pointers({**known, **missing})
if self._newer_server_pick() is None:
return
model, keys = self._server_row(theirs), self.op.keys
if keys.catalog is None:
if keys.big_model:
self.op.log.warning("jetlink: %s was picked on the server, picking it here too", model['name'])
slot = {k: v for k, v in (('displayName', model['name']), ('ref', model['ref']), ('sha256', model['oid'])) if v}
self.op.put(keys.big_model, slot, block=True)
self._slot_cache = None
return
# a download the user asked for that the model manager has not taken
# yet is left alone: the next hello asks again
key = keys.select
if key and model['ref'] and self.op.get(key) is None and any(m['ref'] == model['ref'] for m in self.model_index()):
self.op.log.warning("jetlink: %s was picked on the server, picking it here too", model['name'])
self.op.put(key, model['ref'])
self._slot_cache = None
except Exception:
self.op.log.exception("jetlink: could not take the server's pick")
def selected_model_name(self) -> str | None:
selected = self.selected_model()
return selected['name'] if selected else None
@@ -227,11 +398,8 @@ class Models:
def _fetch_once(self, pointer, dest: Path, progress, should_stop) -> Path:
from jetlink.registry.catalog import NetworkError
from jetlink.registry.lfs import lfs_download, lfs_resolve
for endpoint in self.lfs_endpoints():
href = lfs_resolve(endpoint, pointer)
if href is None:
continue
from jetlink.registry.lfs import lfs_download, lfs_hrefs
for endpoint, href in lfs_hrefs(self.lfs_endpoints(), pointer):
self.op.log.warning("jetlink: fetching the large model (%d MB) from %s", pointer.size >> 20, endpoint)
return lfs_download(href, pointer, dest, progress=progress, should_stop=should_stop)
raise NetworkError(f"no LFS server has {pointer.oid[:16]}")
+53 -32
View File
@@ -126,10 +126,8 @@ class ProvisioningRun:
return path
def needs_download(self) -> bool:
"""Does the comma have to fetch the picked model before a Jetson can build
it? Only while its engine is not known to be built and the file is not here.
Asked without the Jetson: a download needs only the internet, and a car
whose Jetson goes off with the ignition has no Jetson while parked."""
"""Could the Jetson need the picked model from the comma? Only while its
engine is not known to be built and the file is not here."""
parts = self.parts
entry = parts.models.selected_model()
if entry is None:
@@ -140,12 +138,17 @@ class ProvisioningRun:
return parts.models.shipped_model_path() is None
def provision(self) -> bool:
"""Make the Jetson ready for the selected model; a host is attached. run()
has fetched the file already unless the spec record said the model was
built; a server that lost it since asks for the bytes, and they are
fetched then."""
"""Make the Jetson ready for the picked model; a host is attached. The
hello settles which pick that is, the comma's or a newer one made on the
server. A server without the model downloads it itself; only one that
cannot (no internet, or too old) gets the bytes from the comma, fetched
here then."""
from jetlink.client import EngineMissing
parts = self.parts
parts.progress.report('connect', 0.0, 'connecting', code='connecting')
hello = self.hello()
self.log.warning("jetlink: server %s trt %s", hello.get('device'), hello.get('trt_version'))
parts.models.adopt()
entry = parts.models.selected_model()
if entry is None:
# no catalog yet; not an error
@@ -154,27 +157,24 @@ class ProvisioningRun:
return False
sha256, nbytes = link.identity(parts, entry)
# only needed if the server turns out not to have this model; None is a
# legitimate state here, see EngineMissing below
# only needed if the server turns out not to have this model and cannot
# get it; None is a legitimate state here, see EngineMissing below
model_path = parts.models.shipped_model_path()
self.log.warning("jetlink: provisioning %s (%d MB, sha %s)",
entry.get('name', sha256[:16]), nbytes >> 20, sha256[:16])
parts.progress.report('connect', 0.0, 'connecting', code='connecting')
def ensure(path):
return link.ensure(parts, self.client, sha256, nbytes, path, progress=parts.progress.report_with_eta,
should_stop=lambda: self.stop)
should_stop=lambda: self.stop, ref=entry.get('ref'))
hello = self.hello()
self.log.warning("jetlink: server %s trt %s", hello.get('device'), hello.get('trt_version'))
try:
spec = ensure(model_path)
except EngineMissing:
# the server has nothing to build from, and neither have we: fetch the
# model and hand it over in this run. The owner lends the link for the
# whole run; leaving after the download would put the build a
# WORKER_BACKOFF later
# the server has nothing to build from and could not download it, and
# neither have we: fetch the model and hand it over in this run. The
# owner lends the link for the whole run; leaving after the download
# would put the build a WORKER_BACKOFF later
if model_path is not None or (model_path := self.fetch_model()) is None:
raise
# a download of minutes can outlast the session; a hello starts a new one
@@ -186,16 +186,35 @@ class ProvisioningRun:
return True
def hello(self) -> dict:
"""Say hello, and pass the answer on to the owner, which needs its
sleep_after to decide whether letting go of the gadget is worth what it
costs and never speaks the protocol to learn it."""
hello = self.client.hello(timeout=10.0)
"""Say hello with our pick, and pass the answer on to the owner, which
needs its sleep_after to decide whether letting go of the gadget is worth
what it costs, never speaks the protocol to learn it, and keeps the
server's pick and whether it downloads for the comma (Models.heard)."""
models = self.parts.models
hello = models.heard(self.client.hello(timeout=10.0, pick=models.hello_pick()))
if self.loan is not None:
self.loan.note_server(hello)
return hello
# -- the parked car -------------------------------------------------------
def without_jetson(self) -> None:
"""No Jetson came to the bind: a car whose Jetson is switched with the
ignition has none while parked. Only the Jetson needs the model, and one
that can download it gets it itself when it is on; the comma fetches it
now only for a Jetson that cannot (no internet when it was last here, or
too old to say), so the build can start the moment it is."""
if not self.has_work():
return
if self.needs_download():
if server_fetches():
self.parts.progress.report('waiting', 0.0, 'the jetson downloads it when it is on', code='waiting_for_host')
return
if self.fetch_model() is None:
return
self.parts.progress.report('waiting', 0.0, 'downloaded, builds when the jetson is on',
code='downloaded_waiting_host')
def has_work(self) -> bool:
"""Is there a reason to wake the Jetson? Only things the link can fix count."""
spec = self.parts.spec.load()
@@ -259,25 +278,21 @@ class ProvisioningRun:
self.log.warning("jetlink: no warp built for this camera, nothing to provision for")
return True
if not self.has_work():
# Nothing to build, unless a model was picked on the server since: a
# Jetson awake on the bus is asked, which costs a hello and no wake. Not
# a phone's cable, which is there only once the app dials in
if not self.has_work() and not (gadget.host_attached() and gadget.link_kind(self.parts.settings.mode()) == 'usb'):
self.log.warning("jetlink: nothing to provision")
return True
finished = False
try:
# the bytes first while no host is on the cable: a parked car whose
# Jetson is switched with the ignition never has one to wait for. A host
# that is there is asked first, since one with the model built needs
# nothing from the comma; provision() fetches it if the host has not
if self.needs_download() and not gadget.host_attached() and self.fetch_model() is None:
return False
if not self.open_link():
return False
if not self.wait_for_jetson():
self.log.warning("jetlink: no jetson within %.0f s, leaving it for the next run", WAKE_TIMEOUT)
if self.parts.models.shipped_model_path() is not None:
self.parts.progress.report('waiting', 0.0, 'downloaded, builds when the jetson is on',
code='downloaded_waiting_host')
self.close_link()
self.without_jetson()
return False
finished = self.provision()
except Exception:
@@ -288,6 +303,12 @@ class ProvisioningRun:
return finished
def server_fetches() -> bool:
"""Did the server the comma last spoke to say it downloads a model itself
(its hello's `fetches`, as the owner keeps it)? No for one too old to say."""
return (gadget.read_record(gadget.SERVER) or {}).get('fetches') is True
def main(argv: list[str] | None = None) -> None:
"""One provisioning round over the fork's adapter, as the owner starts it.
Exits 0 when nothing is left to do, and 1 when the owner should try again."""
+11 -5
View File
@@ -31,14 +31,17 @@ PROGRESS_MIN_INTERVAL = 0.25
# what a progress record's `code` may be, for a fork that words the line
# itself; `msg` stays the English one. `args`, where a code has them:
# downloading and upload {got_mb, total_mb}, download_stalled {retry_s},
# reconnecting {why: lost, behind, load_failed or bad_output}, build {eta_s}.
# From upload on they are stage names, and the server's stages are its own:
# downloading, download and upload {got_mb, total_mb}, download_stalled
# {retry_s}, reconnecting {why: lost, behind, load_failed or bad_output},
# build {eta_s}. downloading is the comma fetching the model, download the
# server fetching it itself. From download on they are stage names, and the
# server's stages are its own:
# a server can add one, so a fork falls back to `msg` for a code it does not
# know. A server message with no stage has no code
PROGRESS_CODES = ('waiting_for_host', 'finding_model', 'model_missing', 'ready_waiting_window', 'reconnecting',
'connecting', 'downloading', 'download_stalled', 'download_failed', 'downloaded_waiting_host',
'engine_ready', 'failed', 'upload', 'patch', 'parse', 'convert', 'compile', 'build', 'load', 'warm')
'engine_ready', 'failed', 'download', 'upload', 'patch', 'parse', 'convert', 'compile', 'build',
'load', 'warm')
def estimated_build_seconds(size: int) -> int:
@@ -104,12 +107,15 @@ class Progress:
size. Only the build: the upload reports MB of MB and a connect has nothing
to predict."""
args = None
size = self._size() if self._size is not None and stage in ('build', 'upload') else None
size = self._size() if self._size is not None and stage in ('build', 'upload', 'download') else None
if size and stage == 'build':
left = estimated_build_seconds(size) * max(0.0, 1.0 - frac)
msg, args = _eta(left), {'eta_s': round(left)}
elif size:
args = {'got_mb': int(frac * size) >> 20, 'total_mb': size >> 20}
if stage == 'download' and args:
# the server downloading the model itself: not the comma's own 'downloading'
msg = f"jetson downloading {args['got_mb']} of {args['total_mb']} MB"
self.report(stage, frac, msg, code=stage or None, args=args)
+30
View File
@@ -294,6 +294,36 @@ def lfs_resolve(endpoint: str, pointer: Pointer, timeout: float = CONNECT_TIMEOU
return None
def lfs_hrefs(endpoints, pointer: Pointer, timeout: float = CONNECT_TIMEOUT, opener=None):
"""Each of `endpoints` that has the object, in order, with its download href."""
for endpoint in endpoints:
href = lfs_resolve(endpoint, pointer, timeout=timeout, opener=opener)
if href is not None:
yield endpoint, href
def fetch_size(oid: str, endpoints=LFS_ENDPOINTS, timeout: float = POINTER_TIMEOUT, opener=None) -> Pointer:
"""The pointer for an ONNX known by its oid alone, as a fork whose catalog
names a model by its hash knows it (StarPilot's). comma's LFS servers hand
out a download address for any oid they hold, whatever size the batch asks
for, and the first byte of that download says how many there are. No
server with it is NotFound; one that would not say, a NetworkError."""
opener = opener or urllib.request.urlopen
for endpoint, href in lfs_hrefs(endpoints, Pointer(oid, 1), timeout=timeout, opener=opener):
request = urllib.request.Request(href, headers={'Range': 'bytes=0-0'})
try:
with opener(request, timeout=timeout) as response:
# a server that ignores the range answers 200 with the whole length
total = (response.headers.get('Content-Range') or '').rpartition('/')[2] \
if response.status == 206 else response.headers.get('Content-Length')
except (OSError, ValueError) as e:
raise NetworkError(f"could not size {oid[:16]} at {endpoint}: {e}") from e
if not str(total).isdigit() or int(total) <= 0:
raise NetworkError(f"{endpoint} did not say how big {oid[:16]} is")
return Pointer(oid, int(total))
raise NotFound(f"no LFS server has {oid[:16]}")
def lfs_download(href: str, pointer: Pointer, dest: Path, progress: ProgressFn | None = None,
should_stop: StopFn | None = None, opener=None) -> Path:
"""Stream to a .part file, hashing as we go, and only then take the name.
Binary file not shown.
+1
View File
@@ -376,6 +376,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"ChestnutLoading", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}},
{"JetlinkMode", {PERSISTENT, INT, "0"}},
{"JetlinkChargePhone", {PERSISTENT, BOOL, "0"}},
{"JetlinkModel", {PERSISTENT, JSON}},
{"JetlinkSpec", {PERSISTENT | DONT_LOG, JSON}},
{"JetlinkPointers", {PERSISTENT | DONT_LOG, JSON}},
{"JetlinkProgress", {CLEAR_ON_MANAGER_START | DONT_LOG, JSON}},
@@ -96,7 +96,11 @@ globalThis.document = priorDocument
assert.match(ModelsPage.template, /offline preview/)
assert.match(ModelsPage.template, /Active Small/)
assert.match(ModelsPage.template, /None — always use Active Small/)
assert.match(ModelsPage.template, /\{\{ bigNoneLabel \}\}/)
// the manager words no Active Big; under Jetlink, the Jetlink app's choice
const noneLabel = (status) => ModelsPage.computed.bigNoneLabel.call({ status })
assert.equal(noneLabel({}), "None — always use Active Small")
assert.equal(noneLabel({ bigNoneLabel: "Jetlink app's choice" }), "Jetlink app's choice")
console.log("Model status client lifecycle checks passed")
const expanded = fixture()
@@ -129,8 +133,10 @@ const managerPayload = { schemaVersion: 1, models: [small, big, pending, missing
assert.equal(modelActionAllowed(managerPayload, "select-small", small), true)
assert.equal(modelActionAllowed(managerPayload, "select-big", small), false)
assert.equal(modelActionAllowed(managerPayload, "select-small", { ...small, selectable: false }), false)
assert.equal(modelActionAllowed(managerPayload, "select-small", { ...small, installed: false }), false)
assert.equal(modelActionAllowed(managerPayload, "select-big"), true)
// what the manager marks selectable, downloaded or not: under Jetlink, a big model with an ONNX
assert.equal(modelActionAllowed(managerPayload, "select-big", { ...missing, selectable: true }), true)
assert.equal(modelActionAllowed(managerPayload, "select-big", { ...big, selectable: false }), false)
assert.equal(modelActionAllowed(managerPayload, "download", pending), false)
assert.equal(modelActionAllowed(managerPayload, "download", missing), true)
assert.equal(modelActionAllowed(managerPayload, "delete", big), false)
+11 -7
View File
@@ -167,7 +167,8 @@ export function modelActionAllowed(data, action, model = null) {
if (["select-small", "select-big"].includes(action)) {
if (data.downloading && data.refreshing !== true || data.randomizer === true) return false
if (action === "select-big" && model === null) return true
return model?.installed === true && model.selectable === true && fitsModelProfile(model, action === "select-big" ? "big" : "small")
// the manager marks what each Active can take: installed models, or under Jetlink a big one with an ONNX
return model?.selectable === true && fitsModelProfile(model, action === "select-big" ? "big" : "small")
}
if (action === "download") return !data.downloading && !!model && !model.installed && model.downloadAvailable === true
if (action === "downloadAll") return !data.downloading && data.models.some(m => !m.installed && m.downloadAvailable === true)
@@ -339,8 +340,10 @@ export const ModelsPage = {
if (!model.loadedId) return model.health === "loading" ? "Loading…" : "Identity unverified"
return model.catalog.find(item => item.id === model.loadedId)?.name || model.loadedId
},
installedSmallModels() { return this.models.filter(m => (m.value === this.activeSmallModel || m.installed && m.selectable) && fitsModelProfile(m, "small")) },
installedBigModels() { return this.models.filter(m => (m.value === this.activeBigModel || m.installed && m.selectable) && fitsModelProfile(m, "big")) },
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
bigNoneLabel() { return this.status.bigNoneLabel || "None — always use Active Small" },
sorted() {
return this.models.filter(m => matchesHardware(m, this.hardwareFilter))
.filter(m => this.userFilter === "all" || !!m.userFavorite === (this.userFilter === "yes"))
@@ -533,20 +536,21 @@ export const ModelsPage = {
<label class="gx-switch"><input type="checkbox" role="switch" aria-label="Model Randomizer" :checked="status.randomizer === true" :disabled="!actionAvailable(status.randomizer ? 'disable-randomizer' : 'enable-randomizer')" @change="toggleRandomizer($event)"><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></label>
</div>
<p v-if="status.randomizer" class="gx-model-reason">A different verified model is chosen each start. {{ status.gpuAvailable && activeBigModel ? 'Big GPU' : 'Small on-device' }} models are used. {{ (status.blacklistedModels || []).length }} excluded. Favorites only mark your preferred models.</p>
<p v-if="!status.gpuAvailable" class="gx-model-reason">An external GPU is not detected. Active Small is used; your Big selection is saved for later.</p>
<p v-if="status.jetlinkBig === true" class="gx-model-reason">Jetlink runs Active Big on its host, which downloads the model itself. A model chosen in the Jetlink app shows here once the comma connects.</p>
<p v-else-if="!status.gpuAvailable" class="gx-model-reason">An external GPU is not detected. Active Small is used; your Big selection is saved for later.</p>
<div class="gx-model-control">
<label class="gx-row__label" for="gx-model-small">Active Small · On-device</label>
<GalaxySelect id="gx-model-small" class="gx-field" :value="pendingChoice?.action === 'select-small' ? pendingChoice.value : activeSmallModel" :disabled="selectionUncertain || capabilities.select !== true || status.isOnroad || status.randomizer" @change="runAction('select-small', installedSmallModels.find(m => m.value === $event.target.value))">
<option v-for="m in installedSmallModels" :key="m.value" :value="m.value" :disabled="!m.installed || !m.selectable">{{ m.label || m.value }}</option>
<option v-for="m in installedSmallModels" :key="m.value" :value="m.value" :disabled="!m.selectable">{{ m.label || m.value }}</option>
</GalaxySelect>
</div>
<div class="gx-model-control">
<label class="gx-row__label" for="gx-model-big">Active Big · GPU</label>
<GalaxySelect id="gx-model-big" class="gx-field" :value="pendingChoice?.action === 'select-big' ? pendingChoice.value : activeBigModel" :disabled="selectionUncertain || capabilities.select !== true || status.isOnroad || status.randomizer" @change="$event.target.value ? runAction('select-big', installedBigModels.find(m => m.value === $event.target.value)) : runAction('select-big')">
<option value="">None — always use Active Small</option>
<option v-for="m in installedBigModels" :key="m.value" :value="m.value" :disabled="!m.installed || !m.selectable">{{ m.label || m.value }}</option>
<option value="">{{ bigNoneLabel }}</option>
<option v-for="m in installedBigModels" :key="m.value" :value="m.value" :disabled="!m.selectable">{{ m.label || m.value }}</option>
</GalaxySelect>
</div>
+7 -7
View File
@@ -28,14 +28,14 @@ release still needs a successful compatible handshake and device validation.
### Jetson and Linux PC
On a supported NVIDIA host, review and run the [upstream installer](https://github.com/zoompilot/jetlink/blob/bf4dc94/install.sh).
On a supported NVIDIA host, review and run the [upstream installer](https://github.com/zoompilot/jetlink/blob/v0.8.12/install.sh).
The upstream Jetson guide specifies an Orin Nano Super 8 GB and JetPack 7.2.1;
the Linux guide specifies an RTX 20-series or newer GPU with driver 580 or newer.
Keep the computer powered, cool, and awake. Connect its USB-A port to the comma's USB-C port.
Use `jetlink status` to check the host and `jetlink logs` to inspect problems.
Host power, sleep, and driver setup details remain in the credited
[Jetson](https://github.com/zoompilot/jetlink/blob/bf4dc94/docs/jetson.md) and
[Linux](https://github.com/zoompilot/jetlink/blob/bf4dc94/docs/linux-pc.md) references;
[Jetson](https://github.com/zoompilot/jetlink/blob/v0.8.12/docs/jetson.md) and
[Linux](https://github.com/zoompilot/jetlink/blob/v0.8.12/docs/linux-pc.md) references;
their comma-side ZoomPilot instructions are replaced by **Set up StarPilot** above.
### Mac
@@ -45,7 +45,7 @@ Use an Apple silicon Mac with macOS 15 or newer. Get the Jetlink DMG from
in Applications, and start it. Keep it awake and connect by USB 3.
Use **USB computer** in StarPilot's Galaxy, rather than following the comma-side
ZoomPilot installation instructions in the credited
[Mac host reference](https://github.com/zoompilot/jetlink/blob/bf4dc94/docs/macos-app.md).
[Mac host reference](https://github.com/zoompilot/jetlink/blob/v0.8.12/docs/macos-app.md).
### iPhone and iPad
@@ -54,7 +54,7 @@ It requires a USB-C device supporting USB 3 and iOS/iPadOS 26.1 or newer.
Choose **iPhone / iPad** in StarPilot, allow the host app's Local Network access,
connect by USB 3, and keep the app open and the device unlocked. Check sustained
thermal performance with the app's benchmark; that benchmark excludes cable latency.
The [credited Apple host reference](https://github.com/zoompilot/jetlink/blob/bf4dc94/docs/iphone-app.md)
The [credited Apple host reference](https://github.com/zoompilot/jetlink/blob/v0.8.12/docs/iphone-app.md)
contains hardware details. StarPilot supplies the comma-side instructions above.
### Android
@@ -64,7 +64,7 @@ upstream Jetlink Android APK from [its releases](https://github.com/zoompilot/je
Use a recent USB 3-capable Android 12+ phone, a powered USB 3 hub, and separate
comma power. Choose **USB computer** in StarPilot. Follow the host's USB permission
prompt and benchmark sustained performance before use.
[Credited Android host reference](https://github.com/zoompilot/jetlink/blob/bf4dc94/docs/android-app.md).
[Credited Android host reference](https://github.com/zoompilot/jetlink/blob/v0.8.12/docs/android-app.md).
## Use and recovery
@@ -89,7 +89,7 @@ and defaults off.
## Attribution and license
This StarPilot guide adapts the host setup structure from
[ZoomPilot / Jetlink at bf4dc94](https://github.com/zoompilot/jetlink/tree/bf4dc94/docs).
[ZoomPilot / Jetlink at v0.8.12](https://github.com/zoompilot/jetlink/tree/v0.8.12/docs).
Jetlink is copyright (c) 2026 Zeph Leggett and licensed under the
[MIT license](../../../jetlink_repo/LICENSE), whose full text is preserved.
ZoomPilot source links, project names, authorship, and history are attribution;
@@ -52,9 +52,10 @@ def _adapter_class():
from jetlink.openpilot.stock import Stock
class StarPilotAdapter(Stock):
# No big-model slot or catalog: jetlink provisions its default model
# Active Big's choice for Jetlink, by the ONNX's hash (jetlink_settings), and no catalog: jetlink writes a
# newer choice made in its app into it, and with none runs the app's choice or its default model
KEYS = Keys(link="JetlinkMode", offroad="IsOffroad", progress="JetlinkProgress", spec="JetlinkSpec",
pointers="JetlinkPointers", charge_phone="JetlinkChargePhone")
pointers="JetlinkPointers", big_model="JetlinkModel", charge_phone="JetlinkChargePhone")
IN_CONTROL_SERVICES = ("carControl", "selfdriveState")
_aol = None
_aol_since = 0.
+34 -4
View File
@@ -31,13 +31,27 @@ class JetlinkSettings:
"reason": "Jetlink status is unavailable; the local model remains in use.",
"progress": "", "model": None, "transport": "USB", "chargePhone": False}
def _snapshot(self):
from openpilot.starpilot.models.jetlink_adapter import adapter, hooks, supported_device
def _setting(self):
"""The Jetlink setting, whether this device runs Jetlink, and whether a Chestnut is fitted."""
from openpilot.starpilot.models.jetlink_adapter import adapter, supported_device
supported = supported_device(self._device_type() if self._device_type is not None else None)
chestnut = self._chestnut() if self._chestnut is not None else adapter().chestnut_present()
raw = self.params().get("JetlinkMode")
return MODES[raw] if type(raw) is int and 0 <= raw < len(MODES) else "off", supported, chestnut
def chooses_big(self, view=None):
"""Does Active Big choose Jetlink's model? With Jetlink on, on a device it runs on, and no Chestnut to run the
big model itself. From `view`, a snapshot, when one is at hand."""
try:
mode, supported, chestnut = (view["mode"], view["supported"], view["chestnut"]) if view else self._setting()
except (OSError, RuntimeError, ValueError, ImportError):
return False
return mode != "off" and supported and not chestnut
def _snapshot(self):
from openpilot.starpilot.models.jetlink_adapter import hooks
mode, supported, chestnut = self._setting()
params = self.params()
raw = params.get("JetlinkMode")
mode = MODES[raw] if type(raw) is int and 0 <= raw < len(MODES) else "off"
status = (self._link() if self._link is not None else hooks()).status()
reason = "" if supported else UNSUPPORTED + "."
if mode != "off" and status is None:
@@ -59,6 +73,22 @@ class JetlinkSettings:
"transport": status.transport if status is not None else "USB",
"chargePhone": params.get_bool("JetlinkChargePhone")}
def pick(self):
"""Jetlink's big model as Active Big chose it, {sha256, displayName}, or None for the Jetlink app's choice.
jetlink writes a newer choice made in its app here too, with the commit's ref where it has one."""
from openpilot.starpilot.models.manager import SHA
value = self.params().get("JetlinkModel")
return value if isinstance(value, dict) and isinstance(value.get("sha256"), str) and SHA.fullmatch(value["sha256"]) else None
def choose(self, sha256, name=""):
"""Choose Jetlink's big model by its ONNX's hash, or None to leave it to the Jetlink app. Only on a user's
choice: jetlink dates the choice by this write, and the newer of it and the app's drives."""
params = self.params()
if sha256 is None:
params.remove("JetlinkModel")
else:
params.put("JetlinkModel", {"sha256": sha256, "displayName": name}, block=True)
@staticmethod
def _wired_session_running():
"""Wired Android Auto holds the comma's USB-C port while a session runs."""
+42 -6
View File
@@ -207,6 +207,24 @@ def verified_artifact(model_id: str, entries: dict[str, dict], root: Path = ROOT
return None
def onnx_sha256(row: dict) -> str | None:
"""The ONNX a big model was built from, by the hash the catalog gives it: what Jetlink runs. None without one."""
digests = row.get("source_sha256")
if not isinstance(digests, dict) or len(digests) != 1:
return None
digest = next(iter(digests.values()))
return digest if isinstance(digest, str) and SHA.fullmatch(digest) else None
def jetlink_active_big(entries: dict[str, dict], pick: dict | None) -> tuple[str, str]:
"""Active Big while Jetlink runs the big model: the model whose ONNX Jetlink was told to run, and what none
means, the Jetlink app's choice, named when it is one StarPilot does not list."""
sha = pick["sha256"] if pick else None
mid = next((mid for mid, row in entries.items() if row.get("uses_external_gpu") and onnx_sha256(row) == sha), "") if sha else ""
name = pick.get("displayName") if pick and not mid else None
return mid, f"{name} · from the Jetlink app" if isinstance(name, str) and name else "Jetlink app's choice"
def shipped_default() -> Path | None:
from openpilot.common.file_chunker import materialize_file_chunked
@@ -507,6 +525,12 @@ class ModelManager:
entries, prefs = catalog(self.root), preferences(self.root)
parked, gpu = self.parked(), self.gpu_present()
job = self._job_status()
owner = self.jetlink_owner()
jetlink = owner.snapshot()
# with Jetlink running the big model, Active Big is its choice: a model whose ONNX the catalog names
jetlink_big = owner.chooses_big(jetlink)
active_big, big_none = (jetlink_active_big(entries, owner.pick()) if jetlink_big else
(prefs["big"], "None — always use Active Small"))
models = []
for mid, row in entries.items():
installed, checking = self._snapshot_installed(mid, row)
@@ -518,11 +542,13 @@ class ModelManager:
"installed": installed, "checking": checking,
"builtin": mid == DEFAULT_SMALL,
"requiresGpu": requires_gpu, "gpuAvailable": gpu or not requires_gpu, "small": not requires_gpu,
"selectable": installed, "downloadAvailable": published, "artifactSize": row.get("artifact_size", 0),
"selectable": onnx_sha256(row) is not None if requires_gpu and jetlink_big else installed,
"downloadAvailable": published, "artifactSize": row.get("artifact_size", 0),
"unavailableReason": "" if installed else "Checking artifact" if checking else
"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": prefs["big"],
return {"schemaVersion": 1, "models": models, "activeSmallModel": prefs["small"], "activeBigModel": active_big,
"jetlinkBig": jetlink_big, "bigNoneLabel": big_none,
"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},
@@ -530,7 +556,7 @@ class ModelManager:
"downloadVariant": job.get("variant", "standard"),
"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": self.jetlink_owner().snapshot(),
"isOnroad": not parked, "gpuAvailable": gpu, "jetlink": jetlink,
"capabilities": {"select": parked, "download": parked, "downloadAll": parked, "cancel": True,
"jetlink": parked, "refresh": parked, "favorites": True, "delete": parked, "randomizer": parked, "exclusions": parked}}
@@ -644,11 +670,16 @@ class ModelManager:
raise ModelError("Invalid model selection")
if preferences(self.root)["randomizer"]:
raise ModelError("Turn Model Randomizer off before choosing a model")
mid, profile = payload["model"], payload["profile"]
mid, profile, entries = payload["model"], payload["profile"], catalog(self.root)
# Jetlink runs a big model's ONNX, so it needs no download here, only the catalog's hash for it
jetlink = profile == "big" and self.jetlink_owner().chooses_big()
if mid != BUNDLED_CURRENT and not (profile == "big" and mid == ""):
if mid not in catalog(self.root) or catalog(self.root)[mid].get("uses_external_gpu", False) != (profile == "big"):
if mid not in entries or entries[mid].get("uses_external_gpu", False) != (profile == "big"):
raise ModelError("Model does not match the selected hardware profile")
if not self._installed(mid, catalog(self.root)):
if jetlink:
if onnx_sha256(entries[mid]) is None:
raise ModelError("Jetlink cannot run this model yet: its catalog entry names no ONNX")
elif not self._installed(mid, entries):
raise ModelError("Download and verify this model first")
with state_lock(self.root):
self._require_parked()
@@ -658,6 +689,11 @@ class ModelManager:
job = self._read_job()
if self._job_busy() and not (job.get("state") == "running" and job.get("operation") == "refresh_manifest"):
raise ModelError("Wait for the model download to finish before selecting a model")
if jetlink:
# Chestnut's choice stays as it was; none leaves the model to the Jetlink app
row = entries.get(mid, {})
self.jetlink_owner().choose(onnx_sha256(row), row.get("name", ""))
return {"message": "Model selected for Jetlink"}
saved[profile] = mid
atomic_json(self.root / "preferences.json", saved)
return {"message": "Model selected for the next drive"}
@@ -18,7 +18,11 @@ GUARD = install_guard()
@pytest.fixture(autouse=True)
def no_machine_files():
def no_machine_files(tmp_path, monkeypatch):
from openpilot.common.params import Params
monkeypatch.setenv("PARAMS_ROOT", str(tmp_path / "params"))
private_params = Params(str(tmp_path / "params"))
monkeypatch.setattr("openpilot.common.params.Params", lambda *args, **kwargs: private_params)
# on a comma, a test that reaches the live params would clear JetlinkSpec
GUARD.start()
yield
@@ -38,8 +42,10 @@ def test_interface_keys():
op = adapter.adapter()
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
assert op.keys.big_model is None and op.keys.catalog is None
# Active Big's choice for Jetlink, by the ONNX's hash, and no catalog or model manager for jetlink: with no
# choice a fresh Jetson gets the Jetlink app's or jetlink's default, and a newer app choice is written back here
assert op.keys.big_model == "JetlinkModel"
assert op.keys.catalog is None and op.keys.select is None
def test_guard_answers_link_off_without_a_usable_jetlink():
@@ -1,12 +1,16 @@
"""Jetlink connection choices do not change the local model catalog."""
from types import SimpleNamespace
import json
import tempfile
from pathlib import Path
import unittest
from jetlink.openpilot import Status
from openpilot.starpilot.models.catalog import CATALOG_PATH
from openpilot.starpilot.models.jetlink_settings import JetlinkSettings
from openpilot.starpilot.models.manager import ModelManager, preferences
from openpilot.starpilot.models.manager import ModelError, ModelManager, preferences
ONNX = "d" * 64
class MemoryParams:
@@ -25,6 +29,9 @@ class MemoryParams:
put_bool = put
def remove(self, key):
self.values.pop(key, None)
class TestJetlinkSettings(unittest.TestCase):
def setUp(self):
@@ -85,3 +92,54 @@ class TestJetlinkSettings(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "vehicle off"):
manager.action("jetlink", {"mode": "off"})
self.assertEqual(self.params.values["JetlinkMode"], 1)
def manager_with_onnx(self, tmp):
"""A catalog whose Lebowski names the ONNX it was built from and whose TT Driving does not, as the downloaded
manifest does for the newer models."""
catalog = json.loads(CATALOG_PATH.read_text())
catalog["models"] = [{**m, "source_sha256": {"lebowski3_driving_supercombo.onnx": ONNX}} if m["id"] == "lebowski3" else m
for m in catalog["models"] if m["id"] in ("lebowski3", "tt-driving")]
(Path(tmp) / "catalog.json").write_text(json.dumps(catalog))
return ModelManager(root=Path(tmp), parked=lambda: True, gpu_present=lambda: False, jetlink=self.owner)
def test_active_big_is_jetlinks_choice_by_its_onnx(self):
with tempfile.TemporaryDirectory() as tmp:
manager = self.manager_with_onnx(tmp)
self.owner.configure({"mode": "usb"})
view = manager.snapshot()
self.assertTrue(view["jetlinkBig"])
# neither is downloaded: the one whose ONNX the catalog names is selectable
ready = {m["value"]: m["selectable"] for m in view["models"] if m["requiresGpu"]}
self.assertEqual((ready["lebowski3"], ready["tt-driving"]), (True, False))
self.assertEqual((view["activeBigModel"], view["bigNoneLabel"]), ("", "Jetlink app's choice"))
# no download: Jetlink's host fetches the ONNX itself
manager.action("active", {"profile": "big", "model": "lebowski3"})
label = next(m["label"] for m in view["models"] if m["value"] == "lebowski3")
self.assertEqual(self.params.values["JetlinkModel"], {"sha256": ONNX, "displayName": label})
self.assertEqual(manager.snapshot()["activeBigModel"], "lebowski3")
# Chestnut's own choice is not Jetlink's
self.assertEqual(preferences(Path(tmp))["big"], "")
with self.assertRaisesRegex(ModelError, "names no ONNX"):
manager.action("active", {"profile": "big", "model": "tt-driving"})
# a newer choice made in the Jetlink app, which jetlink writes back: shown as the app's when not listed
self.params.values["JetlinkModel"] = {"sha256": "c" * 64, "ref": "b" * 40, "displayName": "Cinque Terre V3 Model"}
view = manager.snapshot()
self.assertEqual((view["activeBigModel"], view["bigNoneLabel"]), ("", "Cinque Terre V3 Model · from the Jetlink app"))
# none leaves it to the Jetlink app
manager.action("active", {"profile": "big", "model": ""})
self.assertNotIn("JetlinkModel", self.params.values)
def test_without_jetlink_active_big_is_chestnuts(self):
with tempfile.TemporaryDirectory() as tmp:
manager = self.manager_with_onnx(tmp)
view = manager.snapshot()
self.assertFalse(view["jetlinkBig"])
self.assertFalse(next(m for m in view["models"] if m["value"] == "lebowski3")["selectable"])
with self.assertRaisesRegex(ModelError, "Download and verify"):
manager.action("active", {"profile": "big", "model": "lebowski3"})
self.owner.configure({"mode": "usb"})
self.chestnut = True
self.assertFalse(manager.snapshot()["jetlinkBig"])
with self.assertRaisesRegex(ModelError, "Download and verify"):
manager.action("active", {"profile": "big", "model": "lebowski3"})
self.assertNotIn("JetlinkModel", self.params.values)
+8 -5
View File
@@ -5,7 +5,7 @@ import time
from typing import Protocol
from openpilot.starpilot.ui.feature_settings_state import FeatureSettingsState
from openpilot.starpilot.ui.models_state import model_action_allowed, model_catalog_rows, model_display_text as _display_text
from openpilot.starpilot.ui.models_state import big_none_label, model_action_allowed, model_catalog_rows, model_display_text as _display_text
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, GreyBigButton
@@ -107,7 +107,8 @@ class ModelsCompact:
_grey_card("model selection", "takes effect at the next start")]
for profile, field in (("small", "activeSmallModel"), ("big", "activeBigModel")):
mid = data.get(field, "")
label = next((m.get("label", mid) for m in data["models"] if m["value"] == mid), mid or "none — always use small")
label = next((m.get("label", mid) for m in data["models"] if m["value"] == mid),
mid or (big_none_label(data).lower() if profile == "big" else "none — always use small"))
cards.append(self._button(f"active {profile}", label, lambda p=profile: self.open("catalog", p),
data.get("capabilities", {}).get("select") is True and not data.get("randomizer")))
cards.append(self._button("model randomizer", "on · choose at each start" if data.get("randomizer") else "off",
@@ -127,13 +128,15 @@ class ModelsCompact:
profile = page.profile
cards = [_grey_card(f"active {profile}" if profile else "models", "choose a model" if profile else "browse the catalog")]
cards.append(self._button("sort and filter", self.SORTS[page.sort_mode], lambda: self._next_sort(page)))
jetlink = profile == "big" and data.get("jetlinkBig") is True
if profile == "big":
cards.append(self._button("none", "always use active small", lambda: self._action(page, "active", profile="big"),
model_action_allowed(data, "active", profile="big")))
cards.append(self._button("none", big_none_label(data).lower() if jetlink else "always use active small",
lambda: self._action(page, "active", profile="big"), model_action_allowed(data, "active", profile="big")))
rows = model_catalog_rows(data, page.sort_mode, profile, installed_only=bool(profile))
for model in rows:
mid = model["value"]
tags = ["installed" if model.get("installed") else model.get("unavailableReason", "download required")]
tags = ["runs over jetlink" if jetlink else "installed" if model.get("installed") else
model.get("unavailableReason", "download required")]
if model.get("blacklisted"):
tags.append("excluded from randomizer")
tags.append("chestnut" if model.get("requiresGpu") else "small")
+9 -4
View File
@@ -51,6 +51,11 @@ def model_fits_profile(model: dict, profile: str) -> bool:
return profile in profiles if isinstance(profiles, list) else model.get("requiresGpu") is (profile == "big")
def big_none_label(data: dict) -> str:
"""Active Big with no model of its own, as the manager words it: under Jetlink, the Jetlink app's choice."""
return str(data.get("bigNoneLabel") or "None — always use Active Small")
def model_action_allowed(data: dict, action: str, model: dict | None = None, profile: str = "") -> bool:
capability = {"active": "select", "preferences": "favorites", "download_all": "downloadAll", "refresh_manifest": "refresh",
"exclusion": "exclusions"}.get(action, action)
@@ -61,7 +66,7 @@ def model_action_allowed(data: dict, action: str, model: dict | None = None, pro
if action == "active":
return (not data.get("randomizer") and (not data.get("downloading") or data.get("refreshing") is True) and profile in ("small", "big") and
((model is None and profile == "big") or
bool(model and model.get("installed") is True and model.get("selectable") is True and model_fits_profile(model, profile))))
bool(model and model.get("selectable") is True and model_fits_profile(model, profile))))
if action == "download":
return not data.get("downloading") and bool(model and not model.get("installed") and model.get("downloadAvailable") is True)
if action == "download_all":
@@ -82,7 +87,7 @@ def model_action_allowed(data: dict, action: str, model: dict | None = None, pro
def model_catalog_rows(data: dict, sort_mode: str = "date", profile: str = "", installed_only: bool = False) -> list[dict]:
rows = [m for m in data.get("models", ()) if isinstance(m, dict) and isinstance(m.get("value"), str) and
(not profile or model_fits_profile(m, profile)) and
(not installed_only or m.get("installed") is True and m.get("selectable") is True)]
(not installed_only or m.get("selectable") is True)]
if sort_mode == "favorites":
rows = [m for m in rows if m.get("userFavorite")]
elif sort_mode == "community":
@@ -97,7 +102,7 @@ def model_catalog_rows(data: dict, sort_mode: str = "date", profile: str = "", i
def model_profile_choices(data: dict, profile: str) -> tuple[tuple[str, str], ...]:
rows = model_catalog_rows(data, "name", profile, installed_only=True)
choices = tuple((m.get("label", m["value"]), m["value"]) for m in rows)
return (("None — always use Active Small", ""),) + choices if profile == "big" else choices
return ((big_none_label(data), ""),) + choices if profile == "big" else choices
def model_profile_request(data: dict, profile: str, model_id: str) -> dict | None:
@@ -112,7 +117,7 @@ def model_manager_rows(data: dict) -> tuple[FeatureRow, ...]:
rows = [FeatureRow("", "Model catalog", f"{summary.get('installed', 0)} installed / {summary.get('total', 0)} total")]
for profile, field in (("small", "activeSmallModel"), ("big", "activeBigModel")):
mid = data.get(field, "")
label = next((m.get("label", mid) for m in data.get("models", ()) if m.get("value") == mid), mid or "None — always use Active Small")
label = next((m.get("label", mid) for m in data.get("models", ()) if m.get("value") == mid), mid or big_none_label(data))
available = (data.get("capabilities", {}).get("select") is True and data.get("isOnroad") is False and
(not data.get("downloading") or data.get("refreshing") is True) and not data.get("randomizer"))
rows.append(FeatureRow("", f"Active {profile.title()}", label, source=mid.encode(), available=available,
+15 -2
View File
@@ -10,8 +10,8 @@ from openpilot.starpilot.models.catalog import BUNDLED_CURRENT, BY_ID
from openpilot.starpilot.models.status import ModelHealth, ModelStatus, ModelVariant
from openpilot.starpilot.ui.feature_settings_state import feature_row_top, FeatureInput
from openpilot.starpilot.ui.models_compact import ModelsCompact, _ModelsPage, _display_text
from openpilot.starpilot.ui.models_state import (model_page, model_action_allowed, model_catalog_rows, model_profile_choices, model_profile_request,
home_model_label)
from openpilot.starpilot.ui.models_state import (model_page, model_action_allowed, model_catalog_rows, model_manager_rows, model_profile_choices,
model_profile_request, home_model_label)
from openpilot.starpilot.ui.presentation import Profile
from openpilot.starpilot.ui.settings_state import Destination
from openpilot.starpilot.ui.shell import ShellMode, ShellView
@@ -138,6 +138,19 @@ class TestModelManager(unittest.TestCase):
self.assertEqual([m["value"] for m in model_catalog_rows(self.data, "community")], ["pending"])
self.assertEqual([m["value"] for m in model_catalog_rows(self.data, "favorites")], ["big"])
def test_jetlink_chooses_active_big_by_its_onnx(self):
# under Jetlink the manager marks the big models with an ONNX selectable, downloaded or not, and words none
onnx = {**self.missing, "value": "onnx", "label": "Onnx", "selectable": True}
data = {**self.data, "models": [self.small, {**self.big, "selectable": False}, onnx], "jetlinkBig": True,
"activeBigModel": "onnx", "bigNoneLabel": "Jetlink app's choice"}
self.assertEqual(model_profile_choices(data, "big"), (("Jetlink app's choice", ""), ("Onnx", "onnx")))
self.assertEqual(model_profile_request(data, "big", "onnx"), {"profile": "big", "model": "onnx"})
self.assertIsNone(model_profile_request(data, "big", "big"))
self.assertEqual(model_profile_choices(data, "small"), (("Small", "small"),))
# a model chosen in the Jetlink app that StarPilot does not list is named as the app's
app = {**data, "activeBigModel": "", "bigNoneLabel": "Cinque Terre V3 Model · from the Jetlink app"}
self.assertEqual(model_manager_rows(app)[2].value, "Cinque Terre V3 Model · from the Jetlink app")
def test_parked_and_protected_guards(self):
self.assertFalse(model_action_allowed(self.data, "delete", self.big))
self.assertTrue(model_action_allowed({**self.data, "activeBigModel": ""}, "delete", self.big))
+1 -1
View File
@@ -1 +1 @@
{"protected_inventory_sha256": "fc6b28adffa551096ac44484e9a477d01f6f47b5a260e60b11af49d7f93705b3", "schema_version": 1}
{"protected_inventory_sha256": "079e3b462e50f09c9838850413c3b3f2ed88ee703c31f3972c4daba20449c1ec", "schema_version": 1}
+2 -2
View File
@@ -65,8 +65,8 @@
{
"path": "jetlink_repo",
"url": "https://github.com/zoompilot/jetlink.git",
"commit": "bf4dc940e98f5d3ed9b7163adb021439010d634f",
"tree": "81237da0aa4423d8b47c4e18c4b02763afbc1917",
"commit": "a863c3801da6fd925d14eb1f5b1e250f8efcc649",
"tree": "70cabdd689e2f1d95f31c95ede0c7990b41e550c",
"include": [
"jetlink",
"LICENSE",