Compare commits

...

2 Commits

Author SHA1 Message Date
Prabhaav Pillai 88a3b5e2a5 Add car control sections and update projections in settings for AA support 2026-10-27 17:10:33 -04:00
Prabhaav Pillai 8ac6b224d7 Add support for host runtime and Bluetooth integration in StarPilot 2026-10-25 17:51:38 -04:00
22 changed files with 427 additions and 29 deletions
Executable
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
profile="${1:-c4}"
case "${profile}" in
c3) big=1 ;;
c4) big=0 ;;
*) echo "Usage: ./device [c3|c4]" >&2; exit 2 ;;
esac
# Run the real manager with host hardware/cloud disabled. The manager owns the
# UI and Galaxy processes, just as it does on a comma device.
export STARPILOT_CLOUD_DISABLED=1
export BIG="${big}"
export BLOCK="${BLOCK:+${BLOCK},}replay_owner,map_snapshot_operations"
exec "${ROOT_DIR}/dev" device
+5 -3
View File
@@ -22,8 +22,10 @@ from jeepney.wrappers import Properties
from openpilot.starpilot.bluetooth.radio_preference import RadioPreference
RADIO_HELPER = Path('/usr/comma/bluetooth-radio')
RADIO_UNIT = 'starpilot-bluetooth-radio.service'
HOST_RUNTIME = os.getenv('SP_HOST_RUNTIME') == '1'
HOST_BLE_DATA = Path(os.getenv('SP_HOST_GALAXY_BLE_DATA', '/tmp/starpilot-host-galaxy-ble'))
RADIO_HELPER = Path('/usr/bin/bluetoothctl') if HOST_RUNTIME else Path('/usr/comma/bluetooth-radio')
RADIO_UNIT = 'bluetooth.service' if HOST_RUNTIME else 'starpilot-bluetooth-radio.service'
ADDRESS = re.compile(r'(?:[0-9A-F]{2}:){5}[0-9A-F]{2}\Z')
# iPhones only list nearby devices that look like audio gear, so the window presents as a loudspeaker.
SPEAKER_CLASS = (4, 5)
@@ -46,7 +48,7 @@ ADAPTER = 'org.bluez.Adapter1'
DEVICE = 'org.bluez.Device1'
AGENT = 'org.bluez.Agent1'
AGENT_PATH = '/org/starpilot/bluetooth/pairing'
ADMISSION_LOCK = Path('/data/starpilot/bluetooth-owner.lock')
ADMISSION_LOCK = HOST_BLE_DATA / 'bluetooth-owner.lock' if HOST_RUNTIME else Path('/data/starpilot/bluetooth-owner.lock')
class PairingSession:
@@ -15,8 +15,10 @@ RADIO_PREFERENCE = Path('/data/params/d/BluetoothEnabled')
class RadioPreference:
def __init__(self, path: Path = RADIO_PREFERENCE):
self.path = path
def __init__(self, path: Path | None = None):
if path is None and os.getenv('SP_HOST_RUNTIME') == '1':
path = Path(os.environ['SP_HOST_BLUETOOTH_PREFERENCE'])
self.path = path or RADIO_PREFERENCE
@staticmethod
def _read(path: Path) -> tuple[bytes | None, tuple | None]:
@@ -22,6 +22,11 @@ class RadioPreferenceTest(unittest.TestCase):
self.assertEqual(RadioPreference().path, Path('/data/params/d/BluetoothEnabled'))
self.assertEqual(RADIO_PREFERENCE, RadioPreference().path)
def test_host_runtime_uses_its_private_radio_preference(self):
path = self.root / 'runtime' / 'BluetoothEnabled'
with patch.dict(os.environ, SP_HOST_RUNTIME='1', SP_HOST_BLUETOOTH_PREFERENCE=str(path)):
self.assertEqual(RadioPreference().path, path)
def test_read_does_not_create_or_enable_and_matches_service_newline_semantics(self):
self.assertFalse(self.preference.enabled())
self.assertFalse(self.preference.path.exists())
@@ -2,6 +2,7 @@
from ipaddress import IPv4Address, ip_network
import json
import os
import subprocess
import threading
import time
@@ -25,6 +26,10 @@ class LocalAccess:
with self.lock:
if self.cached is not None and self.clock() < self.expiry:
return self.cached
if os.getenv('SP_HOST_NETWORK_MODE') not in (None, 'mirrored'):
self.cached = {'available': False, 'addresses': [], 'reason': 'Restart WSL to enable mirrored networking for Galaxy LAN access.'}
self.expiry = self.clock() + 5.0
return self.cached
addresses = []
reason = 'Connect to the Galaxy hotspot, or put the comma and phone on the same Wi-Fi or Ethernet network.'
try:
+47 -2
View File
@@ -47,6 +47,18 @@ MAX_VIEWS = 64
MAX_INTENTS = 64
TTL = 60.0
# Android Auto lists binary controls only.
CAR_CONTROL_SECTIONS = (
("lateral", "Lateral (Steering)", ("aol", "lane_change", "torque", "lane")),
("longitudinal", "Longitudinal (Speed & Following)",
("profiles", "conditional", "curve", "slc", "aggressive", "standard", "relaxed", "traffic")),
("wheel", "Wheel Controls", ("wheel",)),
("visual", "Visual (Display & UI)", ("appearance", "pip")),
("sounds", "Sounds & Alerts", ("sounds",)),
("device", "Device & Data", ("display", "data")),
("developer", "Developer", ("developer",)),
)
class SettingsUnavailable(Exception):
pass
@@ -360,7 +372,7 @@ def _session_key(token: str) -> bytes:
return hashlib.sha256(token.encode("ascii")).digest()
def _projection(row: FeatureRow, page: str) -> dict:
def _projection(row: FeatureRow) -> dict:
return {"label": row.label, "value": row.value, "choices": list(row.choices), "step": row.step,
"minimum": row.minimum, "maximum": row.maximum, "unit": row.unit,
"available": row.available, "reason": description(row),
@@ -629,8 +641,41 @@ class SettingsGateway:
scope = hashlib.sha256(_session_key(token) + generation + (ctx.cp_raw or b"")).digest()
result = {"page": page, "title": state.title, "subtitle": state.subtitle, "parked": state.parked,
"vehicleIdentified": bool(ctx.cp is not None and getattr(ctx.cp, "carFingerprint", None)),
"rows": [{**_projection(row, page), "revision": hashlib.sha256(scope + repr(row).encode()).hexdigest()}
"rows": [{**_projection(row), "revision": hashlib.sha256(scope + repr(row).encode()).hexdigest()}
for row in state.rows], "view": view_id}
if page == "hub" or any(row.page for row in state.rows):
rows_by_page = {page: state}
visible: dict[str, bool] = {}
def page_state(name: str):
if name not in rows_by_page:
rows_by_page[name] = self._state(name, ctx)
return rows_by_page[name]
def has_car_control(name: str, visited: frozenset[str] = frozenset()) -> bool:
if name in visited or name not in PAGES:
return False
if name in visible:
return visible[name]
try:
rows = page_state(name).rows
except SettingsUnavailable:
visible[name] = False
return False
found = any((len(row.choices) == 2 and set(row.choices) == {"On", "Off"}) or
has_car_control(row.page, visited | {name}) for row in rows)
visible[name] = found
return found
if page == "hub":
result["sections"] = []
for section_id, title, names in CAR_CONTROL_SECTIONS:
pages = [{"id": name, "title": page_state(name).title} for name in names if has_car_control(name)]
if pages:
result["sections"].append({"id": section_id, "title": title, "pages": pages})
else:
for source, projected in zip(state.rows, result["rows"]):
if source.page:
projected["carVisible"] = has_car_control(source.page)
if page == "vasm":
result["editor"] = editor
result["editorRow"] = next((index for index, row in enumerate(state.rows) if row.key == VASM_ANNOTATION), -1)
@@ -96,7 +96,7 @@ def test_actual_owner_projections_have_no_uncovered_setting(tmp_path):
for row in rows:
if row.key:
assert purpose(row), (page, row.key)
assert _projection(row, page)['reason'].startswith(purpose(row))
assert _projection(row)['reason'].startswith(purpose(row))
check(DeveloperSettingsOwner(params, lambda: True).snapshot().rows, 'developer')
@@ -17,7 +17,7 @@ from opendbc.car.toyota.interface import CarInterface as ToyotaInterface
from opendbc.car.toyota.values import CAR as ToyotaCar
from openpilot.common.params import Params
from openpilot.starpilot.galaxy.settings import (
AuthorityContext, LiveContextSource, SettingsChanged, SettingsGateway,
AuthorityContext, LiveContextSource, PAGES, SettingsChanged, SettingsGateway,
)
from openpilot.starpilot.galaxy.access import GalaxyAccessOwner
from openpilot.starpilot.galaxy.server import make_server
@@ -247,10 +247,17 @@ class SettingsGatewayTest(unittest.TestCase):
self.assertFalse(self.params.get_bool("SpeedLimitController"))
def test_all_saved_groups_are_display_only_projection(self):
self.context.value = replace(self.context.value,
cp=ToyotaInterface.get_non_essential_params(ToyotaCar.TOYOTA_COROLLA_TSS2))
hub = self.page("hub")
self.assertTrue(hub["sections"])
self.assertTrue(all(section["pages"] and all(page["id"] in PAGES for page in section["pages"])
for section in hub["sections"]))
lateral = next(section for section in hub["sections"] if section["id"] == "lateral")
self.assertEqual(next(page for page in lateral["pages"] if page["id"] == "aol")["title"], self.page("aol")["title"])
self.assertEqual([row["page"] for row in hub["rows"]],
["curve", "slc", "lane", "lane_change", "profiles", "conditional", "torque", "aol", "wheel",
"aggressive", "standard", "relaxed", "traffic"])
"vehicle", "aggressive", "standard", "relaxed", "traffic"])
for page in ("slc", "lane", "lane_change", "profiles", "conditional", "conditional/cem", "conditional/ccm",
"curve", "torque", "aol", "appearance", "display", "sounds", "pip", "sentry", "aggressive/following"):
with self.subTest(page=page):
@@ -258,6 +265,8 @@ class SettingsGatewayTest(unittest.TestCase):
self.assertTrue(result["rows"])
for row in result["rows"]:
self.assertFalse({"source", "related_source", "dependencies", "capability", "vehicle_fingerprint"} & row.keys())
if row["page"]:
self.assertIsInstance(row["carVisible"], bool)
def test_appearance_confirmation_is_source_and_session_bound_without_cp(self):
from openpilot.starpilot.ui.appearance_preferences import read_visibility
@@ -4,11 +4,13 @@ import json
import logging
import os
from pathlib import Path
import signal
import subprocess
import sys
import tempfile
import time
DATA = Path('/data/galaxy-ble')
DATA = Path(os.getenv('SP_HOST_GALAXY_BLE_DATA', '/data/galaxy-ble'))
BRIDGE = Path(__file__).resolve().parent / 'bridge'
UNIT = 'galaxy-ble-fork.service'
LAN_UNIT = 'galaxy-lan-discovery.service'
@@ -60,6 +62,8 @@ class Bootstrap:
self.ready = False
self.last_status = None
self.last_write = 0.
self.host_bridge = None
self.host_discovery = None
def status(self, state, message):
self.ready = state == 'ready'
@@ -75,7 +79,50 @@ class Bootstrap:
def systemctl(self, *args, **kwargs):
return self.command(['sudo', '-n', 'systemctl', *args], **kwargs)
def stop_host_process(self, name):
child = getattr(self, name)
setattr(self, name, None)
if child is not None and child.poll() is None:
child.terminate()
try:
child.wait(timeout=5)
except subprocess.TimeoutExpired:
child.kill()
child.wait()
def start_host_process(self, name, script, *args):
child = getattr(self, name)
if child is None or child.poll() is not None:
env = os.environ.copy()
env['PYTHONPATH'] = str(self.bridge.parent / 'vendor') + os.pathsep + env.get('PYTHONPATH', '')
child = subprocess.Popen([sys.executable, str(self.bridge / script), *args], cwd=self.bridge, env=env)
setattr(self, name, child)
return child
def host_step(self):
from openpilot.starpilot.bluetooth.radio_preference import RadioPreference
from openpilot.starpilot.galaxy_bluetooth.bridge.pairing import ensure_store
if os.getenv('SP_HOST_NETWORK_MODE') in (None, 'mirrored'):
self.start_host_process('host_discovery', 'lan_discovery.py')
else:
self.stop_host_process('host_discovery')
ensure_store(self.data / 'pairing.json')
if not RadioPreference().enabled():
self.stop_host_process('host_bridge')
self.status('waiting', 'Turn Bluetooth on in comma Settings to pair your phone')
return
self.start_host_process('host_bridge', 'server.py', '--key-file', str(self.data / 'pairing.json'))
try:
ready_pid = json.loads((self.data / 'bridge-ready.json').read_text()).get('pid')
except (OSError, ValueError, AttributeError):
ready_pid = None
ready = ready_pid == self.host_bridge.pid and self.host_bridge.poll() is None
self.status('ready' if ready else 'waiting', 'Ready to pair' if ready else 'Waiting for Bluetooth adapter')
def step(self):
if os.getenv('SP_HOST_RUNTIME') == '1':
self.host_step()
return
wake = self.data / 'wake-setup.json'
if wake.exists():
wake.unlink(missing_ok=True)
@@ -147,13 +194,20 @@ def main():
except BlockingIOError:
return
worker = Bootstrap(Params())
while True:
worker.step()
# Once the bridge is up, systemd keeps it running; check back slowly unless the user acts.
for _ in range(60 if worker.ready else 10):
time.sleep(1)
if any((DATA / name).exists() for name in ('retry-setup.json', 'wake-setup.json')):
break
def stop(_signum, _frame):
raise SystemExit
signal.signal(signal.SIGTERM, stop)
try:
while True:
worker.step()
# Once the bridge is up, systemd keeps it running; check back slowly unless the user acts.
for _ in range(60 if worker.ready else 10):
time.sleep(1)
if any((DATA / name).exists() for name in ('retry-setup.json', 'wake-setup.json')):
break
finally:
worker.stop_host_process('host_bridge')
worker.stop_host_process('host_discovery')
if __name__ == '__main__':
@@ -16,7 +16,7 @@ import stat
import tempfile
from contextlib import contextmanager
DEFAULT_KEY_PATH = Path("/data/galaxy-ble/pairing.json")
DEFAULT_KEY_PATH = Path(os.getenv('SP_HOST_GALAXY_BLE_DATA', '/data/galaxy-ble')) / 'pairing.json'
MAX_PHONES = 16
MAX_FILE_BYTES = 16384
LEGACY_ID = "legacy"
@@ -1,4 +1,5 @@
"""Setup never downloads packages and never turns on the shared Bluetooth radio."""
import json
from pathlib import Path
import tempfile
from types import SimpleNamespace
@@ -89,6 +90,23 @@ class BootstrapTest(unittest.TestCase):
self.assertFalse(any('bluetooth.service' in call for call in self.calls))
self.assertTrue(any('stop' in call and UNIT in call for call in self.calls))
def test_host_bridge_runs_in_the_isolated_runtime_without_systemd_units(self):
from openpilot.starpilot.galaxy_bluetooth.bridge.pairing import ensure_store
ensure_store(self.data / 'pairing.json')
child = Mock(pid=1234)
child.poll.return_value = None
with patch.dict('os.environ', SP_HOST_RUNTIME='1', SP_HOST_BLUETOOTH_PREFERENCE=str(self.root / 'bluetooth' / 'BluetoothEnabled'),
SP_HOST_GALAXY_BLE_DATA=str(self.data)), \
patch('openpilot.starpilot.bluetooth.radio_preference.RadioPreference.enabled', return_value=True), \
patch('openpilot.starpilot.galaxy_bluetooth.bridge.pairing.ensure_store'), \
patch('openpilot.starpilot.galaxy_bluetooth.bootstrap.subprocess.Popen', return_value=child) as spawn:
self.worker.step()
self.assertEqual([call.args[0][1].split('/')[-1] for call in spawn.call_args_list], ['lan_discovery.py', 'server.py'])
self.assertTrue(all(call.kwargs['cwd'] == self.bridge for call in spawn.call_args_list))
self.assertTrue(all('vendor' in call.kwargs['env']['PYTHONPATH'] for call in spawn.call_args_list))
self.assertFalse(self.calls)
self.assertEqual(json.loads((self.data / 'setup-status.json').read_text())['state'], 'waiting')
def test_only_bridge_and_discovery_start_when_radio_enabled(self):
self.installed()
with patch('openpilot.starpilot.bluetooth.radio_preference.RadioPreference.enabled', return_value=True):
+81
View File
@@ -40,6 +40,87 @@ an isolated host runtime. Each accepts an optional leading build-job count.
`SP_C3_COMPILE_ONLY=1` or `SP_C4_COMPILE_ONLY=1` prepares that runtime without
opening a window. Host settings are kept in its private Params store.
To run the manager-supervised desktop device, use `./device c3` or
`./device c4` (defaults to C4). This starts the real manager, which owns the
native UI and local/LAN Galaxy service in the same isolated host runtime. Cloud
registration is disabled; it needs no separate Galaxy process. The host
launcher completes the camera-dependent first-run tutorial so the off-road UI
opens directly. The full device command requires Linux; Bluetooth needs BlueZ.
It is verified on Ubuntu under WSL2; native Linux also needs systemd and a
working display. The separate `./c3` and `./c4` UI launchers also support macOS.
Stop it with Ctrl+C. Galaxy listens on port 8082.
For phone access from the LAN, put the following in `%UserProfile%\.wslconfig`
on Windows, then run `wsl --shutdown` in PowerShell to apply it:
```ini
[wsl2]
networkingMode=mirrored
```
For Bluetooth, also set `[boot]` and `systemd=true` in Ubuntu's `/etc/wsl.conf`
before shutting down WSL.
Run these rules once in **Administrator PowerShell**. TCP 8082 serves Galaxy;
UDP 5353 lets the phone app discover it:
```powershell
New-NetFirewallHyperVRule -Name "OpenpilotGalaxy" -DisplayName "Openpilot Galaxy" `
-Direction Inbound -VMCreatorId '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' `
-Protocol TCP -LocalPorts 8082
New-NetFirewallHyperVRule -Name "OpenpilotGalaxyDiscovery" -DisplayName "Openpilot Galaxy discovery" `
-Direction Inbound -VMCreatorId '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' `
-Protocol UDP -LocalPorts 5353
```
After restarting WSL, `./device c4` shows the laptop's LAN IP in Network
settings. Open `http://<laptop-LAN-IP>:8082` on the phone. On the Windows laptop,
use `http://127.0.0.1:8082`. Mirrored networking provides LAN access without a
port proxy. [Microsoft's WSL networking guide](https://learn.microsoft.com/windows/wsl/networking)
documents the mode and firewall rules.
For Bluetooth on Ubuntu 24.04 WSL with this laptop's Intel `8087:0033` adapter:
1. In **Administrator PowerShell**, run `usbipd list`, then
`usbipd bind --force --busid 1-10`. `1-10` was this laptop's Intel adapter;
use its current bus ID if the list changes. A Windows restart may be needed
to release the built-in adapter.
2. From the repository root in Ubuntu, run
`sudo ./tools/setup_wsl_intel_bluetooth.sh` once. It installs BlueZ and
Intel firmware, configures the WSL firmware loader at boot, and grants the
current Linux user limited Bluetooth service controls.
3. Launch `./device c4`; it attaches the already shared adapter to WSL.
If USB/IP reports *Device in error state* after a Windows restart, run
`pnputil /enable-device "USB\VID_8087&PID_0033\5&3B784BF&0&10"` in
Administrator PowerShell, using the current instance ID from `usbipd state`
if it changes, then launch again.
Check `bluetoothctl list` in Ubuntu for a controller, then turn Bluetooth on in
the device settings. Galaxy's phone bridge advertises as `Galaxy`. Windows cannot
use the USB Bluetooth adapter while it is attached to WSL. Galaxy over LAN works
without Bluetooth. [Microsoft's USB guide](https://learn.microsoft.com/windows/wsl/connect-usb)
documents the USB/IP handoff.
### Return Bluetooth to Windows
Stop `./device` first (Ctrl+C or close its window). In **Administrator
PowerShell**, find the Intel adapter's current bus ID with `usbipd list`, then
run:
```powershell
usbipd unbind --busid 1-10
usbipd list
```
Replace `1-10` if its bus ID changed. The adapter should show `Not shared` and
appear again in Windows Bluetooth settings. Because setup used `bind --force`,
`usbipd detach` alone may leave Windows Bluetooth unavailable; `unbind` removes
the sharing registration and detaches it if needed. If Windows still shows the
adapter as disabled, enable it in Device Manager or run
`pnputil /enable-device "USB\VID_8087&PID_0033\5&3B784BF&0&10"` with its current
instance ID, then restart Windows if necessary. This does not affect Galaxy's
LAN access.
For a deterministic presentation capture with supplied sample state:
```sh
+2 -1
View File
@@ -63,7 +63,8 @@ def seed_developer_defaults() -> None:
defaults = (("HasAcceptedTerms", terms_version), ("CompletedTrainingVersion", training_version),
("LanguageSetting", "en"), ("OpenpilotEnabledToggle", True), ("IsDriverViewEnabled", False))
for key, value in defaults:
if params.get(key) is None:
current = params.get(key)
if current is None or key == "CompletedTrainingVersion" and current in (b"0", "0"):
params.put(key, value, block=True)
@@ -70,6 +70,8 @@ class TestHostLaunch(unittest.TestCase):
self.assertIs(params.get("OpenpilotEnabledToggle"), True)
self.assertEqual(params.get("LanguageSetting"), "en")
self.assertIs(params.get("IsDriverViewEnabled"), False)
from openpilot.common.version import training_version
self.assertEqual(params.get("CompletedTrainingVersion"), training_version)
params.put_bool("OpenpilotEnabledToggle", False, block=True)
params.put("LanguageSetting", "es-ES", block=True)
host_launch.seed_developer_defaults()
+2
View File
@@ -45,6 +45,8 @@ def unblock_stdout() -> None:
os._exit(exit_status)
def save_bootlog():
if os.getenv("SP_HOST_RUNTIME") == "1":
return # Desktop manager runs from source; the device-only bootlog binary is absent.
# copy current params
tmp = tempfile.mkdtemp()
params_dirname = pathlib.Path(Params().get_param_path()).name
+46
View File
@@ -0,0 +1,46 @@
"""Start the manager with desktop-only UI defaults in its isolated host runtime."""
import json
import os
from pathlib import Path
import subprocess
import sys
def attach_shared_bluetooth() -> None:
"""Reattach a previously shared Windows Bluetooth adapter after a WSL reboot."""
try:
state = subprocess.run(['usbipd.exe', 'state'], capture_output=True, text=True, timeout=5, check=True)
devices = json.loads(state.stdout)['Devices']
except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError):
return
if not isinstance(devices, list):
return
busid = next((device['BusId'] for device in devices if isinstance(device, dict) and isinstance(device.get('BusId'), str) and
device.get('PersistedGuid') and not device.get('ClientIPAddress') and
'bluetooth' in str(device.get('Description', '')).lower()), None)
if busid is not None:
try:
subprocess.run(['usbipd.exe', 'attach', '--wsl', '--busid', busid], timeout=30, check=True)
except (OSError, subprocess.SubprocessError):
print('Bluetooth USB attach failed; check usbipd list', file=sys.stderr)
def main() -> None:
if os.getenv('WSL_DISTRO_NAME'):
attach_shared_bluetooth()
try:
mode = subprocess.run(['wslinfo', '--networking-mode'], capture_output=True, text=True, timeout=1, check=True)
os.environ['SP_HOST_NETWORK_MODE'] = mode.stdout.strip()
except (OSError, subprocess.SubprocessError):
os.environ['SP_HOST_NETWORK_MODE'] = 'unknown'
for path in (Path(os.environ['SP_HOST_GALAXY_BLE_DATA']), Path(os.environ['SP_HOST_BLUETOOTH_PREFERENCE']).parent):
path.mkdir(mode=0o700, parents=True, exist_ok=True)
path.chmod(0o700)
from openpilot.starpilot.ui.host_launch import seed_developer_defaults
from openpilot.system.manager.manager import main as manager_main
manager_main(after_init=seed_developer_defaults)
if __name__ == "__main__":
main()
+8 -1
View File
@@ -183,6 +183,11 @@ def manager_thread() -> None:
managed_processes['modeld'].observe(sm, started)
ensure_running(managed_processes.values(), started, params=params, CP=sm['carParams'], not_run=ignore)
if os.getenv("SP_HOST_RUNTIME") == "1" and managed_processes['ui'].proc is not None and \
managed_processes['ui'].proc.exitcode is not None:
cloudlog.info("Host UI exited; stopping desktop device")
break
running = ' '.join("{}{}\u001b[0m".format("\u001b[32m" if p.proc.is_alive() else "\u001b[31m", p.name)
for p in managed_processes.values() if p.proc)
print(running)
@@ -213,11 +218,13 @@ def manager_thread() -> None:
break
def main() -> None:
def main(*, after_init=None) -> None:
sentry.init()
sentry.set_tag("daemon", "manager")
try:
manager_init()
if after_init is not None:
after_init()
except Exception:
sentry.capture_exception()
sentry.flush()
+4 -2
View File
@@ -136,12 +136,14 @@ procs = [
PythonProcess("sensord", "openpilot.system.sensord.sensord", sensord_run, enabled=not PC, restart_on_exit=True),
PythonProcess("sentry_motion", "openpilot.starpilot.sentry_mode.runtime", sentry_motion, enabled=not PC),
PythonProcess("vasm_monitor", "openpilot.starpilot.spot_monitor.runtime", vasm_monitor, enabled=not PC),
PythonProcess("ui", "openpilot.selfdrive.ui.ui", always_run, restart_on_exit=True),
PythonProcess("ui", "openpilot.selfdrive.ui.ui", always_run,
restart_on_exit=os.getenv("SP_HOST_RUNTIME") != "1"),
PythonProcess("navigationd", "openpilot.starpilot.navigation.runtime", always_run),
PythonProcess("navtilesd", "openpilot.starpilot.navigation.navtilesd", always_run, enabled=platform.system() == "Linux"),
PythonProcess("galaxy", "openpilot.starpilot.galaxy.managed", galaxy_local),
PythonProcess("galaxy_phone", "openpilot.starpilot.galaxy_bluetooth.bootstrap", galaxy_local,
enabled=COMMA_HARDWARE and not os.environ.get("OPENPILOT_PREFIX"), restart_on_exit=True),
enabled=(COMMA_HARDWARE and not os.environ.get("OPENPILOT_PREFIX")) or os.getenv("SP_HOST_RUNTIME") == "1",
restart_on_exit=True),
PythonProcess("replay_owner", "openpilot.starpilot.replay.owner", only_offroad,
enabled=platform.system() == "Linux", restart_on_exit=True),
PythonProcess("galaxy_hotspot", "openpilot.starpilot.galaxy.hotspot_daemon", galaxy_local,
+10
View File
@@ -1,8 +1,10 @@
import atexit
import os
import threading
import time
import uuid
import subprocess
from urllib.parse import urlsplit
from collections.abc import Callable
from dataclasses import dataclass, replace
from enum import IntEnum
@@ -180,6 +182,10 @@ class WifiManager:
self._wifi_state: WifiState = WifiState()
self._user_epoch: int = 0
self._ipv4_address: str = ""
self._host_local_access = None
if os.getenv('SP_HOST_RUNTIME') == '1':
from openpilot.starpilot.galaxy.local_access import LocalAccess
self._host_local_access = LocalAccess()
self._current_network_metered: MeteredType = MeteredType.UNKNOWN
self._tethering_password: str = ""
self._ipv4_forward = False
@@ -286,6 +292,10 @@ class WifiManager:
@property
def ipv4_address(self) -> str:
host_access = getattr(self, '_host_local_access', None)
if host_access is not None:
addresses = host_access.snapshot()['addresses']
return (urlsplit(addresses[0]['url']).hostname or '') if addresses else ''
return self._ipv4_address
@property
+27 -2
View File
@@ -1,5 +1,6 @@
import fcntl
import hashlib
import json
import os
import py_compile
from pathlib import Path
@@ -12,6 +13,7 @@ import unittest
from unittest.mock import Mock, patch
from tools.host_runtime import GALAXY, LIVE_RELOAD, LIVE_TAG, LIVE_VERSION, HostRuntime, file_lock, main, parse
from openpilot.system.manager.host_device import attach_shared_bluetooth
class TestDeveloperRuntime(unittest.TestCase):
@@ -80,19 +82,41 @@ class TestDeveloperRuntime(unittest.TestCase):
def test_command_build_prepares_mpc_import_dependency(self):
solver = 'openpilot/selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so'
for command in ('c3', 'c4', 'onroad', 'galaxy', 'python', 'pytest', 'shell'):
for command in ('c3', 'c4', 'device', 'onroad', 'galaxy', 'python', 'pytest', 'shell'):
with self.subTest(command=command), patch('tools.host_runtime.run') as build:
self.runtime.build(command, 4)
self.assertIn(solver, build.call_args.args[0])
def test_device_launches_the_manager_in_parked_host_runtime(self):
with patch.object(self.runtime, 'environment', return_value={}), patch.object(self.runtime, 'private_ipc'), \
patch('tools.host_runtime.subprocess.Popen') as spawn:
spawn.return_value.__enter__.return_value.wait.return_value = 0
self.assertEqual(self.runtime.launch('device', 1, []), 0)
argv, = spawn.call_args.args
self.assertEqual(argv[-2:], ['-m', 'openpilot.system.manager.host_device'])
self.assertEqual(spawn.call_args.kwargs['env']['SP_HOST_PARKED'], '1')
def test_device_reattaches_only_a_shared_bluetooth_adapter(self):
devices = {'Devices': [
{'BusId': '1-8', 'Description': 'Camera', 'PersistedGuid': 'shared', 'ClientIPAddress': None},
{'BusId': '1-10', 'Description': 'Intel Bluetooth', 'PersistedGuid': 'shared', 'ClientIPAddress': None},
]}
with patch('openpilot.system.manager.host_device.subprocess.run', side_effect=[Mock(stdout=json.dumps(devices)), Mock()]) as run:
attach_shared_bluetooth()
self.assertEqual(run.call_args.args[0], ['usbipd.exe', 'attach', '--wsl', '--busid', '1-10'])
def test_cache_environment_ignores_external_imports_and_settings(self):
with patch.dict(os.environ, PYTHONPATH='/elsewhere', PARAMS_ROOT='/real/params', OPENPILOT_PREFIX='real', CC='cross-compiler'):
with patch.dict(os.environ, PYTHONPATH='/elsewhere', PARAMS_ROOT='/real/params', OPENPILOT_PREFIX='real', CC='cross-compiler',
SP_HOST_NETWORK_MODE='nat'):
env = self.runtime.environment()
self.assertNotIn('/elsewhere', env['PYTHONPATH'])
self.assertNotIn('SP_HOST_NETWORK_MODE', env)
self.assertEqual(env['CC'], '/usr/bin/clang')
self.assertTrue(Path(env['PARAMS_ROOT']).is_relative_to(self.runtime.cache))
self.assertNotEqual(env['OPENPILOT_PREFIX'], 'real')
self.assertEqual(Path(env['ANDROID_AUTO_DIR']), self.runtime.cache / 'android_auto')
self.assertEqual(Path(env['SP_HOST_GALAXY_BLE_DATA']), self.runtime.cache / 'galaxy-ble')
self.assertEqual(Path(env['SP_HOST_BLUETOOTH_PREFERENCE']), self.runtime.cache / 'bluetooth' / 'BluetoothEnabled')
second = HostRuntime(self.root, 'shared', system='Darwin', machine='arm64')
cabana = HostRuntime(self.root, 'cabana', system='Darwin', machine='arm64')
self.assertEqual(second.prefix, self.runtime.prefix)
@@ -113,6 +137,7 @@ class TestDeveloperRuntime(unittest.TestCase):
def test_command_parsing_preserves_tool_arguments(self):
self.assertEqual(parse(['c3', '8', '--demo']), ('c3', 8, ['--demo']))
self.assertEqual(parse(['device'])[0], 'device')
self.assertEqual(parse(['juggle', '4', '--demo']), ('plotjuggler', 4, ['--demo']))
self.assertEqual(parse(['python', '-c', 'print(1)'])[2], ['-c', 'print(1)'])
self.assertEqual(parse(['pytest', '-n', '2'])[2], ['-n', '2'])
+11 -4
View File
@@ -25,6 +25,7 @@ HELP = """Usage: ./dev <command> [args...]
./tool <command> [args...] ./tools/host <command> [args...]
c3 / c4 Build and launch the large / compact StarPilot Raylib UI.
device Run the full local manager with its UI and Galaxy services (Linux).
onroad Build replay and launch route UI(s); see ./onroad --help.
replay Build and run upstream replay.
cabana Build and run Cabana in its own concurrent cache.
@@ -46,14 +47,14 @@ Native build artifacts, editable installs and Params stay in that cache.
Shared commands wait while another shared command is running; Cabana is separate.
./build remains the Linux ARM64 device build, including ./build --panda.
"""
COMMANDS = {'c3', 'c4', 'onroad', 'replay', 'cabana', 'plotjuggler', 'galaxy', 'python', 'pytest', 'shell', 'sync'}
COMMANDS = {'c3', 'c4', 'device', 'onroad', 'replay', 'cabana', 'plotjuggler', 'galaxy', 'python', 'pytest', 'shell', 'sync'}
BUCKET_ALIASES = dict.fromkeys(('shared', 'default', 'ui', 'c3', 'c4', 'onroad', 'replay', 'shell', 'plotjuggler', 'juggle'), 'shared')
BUCKET_ALIASES['cabana'] = 'cabana'
VENDORS = ('msgq_repo', 'opendbc_repo', 'rednose_repo', 'teleoprtc_repo', 'tinygrad_repo', 'jetlink_repo')
# Never inherit a device/cross compiler or another project's editable imports.
REMOVE_ENV = ('PYTHONPATH', 'PYTHONHOME', 'VIRTUAL_ENV', 'CC', 'CXX', 'CFLAGS', 'CXXFLAGS', 'CPPFLAGS', 'LDFLAGS',
'LD_LIBRARY_PATH', 'DYLD_LIBRARY_PATH', 'PKG_CONFIG_PATH', 'CPATH', 'LIBRARY_PATH',
'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'SP_HOST_PARKED')
'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'SP_HOST_PARKED', 'SP_HOST_NETWORK_MODE')
EXCLUDE_DIRS = {'.git', '.venv', '.venv-linux-arm64', '.host_runtime', '.comma_sysroot', '.cache', '__pycache__'}
NATIVE_SUFFIXES = {'.a', '.so', '.dylib', '.o', '.os'}
GALAXY = Path('openpilot/starpilot/galaxy')
@@ -295,6 +296,8 @@ class HostRuntime:
BASEDIR=str(self.work), PWD=str(self.work), PARAMS_ROOT=str(inside(self.cache / 'params', self.cache)),
OPENPILOT_PREFIX=self.prefix, SP_HOST_PREFIX=self.prefix,
SP_HOST_PARAMS_ROOT=str(self.cache / 'params'), SP_HOST_RUNTIME='1',
SP_HOST_GALAXY_BLE_DATA=str(self.cache / 'galaxy-ble'),
SP_HOST_BLUETOOTH_PREFERENCE=str(self.cache / 'bluetooth' / 'BluetoothEnabled'),
ANDROID_AUTO_DIR=str(inside(self.cache / 'android_auto', self.cache)),
SP_SCONS_CACHE_DIR=str(self.cache / 'scons-cache'),
SCONS_CACHE=str(self.cache / 'scons-cache'),
@@ -328,7 +331,7 @@ class HostRuntime:
def build(self, command, jobs):
params = 'openpilot/common/libparams_c' + ('.dylib' if self.system == 'Darwin' else '.so')
targets = [params, 'msgq_repo/msgq/ipc_pyx.so', 'msgq_repo/msgq/visionipc/visionipc_pyx.so']
if command in ('c3', 'c4', 'onroad', 'galaxy', 'python', 'pytest', 'shell'):
if command in ('c3', 'c4', 'device', 'onroad', 'galaxy', 'python', 'pytest', 'shell'):
targets += ['rednose_repo/rednose/helpers/ekf_sym_pyx.so', 'openpilot/selfdrive/locationd',
'openpilot/selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so']
if command in ('onroad', 'replay', 'cabana'):
@@ -343,7 +346,7 @@ class HostRuntime:
def launch(self, command, jobs, arguments):
env = self.environment()
if command in ('c3', 'c4', 'galaxy'):
if command in ('c3', 'c4', 'galaxy', 'device'):
env['SP_HOST_PARKED'] = '1' # No car or drive-state publishers: parked-only setup stays usable.
python = self.venv / 'bin/python'
if command in ('c3', 'c4', 'onroad'):
@@ -355,6 +358,8 @@ class HostRuntime:
argv = [python, '-m', 'openpilot.tools.plotjuggler.juggle', *arguments]
elif command == 'galaxy':
argv = [python, '-m', 'openpilot.starpilot.galaxy.server', *arguments]
elif command == 'device':
argv = [python, '-m', 'openpilot.system.manager.host_device', *arguments]
elif command == 'pytest':
argv = [python, '-m', 'pytest', *arguments]
env.setdefault('SCALE', '1') # UI imports must not probe a monitor in test workers.
@@ -545,6 +550,8 @@ def main(arguments=None):
if command == 'help':
print(HELP)
return 0
if command == 'device' and platform.system() != 'Linux':
raise RuntimeError('The full desktop device requires Linux; use ./c3 or ./c4 for the macOS UI.')
if command in ('c3', 'c4', 'onroad') and args in (['--help'], ['-h']):
helper = ROOT / ('openpilot/tools/replay/onroad.py' if command == 'onroad' else 'openpilot/starpilot/ui/host_launch.py')
profile = [] if command == 'onroad' else ['large' if command == 'c3' else 'compact']
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $EUID -ne 0 || -z "${SUDO_USER:-}" || ! -x /usr/bin/wslinfo ]]; then
echo 'Run this from Ubuntu WSL with sudo.' >&2
exit 1
fi
source /etc/os-release
if [[ $ID != ubuntu || $VERSION_ID != 24.04 ]]; then
echo 'This setup supports Ubuntu 24.04 under WSL.' >&2
exit 1
fi
if [[ ! $SUDO_USER =~ ^[a-z_][a-z0-9_-]*$ ]]; then
echo 'Unsupported Linux username for Bluetooth service permissions.' >&2
exit 1
fi
apt-get update
apt-get install -y bluez linux-firmware-intel-wireless zstd usbutils
install -d -m 0755 /usr/local/libexec
cat > /usr/local/libexec/openpilot-wsl-bluetooth-firmware <<'SH'
#!/bin/sh
set -eu
source_dir=/usr/lib/firmware/intel
shared_dir=/mnt/wsl/firmware/intel
mkdir -p "$shared_dir"
for file in ibt-0040-0041.sfi ibt-0040-0041.ddc; do
zstd -dc "$source_dir/$file.zst" > "$shared_dir/$file"
done
printf '%s' /mnt/wsl/firmware > /sys/module/firmware_class/parameters/path
SH
chmod 0755 /usr/local/libexec/openpilot-wsl-bluetooth-firmware
cat > /etc/systemd/system/openpilot-wsl-bluetooth-firmware.service <<'UNIT'
[Unit]
Description=Expose Intel Bluetooth firmware to the WSL kernel
ConditionPathExists=/usr/lib/firmware/intel/ibt-0040-0041.sfi.zst
After=local-fs.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/libexec/openpilot-wsl-bluetooth-firmware
[Install]
WantedBy=multi-user.target
UNIT
sudoers_file=$(mktemp)
trap 'rm -f "$sudoers_file"' EXIT
printf '%s\n' "$SUDO_USER ALL=(root) NOPASSWD: /usr/bin/systemctl start bluetooth.service, /usr/bin/systemctl stop bluetooth.service, /usr/bin/btmgmt --index 0 class *" > "$sudoers_file"
visudo -cf "$sudoers_file"
install -m 0440 "$sudoers_file" /etc/sudoers.d/starpilot-bluetooth
systemctl daemon-reload
systemctl enable --now openpilot-wsl-bluetooth-firmware.service