mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-29 09:33:48 +08:00
Jetlink API 3 adapter
StarPilotAdapter subclasses jetlink's Stock: StarPilot's param names, the AOL engagement rule, the model root and a CatalogModelState history reset that keeps its JIT buffers. jetlinkd runs jetlink's owner as it is, modeld calls the hooks, and Galaxy and analytics read jetlink's runtime record in place of JetlinkRuntime. - no big-model slot or catalog keys: jetlink provisions its default model - jetlink restores the owner's tuning itself; the port draw stays cut with a host plugged in - the Jetson is asked to power off on DoShutdown only, capped at 30 s
This commit is contained in:
@@ -45,8 +45,8 @@ from openpilot.starpilot.models.status import ModelVariant
|
||||
from openpilot.starpilot.models.startup import report_load_timeout, wait_for_chestnut_power
|
||||
from openpilot.starpilot.navigation.intent import TurnIntent, matching_turn_signal
|
||||
from openpilot.starpilot.models.catalog import BUNDLED_CURRENT, BY_ID, DEFAULT_SMALL, DEFAULT_SMALL_SHA256
|
||||
from openpilot.starpilot.models.runner import CatalogModelState, action_from_outputs, load_verified_model
|
||||
from openpilot.starpilot.models.jetlink_adapter import JetlinkModel, attach as attach_jetlink
|
||||
from openpilot.starpilot.models.runner import CatalogModelState, load_verified_model
|
||||
from openpilot.starpilot.models import jetlink_adapter
|
||||
import uuid
|
||||
from openpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan, should_stop, smooth_value, get_curvature_from_plan
|
||||
from openpilot.selfdrive.modeld.parse_model_outputs import Parser
|
||||
@@ -159,6 +159,7 @@ def input_view(buffer: Buffer, shape: tuple[int, ...], dtype: DType, offset: int
|
||||
|
||||
class ModelState:
|
||||
prev_desire: np.ndarray # for tracking the rising edge of the pulse
|
||||
get_action_from_model = staticmethod(get_action_from_model)
|
||||
|
||||
def __init__(self, cam_w: int, cam_h: int, chestnut: bool):
|
||||
if not chestnut:
|
||||
@@ -388,8 +389,13 @@ def main(demo=False):
|
||||
"chestnut-load-failed" if initial_chestnut_fallback else
|
||||
"selected-load-failed" if (selected_small_failed and not model.chestnut) or requested_model_id != model.model_id else None,
|
||||
model_id=model.model_id)
|
||||
model = attach_jetlink(model, vipc_client_main.width, vipc_client_main.height,
|
||||
chestnut=CHESTNUT, recovery=recovery_small_only)
|
||||
HOOKS = jetlink_adapter.hooks()
|
||||
jetlink_model = None
|
||||
# before going realtime: the GPU thread it starts would inherit SCHED_FIFO 54 on core 7
|
||||
if HOOKS.prepare(CHESTNUT or recovery_small_only):
|
||||
# the joining model drives as small_model until the big model is ready
|
||||
model = HOOKS.attach(model, small_model, vipc_client_main.width, vipc_client_main.height)
|
||||
jetlink_model = model if model is not small_model else None
|
||||
params.put_bool("ChestnutLoading", False)
|
||||
cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting")
|
||||
release_compile_pool()
|
||||
@@ -517,14 +523,13 @@ def main(demo=False):
|
||||
'action_t': np.array([lat_action_t, long_action_t], dtype=np.float32),
|
||||
}
|
||||
|
||||
if isinstance(model, (CatalogModelState, JetlinkModel)):
|
||||
if isinstance(model, CatalogModelState) or model is jetlink_model:
|
||||
inputs['prev_action'] = np.array([prev_action.desiredCurvature * max(1.0, v_ego) ** 2,
|
||||
prev_action.desiredAcceleration], dtype=np.float32)
|
||||
inputs['lateral_control_params'] = np.array([v_ego, lat_delay], dtype=np.float32)
|
||||
|
||||
jetlink_handovers = model.handovers if isinstance(model, JetlinkModel) else None
|
||||
if isinstance(model, JetlinkModel):
|
||||
model.observe_frame(lat_delay, frame_drop_ratio)
|
||||
model.lat_delay = lat_delay
|
||||
model.frame_drop_ratio = frame_drop_ratio
|
||||
mt1 = time.perf_counter()
|
||||
try:
|
||||
send_chestnut = (chestnut_state is not None and
|
||||
@@ -535,7 +540,7 @@ def main(demo=False):
|
||||
raise
|
||||
# fallback to small model
|
||||
fallback_reason = "chestnut-load-failed"
|
||||
if isinstance(model, JetlinkModel):
|
||||
if model is jetlink_model:
|
||||
model.close()
|
||||
fallback_reason = None
|
||||
else:
|
||||
@@ -550,27 +555,21 @@ def main(demo=False):
|
||||
model_output = None
|
||||
mt2 = time.perf_counter()
|
||||
model_execution_time = mt2 - mt1
|
||||
if isinstance(model, JetlinkModel) and model.handovers != jetlink_handovers:
|
||||
if model is jetlink_model and HOOKS.handed_over(model):
|
||||
frame_dropped_filter.x = 0.
|
||||
frame_drop_ratio = 0.
|
||||
if model.chestnut:
|
||||
receipt_owner.loaded(None, ModelVariant.CHESTNUT)
|
||||
else:
|
||||
receipt_owner.loaded(small_prepared, ModelVariant.SMALL, model_id=small_model.model_id)
|
||||
cloudlog.event("jetlinkHandover", state=model.big_model_state, handovers=model.handovers)
|
||||
cloudlog.event("jetlinkHandover", state=HOOKS.state(model), handovers=model.handovers)
|
||||
|
||||
if model_output is not None:
|
||||
modelv2_send = messaging.new_message('modelV2')
|
||||
drivingdata_send = messaging.new_message('drivingModelData')
|
||||
posenet_send = messaging.new_message('cameraOdometry')
|
||||
|
||||
if isinstance(model, JetlinkModel):
|
||||
action = model.action(model_output, prev_action, lat_action_t, long_action_t, v_ego)
|
||||
elif isinstance(model, CatalogModelState):
|
||||
action = action_from_outputs(model_output, model.behavior_version, prev_action, lat_action_t, long_action_t, v_ego,
|
||||
LAT_SMOOTH_SECONDS, LONG_SMOOTH_SECONDS)
|
||||
else:
|
||||
action = get_action_from_model(model_output, prev_action, lat_action_t, long_action_t, v_ego)
|
||||
action = model.get_action_from_model(model_output, prev_action, lat_action_t, long_action_t, v_ego)
|
||||
prev_action = action
|
||||
fill_model_msg(modelv2_send, model_output, action,
|
||||
publish_state, meta_main.frame_id, meta_extra.frame_id, frame_id,
|
||||
|
||||
@@ -17,13 +17,12 @@ COUNTERS = ('drives', 'meters', 'seconds', 'current_months_meters', 'total_track
|
||||
|
||||
|
||||
def executed_model(sm, params, now):
|
||||
from openpilot.starpilot.models.jetlink_adapter import runtime_status
|
||||
from openpilot.starpilot.models.jetlink_adapter import hooks
|
||||
from openpilot.starpilot.models.runtime import snapshot
|
||||
from openpilot.starpilot.models.status import ModelHealth
|
||||
from openpilot.starpilot.models.catalog import model_entries
|
||||
remote = runtime_status()
|
||||
if remote.get('active') and bool(sm['modelV2'].big):
|
||||
digest = remote['artifact_sha256']
|
||||
if bool(sm['modelV2'].big) and (remote := hooks().runtime()) is not None and remote.active and remote.sha256:
|
||||
digest = remote.sha256
|
||||
return {'driving_model': 'jetlink:' + digest[:16], 'metrics': {'model_sha256': digest, 'model_variant': 'jetlink'}}
|
||||
status = snapshot(sm, params, now)
|
||||
entries = model_entries()
|
||||
|
||||
@@ -4,6 +4,11 @@ from unittest.mock import Mock
|
||||
from openpilot.starpilot.analytics.runtime import AnalyticsWorker
|
||||
|
||||
|
||||
def jetlink(record):
|
||||
"""Jetlink's hooks with runtime() answering `record`."""
|
||||
return SimpleNamespace(runtime=lambda: record)
|
||||
|
||||
|
||||
class Source:
|
||||
def __init__(self):
|
||||
self.data = {'deviceState': SimpleNamespace(started=False), 'carState': SimpleNamespace(vEgo=10),
|
||||
@@ -168,15 +173,15 @@ def test_executed_model_reports_verified_fallback_not_saved_request():
|
||||
source = Source()
|
||||
status = SimpleNamespace(health=ModelHealth.ACTIVE, loaded_id='rdf43', artifact_sha256='a' * 64,
|
||||
variant=ModelVariant.SMALL, requested_id='some-selected-large-model')
|
||||
with patch('openpilot.starpilot.models.jetlink_adapter.runtime_status', return_value={'active': False}), \
|
||||
with patch('openpilot.starpilot.models.jetlink_adapter.hooks', return_value=jetlink(None)), \
|
||||
patch('openpilot.starpilot.models.runtime.snapshot', return_value=status):
|
||||
result = executed_model(source, Mock(), 1_000_000_000)
|
||||
assert result['driving_model'] == BY_ID['rdf43'].name
|
||||
assert result['metrics']['model_variant'] == 'small'
|
||||
assert 'some-selected-large-model' not in str(result)
|
||||
source.data['modelV2'].big = True
|
||||
with patch('openpilot.starpilot.models.jetlink_adapter.runtime_status',
|
||||
return_value={'active': True, 'artifact_sha256': 'b' * 64}):
|
||||
with patch('openpilot.starpilot.models.jetlink_adapter.hooks',
|
||||
return_value=jetlink(SimpleNamespace(active=True, sha256='b' * 64))):
|
||||
remote = executed_model(source, Mock(), 1_000_000_000)
|
||||
assert remote['metrics']['model_sha256'] == 'b' * 64
|
||||
assert remote['metrics']['model_variant'] == 'jetlink'
|
||||
@@ -228,7 +233,7 @@ def test_executed_model_real_message_catalog_identity_and_actual_small_fallback(
|
||||
source.data['modelV2'] = messaging.new_message('modelV2').modelV2
|
||||
status = ModelStatus('selected-large', 'rdf43', ModelVariant.SMALL, ModelHealth.ACTIVE, True,
|
||||
'large model unavailable', 'a' * 64)
|
||||
with patch('openpilot.starpilot.models.jetlink_adapter.runtime_status', return_value={'active': False}), \
|
||||
with patch('openpilot.starpilot.models.jetlink_adapter.hooks', return_value=jetlink(None)), \
|
||||
patch('openpilot.starpilot.models.runtime.snapshot', return_value=status) as projection:
|
||||
params = Mock()
|
||||
result = executed_model(source, params, 1_000_000_000)
|
||||
@@ -247,8 +252,8 @@ def test_executed_model_remote_requires_actual_big_output_not_remote_selection()
|
||||
source = Source()
|
||||
source.data['modelV2'] = messaging.new_message('modelV2').modelV2
|
||||
small = ModelStatus('selected-large', 'rdf43', ModelVariant.SMALL, ModelHealth.ACTIVE, False, None, 'a' * 64)
|
||||
remote = {'active': True, 'artifact_sha256': 'b' * 64}
|
||||
with patch('openpilot.starpilot.models.jetlink_adapter.runtime_status', return_value=remote), \
|
||||
remote = SimpleNamespace(active=True, sha256='b' * 64)
|
||||
with patch('openpilot.starpilot.models.jetlink_adapter.hooks', return_value=jetlink(remote)), \
|
||||
patch('openpilot.starpilot.models.runtime.snapshot', return_value=small) as projection:
|
||||
source.data['modelV2'].big = True
|
||||
big = executed_model(source, Mock(), 1_000_000_000)
|
||||
@@ -271,6 +276,6 @@ def test_executed_model_unverified_receipt_never_reports_requested_identity():
|
||||
for health in (ModelHealth.UNAVAILABLE, ModelHealth.IDENTITY_UNAVAILABLE, ModelHealth.LOADING,
|
||||
ModelHealth.STALE, ModelHealth.FAILED):
|
||||
status = ModelStatus('selected-large', None, None, health, True, 'unverified', None)
|
||||
with patch('openpilot.starpilot.models.jetlink_adapter.runtime_status', return_value={'active': False}), \
|
||||
with patch('openpilot.starpilot.models.jetlink_adapter.hooks', return_value=jetlink(None)), \
|
||||
patch('openpilot.starpilot.models.runtime.snapshot', return_value=status):
|
||||
assert executed_model(source, Mock(), 1_000_000_000) == {}
|
||||
|
||||
@@ -1,333 +1,165 @@
|
||||
"""StarPilot's boundary to the pinned Jetlink public API."""
|
||||
"""StarPilot's adapter to jetlink's openpilot layer, and the guard every Jetlink hook line calls.
|
||||
|
||||
The top level imports the standard library only: manager builds its process list from this module and the
|
||||
gadget owner (jetlinkd) runs it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import cached_property, lru_cache
|
||||
import logging
|
||||
import functools
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
API = 3 # the jetlink.openpilot API this adapter is written to
|
||||
UNSUPPORTED = "Jetlink requires a comma 3X or comma 4"
|
||||
# AOL steers on its own axes, and a paused AOL is still armed: only its intent says so
|
||||
AOL_SERVICES = ("aolIntentWire", "aolAxisState")
|
||||
AOL_AXES = ("desiredLateral", "desiredLongitudinal", "lateralActive", "longitudinalActive")
|
||||
# how long after AOL's reader is built an intent not yet heard counts as armed: its first polls run before the
|
||||
# subscription connects, though card publishes the intent at 100 Hz
|
||||
AOL_GRACE_S = 1.
|
||||
# CatalogModelState's per-frame camera input, not history
|
||||
FRAME_INPUTS = ("mapped_gpu_image",)
|
||||
|
||||
|
||||
ADAPTER_MODULE = "openpilot.starpilot.models.jetlink_adapter"
|
||||
BASEDIR = Path(__file__).resolve().parents[3]
|
||||
API_VERSION = 1
|
||||
|
||||
|
||||
def supported_device(device_type=None):
|
||||
def supported_device(device_type=None) -> bool:
|
||||
if device_type is None:
|
||||
from openpilot.common.hardware import HARDWARE
|
||||
device_type = HARDWARE.get_device_type()
|
||||
return device_type in ("tizi", "mici")
|
||||
|
||||
|
||||
def params_directory() -> Path:
|
||||
# Match Path::params() and Params' prefix without loading native libraries.
|
||||
home = Path.home() / (".comma" + os.environ.get("OPENPILOT_PREFIX", ""))
|
||||
root = os.environ.get("PARAMS_ROOT", "/data/params" if Path("/AGNOS").is_file() else str(home / "params"))
|
||||
return Path(root) / os.environ.get("OPENPILOT_PREFIX", "d")
|
||||
def aol_in_control(intent, alive, axes, waiting=False) -> bool:
|
||||
"""AOL armed (paused or not) or on its axes: the half of the swap's in-control rule that is not openpilot's,
|
||||
shared by engaged() and selfdrived. `intent` is aolIntentWire's message, None until heard; `alive` that it still
|
||||
is; `axes` the AOL_AXES flags, empty when unknown; `waiting` that its reader is younger than AOL_GRACE_S, when an
|
||||
intent not heard yet may be armed."""
|
||||
if intent is None:
|
||||
if waiting:
|
||||
return True
|
||||
elif not alive:
|
||||
return True
|
||||
else:
|
||||
from openpilot.starpilot.aol.wire import decode_intent
|
||||
decoded = decode_intent(intent)
|
||||
if decoded is None or decoded.allowedLatch or decoded.lateralArmed:
|
||||
return True
|
||||
return any(axes)
|
||||
|
||||
|
||||
def keys():
|
||||
from jetlink.openpilot import Keys
|
||||
return Keys(link="JetlinkMode", offroad="IsOffroad", progress="JetlinkProgress", spec="JetlinkSpec",
|
||||
pointers="JetlinkPointers", big_model="JetlinkBigModel", catalog="JetlinkCatalog",
|
||||
charge_phone="JetlinkChargePhone")
|
||||
@functools.cache
|
||||
def _adapter_class():
|
||||
from jetlink.openpilot.interface import Keys
|
||||
from jetlink.openpilot.stock import Stock
|
||||
|
||||
class StarPilotAdapter(Stock):
|
||||
# No big-model slot or catalog: jetlink provisions its default model
|
||||
KEYS = Keys(link="JetlinkMode", offroad="IsOffroad", progress="JetlinkProgress", spec="JetlinkSpec",
|
||||
pointers="JetlinkPointers", charge_phone="JetlinkChargePhone")
|
||||
IN_CONTROL_SERVICES = ("carControl", "selfdriveState")
|
||||
_aol = None
|
||||
_aol_since = 0.
|
||||
|
||||
def model_root(self):
|
||||
from openpilot.starpilot.models.manager import ROOT
|
||||
return ROOT
|
||||
|
||||
def engaged(self, sm) -> bool:
|
||||
# openpilot, or aol_in_control(); Stock.in_control() answers True if this raises. AOL's reader is built and
|
||||
# polled on every call, openpilot engaged or not, so it has heard AOL by the time an offer or a fall asks,
|
||||
# rather than answering from its grace
|
||||
if self._aol is None:
|
||||
from openpilot.cereal import messaging
|
||||
self._aol, self._aol_since = messaging.SubMaster(list(AOL_SERVICES)), time.monotonic()
|
||||
aol = self._aol
|
||||
aol.update(0)
|
||||
if super().engaged(sm):
|
||||
return True
|
||||
heard = aol.seen["aolIntentWire"]
|
||||
axes = aol["aolAxisState"] if aol.seen["aolAxisState"] and aol.alive["aolAxisState"] else None
|
||||
return aol_in_control(aol["aolIntentWire"] if heard else None, aol.alive["aolIntentWire"],
|
||||
() if axes is None else [getattr(axes, axis) for axis in AOL_AXES],
|
||||
waiting=time.monotonic() - self._aol_since < AOL_GRACE_S)
|
||||
|
||||
def small_reset(self, small):
|
||||
# CatalogModelState's history zeroed in place: GPU queues, numpy inputs (prev_feat, packed inputs) and
|
||||
# the desire edge. Never _reset_state(), which makes tensors the run JIT was not captured with
|
||||
from openpilot.starpilot.models.runner import CatalogModelState
|
||||
if not isinstance(small, CatalogModelState):
|
||||
return None
|
||||
from jetlink.openpilot.warp import reset_queues
|
||||
queues = [q for name, q in small.input_queues.items() if q.device != "NPY" and name not in FRAME_INPUTS]
|
||||
return reset_queues(queues, small.npy.values(), small.prev_desire)
|
||||
|
||||
return StarPilotAdapter
|
||||
|
||||
|
||||
def process_start_ticks(pid):
|
||||
try:
|
||||
return int(Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()[19])
|
||||
except (OSError, ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def runtime_snapshot(record, now_ns, boot_id, start_ticks):
|
||||
unavailable = {"state": "unavailable", "active": False}
|
||||
if not isinstance(record, dict) or type(record.get("version")) is not int or record["version"] != 1:
|
||||
return unavailable
|
||||
mono, output = record.get("mono_ns"), record.get("remote_output_mono_ns")
|
||||
session = record.get("session")
|
||||
if (not boot_id or record.get("boot_id") != boot_id or
|
||||
not isinstance(session, str) or len(session) != 32 or any(c not in "0123456789abcdef" for c in session) or
|
||||
type(record.get("pid")) is not int or record["pid"] <= 0 or type(start_ticks) is not int or start_ticks <= 0 or
|
||||
type(record.get("process_start_ticks")) is not int or
|
||||
record.get("process_start_ticks") != start_ticks or type(mono) is not int or
|
||||
not 0 < mono <= now_ns or now_ns - mono > 1_500_000_000 or type(output) is not int or
|
||||
not 0 <= output <= mono or record.get("state") not in
|
||||
("none", "joining", "retrying", "ready", "running", "unavailable")):
|
||||
return unavailable
|
||||
digest = record.get("artifact_sha256")
|
||||
identity = (isinstance(digest, str) and len(digest) == 64 and all(c in "0123456789abcdef" for c in digest))
|
||||
active = record["state"] == "running" and identity and output > 0 and mono - output <= 250_000_000
|
||||
snapshot = {"state": record["state"], "active": active}
|
||||
if active:
|
||||
snapshot["artifact_sha256"] = digest
|
||||
checkpoint = record.get("model_checkpoint")
|
||||
if isinstance(checkpoint, str) and len(checkpoint) <= 256:
|
||||
snapshot["model_checkpoint"] = checkpoint
|
||||
return snapshot
|
||||
|
||||
|
||||
def runtime_status():
|
||||
op = adapter()
|
||||
record = op.get("JetlinkRuntime")
|
||||
try:
|
||||
boot_id = Path("/proc/sys/kernel/random/boot_id").read_text().strip()
|
||||
except OSError:
|
||||
boot_id = None
|
||||
pid = record.get("pid") if isinstance(record, dict) else None
|
||||
start = process_start_ticks(pid) if type(pid) is int and pid > 0 else None
|
||||
return runtime_snapshot(record, time.monotonic_ns(), boot_id, start)
|
||||
|
||||
|
||||
class StarPilotAdapter:
|
||||
basedir = BASEDIR
|
||||
catalog_selector = 0
|
||||
|
||||
@property
|
||||
def keys(self):
|
||||
return keys()
|
||||
|
||||
@cached_property
|
||||
def log(self):
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
return cloudlog
|
||||
|
||||
def params_dir(self) -> Path:
|
||||
return params_directory()
|
||||
|
||||
@property
|
||||
def params(self):
|
||||
from openpilot.common.params import Params
|
||||
directory = self.params_dir()
|
||||
if getattr(self, "_params_directory", None) != directory:
|
||||
self._params = Params()
|
||||
self._params_directory = directory
|
||||
return self._params
|
||||
|
||||
def get(self, key):
|
||||
try:
|
||||
return self.params.get(key)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def put(self, key, value, *, block=False):
|
||||
self.params.put(key, value, block=block)
|
||||
|
||||
def remove(self, key):
|
||||
self.params.remove(key)
|
||||
|
||||
def chestnut_present(self):
|
||||
from openpilot.common.hardware.usb import is_chestnut_usb_id, read_int, usb_devices
|
||||
return any(is_chestnut_usb_id(read_int(path / "idVendor", 16), read_int(path / "idProduct", 16), True)
|
||||
for path in usb_devices())
|
||||
|
||||
def camera(self):
|
||||
from openpilot.common.hardware import HARDWARE
|
||||
camera = (1344, 760) if HARDWARE.get_device_type() == "mici" else (1928, 1208)
|
||||
return (*camera, 512, 256)
|
||||
|
||||
def warp_path(self, cam_w, cam_h, model_w, model_h):
|
||||
return BASEDIR / "openpilot/selfdrive/modeld/models" / f"jetlink_warp_{cam_w}x{cam_h}_{model_w}x{model_h}.pkl"
|
||||
|
||||
def model_root(self):
|
||||
from openpilot.starpilot.models.manager import ROOT
|
||||
return ROOT
|
||||
|
||||
def model_face(self):
|
||||
from jetlink.openpilot import ModelFace
|
||||
from openpilot.selfdrive.modeld.constants import ModelConstants
|
||||
from openpilot.selfdrive.modeld.modeld import LAT_SMOOTH_SECONDS, LONG_SMOOTH_SECONDS, get_action_from_model
|
||||
from openpilot.system.camerad.cameras.nv12_info import get_nv12_info
|
||||
return ModelFace(remote_parser, lambda w, h: get_nv12_info(w, h)[3], ModelConstants.DESIRE_LEN, ModelConstants,
|
||||
LAT_SMOOTH_SECONDS, LONG_SMOOTH_SECONDS, get_action_from_model)
|
||||
|
||||
def engagement(self):
|
||||
from openpilot.cereal.messaging import SubMaster
|
||||
sm = SubMaster(["selfdriveState", "carControl"])
|
||||
|
||||
def engaged(timeout_ms):
|
||||
sm.update(timeout_ms)
|
||||
return (not sm.all_checks() or sm["selfdriveState"].enabled or sm["selfdriveState"].active or
|
||||
sm["carControl"].latActive or sm["carControl"].longActive)
|
||||
return engaged
|
||||
|
||||
def event(self, name, **fields):
|
||||
self.log.event(name, **fields)
|
||||
|
||||
def make_warp(self, cam_w, cam_h, model_w, model_h):
|
||||
from openpilot.starpilot.models.jetlink_warp import make_warp
|
||||
return make_warp(cam_w, cam_h, model_w, model_h)
|
||||
|
||||
|
||||
def remote_parser():
|
||||
import numpy as np
|
||||
from openpilot.selfdrive.modeld.parse_model_outputs import Parser
|
||||
|
||||
class RemoteParser(Parser):
|
||||
def parse_outputs(self, outs):
|
||||
if not all(np.isfinite(value).all() for value in outs.values()):
|
||||
raise ValueError("non-finite Jetlink model output")
|
||||
parsed = super().parse_outputs(outs)
|
||||
if not all(np.isfinite(value).all() for value in parsed.values()):
|
||||
raise ValueError("non-finite parsed Jetlink model output")
|
||||
return parsed
|
||||
return RemoteParser()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def adapter():
|
||||
return StarPilotAdapter()
|
||||
return _adapter_class()()
|
||||
|
||||
|
||||
def owner_config():
|
||||
from jetlink.openpilot import OwnerConfig
|
||||
from openpilot.common.hardware.usb import CHESTNUT_USB_IDS, CHESTNUT_ROM_USB_IDS
|
||||
return OwnerConfig(params_directory(), keys(), frozenset(CHESTNUT_USB_IDS + CHESTNUT_ROM_USB_IDS),
|
||||
ADAPTER_MODULE, BASEDIR, {}, Path("/data/log/jetlink-owner.log"))
|
||||
return _adapter_class().owner_config()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_jetlink():
|
||||
def main():
|
||||
_adapter_class().main()
|
||||
|
||||
|
||||
def link_on() -> bool:
|
||||
"""The Jetlink setting, read as a file, so it works with no jetlink at all."""
|
||||
prefix = os.environ.get("OPENPILOT_PREFIX", "")
|
||||
root = os.environ.get("PARAMS_ROOT") or ("/data/params" if os.path.isfile("/AGNOS") else
|
||||
os.path.expanduser(f"~/.comma{prefix}/params"))
|
||||
try:
|
||||
from jetlink.openpilot import API, bind
|
||||
if API != API_VERSION:
|
||||
raise RuntimeError(f"unsupported Jetlink API {API}")
|
||||
return bind(adapter())
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception("Jetlink unavailable; retaining local model")
|
||||
with open(os.path.join(root, os.environ.get("OPENPILOT_PREFIX", "d"), "JetlinkMode")) as f:
|
||||
return f.read().strip() in ("1", "2")
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _cloudlog():
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
return cloudlog
|
||||
|
||||
|
||||
@functools.cache
|
||||
def hooks():
|
||||
"""What every hook line calls, one per process. Never raises."""
|
||||
try:
|
||||
from jetlink.openpilot.hooks import Hooks, Off
|
||||
except Exception as e:
|
||||
return _NoJetlink(f"jetlink is missing: {e}")
|
||||
try:
|
||||
if not supported_device():
|
||||
return Off(UNSUPPORTED, link_on=link_on)
|
||||
return Hooks(API, adapter, link_on=link_on, log=_cloudlog)
|
||||
except Exception as e:
|
||||
return Off(str(e), link_on=link_on)
|
||||
|
||||
|
||||
class _NoJetlink:
|
||||
"""The hooks with no usable jetlink checkout, each answering link off. Only these are reached: modeld stops at
|
||||
prepare()."""
|
||||
def __init__(self, why):
|
||||
self._why = why
|
||||
|
||||
def should_run(self, started, params, CP):
|
||||
return False
|
||||
|
||||
def status(self):
|
||||
return None
|
||||
|
||||
def reason(self):
|
||||
return self._why if link_on() else None
|
||||
|
||||
class SmallModelFace:
|
||||
"""Expose catalog history to Jetlink without changing the catalog runner."""
|
||||
def __init__(self, model):
|
||||
self.model = model
|
||||
self.lat_delay = 0.0
|
||||
self.PLANPLUS_CONTROL = 1.0
|
||||
self.frame_drop_ratio = 0.0
|
||||
def prepare(self, chestnut):
|
||||
return False
|
||||
|
||||
@property
|
||||
def prev_desire(self):
|
||||
return self.model.prev_desire
|
||||
def shutdown_step(self, should_shutdown, reason=""):
|
||||
return should_shutdown
|
||||
|
||||
@property
|
||||
def numpy_inputs(self):
|
||||
return self.model.npy
|
||||
def runtime(self):
|
||||
return None
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.model, name)
|
||||
|
||||
|
||||
class JetlinkModel:
|
||||
"""Keep the current catalog's action contract while the remote model shadows."""
|
||||
def __init__(self, joined, small):
|
||||
self.joined = joined
|
||||
self.small = small
|
||||
self._session = uuid.uuid4().hex
|
||||
self._process_start_ticks = process_start_ticks(os.getpid())
|
||||
try:
|
||||
self._boot_id = Path("/proc/sys/kernel/random/boot_id").read_text().strip()
|
||||
except OSError:
|
||||
self._boot_id = None
|
||||
self._published_ns = 0
|
||||
self._published_state = None
|
||||
self._output = None
|
||||
self._output_ns = 0
|
||||
self._runtime_pending = None
|
||||
self._runtime_closed = False
|
||||
self._runtime_condition = threading.Condition()
|
||||
self._runtime_thread = threading.Thread(target=self._write_runtime, name="jetlink-runtime", daemon=True)
|
||||
self._runtime_thread.start()
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.joined, name)
|
||||
|
||||
def run(self, bufs, transforms, inputs, after_enqueue=None):
|
||||
outputs = self.joined.run(bufs, transforms, inputs, after_enqueue)
|
||||
client = self.joined.client
|
||||
if self.chestnut and outputs is not None and client is not None and client.last_output is not self._output:
|
||||
self._output = client.last_output
|
||||
self._output_ns = time.monotonic_ns()
|
||||
self.publish_runtime()
|
||||
return outputs
|
||||
|
||||
def action(self, outputs, previous, lat_action_t, long_action_t, v_ego):
|
||||
from openpilot.selfdrive.modeld.modeld import LAT_SMOOTH_SECONDS, LONG_SMOOTH_SECONDS, get_action_from_model
|
||||
from openpilot.starpilot.models.runner import action_from_outputs
|
||||
if self.chestnut:
|
||||
return get_action_from_model(outputs, previous, lat_action_t, long_action_t, v_ego)
|
||||
return action_from_outputs(outputs, self.small.behavior_version, previous, lat_action_t, long_action_t, v_ego,
|
||||
LAT_SMOOTH_SECONDS, LONG_SMOOTH_SECONDS)
|
||||
|
||||
def observe_frame(self, lat_delay, drop_ratio):
|
||||
self.joined.lat_delay = lat_delay
|
||||
self.joined.frame_drop_ratio = drop_ratio
|
||||
|
||||
def publish_runtime(self, *, closed=False):
|
||||
now = time.monotonic_ns()
|
||||
state = "unavailable" if closed else self.big_model_state
|
||||
if state == self._published_state and now - self._published_ns < 1_000_000_000:
|
||||
return
|
||||
self._published_ns, self._published_state = now, state
|
||||
record = {"version": 1, "boot_id": self._boot_id, "session": self._session, "pid": os.getpid(),
|
||||
"process_start_ticks": self._process_start_ticks,
|
||||
"mono_ns": now, "state": state, "handovers": self.handovers,
|
||||
"remote_output_mono_ns": self._output_ns if self.chestnut and not closed else 0}
|
||||
if self.chestnut and not closed:
|
||||
spec = self.joined.spec
|
||||
record["artifact_sha256"] = spec.sha256
|
||||
record["artifact_nbytes"] = spec.nbytes
|
||||
if isinstance(spec.checkpoint, str):
|
||||
record["model_checkpoint"] = spec.checkpoint[:256]
|
||||
with self._runtime_condition:
|
||||
self._runtime_pending = record
|
||||
self._runtime_condition.notify()
|
||||
|
||||
def _write_runtime(self):
|
||||
while True:
|
||||
with self._runtime_condition:
|
||||
self._runtime_condition.wait_for(lambda: self._runtime_pending is not None or self._runtime_closed)
|
||||
if self._runtime_pending is None:
|
||||
return
|
||||
record, self._runtime_pending = self._runtime_pending, None
|
||||
closed = self._runtime_closed
|
||||
try:
|
||||
adapter().put("JetlinkRuntime", record, block=True)
|
||||
except Exception:
|
||||
adapter().log.exception("Jetlink runtime status unavailable")
|
||||
if closed:
|
||||
return
|
||||
|
||||
def close(self):
|
||||
if self._runtime_closed:
|
||||
return
|
||||
self.joined.close()
|
||||
self.publish_runtime(closed=True)
|
||||
with self._runtime_condition:
|
||||
self._runtime_closed = True
|
||||
self._runtime_condition.notify()
|
||||
|
||||
|
||||
def attach(small, cam_w, cam_h, *, chestnut=False, recovery=False):
|
||||
if chestnut or recovery or not supported_device():
|
||||
return small
|
||||
link = get_jetlink()
|
||||
if link is None:
|
||||
return small
|
||||
try:
|
||||
if not link.prepare():
|
||||
return small
|
||||
face = SmallModelFace(small)
|
||||
joined = link.attach(face, cam_w, cam_h)
|
||||
if joined is None or joined is face:
|
||||
return small
|
||||
model = JetlinkModel(joined, small)
|
||||
import atexit
|
||||
atexit.register(model.close)
|
||||
return model
|
||||
except Exception:
|
||||
adapter().log.exception("Jetlink startup failed; retaining local model")
|
||||
return small
|
||||
def swap_events(self, tick_s):
|
||||
return None
|
||||
|
||||
@@ -4,6 +4,14 @@ from __future__ import annotations
|
||||
MODES = ("off", "usb", "ios")
|
||||
|
||||
|
||||
def runtime_view(status):
|
||||
# what modeld runs over the link, by jetlink's runtime record
|
||||
if status is None or not status.active:
|
||||
return {"state": getattr(status, "running", None) or "unavailable", "active": False}
|
||||
return {"state": status.running, "active": True, "artifact_sha256": status.running_sha256,
|
||||
"model_checkpoint": status.running_checkpoint}
|
||||
|
||||
|
||||
class JetlinkSettings:
|
||||
def __init__(self, *, params=None, link=None, device_type=None, chestnut=None, runtime=None):
|
||||
self._params = params
|
||||
@@ -28,7 +36,7 @@ class JetlinkSettings:
|
||||
"progress": "", "model": None, "transport": "USB", "chargePhone": False}
|
||||
|
||||
def _snapshot(self):
|
||||
from openpilot.starpilot.models.jetlink_adapter import adapter, get_jetlink, runtime_status, supported_device
|
||||
from openpilot.starpilot.models.jetlink_adapter import adapter, hooks, supported_device
|
||||
if self._device_type is None:
|
||||
from openpilot.common.hardware import HARDWARE
|
||||
device = HARDWARE.get_device_type()
|
||||
@@ -39,7 +47,7 @@ class JetlinkSettings:
|
||||
params = self.params()
|
||||
raw = params.get("JetlinkMode")
|
||||
mode = MODES[raw] if type(raw) is int and 0 <= raw < len(MODES) else "off"
|
||||
link = self._link() if self._link is not None else get_jetlink()
|
||||
link = self._link() if self._link is not None else hooks()
|
||||
status = link.status() if link is not None else None
|
||||
reason = "" if supported else "Jetlink requires a comma 3X or comma 4."
|
||||
if mode != "off" and status is None:
|
||||
@@ -47,7 +55,7 @@ class JetlinkSettings:
|
||||
if status is not None and status.reason:
|
||||
reason = str(status.reason)
|
||||
progress = status.progress if status is not None and isinstance(status.progress, dict) else {}
|
||||
runtime = self._runtime() if self._runtime is not None else runtime_status()
|
||||
runtime = self._runtime() if self._runtime is not None else runtime_view(status)
|
||||
active = mode != "off" and supported and not chestnut and runtime.get("active") is True
|
||||
runtime_state = runtime.get("state", "unavailable")
|
||||
stage = str(progress.get("stage", ""))
|
||||
|
||||
@@ -239,6 +239,13 @@ class CatalogModelState:
|
||||
parsed.update(results["on_policy" if "on_policy" in results else "policy"])
|
||||
return parsed
|
||||
|
||||
def get_action_from_model(self, outputs: dict[str, np.ndarray], previous: log.ModelDataV2.Action,
|
||||
lat_action_t: float, long_action_t: float, v_ego: float) -> log.ModelDataV2.Action:
|
||||
"""modeld's action call: this catalog generation's units, with modeld's smoothing."""
|
||||
from openpilot.selfdrive.modeld.modeld import LAT_SMOOTH_SECONDS, LONG_SMOOTH_SECONDS
|
||||
return action_from_outputs(outputs, self.behavior_version, previous, lat_action_t, long_action_t, v_ego,
|
||||
LAT_SMOOTH_SECONDS, LONG_SMOOTH_SECONDS)
|
||||
|
||||
def warmup(self) -> None:
|
||||
frames = {name: np.zeros(self.frame_buf_size, dtype=np.uint8) for name in self.vision_input_names}
|
||||
self._blob_cache.update({(name, frame.ctypes.data): Tensor(frame, device=self.warp_device).realize()
|
||||
|
||||
@@ -1,141 +1,62 @@
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from jetlink.openpilot import Openpilot, conformance
|
||||
from jetlink.openpilot.hooks import Hooks, Off
|
||||
from openpilot.starpilot.models import jetlink_adapter as adapter
|
||||
|
||||
|
||||
def test_public_interface_and_params_directory(monkeypatch, tmp_path):
|
||||
def test_interface_keys():
|
||||
# the params directory is Stock's, held against openpilot's Params in test_jetlink_lifecycle
|
||||
op = adapter.adapter()
|
||||
op.log = Mock()
|
||||
assert conformance(op, Openpilot) == []
|
||||
monkeypatch.setenv("PARAMS_ROOT", str(tmp_path))
|
||||
monkeypatch.setenv("OPENPILOT_PREFIX", "jetlink-test")
|
||||
assert op.params_dir() == tmp_path / "jetlink-test"
|
||||
assert adapter.owner_config().params_dir == op.params_dir()
|
||||
assert adapter.owner_config().adapter == adapter.__name__
|
||||
assert op.keys.link == "JetlinkMode"
|
||||
assert op.keys.big_model != "ModelSelected"
|
||||
# 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
|
||||
|
||||
|
||||
def test_small_history_and_current_delay_forwarding():
|
||||
small = SimpleNamespace(prev_desire=object(), npy={"desire": object()}, input_queues={"history": object()},
|
||||
chestnut=False, behavior_version="v16")
|
||||
face = adapter.SmallModelFace(small)
|
||||
assert face.prev_desire is small.prev_desire
|
||||
assert face.numpy_inputs is small.npy
|
||||
assert face.input_queues is small.input_queues
|
||||
joined = SimpleNamespace(chestnut=False, lat_delay=0., frame_drop_ratio=0.)
|
||||
model = adapter.JetlinkModel(joined, small)
|
||||
model.observe_frame(.23, .004)
|
||||
assert joined.lat_delay == .23
|
||||
assert joined.frame_drop_ratio == .004
|
||||
def test_guard_answers_link_off_without_a_usable_jetlink():
|
||||
adapter.hooks.cache_clear()
|
||||
try:
|
||||
with patch.object(adapter, "supported_device", return_value=False):
|
||||
assert isinstance(adapter.hooks(), Off)
|
||||
adapter.hooks.cache_clear()
|
||||
with patch.object(adapter, "supported_device", return_value=True), patch.object(adapter, "API", 2):
|
||||
assert isinstance(adapter.hooks(), Off)
|
||||
adapter.hooks.cache_clear()
|
||||
with patch.object(adapter, "supported_device", return_value=True):
|
||||
assert isinstance(adapter.hooks(), Hooks)
|
||||
adapter.hooks.cache_clear()
|
||||
with patch.dict("sys.modules", {"jetlink.openpilot.hooks": None}):
|
||||
hooks = adapter.hooks()
|
||||
assert not hooks.prepare(False) and not hooks.should_run(False, None, None) and hooks.status() is None
|
||||
assert hooks.shutdown_step(True) and not hooks.shutdown_step(False)
|
||||
finally:
|
||||
adapter.hooks.cache_clear()
|
||||
|
||||
|
||||
def test_chestnut_and_recovery_never_prepare_link():
|
||||
small = object()
|
||||
with patch.object(adapter, "get_jetlink") as get:
|
||||
assert adapter.attach(small, 1344, 760, chestnut=True) is small
|
||||
assert adapter.attach(small, 1344, 760, recovery=True) is small
|
||||
get.assert_not_called()
|
||||
|
||||
|
||||
def test_link_failure_preserves_small():
|
||||
small = object()
|
||||
link = Mock()
|
||||
link.prepare.side_effect = RuntimeError("link unavailable")
|
||||
op = Mock()
|
||||
with patch.object(adapter, "get_jetlink", return_value=link), patch.object(adapter, "adapter", return_value=op), \
|
||||
patch.object(adapter, "supported_device", return_value=True):
|
||||
assert adapter.attach(small, 1344, 760) is small
|
||||
op.log.exception.assert_called_once()
|
||||
|
||||
|
||||
def test_runtime_requires_fresh_same_boot_and_process():
|
||||
record = {"version": 1, "boot_id": "boot", "session": "a" * 32, "pid": os.getpid(), "process_start_ticks": 12,
|
||||
"artifact_sha256": "b" * 64, "mono_ns": 2_000_000_000, "remote_output_mono_ns": 1_999_000_000, "state": "running"}
|
||||
assert adapter.runtime_snapshot(record, 3_000_000_000, "boot", 12)["active"]
|
||||
for changes in ({"boot_id": "other"}, {"process_start_ticks": 13}, {"session": "bad"},
|
||||
{"mono_ns": 3_000_000_001}, {"remote_output_mono_ns": 0}, {"state": "ready"}):
|
||||
assert not adapter.runtime_snapshot(record | changes, 3_000_000_000, "boot", 12)["active"]
|
||||
assert not adapter.runtime_snapshot(record, 3_500_000_001, "boot", 12)["active"]
|
||||
|
||||
|
||||
def test_handover_action_contract_tracks_actual_driver():
|
||||
def test_one_action_call_for_every_small_model():
|
||||
from openpilot.selfdrive.modeld import modeld
|
||||
from openpilot.starpilot.models import runner
|
||||
|
||||
small = SimpleNamespace(behavior_version="v9")
|
||||
joined = SimpleNamespace(chestnut=False)
|
||||
model = adapter.JetlinkModel(joined, small)
|
||||
small = runner.CatalogModelState.__new__(runner.CatalogModelState)
|
||||
small.behavior_version = "v9"
|
||||
outputs, previous = object(), object()
|
||||
with patch.object(runner, "action_from_outputs", return_value="catalog") as catalog, \
|
||||
patch.object(modeld, "get_action_from_model", return_value="remote") as remote:
|
||||
assert model.action(outputs, previous, .4, .7, 12.) == "catalog"
|
||||
assert catalog.call_args.args[:6] == (outputs, "v9", previous, .4, .7, 12.)
|
||||
joined.chestnut = True
|
||||
assert model.action(outputs, previous, .4, .7, 12.) == "remote"
|
||||
remote.assert_called_once_with(outputs, previous, .4, .7, 12.)
|
||||
joined.chestnut = False
|
||||
assert model.action(outputs, previous, .4, .7, 12.) == "catalog"
|
||||
assert catalog.call_count == 2
|
||||
with patch.object(runner, "action_from_outputs", return_value="catalog") as catalog:
|
||||
assert small.get_action_from_model(outputs, previous, .4, .7, 12.) == "catalog"
|
||||
catalog.assert_called_once_with(outputs, "v9", previous, .4, .7, 12., modeld.LAT_SMOOTH_SECONDS, modeld.LONG_SMOOTH_SECONDS)
|
||||
assert modeld.ModelState.get_action_from_model is modeld.get_action_from_model
|
||||
|
||||
|
||||
def test_remote_parser_rejects_nonfinite_and_missing_outputs():
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
parser = adapter.remote_parser()
|
||||
with pytest.raises(ValueError, match="non-finite"):
|
||||
parser.parse_outputs({"action": np.array([[np.nan, 0.]])})
|
||||
with pytest.raises(ValueError, match="Missing output"):
|
||||
parser.parse_outputs({})
|
||||
def test_small_reset_only_for_the_catalog_runner():
|
||||
assert adapter.adapter().small_reset(SimpleNamespace(input_queues={}, npy={}, prev_desire=np.zeros(8))) is None
|
||||
|
||||
|
||||
def test_vendor_attach_failure_returns_unwrapped_small():
|
||||
small = object()
|
||||
link = Mock()
|
||||
link.prepare.return_value = True
|
||||
link.attach.side_effect = lambda face, w, h: face
|
||||
with patch.object(adapter, "get_jetlink", return_value=link), patch.object(adapter, "supported_device", return_value=True):
|
||||
assert adapter.attach(small, 1344, 760) is small
|
||||
|
||||
|
||||
def test_runtime_persistence_runs_off_frame_thread():
|
||||
import threading
|
||||
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
writes = []
|
||||
def write(key, record, *, block=False):
|
||||
writes.append((threading.get_ident(), key, record, block))
|
||||
entered.set()
|
||||
release.wait(1.)
|
||||
|
||||
joined = SimpleNamespace(chestnut=False, big_model_state="joining", handovers=0, close=Mock())
|
||||
op = Mock()
|
||||
op.put.side_effect = write
|
||||
with patch.object(adapter, "adapter", return_value=op):
|
||||
model = adapter.JetlinkModel(joined, object())
|
||||
try:
|
||||
model.publish_runtime()
|
||||
assert entered.wait(1.)
|
||||
assert writes[0][0] != threading.get_ident()
|
||||
assert writes[0][1] == "JetlinkRuntime"
|
||||
assert writes[0][3] is True
|
||||
model.close()
|
||||
finally:
|
||||
release.set()
|
||||
model._runtime_thread.join(1.)
|
||||
assert not model._runtime_thread.is_alive()
|
||||
assert writes[-1][2]["state"] == "unavailable"
|
||||
|
||||
|
||||
def test_unsupported_device_never_prepares_link():
|
||||
small = object()
|
||||
def test_unsupported_device():
|
||||
assert adapter.supported_device("tizi")
|
||||
assert adapter.supported_device("mici")
|
||||
for device in ("tici", "pc", "unknown", "neo"):
|
||||
assert not adapter.supported_device(device)
|
||||
with patch.object(adapter, "supported_device", return_value=False), patch.object(adapter, "get_jetlink") as get:
|
||||
assert adapter.attach(small, 1928, 1208) is small
|
||||
get.assert_not_called()
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from jetlink.openpilot.testing import FakeJoinedModel
|
||||
from openpilot.starpilot.models.status import ModelVariant
|
||||
from openpilot.starpilot.models.jetlink_adapter import JetlinkModel, attach as attach_jetlink
|
||||
|
||||
|
||||
MODEL_SOURCE = Path(__file__).resolve().parents[3] / "selfdrive/modeld/modeld.py"
|
||||
@@ -30,6 +30,17 @@ def test_realtime_starts_after_model_load_and_receipt():
|
||||
assert max(line("load_small") + line("loader.join")) < startup_receipt.lineno < line("config_realtime_process")[0] < line("PubMaster")[0]
|
||||
|
||||
|
||||
def test_jetlink_gets_the_frames_delay_before_every_run():
|
||||
# a model that joins starts on the current delay; check_seam holds the frame_drop_ratio write
|
||||
loop = [node for node in main_body() if isinstance(node, ast.While)][-1] # the frame loop
|
||||
statements = [node for node in ast.walk(loop) if isinstance(node, (ast.Assign, ast.Try))]
|
||||
def line(source):
|
||||
return next(node.lineno for node in statements if isinstance(node, ast.Assign) and ast.unparse(node) == source)
|
||||
run = next(node.lineno for node in statements if isinstance(node, ast.Try) and
|
||||
any(isinstance(child, ast.Call) and ast.unparse(child.func) == "model.run" for child in ast.walk(node)))
|
||||
assert line("lat_delay = sm['lateralDelay'].lateralDelay + LAT_SMOOTH_SECONDS") < line("model.lat_delay = lat_delay") < run
|
||||
|
||||
|
||||
@pytest.mark.parametrize("runner_chestnut,stored_active", [(True, False), (True, True), (False, True), (False, False)])
|
||||
def test_inference_failure_uses_runner_identity(runner_chestnut, stored_active):
|
||||
# Run the production try/except statement with deliberately stale status Params.
|
||||
@@ -46,7 +57,7 @@ def test_inference_failure_uses_runner_identity(runner_chestnut, stored_active):
|
||||
env: dict = {"model": failed_model, "small_model": small_model, "params": params, "receipt_owner": receipt,
|
||||
"chestnut_state": chestnut_state, "run_count": 1, "ModelConstants": SimpleNamespace(MODEL_RUN_FREQ=20),
|
||||
"SERVICE_LIST": {"chestnutGpuState": SimpleNamespace(frequency=1)}, "bufs": {}, "transforms": {}, "inputs": {},
|
||||
"small_prepared": object(), "ModelVariant": ModelVariant, "JetlinkModel": JetlinkModel, "drop_chestnut": Mock(), "cloudlog": Mock()}
|
||||
"small_prepared": object(), "ModelVariant": ModelVariant, "jetlink_model": None, "drop_chestnut": Mock(), "cloudlog": Mock()}
|
||||
code = compile(ast.fix_missing_locations(ast.Module(body=[fallback], type_ignores=[])), str(MODEL_SOURCE), "exec")
|
||||
if runner_chestnut:
|
||||
exec(code, env)
|
||||
@@ -71,14 +82,14 @@ def test_remote_inference_failure_does_not_change_chestnut_status():
|
||||
fallback = next(node for statement in main_body() for node in ast.walk(statement)
|
||||
if isinstance(node, ast.Try) and any(isinstance(child, ast.Call) and ast.unparse(child.func) == "model.run"
|
||||
for child in ast.walk(node)))
|
||||
failed_model = object.__new__(JetlinkModel)
|
||||
failed_model.joined = SimpleNamespace(chestnut=True, run=Mock(side_effect=RuntimeError("remote inference failed")))
|
||||
failed_model = FakeJoinedModel(chestnut=True)
|
||||
failed_model.run = Mock(side_effect=RuntimeError("remote inference failed"))
|
||||
failed_model.close = Mock()
|
||||
small_model = SimpleNamespace(chestnut=False, model_id="fallback-small")
|
||||
params, receipt = Mock(), Mock()
|
||||
env = {"model": failed_model, "small_model": small_model, "params": params, "receipt_owner": receipt,
|
||||
"chestnut_state": None, "run_count": 1, "bufs": {}, "transforms": {}, "inputs": {},
|
||||
"small_prepared": object(), "ModelVariant": ModelVariant, "JetlinkModel": JetlinkModel, "drop_chestnut": Mock(), "cloudlog": Mock()}
|
||||
"small_prepared": object(), "ModelVariant": ModelVariant, "jetlink_model": failed_model, "drop_chestnut": Mock(), "cloudlog": Mock()}
|
||||
execute([fallback], env)
|
||||
failed_model.close.assert_called_once_with()
|
||||
env["drop_chestnut"].assert_not_called()
|
||||
@@ -172,6 +183,10 @@ def test_big_worker_waits_before_load_and_timeout_preserves_small_fallback(custo
|
||||
return state(width, height, chestnut), "prepared-" + model_id
|
||||
|
||||
params, receipt = Mock(), Mock()
|
||||
from jetlink.openpilot.hooks import Hooks
|
||||
from openpilot.starpilot.models.jetlink_adapter import API
|
||||
bind = Mock(side_effect=AssertionError("a chestnut drive must not start jetlink"))
|
||||
hooks = Hooks(API, bind)
|
||||
selection = SimpleNamespace(big_path="selected-big" if custom else None, small_path="selected-small" if custom else None,
|
||||
small_version=1, small_id="small", small_sha256="small-hash",
|
||||
big_version=1, big_id="big", big_sha256="hash")
|
||||
@@ -183,14 +198,15 @@ def test_big_worker_waits_before_load_and_timeout_preserves_small_fallback(custo
|
||||
"receipt_owner": receipt, "threading": SimpleNamespace(Thread=Thread), "BIG_MODEL_TIMEOUT": 30,
|
||||
"params": params, "requested_model_id": "big", "ModelVariant": ModelVariant,
|
||||
"CP": SimpleNamespace(brand="mock"), "demo": False,
|
||||
"wait_for_chestnut_power": lambda CP, timeout: events.append("power"), "attach_jetlink": attach_jetlink,
|
||||
"drop_chestnut": Mock(), "release_compile_pool": Mock(), "gc": Mock()}
|
||||
"wait_for_chestnut_power": lambda CP, timeout: events.append("power"),
|
||||
"jetlink_adapter": SimpleNamespace(hooks=lambda: hooks), "drop_chestnut": Mock(),
|
||||
"release_compile_pool": Mock(), "gc": Mock()}
|
||||
wrapper = ast.parse("def startup():\n pass").body[0]
|
||||
wrapper.body = body[start:end] + [ast.Return(value=ast.Call(func=ast.Name(id="locals", ctx=ast.Load()), args=[], keywords=[]))]
|
||||
execute([wrapper], env)
|
||||
with patch("openpilot.starpilot.models.jetlink_adapter.get_jetlink") as get_link:
|
||||
env.update(env["startup"]())
|
||||
get_link.assert_not_called()
|
||||
env.update(env["startup"]())
|
||||
bind.assert_not_called()
|
||||
assert env["jetlink_model"] is None
|
||||
assert events[0] == ("small" if custom else "wait")
|
||||
assert events.count("small") == 1
|
||||
assert ("big" in events) is not timeout
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Resident FunctionFS owner; release shared resources before waiting on its worker."""
|
||||
import signal
|
||||
|
||||
|
||||
def owner_class():
|
||||
from jetlink.comma import owner as comma_owner, root
|
||||
|
||||
class ManagedOwner(comma_owner.Owner):
|
||||
def stop_worker(self):
|
||||
if self.stop or self.settings.mode() == 'off':
|
||||
# Mode-off can stop the process before the upstream next poll restores
|
||||
# tuning. Chestnut takeover also needs its host port back immediately.
|
||||
self.port.off()
|
||||
self.close_link()
|
||||
commands = [('vm', 'restore'), ('udc', 'restore')]
|
||||
if self.tuned == 'ios':
|
||||
commands.append(('draw', 'on'))
|
||||
for command in commands:
|
||||
if not root.run(*command, timeout=1.0):
|
||||
comma_owner.gadget.log.error('Jetlink cleanup failed: %s', command)
|
||||
self.tuned = None
|
||||
super().stop_worker()
|
||||
|
||||
return ManagedOwner
|
||||
|
||||
|
||||
def main() -> None:
|
||||
from jetlink.comma import gadget, owner as comma_owner
|
||||
from jetlink.openpilot.owner import worker
|
||||
from jetlink.openpilot.settings import FileParams, Settings
|
||||
from openpilot.starpilot.models.jetlink_adapter import owner_config
|
||||
config = owner_config()
|
||||
gadget.set_logger(comma_owner.logger(config.log_file))
|
||||
owner = owner_class()(worker(config), cwd=str(config.cwd), env=dict(config.env),
|
||||
settings=Settings(FileParams(config.params_dir), config.keys), chestnut_ids=config.chestnut_ids)
|
||||
signal.signal(signal.SIGTERM, owner.request_stop)
|
||||
signal.signal(signal.SIGINT, owner.request_stop)
|
||||
owner.run()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,25 +1,26 @@
|
||||
"""Manager's opt-in Jetlink owner and bounded remote power-off handoff."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
# jetlink's SHUTDOWN_TIMEOUT (25 s) plus 5 s, written out so it holds without jetlink: a jetlink regression
|
||||
# must never keep a comma from powering off and drain the car's battery
|
||||
SHUTDOWN_CAP_S = 30.
|
||||
|
||||
|
||||
def enabled(params) -> bool:
|
||||
# Keep off/default startup independent of the transport and model runtime.
|
||||
raw = params.get('JetlinkMode')
|
||||
if type(raw) is not int or raw not in (1, 2):
|
||||
return False
|
||||
from openpilot.starpilot.models.jetlink_adapter import get_jetlink, supported_device
|
||||
if not supported_device():
|
||||
return False
|
||||
link = get_jetlink()
|
||||
return link is not None and link.enabled()
|
||||
# with the link off, should_run() binds nothing: jetlink's light hooks module alone loads
|
||||
from openpilot.starpilot.models.jetlink_adapter import hooks
|
||||
return hooks().should_run(False, params, None)
|
||||
|
||||
|
||||
def shutdown(params) -> None:
|
||||
reason = next((key for key in ('DoUninstall', 'DoReboot', 'DoShutdown') if params.get_bool(key)), None)
|
||||
if reason is None or not enabled(params):
|
||||
def shutdown(params, *, clock=time.monotonic, sleep=time.sleep) -> None:
|
||||
# A power-off only: the Jetson is on an always-on supply and wakes only on a USB edge, so one powered off at
|
||||
# a comma reboot would not come back. The resident owner must still be alive to run it.
|
||||
from openpilot.starpilot.models.jetlink_adapter import hooks, link_on
|
||||
if not params.get_bool('DoShutdown') or not link_on():
|
||||
return
|
||||
from openpilot.starpilot.models.jetlink_adapter import get_jetlink
|
||||
# The resident owner must still be alive to lend the shutdown worker its link.
|
||||
link = get_jetlink()
|
||||
if link is not None:
|
||||
link.shutdown(reason, timeout=25.0)
|
||||
link = hooks()
|
||||
deadline = clock() + SHUTDOWN_CAP_S
|
||||
while not link.shutdown_step(True, 'DoShutdown') and clock() < deadline:
|
||||
sleep(0.1)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Opt-in process ownership and remote shutdown before manager cleanup."""
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
@@ -25,45 +24,54 @@ def _cleanup_child(ready, cleaned, release):
|
||||
|
||||
|
||||
class TestJetlinkLifecycle(unittest.TestCase):
|
||||
def setUp(self):
|
||||
hardware = patch('openpilot.common.hardware.HARDWARE.get_device_type', return_value='tizi')
|
||||
hardware.start()
|
||||
self.addCleanup(hardware.stop)
|
||||
|
||||
def test_disabled_or_malformed_mode_never_imports_adapter(self):
|
||||
for mode in (None, 0, -1, 3, True, False, b'1', b'2', b'corrupt'):
|
||||
params = SimpleNamespace(get=lambda key, mode=mode: mode)
|
||||
with self.subTest(mode=mode), patch.dict(sys.modules, {'openpilot.starpilot.models.jetlink_adapter': None}):
|
||||
self.assertFalse(lifecycle.enabled(params))
|
||||
|
||||
def test_selected_owner_requires_available_transport_without_chestnut(self):
|
||||
params = SimpleNamespace(get=lambda key: 1)
|
||||
def jetlink(self, on):
|
||||
link = Mock()
|
||||
adapter = SimpleNamespace(get_jetlink=Mock(return_value=link), supported_device=lambda: True)
|
||||
with patch.dict(sys.modules, {'openpilot.starpilot.models.jetlink_adapter': adapter}):
|
||||
for available in (True, False):
|
||||
link.enabled.return_value = available
|
||||
self.assertEqual(lifecycle.enabled(params), available)
|
||||
adapter.get_jetlink.return_value = None
|
||||
self.assertFalse(lifecycle.enabled(params))
|
||||
for name, value in (('hooks', lambda: link), ('link_on', lambda: on)):
|
||||
patcher = patch(f'openpilot.starpilot.models.jetlink_adapter.{name}', value)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
return link
|
||||
|
||||
def test_only_power_actions_request_bounded_remote_shutdown(self):
|
||||
link = Mock()
|
||||
link.enabled.return_value = True
|
||||
adapter = SimpleNamespace(get_jetlink=lambda: link, supported_device=lambda: True)
|
||||
with patch.dict(sys.modules, {'openpilot.starpilot.models.jetlink_adapter': adapter}):
|
||||
for reason in (None, 'DoShutdown', 'DoReboot', 'DoUninstall'):
|
||||
link.shutdown.reset_mock()
|
||||
params = SimpleNamespace(get=lambda key: 2, get_bool=lambda key, reason=reason: key == reason)
|
||||
lifecycle.shutdown(params)
|
||||
if reason is None:
|
||||
link.shutdown.assert_not_called()
|
||||
else:
|
||||
link.shutdown.assert_called_once_with(reason, timeout=25.0)
|
||||
link.enabled.return_value = False
|
||||
link.shutdown.reset_mock()
|
||||
def test_with_the_link_off_jetlink_is_never_bound(self):
|
||||
from jetlink.openpilot.hooks import Hooks
|
||||
from openpilot.starpilot.models.jetlink_adapter import API
|
||||
self.jetlink(False)
|
||||
factory = Mock()
|
||||
with patch('openpilot.starpilot.models.jetlink_adapter.hooks', lambda: Hooks(API, factory, link_on=lambda: False)):
|
||||
self.assertFalse(lifecycle.enabled(None))
|
||||
lifecycle.shutdown(SimpleNamespace(get_bool=lambda key: key == 'DoShutdown'))
|
||||
factory.assert_not_called()
|
||||
|
||||
def test_the_owner_follows_jetlink(self):
|
||||
link = self.jetlink(True)
|
||||
for available in (True, False):
|
||||
link.should_run.return_value = available
|
||||
self.assertEqual(lifecycle.enabled(None), available)
|
||||
|
||||
def test_only_power_off_asks_the_host_and_is_capped(self):
|
||||
link = self.jetlink(True)
|
||||
for reason in (None, 'DoReboot', 'DoUninstall', 'DoShutdown'):
|
||||
link.shutdown_step.reset_mock()
|
||||
link.shutdown_step.return_value = True
|
||||
params = SimpleNamespace(get_bool=lambda key, reason=reason: key == reason)
|
||||
lifecycle.shutdown(params)
|
||||
link.shutdown.assert_not_called()
|
||||
if reason == 'DoShutdown':
|
||||
link.shutdown_step.assert_called_once_with(True, 'DoShutdown')
|
||||
else:
|
||||
link.shutdown_step.assert_not_called()
|
||||
|
||||
# a host that never answers holds the power-off for the cap, no longer
|
||||
now, waits = [0.], []
|
||||
|
||||
def sleep(seconds):
|
||||
waits.append(seconds)
|
||||
if len(waits) > 1000:
|
||||
raise AssertionError("the power-off wait has no cap")
|
||||
now[0] += seconds
|
||||
link.shutdown_step.return_value = False
|
||||
lifecycle.shutdown(params, clock=lambda: now[0], sleep=sleep)
|
||||
self.assertGreaterEqual(now[0], lifecycle.SHUTDOWN_CAP_S)
|
||||
self.assertLess(now[0], lifecycle.SHUTDOWN_CAP_S + 1)
|
||||
|
||||
def test_registered_native_int_and_owner_file_reader_agree(self):
|
||||
from pathlib import Path
|
||||
@@ -76,9 +84,11 @@ class TestJetlinkLifecycle(unittest.TestCase):
|
||||
params = Params()
|
||||
config = owner_config()
|
||||
settings = Settings(FileParams(config.params_dir), config.keys)
|
||||
link = Mock()
|
||||
link.enabled.return_value = True
|
||||
with patch('openpilot.starpilot.models.jetlink_adapter.get_jetlink', return_value=link):
|
||||
from openpilot.starpilot.models import jetlink_adapter
|
||||
jetlink_adapter.hooks.cache_clear()
|
||||
self.addCleanup(jetlink_adapter.hooks.cache_clear)
|
||||
with patch('jetlink.openpilot.parts.Parts.chestnut_fitted', return_value=False), \
|
||||
patch('openpilot.common.hardware.HARDWARE.get_device_type', return_value='tizi'):
|
||||
for value, mode in ((0, 'off'), (1, 'usb'), (2, 'ios'), (3, 'off'), (-1, 'off')):
|
||||
with self.subTest(value=value):
|
||||
params.put('JetlinkMode', value, block=True)
|
||||
@@ -86,46 +96,13 @@ class TestJetlinkLifecycle(unittest.TestCase):
|
||||
self.assertEqual(params.get('JetlinkMode'), value)
|
||||
self.assertEqual(FileParams(config.params_dir).get_int('JetlinkMode'), value)
|
||||
self.assertEqual(settings.mode(), mode)
|
||||
self.assertEqual(jetlink_adapter.link_on(), value in (1, 2))
|
||||
self.assertEqual(lifecycle.enabled(params), value in (1, 2))
|
||||
params.put('JetlinkMode', 1, block=True)
|
||||
for device, supported in (('tizi', True), ('mici', True), ('tici', False), ('pc', False)):
|
||||
with self.subTest(device=device), patch('openpilot.common.hardware.HARDWARE.get_device_type', return_value=device):
|
||||
self.assertEqual(lifecycle.enabled(params), supported)
|
||||
Path(params.get_param_path('JetlinkMode')).write_bytes(b'corrupt')
|
||||
self.assertEqual(settings.mode(), 'off')
|
||||
self.assertFalse(lifecycle.enabled(params))
|
||||
|
||||
def test_owner_releases_resources_before_blocked_worker_wait(self):
|
||||
import subprocess
|
||||
from openpilot.starpilot.system.jetlink.daemon import owner_class
|
||||
from jetlink.comma import owner as upstream
|
||||
from jetlink.comma import root
|
||||
from unittest.mock import call
|
||||
|
||||
owner = owner_class()([], settings=SimpleNamespace(mode=lambda: 'usb'), chestnut_ids=frozenset())
|
||||
owner.stop = True
|
||||
owner.tuned = 'ios'
|
||||
owner.port = Mock()
|
||||
owner.close_link = Mock()
|
||||
owner.beat = Mock()
|
||||
order = Mock()
|
||||
order.attach_mock(owner.port.off, 'port')
|
||||
order.attach_mock(owner.close_link, 'close')
|
||||
worker = Mock()
|
||||
owner.worker = worker
|
||||
with patch.object(root, 'run', return_value=True) as restore, patch.object(upstream, 'WORKER_GRACE', 0):
|
||||
order.attach_mock(restore, 'restore')
|
||||
order.attach_mock(worker.wait, 'wait')
|
||||
worker.wait.side_effect = subprocess.TimeoutExpired('provision', .5)
|
||||
owner.stop_worker()
|
||||
self.assertEqual(order.mock_calls[:6], [call.port(), call.close(), call.restore('vm', 'restore', timeout=1.0),
|
||||
call.restore('udc', 'restore', timeout=1.0), call.restore('draw', 'on', timeout=1.0),
|
||||
call.wait(upstream.POLL)])
|
||||
worker.terminate.assert_called_once()
|
||||
worker.kill.assert_called_once()
|
||||
self.assertIsNone(owner.worker)
|
||||
self.assertIsNone(owner.tuned)
|
||||
|
||||
def test_process_nonblocking_stop_and_blocking_signal_cleanup(self):
|
||||
import multiprocessing
|
||||
import time
|
||||
|
||||
@@ -137,7 +137,7 @@ procs = [
|
||||
enabled=platform.system() == "Linux", restart_on_exit=True),
|
||||
PythonProcess("galaxy_hotspot", "openpilot.starpilot.galaxy.hotspot_daemon", galaxy_local,
|
||||
enabled=COMMA_HARDWARE and not os.environ.get('OPENPILOT_PREFIX'), restart_on_exit=True),
|
||||
JetlinkProcess("jetlinkd", "openpilot.starpilot.system.jetlink.daemon", jetlink_enabled, enabled=COMMA_HARDWARE),
|
||||
JetlinkProcess("jetlinkd", "openpilot.starpilot.models.jetlink_adapter", jetlink_enabled, enabled=COMMA_HARDWARE),
|
||||
PythonProcess("android_autod", "openpilot.starpilot.system.android_auto.daemon", android_auto_enabled, enabled=COMMA_HARDWARE,
|
||||
restart_on_exit=True), # boot can race bluetoothd (NoSuchUnit)
|
||||
# Boot can race the audio device; selfdrived blocks engagement while soundd is down.
|
||||
|
||||
Reference in New Issue
Block a user