mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-31 19:13:52 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88a3b5e2a5 | |||
| 8ac6b224d7 |
@@ -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
|
||||
@@ -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,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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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']
|
||||
|
||||
Executable
+57
@@ -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
|
||||
Reference in New Issue
Block a user