Files
StarPilot/jetlink_repo/jetlink/comma/port.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

406 lines
19 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-C port, kept the device end of a USB link.
The port is dual role. It hosts a chestnut, and it is the device for a jetlink
host. An A-to-C cable settles which by construction: the A end only pulls CC
up, so the comma can only be the sink and the device. A C-to-C cable does not.
Both ends are dual role, and the comma can come out the source and the host:
facing a Mac, nothing enumerates; facing an iPhone or a Jetson's own USB-C
port, the far end enumerates as a device (a Jetson on C-to-C reads
"Powered cable w/ sink" and enumerates as 0955:7020).
While the link is on, a chestnut is the only thing the comma should host on
this port. So once the comma has been the source for a few seconds with no
chestnut on the port, whatever is on the other end is a host that lost the
toss, and the port is held at sink until that cable comes out. Nothing happens
anywhere else: the comma as the device (every USB-A host, a C-to-C host that
won), a power supply and a chestnut all leave the port as AGNOS boots it.
Holding for the whole session would be simpler and would hide a chestnut
plugged in while the link is on, since chestnut_present() needs the comma to
host it before jetlink stands aside.
The lever is the charger's DISABLE_POWER_ROLE_SWITCH voter, forced from
debugfs. It is the one that holds. The charger puts the port back to dual role
on every unplug and refuses a role written through the power supply once
nothing is attached, so the policy engine's rev3_sink_only and dual_role/mode
last one plug at most; a forced voter gates all of those writes.
The voter only moves the power role, and a USB PD power role swap leaves the
data roles where they were. The comma's policy engine, as the source, accepts
a PR_Swap at any time, and its sink startup keeps the host role it had. So a
far end that came out the device and then asked for the source role, as a
phone or tablet that powers what it plugs into may, leaves the comma the sink
and still the host: no hold fires, and none would help. For that pair the
comma asks over USB PD for a data role swap (the dual_role class's data_role,
which sends a DR_Swap and moves nothing else), a few times, then once for a
hard reset, after which a sink is the device by the spec. Neither is ever sent
to a far end the comma is the device of.
USB PD is otherwise left alone; a host that comes back negotiates as over any
cable. The hold does not survive a reboot.
The right roles are not enough on their own: the comma also has to turn its
device side on. As a sink the policy engine does that only when the charger
detection read a USB port or floating data lines, and dwc3 turns it off again
when the lines read floating and no host has enumerated it in 10 s. Behind a
hub the lines are a USB port's. On a direct cable an Apple port connects them
only once USB PD is done, and a comma that boots with the phone plugged in has
no gadget bound for far longer than 10 s. So while the link is on both are
told to keep the device (jetlink-root.sh udc apply, which the owner runs with
the VM tuning), a sink that is the device with the device side still off a few
seconds into a plug has it turned on, and an empty port with it still on has it
turned off.
An iPhone on a direct cable ends up powering the comma: after a hold it is the
source and the host. It can be the host and charge from the comma instead, and
it negotiates USB PD, so with the link set to iOS and phone charging turned on
the comma asks it once a plug for the source role (a PR_Swap, jetlink-root.sh
port source), once the gadget is configured. The swap leaves the data roles
and the link as they are. The comma's own sink capabilities never invite the
phone to offer it. Being the source and still the device is left alone: only
the host role is judged.
Phone charging is off unless the fork's param turns it on (Keys.charge_phone):
some iPhones lose the link once the comma powers them. The link comes first,
so a link that goes down while the comma charges the phone has the source role
handed back (port sink), and the phone powers the port again. Charging the
phone adds up to 7.5 W through the comma's boost converter, so a comma that
runs hot waits to ask until it has cooled. A phone that takes the source role
back, or is handed it, is not asked again until the next plug.
Every change of the port's roles is a line in the owner's log, so a plug that
did not connect can be read afterwards.
"""
from __future__ import annotations
import os
import time
from pathlib import Path
from jetlink.comma import gadget, root
USBPD = Path('/sys/class/usbpd/usbpd0')
POWER_ROLE = USBPD / 'current_pr'
DATA_ROLE = USBPD / 'current_dr'
CONTRACT = USBPD / 'contract'
USB_PSY = Path('/sys/class/power_supply/usb')
TYPEC_MODE = USB_PSY / 'typec_mode'
CHARGER = USB_PSY / 'real_type'
# dwc3's glue for usb0: 'peripheral' while the device side is on
UDC_MODE = Path('/sys/devices/platform/soc/a600000.ssusb/mode')
USB_DEVICES = Path('/sys/bus/usb/devices')
THERMAL = Path('/sys/devices/virtual/thermal')
# the zones hardwared takes the comma's temperature from (its ThermalConfig's
# cpu, gpu, memory and pmic), the same names on the four and the 3X
HOT_ZONES = frozenset([f'cpu{i}-{c}-usr' for c in ('silver', 'gold') for i in range(4)] +
['gpu0-usr', 'gpu1-usr', 'ddr-usr', 'pm8998_tz', 'pm8005_tz'])
# a comma this hot, the hottest zone smoothed over a few seconds, does not
# start charging a phone. Above the fan's setpoints (75 C on the four, 80 C on
# the 3X) and below openpilot's overheated bands (100 C and 96 C)
HOT_C = 90.0
HOT_SMOOTHING = 0.2 # of each half-second reading
# how long the comma hosts the far end before judging it. A chestnut enumerates
# well inside this, and a far end that powers us and wants the host role has
# asked for it by then
SWAP_AFTER = 3.0
# DR_Swap requests to a far end that powers us and came out the device, a plug,
# this far apart; then one hard reset
SWAP_TRIES = 3
RETRY_AFTER = 2.0
# a device side turned on that went off again is turned on no sooner than
# this: dwc3 gives floating lines 10 s
RESTART_AFTER = 10.0
# how long the port reads empty before a hold is let go. The hold itself is an
# unplug and replug, so this has to outlast the replug
RELEASE_AFTER = 5.0
# how long the port reads empty before a plug counts as gone. A device let go
# by a hold reattaches in a DRP toggle and a CC debounce, well under this
UNPLUGGED = 1.5
ATTACHED = ('source', 'sink')
def power_role() -> str | None:
"""'source', 'sink' or 'none' from the policy engine, or None without one."""
return gadget.read(POWER_ROLE) or None
def data_role() -> str | None:
"""'dfp' (the comma is the host), 'ufp' (the device) or 'none'."""
return gadget.read(DATA_ROLE) or None
def thermal_zones() -> list[Path]:
"""The temp files of HOT_ZONES; none off a comma."""
try:
names = os.listdir(THERMAL)
except OSError:
return []
zones = []
for name in names:
if name.startswith('thermal_zone') and gadget.read(THERMAL / name / 'type') in HOT_ZONES:
zones.append(THERMAL / name / 'temp')
return zones
def hottest(zones: list[Path]) -> float | None:
"""The hottest of `zones` in degrees C, or None when none reads."""
temps = []
for zone in zones:
try:
temps.append(int(gadget.read(zone)) / 1000)
except ValueError:
continue
return max(temps, default=None)
def chestnut_attached(chestnut_ids: frozenset[tuple[int, int]]) -> bool:
"""Is a chestnut enumerated, running or in its ROM? It can only be on this port."""
try:
names = os.listdir(USB_DEVICES)
except OSError:
return False
for name in names:
if ':' in name:
continue # an interface
try:
ids = tuple(int((USB_DEVICES / name / f).read_text(), 16) for f in ('idVendor', 'idProduct'))
except (OSError, ValueError):
continue # a device going away
if ids in chestnut_ids:
return True
return False
def run_script(command: str) -> bool:
"""jetlink-root.sh port hold|off|device|reset|source|sink. Both commas have
the levers, so a False is a failure, and root.run has logged why. Its timeout
is short because the off in the owner's finally comes before the FunctionFS
close, inside manager's 5 s; source and sink wait for the far end's PR_Swap."""
return root.run('port', command, timeout=root.SWAP_TIMEOUT if command in ('source', 'sink') else root.PORT_TIMEOUT)
def run_udc(command: str) -> bool:
"""jetlink-root.sh udc start|stop, on the same short timeout."""
return root.run('udc', command, timeout=root.PORT_TIMEOUT)
class Port:
"""Called once a cycle by the owner. A cycle reads the power role, the data
role while something is attached, and the device controller's mode while a
host that powers us has not configured the gadget; sudo only runs on a
change."""
def __init__(self, chestnut_ids):
# what a chestnut enumerates as, running or in its ROM: openpilot's
# CHESTNUT_USB_IDS and CHESTNUT_ROM_USB_IDS, as the fork's adapter hands
# them over (OwnerConfig.chestnut_ids), so a chestnut being flashed is
# never taken for a host
self.chestnut_ids = frozenset(chestnut_ids)
self.zones: list[Path] | None = None # thermal_zones(), found on first use
self._reset()
def _reset(self) -> None:
self.cleared = False # the port put back as AGNOS boots it, once a session
self.deferred = False # that waits for a host an owner before this one had
self.held = False # the voter is forced to sink
# this plug has been judged, so leave it until it comes out. Also set for
# the length of a hold until a host comes back
self.settled = False
self.role: str | None = None
self.role_since = 0.0
self.roles: tuple[str | None, str | None] = (None, None) # power and data
self.roles_since = 0.0
# something is on the port, or was until less than UNPLUGGED ago. Set at
# the start, so the first empty port is looked at too
self.plugged = True
self.asks = 0 # this plug's: SWAP_TRIES DR_Swaps, then a hard reset
self.next_ask = 0.0
self.next_start = 0.0
# this plug's phone charging: asked for (once a plug), the swap went
# through, said once that the comma is too hot to ask yet, and the
# smoothed temperature while waiting to ask
self.charge_asked = False
self.charging = False
self.charge_since = 0.0
self.said_hot = False
self.temp: float | None = None
def update(self, now: float | None = None, configured: bool = False, charge: bool = False) -> None:
"""configured: a host had the gadget configured at the last cycle, so the
device side is on. charge: the link is set to iOS with phone charging on,
so a host that powers the comma is asked to charge from it instead."""
if not self.cleared:
# whatever an owner killed mid-hold left behind. Not under a live link:
# an owner started after one that died can find a borrower still on the
# gadget the dead one presented, through a hold it made, and back at
# dual role the port may toss the roles again. Once no host has us
# configured the link has gone anyway, and the hold goes then
if gadget.host_attached():
if not self.deferred:
gadget.log.warning("jetlink: a host is still on the gadget; leaving the USB-C port as it is until it goes")
self.deferred = True
else:
run_script('off')
self.cleared = True
now = time.monotonic() if now is None else now
role = power_role()
data = data_role() if role in ATTACHED else None
if (role, data) != self.roles:
if role is not None:
self._note(role, data)
if self.charging and (role, data) == ('sink', 'ufp'):
gadget.log.warning("jetlink: the iPhone took the source role back; not asking again until the next plug")
self.charging = False
self.roles, self.roles_since = (role, data), now
if role != self.role:
self.role, self.role_since = role, now
lasted = now - self.role_since
if role in ATTACHED:
self.plugged = True
elif self.plugged and lasted >= UNPLUGGED:
self._unplugged()
if role == 'sink' and data == 'ufp':
if not configured:
self._keep_the_device_on(now)
elif charge and not self.charge_asked and now - self.roles_since >= SWAP_AFTER:
self._charge_the_phone(now)
if self.charging and not configured and role == 'source':
self._stop_charging(now)
if self.held:
if role == 'sink':
self.settled = False # a host came back
elif role != 'source' and lasted >= RELEASE_AFTER:
self._release()
# the accessory reattaches in a moment; time the gap afresh
self.role_since, self.plugged = now, True
elif data == 'dfp' or (role == 'source' and data != 'ufp'):
# the comma is the host, which only a chestnut should make it. The
# source is held at sink; a sink is asked over USB PD. A source that is
# the device got there by a swap, and is what charging a phone looks like
hosted = now - self.roles_since
if not self.settled and hosted >= SWAP_AFTER and now >= self.next_ask:
if self.asks == 0 and chestnut_attached(self.chestnut_ids):
self.settled = True
elif role == 'source':
self._hold(hosted)
else:
self._ask_for_a_host(now)
elif role == 'sink' or not self.plugged:
self.settled = False # a host, or the plug is gone
def _note(self, role: str, data: str | None) -> None:
gadget.log.info("jetlink: USB-C port %s, %s; USB PD contract %s; Type-C %s; charger detection %s",
role, {'dfp': 'host', 'ufp': 'device'}.get(data, 'no data role'),
gadget.read(CONTRACT) or 'unknown', gadget.read(TYPEC_MODE) or 'unknown',
gadget.read(CHARGER) or 'unknown')
def _unplugged(self) -> None:
self.plugged = False
self.asks = 0 # the next plug is asked afresh
self.charge_asked = self.charging = self.said_hot = False
self.temp = None
# the policy engine turns off only a device side it turned on itself, so
# one turned on here, by this owner or one before it, is turned off here
if gadget.read(UDC_MODE) == 'peripheral':
run_udc('stop')
def _keep_the_device_on(self, now: float) -> None:
"""A host powers the port and the comma is its device, so the device side
should be on; see the module's docstring for when it is not."""
if now - self.roles_since < SWAP_AFTER or now < self.next_start:
return
mode = gadget.read(UDC_MODE)
if mode in ('', 'peripheral'):
return
gadget.log.warning("jetlink: a host powers the USB-C port but the USB device controller is %s "
"(charger detection %s); turning its device side on", mode, gadget.read(CHARGER) or 'unknown')
self.next_start = now + RESTART_AFTER
run_udc('start')
def _charge_the_phone(self, now: float) -> None:
"""The phone powers the comma and hosts it over a USB PD contract: ask it
for the source role, unless the comma is hot. See the module's docstring."""
if gadget.read(CONTRACT) != 'explicit':
return # a PR_Swap needs USB PD; a phone without it is left powering us
if self.zones is None:
self.zones = thermal_zones()
temp = hottest(self.zones)
if temp is not None:
self.temp = temp if self.temp is None else self.temp + (temp - self.temp) * HOT_SMOOTHING
if self.temp >= HOT_C:
if not self.said_hot:
gadget.log.warning("jetlink: the comma is at %.0f C; charging the iPhone waits until it is below %.0f C",
self.temp, HOT_C)
self.said_hot = True
return
self.charge_asked = True
if self.held:
# the voter gates every power role change
self.held = False
run_script('off')
gadget.log.warning("jetlink: the iPhone powers the comma; asking it over USB PD to charge from it instead")
if run_script('source'):
gadget.log.warning("jetlink: charging the iPhone")
self.charging, self.charge_since = True, now
else:
gadget.log.warning("jetlink: the iPhone kept the source role; not asking again until the next plug")
def _stop_charging(self, now: float) -> None:
"""The link went down with the comma charging the phone: what a swap must
never cost. Hand the phone the source role back, so it powers the port as
it does with charging off, and leave this plug alone from then on."""
self.charging = False # before the swap, whose sink+ufp would read as the phone taking it back
gadget.log.warning("jetlink: the link went down %.0f s into charging the iPhone; giving it the source role back",
now - self.charge_since)
if run_script('sink'):
gadget.log.warning("jetlink: the iPhone powers the comma again; not charging it until the next plug")
else:
gadget.log.warning("jetlink: the iPhone kept charging from the comma; unplug the cable to start over")
def _ask_for_a_host(self, now: float) -> None:
"""The far end powers the comma and is still its device; see the module's
docstring for how that comes about."""
self.asks += 1
self.next_ask = now + RETRY_AFTER
if self.asks <= SWAP_TRIES:
gadget.log.warning("jetlink: the far end powers the USB-C port but came out the device; asking it over USB PD "
"to take the host role (%d of %d)", self.asks, SWAP_TRIES)
run_script('device')
elif self.asks == SWAP_TRIES + 1:
gadget.log.warning("jetlink: the far end did not take the host role; resetting USB PD")
run_script('reset')
else:
gadget.log.warning("jetlink: the far end still powers the USB-C port as the device; leaving it until the next "
"plug. A USB-C hub, or a USB-C to USB-A adapter and an A-to-C cable, settles the roles")
self.settled = True
def _hold(self, lasted: float) -> None:
gadget.log.warning(f"jetlink: hosting something that is not a chestnut for {lasted:.0f} s on the USB-C port; holding it as a device")
# settled stays set if the hold did not happen, so this plug is not tried every cycle
self.held = run_script('hold')
self.settled = True
def _release(self) -> None:
if self.settled:
# a sink-only accessory: it comes back as a sink on dual role, and
# holding again would only cycle it
gadget.log.warning("jetlink: no host came back on the USB-C port; leaving it dual role until the next plug")
else:
gadget.log.warning("jetlink: the USB-C port is empty; back to dual role")
self.held = False
run_script('off')
def off(self) -> None:
"""Undo a hold. Outside one the port is already as AGNOS boots it. A
device side turned on here is left: this runs as a chestnut turns up, and
turning it off would take the host role from under the chestnut; the next
owner turns it off once the port is empty."""
if self.held:
run_script('off')
self._reset()