mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-12 23:43:44 +08:00
de40ad2ff1
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.
479 lines
18 KiB
Python
479 lines
18 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.
|
|
|
|
The comma's USB gadget, and how to look at it, using nothing but the
|
|
standard library and jetlink's own transport.
|
|
|
|
Kept apart from openpilot so the process that owns the gadget can be small.
|
|
Holding ep0 needs sysfs and a unix socket; `openpilot.common.swaglog` costs
|
|
28 MB because it drags numpy, capnp and zmq in to publish a log line, and
|
|
`openpilot.common.params` imports swaglog, so a module that touches either
|
|
prices the owner out of being minimal. Measured on the comma: python plus this
|
|
plus the FunctionFS transport is 10.4 MB against 47.5 MB with openpilot's
|
|
modules imported.
|
|
|
|
Nothing in jetlink.comma may import openpilot or knows a param: the settings
|
|
come from jetlink.openpilot.settings, over the directory and the keys the
|
|
fork's adapter names. tests/test_comma_gadget.py holds the line.
|
|
|
|
jetlink.openpilot's heavy processes import it too, and bind() points `log` at
|
|
the fork's cloudlog (set_logger), so their lines still reach the drive's log
|
|
while the owner's go to a file.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from jetlink.comma import root
|
|
from jetlink.transport import ffs
|
|
from jetlink.transport.base import UDC_SYSFS, udc_speed
|
|
from jetlink.transport.tcp import CABLE_ADDRESS, DEFAULT_PORT
|
|
|
|
# -- logging --------------------------------------------------------------
|
|
# a module-level indirection rather than an import: the owner has no swaglog
|
|
# and must not grow one, and every other process wants its lines in the drive.
|
|
log = logging.getLogger('jetlink.comma.gadget')
|
|
|
|
|
|
def set_logger(logger) -> None:
|
|
"""Send this module's lines somewhere else, and the root script's failures
|
|
with them: jetlink.openpilot.bind points it at the fork's cloudlog."""
|
|
global log
|
|
log = logger
|
|
root.log = logger
|
|
|
|
|
|
# -- what carries the link ------------------------------------------------
|
|
# The Jetlink setting names the host: USB (a Jetson or a Mac on the
|
|
# FunctionFS vendor interface) or iOS (an iPhone, which gives apps no USB
|
|
# access). For iOS the gadget is composite, with a CDC-NCM network interface
|
|
# whose comma end is 192.168.60.1 (jetlink-root.sh gadget --ios, and net, which
|
|
# also runs the DHCP server), and the phone dials CABLE_ADDR whenever it holds
|
|
# a lease on that network.
|
|
# Whoever holds the loan listens for the dial, the owner while nobody does;
|
|
# nothing writes to the endpoint files, which a phone never reads. The setting,
|
|
# not a guess, says which: a hello over FunctionFS to a phone blocks 15 s and
|
|
# bounces the gadget, and waiting to see whether a phone dials cost every
|
|
# Jetson reconnect 5 to 10 s.
|
|
LINK = Path("/dev/shm/jetlink-link") # "cable <peer ip>" while a phone is dialed in
|
|
NET_STATUS = Path("/dev/shm/jetlink-net") # jetlink-root.sh: "ok 192.168.60.1 <netdev>", "error: ...", "net: off" (USB)
|
|
CABLE_ADDR = (CABLE_ADDRESS, DEFAULT_PORT)
|
|
|
|
|
|
def read(path: Path) -> str:
|
|
"""A record's or a sysfs file's text, stripped; '' when it is missing or
|
|
unreadable."""
|
|
try:
|
|
return path.read_text().strip()
|
|
except OSError:
|
|
return ''
|
|
|
|
|
|
def _write(path: Path, text: str | None, what: str) -> bool:
|
|
"""Write a record, or remove it with None. False, and logged, when that fails."""
|
|
try:
|
|
if text is None:
|
|
path.unlink(missing_ok=True)
|
|
else:
|
|
path.write_text(text)
|
|
return True
|
|
except OSError:
|
|
log.exception("jetlink: could not %s", what)
|
|
return False
|
|
|
|
|
|
def link_state() -> tuple[str | None, str | None]:
|
|
"""The link record in one read: the gadget the owner built, 'cable' for
|
|
iOS (the phone's network interface on the gadget) or 'usb', and on the
|
|
cable the phone's address while a link is up. (None, None) before the
|
|
owner has said."""
|
|
record = read(LINK).split()
|
|
if record[:1] not in (['cable'], ['usb']):
|
|
return None, None
|
|
return record[0], (record[1] if record[0] == 'cable' and len(record) > 1 else None)
|
|
|
|
|
|
def link_kind(mode: str | None = None) -> str:
|
|
"""link_state's kind, or a stand-in: `mode`, the Jetlink setting as
|
|
the caller read it, stands in only until the owner has said: it may have
|
|
moved and be waiting for the car to park. Without either, 'usb'."""
|
|
kind, _ = link_state()
|
|
return kind or ('cable' if mode == 'ios' else 'usb')
|
|
|
|
|
|
def link_peer() -> str | None:
|
|
"""The phone's address, while a cable link is up."""
|
|
return link_state()[1]
|
|
|
|
|
|
def note_link(kind: str, peer: str | None = None) -> None:
|
|
"""The owner's record of the gadget it built, 'usb' or 'cable', and on the
|
|
cable the phone that dialed in; see link_kind and link_peer."""
|
|
_write(LINK, f"{kind} {peer}".strip() if peer else kind, "record the link")
|
|
|
|
|
|
def clear_link() -> None:
|
|
_write(LINK, None, "clear the link record")
|
|
|
|
|
|
def net_status() -> str | None:
|
|
"""What jetlink-root.sh said about the gadget's network interface, if it ran."""
|
|
return read(NET_STATUS) or None
|
|
|
|
|
|
def usb_speed() -> str | None:
|
|
"""The bus speed the host enumerated us at: 458 KB a frame is ~1 ms on
|
|
super-speed and ~11 ms on high-speed, so this is the first thing to read
|
|
when the link is slow. None while unbound or where the UDC does not say."""
|
|
udc = bound_udc()
|
|
return None if udc is None else udc_speed(udc, str(UDC_PATH))
|
|
|
|
|
|
# -- where the gadget lives -----------------------------------------------
|
|
# the comma is the USB gadget and the Jetson the host, decided by the kernels:
|
|
# AGNOS has CONFIG_USB_F_FS built in, L4T images are often stripped of the
|
|
# gadget modules. See docs/transport.md
|
|
GADGET_PATH = Path(ffs.GADGET)
|
|
FFS_MOUNT = Path(ffs.MOUNT)
|
|
UDC_PATH = Path(UDC_SYSFS)
|
|
# the network function jetlink-root.sh gadget --ios adds after ffs.jetlink. Its
|
|
# netdev is not usb0, which the modem holds, but whatever the kernel names it
|
|
NET_FUNCTION = 'ncm.usb0'
|
|
# written by jetlink/comma/jetlink-root.sh gadget, which the owner runs when the
|
|
# link is on and there is no gadget: "ok", or "error: <reason>"
|
|
GADGET_STATUS = Path("/dev/shm/jetlink-gadget")
|
|
# the owner's own: "error: the lender could not listen: <reason>" while nothing
|
|
# can borrow the gadget from it, absent otherwise. Not GADGET_STATUS, which
|
|
# root owns and every build rewrites
|
|
LENDER_STATUS = Path("/dev/shm/jetlink-lender")
|
|
CC_ORIENTATION = Path('/sys/class/power_supply/usb/typec_cc_orientation')
|
|
# the owner's pid while it has released the gadget on purpose so the Jetson can
|
|
# sleep. Presence comes from this, not the UDC; a marker whose writer is dead is
|
|
# a leftover from a kill
|
|
DORMANT = Path("/dev/shm/jetlink-dormant")
|
|
# hardwared's request to power the Jetson off; see request_shutdown
|
|
SHUTDOWN_REQUEST = Path("/dev/shm/jetlink-shutdown")
|
|
# the owner's status record: the owner's state in one file, rewritten whole
|
|
# every step, which makes it the owner's heartbeat too. A clean stop removes it
|
|
STATUS = Path("/dev/shm/jetlink/status.json")
|
|
# how old that record may be before a reader takes its owner for gone: six of
|
|
# its 0.5 s steps. A wait inside a step renews it (Owner.beat)
|
|
HEARTBEAT_TIMEOUT = 3.0
|
|
# how the owner's crash-loop backoff starts its record's error, which
|
|
# status.Status.reason_code reads it by
|
|
BACKING_OFF = "the service keeps stopping"
|
|
# the owner's start times, for its crash-loop backoff (see the owner)
|
|
STARTS = Path("/dev/shm/jetlink/starts.json")
|
|
# held with flock by the owner for its whole life, so there is only ever one
|
|
OWNER_LOCK = Path("/dev/shm/jetlink/owner.lock")
|
|
# what the server's last hello said (lending.SERVER_FIELDS), kept apart from
|
|
# the status record so a clean stop leaves it for the next owner
|
|
SERVER = Path("/dev/shm/jetlink/server.json")
|
|
# the joining model's runtime record, rewritten by a thread in modeld once a
|
|
# second and on every handover: whether the big model drives, for a reader
|
|
# with no modelV2 (status.runtime). modeld's close() leaves it stopping
|
|
MODELD = Path("/dev/shm/jetlink/modeld.json")
|
|
# how often modeld rewrites that record without a handover
|
|
RECORD_PERIOD = 1.0
|
|
# how long a host that stopped reading configured still counts as there: long
|
|
# enough to bridge a USB3 link recovery passing through "addressed", and a
|
|
# bounce made on purpose when a host will not enumerate (wait_for_host)
|
|
PRESENCE_HOLD = 5.0
|
|
|
|
|
|
def write_record(path: Path, record) -> None:
|
|
"""Replace a JSON record whole: a reader gets the old one or the new one,
|
|
never half of either. Raises OSError, for the writer to say once; the
|
|
temporary goes with a failure (a SIGKILL between the two leaves it for
|
|
the next owner to clear, clear_leftovers)."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_name(f".{path.name}.{os.getpid()}")
|
|
try:
|
|
tmp.write_text(json.dumps(record))
|
|
os.replace(tmp, path)
|
|
except BaseException:
|
|
tmp.unlink(missing_ok=True)
|
|
raise
|
|
|
|
|
|
def clear_leftovers(directory: Path) -> None:
|
|
"""The temporaries a writer killed between its write and its rename left
|
|
in `directory`, for the owner's start. Only a dead writer's (write_record
|
|
names them by pid): modeld writes its runtime record there too, and a
|
|
live one's is mid-write."""
|
|
try:
|
|
for leftover in directory.glob('.*.json.*'):
|
|
pid = leftover.name.rsplit('.', 1)[-1]
|
|
if not (pid.isdigit() and pid_alive(int(pid))):
|
|
leftover.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def read_record(path: Path) -> dict | None:
|
|
"""A JSON record write_record wrote, or None: missing, or not a record."""
|
|
try:
|
|
record = json.loads(read(path))
|
|
except ValueError:
|
|
return None
|
|
return record if isinstance(record, dict) else None
|
|
|
|
|
|
def owner_status() -> dict | None:
|
|
"""The owner's status record, or None without one: no owner has run since
|
|
boot, the last one stopped cleanly, or it is one too old to write it."""
|
|
return read_record(STATUS)
|
|
|
|
|
|
def owner_alive(record: dict) -> bool:
|
|
"""Was this record written within HEARTBEAT_TIMEOUT? time.monotonic is the
|
|
system's CLOCK_MONOTONIC, the same in every process on the device, and
|
|
unlike the wall clock it does not jump when the comma sets its time."""
|
|
at = record.get('at')
|
|
return isinstance(at, (int, float)) and time.monotonic() - at < HEARTBEAT_TIMEOUT
|
|
|
|
|
|
def _status_error(path: Path) -> str | None:
|
|
"""The reason in an "ok" or "error: <reason>" file. A missing file is not an
|
|
error: whatever writes it has not run."""
|
|
reason = read(path)
|
|
if not reason or reason == 'ok':
|
|
return None
|
|
return reason.removeprefix('error:').strip() or None
|
|
|
|
|
|
def build_error() -> str | None:
|
|
"""Why the gadget could not be built, if it could not."""
|
|
return _status_error(GADGET_STATUS)
|
|
|
|
|
|
def gadget_error() -> str | None:
|
|
"""Why the link is unavailable, if it is: the gadget could not be built, or
|
|
it was and nothing can borrow it from the owner.
|
|
|
|
Both happen in the owner, nowhere a user would look, so this is how the
|
|
reason reaches the panels.
|
|
"""
|
|
return build_error() or _status_error(LENDER_STATUS)
|
|
|
|
|
|
def note_lender_error(reason: str | None) -> None:
|
|
"""The owner's record of a lender that cannot listen, or None once it can."""
|
|
_write(LENDER_STATUS, None if reason is None else f"error: the lender could not listen: {reason}\n",
|
|
"record the lender's state")
|
|
|
|
|
|
def bound_udc() -> str | None:
|
|
"""The device controller our gadget is attached to, if it is attached."""
|
|
return read(GADGET_PATH / "UDC") or None
|
|
|
|
|
|
def other_gadget() -> str | None:
|
|
"""Another gadget bound to a device controller, by name: on a comma, AGNOS's
|
|
ADB gadget (g1) while ADB is on. jetlink-root.sh refuses to take the
|
|
controller from it."""
|
|
for udc in GADGET_PATH.parent.glob("*/UDC"):
|
|
if udc.parent != GADGET_PATH and read(udc):
|
|
return udc.parent.name
|
|
return None
|
|
|
|
|
|
def udc_state() -> str | None:
|
|
"""What the device controller says about the bus, or None if we are unbound.
|
|
|
|
"configured" is a host that has us; "default" and "addressed" are one that
|
|
reset the bus and stopped part way, which is what a Jetson that took the
|
|
bind as a wake and did not finish waking looks like.
|
|
"""
|
|
udc = bound_udc()
|
|
return None if udc is None else read(UDC_PATH / udc / "state") or None
|
|
|
|
|
|
def host_attached() -> bool:
|
|
"""Has a host (the Jetson) enumerated and configured us?"""
|
|
return udc_state() == "configured"
|
|
|
|
|
|
def cc_orientation() -> int | None:
|
|
"""What the USB-C port controller reads on the CC pin, so electrically true
|
|
whether or not anything enumerated: 0 is a port with nothing on it, 1 or 2 a
|
|
cable with a live host behind it (it cannot say what kind). A legacy A-to-C
|
|
cable's pull-up rides on the host's VBUS and reads the same. None where the
|
|
kernel does not say."""
|
|
try:
|
|
return int(read(CC_ORIENTATION))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def port_has_host() -> bool:
|
|
"""Does the USB-C port controller see a host on the cable?"""
|
|
return bool(cc_orientation())
|
|
|
|
|
|
# how long the UDC may sit half enumerated with a host on the cable before the
|
|
# gadget is bounced. A real enumeration is milliseconds; this only fires for a
|
|
# host that answered the bind with a bus reset and then stopped, which is what
|
|
# an unarmed hub does to a box asleep. See FfsTransport.rebind
|
|
STALLED_ENUMERATION = 20.0
|
|
STALLED_STATES = ('default', 'addressed')
|
|
HOST_POLL = 0.5
|
|
|
|
|
|
def wait_for_host(timeout: float, bounce=None, should_stop=None, report=None, mode: str | None = None) -> bool:
|
|
"""Wait for the Jetson to enumerate us, bouncing a bus that stalled.
|
|
|
|
The gadget stays bound throughout. An unbind is an unplug as the far end sees
|
|
it, and while one boots it takes ~50 s a cycle, so an unbind on a timer can
|
|
land on a box seconds from enumerating.
|
|
|
|
The one case that needs an edge is a host that took the bind as a wake, reset
|
|
the bus and stopped. The UDC then sits in default or addressed with the CC
|
|
pin still showing a host, and only another connect moves it: that is what
|
|
`bounce` is for, and it is spent once.
|
|
|
|
On the cable there is nothing to wait for: the connect that made the client
|
|
already reached the phone. The UDC is configured too, but by the phone, and
|
|
it is the dial that proved it is there. `mode` is the link setting, for
|
|
before the owner has recorded which gadget it built (link_kind).
|
|
"""
|
|
if link_kind(mode) == 'cable':
|
|
return True
|
|
deadline = time.monotonic() + timeout
|
|
stalled_since = None
|
|
bounced = False
|
|
reported = False
|
|
while True:
|
|
state = udc_state()
|
|
if state == "configured":
|
|
return True
|
|
now = time.monotonic()
|
|
if now >= deadline or (should_stop is not None and should_stop()):
|
|
return False
|
|
if report is not None and not reported:
|
|
reported = True
|
|
report()
|
|
if state in STALLED_STATES and port_has_host():
|
|
stalled_since = now if stalled_since is None else stalled_since
|
|
if not bounced and bounce is not None and now - stalled_since > STALLED_ENUMERATION:
|
|
bounced = True
|
|
log.warning("jetlink: the bus has been half enumerated for %.0f s, bouncing the gadget",
|
|
STALLED_ENUMERATION)
|
|
try:
|
|
bounce()
|
|
except Exception:
|
|
log.exception("jetlink: could not bounce the gadget")
|
|
else:
|
|
stalled_since = None
|
|
time.sleep(HOST_POLL)
|
|
|
|
|
|
def setup_gadget(ios: bool) -> bool:
|
|
"""Create the gadget, for USB or iOS: there is none yet, or the setting
|
|
moved between the two. The script records "ok" or the reason itself, in
|
|
GADGET_STATUS, so a failure here reaches the offroad alert. Off AGNOS
|
|
there is nothing to create it with, and this is a False."""
|
|
if not root.run('gadget', *(['--ios'] if ios else [])):
|
|
return False
|
|
log.warning("jetlink: gadget set up for %s", 'iOS' if ios else 'USB')
|
|
return link_configured()
|
|
|
|
|
|
def built_for_ios() -> bool:
|
|
"""Does the gadget carry the network function, as jetlink-root.sh gadget --ios builds it?"""
|
|
return os.path.lexists(GADGET_PATH / 'configs' / 'c.1' / NET_FUNCTION)
|
|
|
|
|
|
def net_up() -> bool:
|
|
"""Bring the gadget's network interface up, for a phone to dial over.
|
|
|
|
The netdev does not exist until the first UDC bind (f_ncm registers it in
|
|
its bind), and the gadget subcommand never binds, so the owner runs this
|
|
after it binds. `net` is idempotent: nmcli unmanaged, 192.168.60.1/24, the
|
|
DHCP server, and NET_STATUS written as "ok 192.168.60.1 <netdev>" or
|
|
"error: <reason>".
|
|
"""
|
|
if not root.run('net'):
|
|
return False
|
|
status = net_status() or ''
|
|
log.warning("jetlink: gadget network: %s", status or 'no status written')
|
|
return status.startswith('ok')
|
|
|
|
|
|
def link_configured() -> bool:
|
|
"""Can we even attempt a link? The gadget exists.
|
|
|
|
Not host_attached(): the UDC only binds when something opens ep0, and nothing
|
|
opens ep0 unless the link looks usable. Waiting for a host deadlocks.
|
|
|
|
The cable too: a phone is on the gadget's own network interface, which
|
|
exists only while ep0 is held.
|
|
"""
|
|
if build_error() is not None:
|
|
return False
|
|
try:
|
|
return (FFS_MOUNT / "ep0").exists()
|
|
except OSError:
|
|
# a root-only mount raises PermissionError from stat; unusable either way
|
|
return False
|
|
|
|
|
|
def set_dormant(on: bool) -> None:
|
|
_write(DORMANT, str(os.getpid()) if on else None, "update the dormant marker")
|
|
|
|
|
|
def dormant() -> bool:
|
|
"""Has a live owner released the gadget on purpose?"""
|
|
try:
|
|
pid = int(read(DORMANT))
|
|
except ValueError:
|
|
return False
|
|
return pid_alive(pid)
|
|
|
|
|
|
def pid_alive(pid) -> bool:
|
|
if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
|
|
return False # 0 and below name process groups, never one process
|
|
try:
|
|
os.kill(pid, 0)
|
|
except (ProcessLookupError, OverflowError, ValueError):
|
|
return False
|
|
except PermissionError:
|
|
pass # alive, just not ours to signal
|
|
return True
|
|
|
|
|
|
def request_shutdown(reason: str) -> bool:
|
|
"""Hand the owner a power-off request, whole or not at all: a partial one (a
|
|
full /dev/shm) is one the owner cannot read, so it never takes it. False,
|
|
and logged, when it cannot be written."""
|
|
try:
|
|
write_record(SHUTDOWN_REQUEST, {'reason': reason})
|
|
return True
|
|
except OSError:
|
|
log.exception("jetlink: could not write the shutdown request")
|
|
return False
|
|
|
|
|
|
def pending_shutdown() -> str | None:
|
|
"""The reason in a shutdown request that has not been dealt with, if any.
|
|
|
|
Read twice a second for the life of the process and almost never there, so
|
|
the miss is a stat rather than an open that raises.
|
|
"""
|
|
if not SHUTDOWN_REQUEST.exists():
|
|
return None
|
|
record = read_record(SHUTDOWN_REQUEST)
|
|
return None if record is None else str(record.get('reason', ''))
|
|
|
|
|
|
def finish_shutdown() -> None:
|
|
_write(SHUTDOWN_REQUEST, None, "remove the shutdown request")
|