Files
StarPilot/jetlink_repo/jetlink/openpilot/__init__.py
T
Zeph Leggett de40ad2ff1 Jetlink d24328d
Vendor only the comma payload jetlink commits to (jetlink/, LICENSE,
pyproject.toml, README.md) with an include list, so the exclude list goes
and upstream's README stays as published; StarPilot's notes live in
JETLINK.md. The root helper now ships inside the package.

The adapter moves to API 3 in the next commit; until then Jetlink stays
off.

d24328d stands in for the jetlink release tag commit: re-run
tools/vendor/sync.py jetlink_repo <tag commit> --apply before merging and
update the revision in CREDITS.md and JETLINK.md with it.
2026-10-18 16:18:37 -05:00

315 lines
14 KiB
Python

"""
Copyright (c) 2026-, Zeph Leggett.
This file is part of jetlink and is licensed under the MIT License.
See the LICENSE file in the root directory for more details.
jetlink on an openpilot device: everything between the fork and the comma's
device layer (jetlink.comma).
The fork implements Openpilot (interface.py) once, in an adapter module that
holds every openpilot import, binds it here (bind) and calls the methods of
the Jetlink that comes back. That is the whole contract between the two repos:
the names in __all__, the Jetlink methods and Status, which
tests/openpilot/test_api.py and test_interface.py pin, and the facade the
fork's hook lines call them through (hooks.py) and the kit its CI runs
(testing.py). API names its version: an adapter declares the one it was
written to, hooks.Hooks refuses one it does not accept, and the fork treats
that as jetlink being absent, an offroad alert and no link rather than a
crash. Everything else in this package is internal to jetlink and may change
at any time.
API changes only for a breaking change to the contract: removing or renaming
anything in it, changing what one of its members means, or making an optional
member required. A new Status field, a new Jetlink method, or a new interface
member that jetlink reads with getattr and a fallback, is additive and keeps
it. A bump keeps the previous API working for one minor release, so a fork can
move its pin and then its adapter. Each release that changes the contract
says how in its CHANGELOG section (Forks, docs/publishing.md).
Imported by the resident gadget owner, so nothing heavy at module level: the
parts a heavy process uses are imported when it first uses them.
"""
from __future__ import annotations
import time
from jetlink.comma import gadget
from jetlink.openpilot.events import Flags, SwapEvents
from jetlink.openpilot.interface import MODES, STATES, Keys, ModelFace, Openpilot, OwnerConfig, conformance
from jetlink.openpilot.parts import Parts, for_this_process
from jetlink.openpilot.status import Status
API = 3
__all__ = ['API', 'MODES', 'STATES', 'Jetlink', 'Keys', 'ModelFace', 'Openpilot', 'OwnerConfig', 'Status', 'bind',
'conformance', 'SHUTDOWN_TIMEOUT', 'Flags', 'SwapEvents']
# how long a power-off request waits for the owner's run to shut the Jetson
# down before it is withdrawn. Wake from suspend is ~8 s to a server
SHUTDOWN_TIMEOUT = 25.0
def bind(op) -> Jetlink:
"""jetlink for this process, over the fork's adapter. One per process: it
keeps the process's caches (parts.for_this_process)."""
return Jetlink(for_this_process(op))
class Jetlink:
"""What the fork calls: one per process, from bind(). Its public methods are
the API; the parts behind it are not."""
def __init__(self, parts: Parts):
self._parts = parts
self._log = parts.log
# prepare() said yes in this process, so attach() may join modeld
self._prepared = False
# what -> the last failure logged reading it; cleared by a read that works
self._failures: dict[str, str] = {}
# when this process asked for the Jetson to power off, until it was taken
self._shutdown_asked: float | None = None
# shutdown_step: has it run in this process, and has it said yes
self._stepped = False
self._may_power_off = False
# the joining model's handovers as handed_over() last saw them
self._handovers = 0
def _read(self, what: str, read, fallback):
"""read(), or fallback(error) when it raises. The readers never raise:
they are asked several times a second, from threads that have nothing to
do with jetlink. A failure is logged once per distinct error, and again
after a read that worked."""
try:
value = read()
except Exception as e:
error = f"{type(e).__name__}: {e}"
if self._failures.get(what) != error:
self._failures[what] = error
self._log.exception("jetlink: could not read %s", what)
return fallback(error)
self._failures.pop(what, None)
return value
def _mode(self) -> str:
"""The link setting, or 'off' when it cannot be read."""
return self._read('the link setting', lambda: self._parts.settings.mode(), lambda error: 'off')
def enabled(self) -> bool:
"""Has the user turned the link on, with no chestnut fitted? The setting,
never link state or readiness. A chestnut runs the big model natively and
the link stays off beside it, so jetlinkd never takes the USB controller
from it. manager's should_run for jetlinkd, on every device."""
mode = self._mode()
return self._read('whether the link is on', lambda: self._parts.enabled(mode), lambda error: False)
def status(self) -> Status:
"""One snapshot for the UI and the panels."""
from jetlink.openpilot import status
mode = self._mode()
return self._read('the status', lambda: status.read(self._parts, mode), lambda error: status.failed(error, mode))
def runtime(self):
"""What modeld runs (status.Runtime), the part of status() that changes
while driving, for a loop: the setting, the chestnut test and modeld's
record, nothing else. The chestnut test may walk the USB devices in sysfs,
once per parts.CHESTNUT_TTL (2 s), as status() does. None where
status().running is None for want of the link: off, or beside a chestnut."""
from jetlink.openpilot import status
return status.runtime() if self.enabled() else None
def model_state(self, ref: str) -> str | None:
"""One catalog model for the picker's list: 'ready' (built on the Jetson),
'downloaded' (on the comma) or None. Asked when a list opens."""
from jetlink.openpilot import status
return self._read('a model\'s state', lambda: status.model_state(self._parts, ref), lambda error: None)
def reason(self) -> str | None:
"""Why the link the user asked for cannot run: hardwared's offroad alert.
None with the link off. The files the gadget and the build leave, nothing
else: hardwared asks twice a second on every device."""
from jetlink.openpilot import status
mode = self._mode()
return self._read('why the link cannot run', lambda: status.reason(self._parts, mode),
lambda error: status.failure(error, mode))
def prepare(self) -> bool:
"""Will the link join this modeld? modeld only, before it goes realtime:
enabled(), then the process-wide setup that has to happen before then,
which is also a last veto."""
from jetlink.openpilot.status import NO_WARP
from jetlink.openpilot.warp import init_device
self._prepared = False
if not self.enabled():
return False
# the link is not worth waiting for: attach() joins in the background.
# enabled() is the setting alone, so this is where a device that cannot
# present a gadget at all says so; nothing here would ever reach a Jetson
if not gadget.link_configured():
self._log.warning("jetlink: no usable gadget (%s), staying on the small model",
gadget.gadget_error() or 'not set up')
return False
# the warp is a build product and nothing compiles one at runtime, so one
# missing now stays missing, and saying no keeps modeld on the plain small
# model; the offroad alert has said why
if not self._parts.warps.built():
self._log.warning("jetlink: %s, staying on the small model", NO_WARP)
return False
# the last hook before modeld goes SCHED_FIFO on core 7, and the GPU's init
# spawns a thread that would inherit that. See warp.init_device
init_device(self._log)
self._prepared = True
return True
def attach(self, small, cam_w: int, cam_h: int):
"""Join the link to modeld, once the camera is up and `small` is built:
the joining model, which drives as `small` until the Jetson is there.
Before every run() it asks the adapter's in_control() (is openpilot or
MADS in control? it swaps only while not), and modeld writes onto it
`frame_drop_ratio` (its share of dropped camera frames, which hands a
lagging large model back).
None unless prepare() said yes in this process: without it the GPU's
thread would start on modeld's realtime core. If the joining model cannot
be built, `small`, and the failure is logged.
"""
if not self._prepared:
return None
from jetlink.openpilot.joining import join
try:
return join(self._parts, cam_w, cam_h, small)
except Exception:
self._log.exception("jetlink load failed")
return small
def handed_over(self, model) -> bool:
"""modeld, after every run(): did it change which model drives? The
joining model counts its swaps and hand-backs (JoinedModel.handovers),
and the stall of one is not lag, nor are the drops of its frame. Any
other model has no count and never hands over. Since the last call in
this process, which runs one modeld loop."""
handovers = getattr(model, 'handovers', 0)
changed, self._handovers = handovers != self._handovers, handovers
return changed
def state(self, model) -> str:
"""What modeld publishes as the big model's state: the model's
big_model_state as it is, one of STATES on a joining model, and 'none' for
a model without one. A fork's capnp enum holds exactly STATES, so a value
outside it would raise on the frame thread."""
return getattr(model, 'big_model_state', 'none')
def request_shutdown(self, reason: str = '') -> bool:
"""The device is powering off for good: ask for the Jetson to go down
with it, and return at once. True when the request now waits for the
owner, which shutdown_pending() follows; False when there was nothing to
ask, with the link off, beside a chestnut, or with no Jetson known to be
there. Never raises.
hardwared calls this once and goes on publishing deviceState, putting
DoShutdown once shutdown_pending() clears or SHUTDOWN_TIMEOUT has passed.
The owner wakes a sleeping Jetson and starts a run that asks it; the
wake and one round trip take ~10 s.
"""
try:
return self._ask_for_power_off(reason)
except Exception:
self._log.exception("jetlink: shutdown request failed")
return False
def shutdown_pending(self) -> bool:
"""Has the owner still to take the power-off request? A stat, for a
caller's loop. Never raises.
Once SHUTDOWN_TIMEOUT has passed since this process asked, the request is
withdrawn and False: a comma that outlives its DoShutdown must not have
the Jetson powered off later, when nobody wants it off, and the log says
the request went unanswered."""
try:
return self._pending()
except Exception:
return False
def shutdown_step(self, should_shutdown: bool, reason: str = '') -> bool:
"""hardwared, every loop: may the device power off now? True once it may
(put DoShutdown), and from then on. False while should_shutdown is False
and nothing has been asked. The first True asks once (request_shutdown):
True at once when there was nothing to ask, otherwise once the owner has
taken the request, or SHUTDOWN_TIMEOUT after asking, when it is withdrawn.
Once asked, should_shutdown is not read again, and the caller keeps no
timer. A stat a call. Never raises: a failure once a power-off is wanted
is a yes, since a jetlink bug must not keep a comma on to drain the car's
battery, and one before then is a no."""
if self._may_power_off:
return True
try:
first, self._stepped = not self._stepped, True
# a request this process did not write: a partial one, or one from a
# hardwared that has since restarted. Left, the owner would power the
# Jetson off whenever it next saw one, maybe a drive later
if first and self._shutdown_asked is None:
gadget.finish_shutdown()
if self._shutdown_asked is None and not should_shutdown:
return False
asked = self._shutdown_asked is not None or self.request_shutdown(reason)
self._may_power_off = not asked or not self._pending()
except Exception:
self._log.exception("jetlink: the shutdown step failed")
if self._shutdown_asked is not None:
# nobody follows the request any more, so it must not stay for the owner
try:
gadget.finish_shutdown()
except Exception:
pass
self._may_power_off = should_shutdown or self._shutdown_asked is not None
return self._may_power_off
def _pending(self) -> bool:
"""shutdown_pending(), raising."""
pending = gadget.SHUTDOWN_REQUEST.exists()
asked, now = self._shutdown_asked, time.monotonic()
if asked is None:
return pending
if not pending:
# the run removes it whether or not the Jetson answered; its log says which
self._log.warning("jetlink: the owner took the shutdown request after %.1f s", now - asked)
elif now - asked >= SHUTDOWN_TIMEOUT:
self._log.warning("jetlink: nobody took the shutdown request within %.0f s", SHUTDOWN_TIMEOUT)
gadget.finish_shutdown()
pending = False
if not pending:
self._shutdown_asked = None
return pending
def _ask_for_power_off(self, reason: str) -> bool:
"""Hand the request to the owner: nothing else can touch the link, and
the request is a file (gadget.SHUTDOWN_REQUEST) the owner's step looks
for before anything else. Skipped when no Jetson is known to be there
(dormant counts as there). A run busy in a long provision is stopped for
it; shutdown_pending()'s deadline covers a Jetson that never answers."""
from jetlink.openpilot.status import Presence
if not self.enabled():
return False
# a presence of its own, off the files: one this process's readers keep
# refreshed, or the owner's with its hold, would count a Jetson seen
# seconds before the power-off, and the run would wait 20 s for a host
# that has gone
if not Presence().present():
return False
self._log.warning("jetlink: asking the jetson to power off: %s", reason)
if not gadget.request_shutdown(reason):
return False
self._shutdown_asked = time.monotonic()
return True
def should_extend_catalog(self) -> bool:
"""Should the model manager's big-model catalog carry the newer catalogs'
models? With no chestnut fitted. Hardware, not the link setting: the model
manager drops a pick its catalog does not list."""
return not self._parts.chestnut_fitted()
def extend_catalog(self, catalog: dict) -> dict:
"""The big-model catalog the model manager fetched, with the models newer
catalogs list folded in. Never raises."""
return self._parts.models.big_catalog(catalog)