This commit is contained in:
firestar5683
2026-08-29 21:07:30 -05:00
parent 02a45f13a2
commit 147b9df247
40 changed files with 3902 additions and 33 deletions
+9
View File
@@ -16,6 +16,9 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"AthenadUploadQueue", {PERSISTENT, JSON}},
{"AthenadRecentlyViewedRoutes", {PERSISTENT, STRING}},
{"BootCount", {PERSISTENT, INT}},
{"BluetoothAudioAddress", {PERSISTENT, STRING}},
{"BluetoothAudioTestActive", {CLEAR_ON_MANAGER_START | DONT_LOG, BOOL}},
{"BluetoothEnabled", {PERSISTENT, BOOL, "0"}},
{"CalibrationParams", {PERSISTENT, BYTES}},
{"CameraDebugExpGain", {CLEAR_ON_MANAGER_START, STRING}},
{"CameraDebugExpTime", {CLEAR_ON_MANAGER_START, STRING}},
@@ -84,6 +87,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"IsTakingSnapshot", {CLEAR_ON_MANAGER_START, BOOL}},
{"IsTestedBranch", {CLEAR_ON_MANAGER_START, BOOL}},
{"JoystickDebugMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}},
{"JoystickControlDevice", {PERSISTENT, STRING}},
{"LanguageSetting", {PERSISTENT, STRING, "main_en"}},
{"LastAthenaPingTime", {CLEAR_ON_MANAGER_START, INT}},
{"LastGPSPosition", {PERSISTENT, STRING}},
@@ -365,6 +369,11 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"StarPilotCarParamsPersistent", {PERSISTENT, BYTES, "", ""}},
{"StarPilotDongleId", {PERSISTENT | DONT_LOG, STRING, "", "", 0}},
{"StarPilotFavoriteSlots", {PERSISTENT, JSON, "[]", "[]", 1}},
{"WheelControlLearnSlot", {CLEAR_ON_MANAGER_START | DONT_LOG, INT}},
{"WheelControlMappings", {PERSISTENT, JSON, "[]", "[]", 1}},
{"WheelControlStatus", {CLEAR_ON_MANAGER_START | DONT_LOG, JSON, "{}", "{}"}},
{"WheelControlTestActive", {CLEAR_ON_MANAGER_START | DONT_LOG, BOOL}},
{"WheelControlsEnabled", {PERSISTENT, BOOL, "0"}},
{"StarPilotStats", {PERSISTENT | DONT_LOG, JSON, "{}", "{}"}},
{"StarPilotTogglesUpdated", {CLEAR_ON_MANAGER_START, BOOL, "0", "0"}},
{"GoatScream", {PERSISTENT, BOOL, "0", "0", 1, SETTINGS_SIMPLE}},
+1 -1
View File
@@ -21,7 +21,7 @@ fi
export QCOM_PRIORITY=12
if [ -z "$AGNOS_VERSION" ]; then
export AGNOS_VERSION="19.6.12"
export AGNOS_VERSION="19.6.13"
fi
if [ -z "$AGNOS_ACCEPTED_VERSIONS" ]; then
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<path d="M28 4v56M28 4l20 16-35 28M13 16l35 28-20 16" fill="none" stroke="#fff" stroke-width="5.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 218 B

+4
View File
@@ -160,6 +160,8 @@ class MiciHomeLayout(Widget):
self._current_model_name = "default"
self._mode_status_atom = ModeStatusAtom()
self._bluetooth_icon = IconWidget("icons_mici/settings/bluetooth.png", (38, 38), opacity=0.9)
self._bluetooth_icon.set_visible(False)
self._egpu_icon = IconWidget("icons_mici/egpu.png", (50, 37))
self._egpu_icon_gray = IconWidget("icons_mici/egpu_gray.png", (50, 37))
self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46))
@@ -167,6 +169,7 @@ class MiciHomeLayout(Widget):
self._status_bar_layout = HBoxLayout([
IconWidget("icons_mici/settings.png", (48, 48), opacity=0.9),
NetworkIcon(),
self._bluetooth_icon,
self._mode_status_atom,
self._egpu_icon,
self._egpu_icon_gray,
@@ -187,6 +190,7 @@ class MiciHomeLayout(Widget):
def _update_params(self):
self._experimental_mode = ui_state.params.get_bool("ExperimentalMode")
self._bluetooth_icon.set_visible(ui_state.params.get_bool("BluetoothEnabled"))
self._mode_status_atom.refresh()
def _clean_model_name(value: str) -> str:
@@ -0,0 +1,228 @@
import pyray as rl
from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import ForgetButton, LoadingAnimation
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR
from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, BigDialog, BigInputDialog, BigMultiOptionDialog
from openpilot.system.ui.lib.application import FontWeight, MousePos, gui_app
from openpilot.system.ui.lib.bluetooth_manager import BluetoothManager
from openpilot.system.ui.widgets.scroller import NavScroller
class BluetoothDeviceButton(BigButton):
LABEL_PADDING = 98
LABEL_WIDTH = 402 - 98 - 28
SUB_LABEL_WIDTH = 402 - BigButton.LABEL_HORIZONTAL_PADDING * 2
def __init__(self, device, manager: BluetoothManager, icon: rl.Texture, selected_audio: str, offroad: bool):
super().__init__(device.name, "", scroll=True)
self.device = device
self._manager = manager
self._icon = icon
self._offroad = offroad
self._selected_audio = selected_audio
self._check_txt = gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 32, 32)
self._forget_btn = ForgetButton(lambda: self._manager.forget(self.device.address))
self.update_device(device, selected_audio, offroad)
def _get_label_font_size(self):
return 48
@property
def _show_forget_btn(self):
return self.device.paired and self._offroad
def update_device(self, device, selected_audio: str, offroad: bool):
self.device = device
self._selected_audio = selected_audio
self._offroad = offroad
states = ["connected" if device.connected else "paired" if device.paired else "pair"]
if device.audio:
states.append("audio selected" if selected_audio.upper() == device.address.upper() else "audio")
if device.controller:
states.append("controller")
self.set_value(" / ".join(states))
def _handle_mouse_release(self, mouse_pos: MousePos):
if self._show_forget_btn and rl.check_collision_point_rec(mouse_pos, self._forget_btn.rect):
return
super()._handle_mouse_release(mouse_pos)
def set_touch_valid_callback(self, touch_callback):
super().set_touch_valid_callback(lambda: touch_callback() and not self._forget_btn.is_pressed)
self._forget_btn.set_touch_valid_callback(touch_callback)
def _draw_content(self, btn_y: float):
self._label.set_color(LABEL_COLOR)
label_rect = rl.Rectangle(self._rect.x + self.LABEL_PADDING, btn_y + self.LABEL_VERTICAL_PADDING,
self.LABEL_WIDTH, self._rect.height - self.LABEL_VERTICAL_PADDING * 2)
self._label.render(label_rect)
sub_label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING
label_y = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING
sub_label_w = self.SUB_LABEL_WIDTH - (self._forget_btn.rect.width if self._show_forget_btn else 0)
sub_label_height = self._sub_label.get_content_height(sub_label_w)
if self.device.connected:
check_y = int(label_y - sub_label_height + (sub_label_height - self._check_txt.height) / 2)
rl.draw_texture_ex(self._check_txt, rl.Vector2(sub_label_x, check_y), 0.0, 1.0,
rl.Color(255, 255, 255, int(255 * 0.585)))
sub_label_x += self._check_txt.width + 14
self._sub_label.set_color(rl.Color(255, 255, 255, int(255 * 0.9)))
self._sub_label.set_font_weight(FontWeight.SEMI_BOLD)
self._sub_label.render(rl.Rectangle(sub_label_x, label_y - sub_label_height, sub_label_w, sub_label_height))
rl.draw_texture_ex(self._icon, (self._rect.x + 30, btn_y + 30), 0.0, 1.0, rl.WHITE)
if self._show_forget_btn:
self._forget_btn.render(rl.Rectangle(
self._rect.x + self._rect.width - self._forget_btn.rect.width,
btn_y + self._rect.height - self._forget_btn.rect.height,
self._forget_btn.rect.width,
self._forget_btn.rect.height,
))
class BluetoothScanningButton(BigButton):
def __init__(self):
super().__init__("", "searching for devices")
self.set_enabled(False)
self._loading_animation = LoadingAnimation()
def _draw_content(self, btn_y: float):
super()._draw_content(btn_y)
animation = self._loading_animation
animation.set_position(self._rect.x + self._rect.width - animation.rect.width - 40,
btn_y + self._rect.height - animation.rect.height - 30)
animation.render()
class BluetoothAudioTestDialog(BigDialog):
def __init__(self, manager: BluetoothManager, icon: rl.Texture):
super().__init__("starting", "The test sound is sent at NOW", icon)
self._manager = manager
def _render(self, rect):
self._card.set_text(self._manager.audio_test_phase())
super()._render(rect)
class BluetoothLayoutMici(NavScroller):
def __init__(self):
super().__init__()
self._manager = BluetoothManager()
self._last_signature = None
self._last_prompt_id = ""
self._bluetooth_icon = gui_app.texture("icons_mici/settings/bluetooth.png", 56, 56)
self._dialog_icon = gui_app.texture("icons_mici/settings/bluetooth.png", 64, 64)
self._power_btn = BigButton("bluetooth", "off", self._dialog_icon, scroll=True)
self._power_btn.set_click_callback(self._toggle_power)
self._scan_btn = BigButton("scan for devices", "scan", self._dialog_icon, scroll=True)
self._scan_btn.set_click_callback(lambda: self._manager.set_scanning(True))
self._scanning_btn = BluetoothScanningButton()
self._rebuild()
def show_event(self):
super().show_event()
self._manager.set_active(True)
gui_app.add_nav_stack_tick(self._tick)
def hide_event(self):
self._manager.set_active(False)
gui_app.remove_nav_stack_tick(self._tick)
super().hide_event()
def _toggle_power(self):
self._manager.set_power(not self._manager.status.enabled)
def _rebuild(self):
status = self._manager.status
self._power_btn.set_value("on" if status.enabled else "off")
self._power_btn.set_enabled(status.available and status.offroad)
self._scan_btn.set_enabled(status.enabled and status.offroad)
items = [self._power_btn]
for device in status.devices:
button = BluetoothDeviceButton(device, self._manager, self._bluetooth_icon, status.selected_audio, status.offroad)
button.set_enabled(status.offroad or device.paired)
button.set_click_callback(lambda selected=device: self._device_actions(selected))
items.append(button)
if status.enabled:
items.append(self._scanning_btn if status.discovering else self._scan_btn)
self._scroller.items.clear()
self._scroller.add_widgets(items)
def _device_actions(self, device):
if not device.paired:
self._manager.pair(device.address)
return
options = ["disconnect" if device.connected else "connect"]
if device.audio:
selected = self._manager.status.selected_audio.upper() == device.address.upper()
options.append("stop using for audio" if selected else "use for audio")
if device.connected and self._manager.status.offroad:
options.append("test audio")
if self._manager.status.offroad:
options.append("forget")
dialog_holder = {}
def apply():
action = dialog_holder["dialog"].get_selected_option()
if action == "connect":
self._manager.connect(device.address)
elif action == "disconnect":
self._manager.disconnect(device.address)
elif action == "use for audio":
self._manager.select_audio(device.address)
elif action == "stop using for audio":
self._manager.select_audio("")
elif action == "test audio":
self._manager.test_audio(device.address)
gui_app.push_widget(BluetoothAudioTestDialog(self._manager, self._dialog_icon))
elif action == "forget":
self._manager.forget(device.address)
dialog = BigMultiOptionDialog(options=options, default=options[0], right_btn_callback=apply)
dialog_holder["dialog"] = dialog
gui_app.push_widget(dialog)
def _handle_prompt(self):
prompt = self._manager.status.prompt
if prompt is None or prompt.get("id") == self._last_prompt_id:
return
self._last_prompt_id = prompt["id"]
name = prompt.get("name") or "Bluetooth device"
value = str(prompt.get("value") or "")
if prompt.get("display_only"):
gui_app.push_widget(BigDialog(name, value))
elif prompt.get("kind") in ("pin", "passkey"):
gui_app.push_widget(BigInputDialog(
f"enter {prompt['kind']} for {name}",
minimum_length=1,
confirm_callback=lambda response: self._manager.respond(prompt["id"], True, response),
))
else:
title = f"slide to pair\n{name}"
if value:
title += f"\n{value}"
gui_app.push_widget(BigConfirmationDialog(
title,
self._dialog_icon,
lambda: self._manager.respond(prompt["id"], True),
))
def _tick(self):
status = self._manager.status
signature = (
status.available,
status.enabled,
status.powered,
status.discovering,
status.offroad,
status.selected_audio,
tuple((device.address, device.name, device.paired, device.connected, device.audio, device.controller) for device in status.devices),
)
if signature != self._last_signature:
self._last_signature = signature
self._rebuild()
error = self._manager.consume_error()
if error:
gui_app.push_widget(BigDialog("Bluetooth", error))
self._handle_prompt()
+5 -1
View File
@@ -93,6 +93,10 @@ class EngagedConfirmationButton(BigButton):
self.set_click_callback(lambda: _engaged_confirmation_click(callback, action_text, icon, exit_on_confirm=exit_on_confirm, red=red))
def _request_user_reboot(params: Params) -> None:
params.put_bool("DoUserReboot", True)
class DeviceInfoLayoutMici(Widget):
def __init__(self):
super().__init__()
@@ -227,7 +231,7 @@ class DeviceLayoutMici(NavScroller):
ui_state.params.put_bool("DoShutdown", True)
def reboot_callback():
ui_state.params.put_bool("DoReboot", True)
_request_user_reboot(ui_state.params)
def reset_calibration_callback():
params = ui_state.params
@@ -3,6 +3,7 @@ from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle
from openpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici
from openpilot.selfdrive.ui.mici.layouts.settings.network.network_layout import NetworkLayoutMici
from openpilot.selfdrive.ui.mici.layouts.settings.bluetooth import BluetoothLayoutMici
from openpilot.selfdrive.ui.mici.layouts.settings.vehicle import VehicleLayoutMici
from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici, PairBigButton
from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici
@@ -67,6 +68,10 @@ class SettingsLayout(NavScroller):
network_btn = SettingsBigButton("network", "", gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 76, 56))
network_btn.set_click_callback(lambda: gui_app.push_widget(network_panel))
bluetooth_panel = BluetoothLayoutMici()
bluetooth_btn = SettingsBigButton("bluetooth", "", gui_app.texture("icons_mici/settings/bluetooth.png", 64, 64))
bluetooth_btn.set_click_callback(lambda: gui_app.push_widget(bluetooth_panel))
vehicle_panel = VehicleLayoutMici()
vehicle_btn = SettingsBigButton("vehicle", "", gui_app.texture("icons_mici/settings/vehicle.png", 64, 57))
vehicle_btn.set_click_callback(lambda: gui_app.push_widget(vehicle_panel))
@@ -94,6 +99,7 @@ class SettingsLayout(NavScroller):
self._scroller.add_widgets([
toggles_btn,
network_btn,
bluetooth_btn,
self._force_drive_state_btn,
vehicle_btn,
device_btn,
@@ -0,0 +1,17 @@
from openpilot.selfdrive.ui.mici.layouts.settings.device import _request_user_reboot
class FakeParams:
def __init__(self):
self.writes = []
def put_bool(self, key, value):
self.writes.append((key, value))
def test_mici_user_reboot_bypasses_automatic_reboot_deferral():
params = FakeParams()
_request_user_reboot(params)
assert params.writes == [("DoUserReboot", True)]
+25 -1
View File
@@ -17,6 +17,7 @@ from openpilot.system import micd
from openpilot.system.hardware import HARDWARE
from openpilot.starpilot.common.starpilot_variables import ACTIVE_THEME_PATH, ERROR_LOGS_PATH, RANDOM_EVENTS_PATH, get_starpilot_toggles
from openpilot.starpilot.system.bluetooth.audio import BluetoothAudioSink
SAMPLE_RATE = 48000
SAMPLE_BUFFER = 4096 # (approx 100ms)
@@ -130,6 +131,11 @@ class Soundd:
self.auto_volume = MIN_VOLUME
self.pending_stream_status = None
self.bluetooth_audio = None
self.bluetooth_supported = HARDWARE.get_device_type() in ("tici", "tizi", "mici")
self.bluetooth_params = Params() if self.bluetooth_supported else None
self.bluetooth_enabled = False
self.bluetooth_last_check = 0.0
self.previous_sound_pack = None
self.previous_sound_source_signature = None
@@ -213,7 +219,24 @@ class Soundd:
def callback(self, data_out: np.ndarray, frames: int, time, status) -> None:
if status:
self.pending_stream_status = status
data_out[:frames, 0] = self.get_sound_data(frames)
samples = self.get_sound_data(frames)
bluetooth_healthy = self.bluetooth_audio.submit(samples) if self.bluetooth_audio is not None else False
data_out[:frames, 0] = 0.0 if bluetooth_healthy else samples
def update_bluetooth_audio(self) -> None:
if not self.bluetooth_supported or time.monotonic() - self.bluetooth_last_check < 1.0:
return
self.bluetooth_last_check = time.monotonic()
enabled = self.bluetooth_params.get_bool("BluetoothEnabled")
if enabled == self.bluetooth_enabled:
return
self.bluetooth_enabled = enabled
if enabled:
self.bluetooth_audio = BluetoothAudioSink(params=self.bluetooth_params)
elif self.bluetooth_audio is not None:
sink = self.bluetooth_audio
self.bluetooth_audio = None
sink.close()
def update_alert(self, new_alert):
current_alert_played_once = self.current_alert == AudibleAlert.none or self.current_sound_frame > len(self.loaded_sounds[self.current_alert])
@@ -313,6 +336,7 @@ class Soundd:
while True:
sm.update(0)
self.update_bluetooth_audio()
if self.pending_stream_status is not None:
status = self.pending_stream_status
+17
View File
@@ -4,11 +4,13 @@ from cereal.messaging import SubMaster, PubMaster
from openpilot.selfdrive.ui.soundd import (
SELFDRIVE_STATE_TIMEOUT,
SOUNDD_SERVICES,
Soundd,
check_selfdrive_timeout_alert,
is_turn_steering_limit_alert,
should_mute_turn_steering_limit_alert,
)
import numpy as np
import time
AudibleAlert = log.SelfdriveState.AudibleAlert
@@ -32,6 +34,21 @@ class TestSoundd:
assert not should_mute_turn_steering_limit_alert("steerSaturated/warning", 10.0, 0.0)
assert not should_mute_turn_steering_limit_alert("laneChangeBlocked/warning", 10.0, 25.0)
def test_bluetooth_audio_mutes_local_only_while_healthy(self):
soundd = Soundd.__new__(Soundd)
samples = np.array([0.25, -0.5], dtype=np.float32)
soundd.get_sound_data = lambda _frames: samples
data_out = np.zeros((2, 1), dtype=np.float32)
soundd.pending_stream_status = None
soundd.bluetooth_audio = type("Sink", (), {"submit": lambda self, _samples: True})()
soundd.callback(data_out, 2, None, None)
np.testing.assert_array_equal(data_out[:, 0], np.zeros(2, dtype=np.float32))
soundd.bluetooth_audio = type("Sink", (), {"submit": lambda self, _samples: False})()
soundd.callback(data_out, 2, None, None)
np.testing.assert_array_equal(data_out[:, 0], samples)
def test_check_selfdrive_timeout_alert(self):
sm = SubMaster(['selfdriveState'])
pm = PubMaster(['selfdriveState'])
+3
View File
@@ -0,0 +1,3 @@
from .protocol import BluetoothClient, BluetoothDevice, BluetoothStatus
__all__ = ["BluetoothClient", "BluetoothDevice", "BluetoothStatus"]
+149
View File
@@ -0,0 +1,149 @@
import queue
import re
import shutil
import subprocess
import threading
import time
import numpy as np
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
ADDRESS_RE = re.compile(r"^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$")
class BluetoothAudioSink:
def __init__(self, params: Params | None = None, popen_factory=subprocess.Popen, start_thread: bool = True):
self.params = params or Params()
self._popen_factory = popen_factory
self._queue: queue.Queue[bytes] = queue.Queue(maxsize=3)
self._lock = threading.Lock()
self._process = None
self._address = ""
self._healthy = False
self._last_write = 0.0
self._exit = False
self._aplay = shutil.which("aplay")
self._thread = threading.Thread(target=self._run, daemon=True)
if start_thread:
self._thread.start()
@property
def healthy(self) -> bool:
if not self._lock.acquire(blocking=False):
return False
try:
process_alive = self._process is not None and self._process.poll() is None
return self._healthy and process_alive and time.monotonic() - self._last_write < 1.0
finally:
self._lock.release()
def close(self) -> None:
self._exit = True
self._stop_process()
if self._thread.is_alive():
self._thread.join(timeout=1.0)
def desired_address(self) -> str:
if not self.params.get_bool("BluetoothEnabled"):
return ""
address = (self.params.get("BluetoothAudioAddress", encoding="utf-8") or "").strip().upper()
if isinstance(address, bytes):
address = address.decode("utf-8", errors="ignore")
return address if ADDRESS_RE.fullmatch(address) else ""
@staticmethod
def pcm_bytes(samples: np.ndarray) -> bytes:
mono = np.clip(samples, -1.0, 1.0)
pcm = (mono * 32767.0).astype(np.int16)
return np.column_stack((pcm, pcm)).tobytes()
def submit(self, samples: np.ndarray) -> bool:
if self._aplay is None or not self._address:
return False
try:
self._queue.put_nowait(self.pcm_bytes(samples))
except queue.Full:
with self._lock:
self._healthy = False
return False
return self.healthy
def _start_process(self, address: str) -> None:
command = [
self._aplay,
"-q",
"-D", f"bluealsa:DEV={address},PROFILE=a2dp",
"-t", "raw",
"-f", "S16_LE",
"-c", "2",
"-r", "48000",
]
process = self._popen_factory(command, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, bufsize=0)
with self._lock:
self._process = process
self._address = address
self._healthy = False
self._last_write = 0.0
def _stop_process(self) -> None:
with self._lock:
process = self._process
self._process = None
self._address = ""
self._healthy = False
self._last_write = 0.0
if process is not None:
try:
process.terminate()
process.wait(timeout=1.0)
except Exception:
try:
process.kill()
except Exception:
pass
while True:
try:
self._queue.get_nowait()
except queue.Empty:
break
def _run(self) -> None:
while not self._exit:
address = self.desired_address()
with self._lock:
current_address = self._address
process = self._process
if not address or self._aplay is None:
if process is not None:
self._stop_process()
time.sleep(0.2)
continue
if process is None or process.poll() is not None or address != current_address:
self._stop_process()
try:
self._start_process(address)
except Exception:
cloudlog.exception("Unable to start Bluetooth audio output")
time.sleep(1.0)
continue
try:
block = self._queue.get(timeout=0.5)
except queue.Empty:
continue
try:
with self._lock:
process = self._process
if process is None or process.stdin is None:
raise BrokenPipeError
process.stdin.write(block)
with self._lock:
self._healthy = True
self._last_write = time.monotonic()
except Exception:
cloudlog.warning("Bluetooth audio output disconnected")
self._stop_process()
time.sleep(0.5)
+250
View File
@@ -0,0 +1,250 @@
import threading
import time
import uuid
from typing import Any
from jeepney import DBusAddress, MatchRule, new_error, new_method_call, new_method_return
from jeepney.io.threading import DBusRouter, open_dbus_connection
from jeepney.low_level import HeaderFields, MessageType
from jeepney.wrappers import Properties
from openpilot.starpilot.system.bluetooth.protocol import device_capabilities, show_pairing_device
BLUEZ = "org.bluez"
OBJECT_MANAGER = "org.freedesktop.DBus.ObjectManager"
ADAPTER_IFACE = "org.bluez.Adapter1"
DEVICE_IFACE = "org.bluez.Device1"
AGENT_MANAGER_IFACE = "org.bluez.AgentManager1"
AGENT_IFACE = "org.bluez.Agent1"
AGENT_PATH = "/link/firestar/starpilot/agent"
def unwrap_variant(value: Any) -> Any:
if isinstance(value, tuple) and len(value) == 2 and isinstance(value[0], str):
return unwrap_variant(value[1])
if isinstance(value, dict):
return {key: unwrap_variant(item) for key, item in value.items()}
if isinstance(value, list):
return [unwrap_variant(item) for item in value]
return value
class PairingAgent:
def __init__(self):
self._condition = threading.Condition()
self._prompt: dict[str, Any] | None = None
self._response: tuple[bool, str] | None = None
@property
def prompt(self) -> dict[str, Any] | None:
with self._condition:
return dict(self._prompt) if self._prompt is not None else None
def clear(self) -> None:
with self._condition:
self._prompt = None
self._response = None
self._condition.notify_all()
def display(self, kind: str, device_path: str, value: str) -> None:
with self._condition:
self._prompt = {"id": uuid.uuid4().hex, "kind": kind, "device_path": device_path, "value": value, "display_only": True}
def request(self, kind: str, device_path: str, value: str = "", timeout: float = 60.0) -> tuple[bool, str]:
prompt_id = uuid.uuid4().hex
with self._condition:
self._response = None
self._prompt = {"id": prompt_id, "kind": kind, "device_path": device_path, "value": value, "display_only": False}
deadline = time.monotonic() + timeout
while self._response is None:
remaining = deadline - time.monotonic()
if remaining <= 0:
self._prompt = None
return False, ""
self._condition.wait(remaining)
response = self._response
self._response = None
self._prompt = None
return response
def respond(self, prompt_id: str, accepted: bool, value: str = "") -> bool:
with self._condition:
if self._prompt is None or self._prompt.get("id") != prompt_id or self._prompt.get("display_only"):
return False
self._response = accepted, value
self._condition.notify_all()
return True
class BlueZClient:
def __init__(self):
self.router = DBusRouter(open_dbus_connection(bus="SYSTEM"))
self.agent = PairingAgent()
self._agent_filter = self.router.filter(MatchRule(type="method_call", interface=AGENT_IFACE, path=AGENT_PATH), bufsize=20)
self._agent_queue = self._agent_filter.__enter__()
self._agent_thread = threading.Thread(target=self._agent_loop, daemon=True)
self._agent_thread.start()
self._register_agent()
def close(self) -> None:
try:
self._call("/org/bluez", AGENT_MANAGER_IFACE, "UnregisterAgent", "o", (AGENT_PATH,))
except Exception:
pass
self._agent_filter.__exit__(None, None, None)
self.router.close()
def _call(self, path: str, interface: str, member: str, signature: str | None = None, body: tuple = (), timeout: float = 15.0):
address = DBusAddress(path, bus_name=BLUEZ, interface=interface)
message = new_method_call(address, member, signature, body) if signature is not None else new_method_call(address, member)
reply = self.router.send_and_get_reply(message, timeout=timeout)
if reply.header.message_type == MessageType.error:
error_name = reply.header.fields.get(HeaderFields.error_name, "org.bluez.Error.Failed")
detail = reply.body[0] if reply.body else error_name
raise RuntimeError(str(detail))
return reply.body
def _register_agent(self) -> None:
self._call("/org/bluez", AGENT_MANAGER_IFACE, "RegisterAgent", "os", (AGENT_PATH, "KeyboardDisplay"))
self._call("/org/bluez", AGENT_MANAGER_IFACE, "RequestDefaultAgent", "o", (AGENT_PATH,))
def _agent_loop(self) -> None:
while True:
message = self._agent_queue.get()
member = message.header.fields.get(HeaderFields.member, "")
try:
response_signature = None
response_body: tuple = ()
device_path = str(message.body[0]) if message.body else ""
if member == "Release":
self.agent.clear()
elif member == "RequestPinCode":
accepted, value = self.agent.request("pin", device_path)
if not accepted:
raise PermissionError
response_signature, response_body = "s", (value,)
elif member == "DisplayPinCode":
self.agent.display("display_pin", device_path, str(message.body[1]))
elif member == "RequestPasskey":
accepted, value = self.agent.request("passkey", device_path)
if not accepted:
raise PermissionError
response_signature, response_body = "u", (int(value),)
elif member == "DisplayPasskey":
self.agent.display("display_passkey", device_path, f"{int(message.body[1]):06d}")
elif member == "RequestConfirmation":
accepted, _ = self.agent.request("confirmation", device_path, f"{int(message.body[1]):06d}")
if not accepted:
raise PermissionError
elif member in ("RequestAuthorization", "AuthorizeService"):
accepted, _ = self.agent.request("authorization", device_path)
if not accepted:
raise PermissionError
elif member == "Cancel":
self.agent.clear()
else:
raise RuntimeError(f"Unsupported pairing request: {member}")
self.router.send(new_method_return(message, response_signature, response_body))
except PermissionError:
self.router.send(new_error(message, "org.bluez.Error.Rejected", "s", ("Pairing rejected",)))
except Exception as error:
self.router.send(new_error(message, "org.bluez.Error.Canceled", "s", (str(error),)))
def managed_objects(self) -> dict[str, dict[str, dict[str, Any]]]:
body = self._call("/", OBJECT_MANAGER, "GetManagedObjects")
return unwrap_variant(body[0]) if body else {}
def adapter(self, objects: dict[str, Any] | None = None) -> tuple[str, dict[str, Any]]:
objects = self.managed_objects() if objects is None else objects
for path, interfaces in objects.items():
if ADAPTER_IFACE in interfaces:
return path, interfaces[ADAPTER_IFACE]
raise RuntimeError("Bluetooth adapter is not available")
def devices(self, objects: dict[str, Any] | None = None) -> list[dict[str, Any]]:
objects = self.managed_objects() if objects is None else objects
devices = []
for path, interfaces in objects.items():
if DEVICE_IFACE not in interfaces:
continue
props = interfaces[DEVICE_IFACE]
uuids = [str(value).lower() for value in props.get("UUIDs", [])]
audio, controller = device_capabilities(uuids, int(props.get("Class", 0)), str(props.get("Icon", "")))
device = {
"path": path,
"address": str(props.get("Address", "")),
"name": str(props.get("Alias") or props.get("Name") or props.get("Address") or "Unknown device"),
"paired": bool(props.get("Paired", False)),
"trusted": bool(props.get("Trusted", False)),
"connected": bool(props.get("Connected", False)),
"blocked": bool(props.get("Blocked", False)),
"rssi": int(props["RSSI"]) if "RSSI" in props else None,
"uuids": uuids,
"audio": audio,
"controller": controller,
}
if show_pairing_device(device["address"], device["name"], device["paired"], device["trusted"], device["connected"],
device["blocked"], audio, controller):
devices.append(device)
return sorted(devices, key=lambda device: (not device["connected"], not device["paired"], -(device["rssi"] or -127), device["name"].lower()))
def status(self) -> dict[str, Any]:
objects = self.managed_objects()
_, adapter = self.adapter(objects)
return {
"powered": bool(adapter.get("Powered", False)),
"discovering": bool(adapter.get("Discovering", False)),
"devices": self.devices(objects),
"prompt": self.agent.prompt,
}
def set_powered(self, powered: bool) -> None:
path, _ = self.adapter()
address = DBusAddress(path, bus_name=BLUEZ, interface=ADAPTER_IFACE)
reply = self.router.send_and_get_reply(Properties(address).set("Powered", "b", powered), timeout=10.0)
if reply.header.message_type == MessageType.error:
raise RuntimeError(str(reply.body[0] if reply.body else "Unable to change Bluetooth power"))
def start_discovery(self) -> None:
path, _ = self.adapter()
self._call(path, ADAPTER_IFACE, "StartDiscovery")
def stop_discovery(self) -> None:
path, props = self.adapter()
if props.get("Discovering", False):
self._call(path, ADAPTER_IFACE, "StopDiscovery")
def device_for_address(self, address: str) -> dict[str, Any]:
normalized = address.upper()
for device in self.devices():
if device["address"].upper() == normalized:
return device
raise RuntimeError(f"Bluetooth device {address} was not found")
def set_device_property(self, address: str, name: str, signature: str, value: Any) -> None:
device = self.device_for_address(address)
dbus_address = DBusAddress(device["path"], bus_name=BLUEZ, interface=DEVICE_IFACE)
reply = self.router.send_and_get_reply(Properties(dbus_address).set(name, signature, value), timeout=10.0)
if reply.header.message_type == MessageType.error:
raise RuntimeError(str(reply.body[0] if reply.body else f"Unable to set {name}"))
def pair(self, address: str) -> None:
device = self.device_for_address(address)
self._call(device["path"], DEVICE_IFACE, "Pair", timeout=90.0)
self.set_device_property(address, "Trusted", "b", True)
self.agent.clear()
def connect(self, address: str) -> None:
device = self.device_for_address(address)
self._call(device["path"], DEVICE_IFACE, "Connect", timeout=30.0)
def disconnect(self, address: str) -> None:
device = self.device_for_address(address)
self._call(device["path"], DEVICE_IFACE, "Disconnect", timeout=15.0)
def remove(self, address: str) -> None:
adapter_path, _ = self.adapter()
device = self.device_for_address(address)
self._call(adapter_path, ADAPTER_IFACE, "RemoveDevice", "o", (device["path"],))
+295
View File
@@ -0,0 +1,295 @@
import json
import os
import socketserver
import threading
import time
from typing import Any
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.starpilot.system.bluetooth.bluez import BlueZClient
from openpilot.starpilot.system.bluetooth.protocol import BLUETOOTH_SOCKET_PATH
from openpilot.starpilot.system.bluetooth.radio import BluetoothRadio
OFFROAD_COMMANDS = {"set_power", "start_scan", "stop_scan", "pair", "forget", "test_audio", "pairing_response"}
SCAN_DURATION = 20.0
AUDIO_TEST_START_DELAY = 3.0
AUDIO_TEST_HOLD_TIME = 3.0
class BluetoothController:
def __init__(self, params: Params | None = None, bluez_factory=BlueZClient, radio: BluetoothRadio | None = None,
params_memory: Params | None = None, sleep=time.sleep):
self.params = params or Params()
self.params_memory = params_memory or Params(memory=True)
self._bluez_factory = bluez_factory
self._radio = radio or BluetoothRadio()
self._lock = threading.RLock()
self._bluez: BlueZClient | None = None
self._pairing_address = ""
self._pairing_error = ""
self._last_reconnect = 0.0
self._scan_deadline = 0.0
self._audio_test_deadline = 0.0
self._sleep = sleep
self.params.remove("BluetoothAudioTestActive")
self.params_memory.remove("TestAlert")
def close(self) -> None:
self.params.remove("BluetoothAudioTestActive")
self.params_memory.remove("TestAlert")
with self._lock:
if self._bluez is not None:
self._bluez.close()
self._bluez = None
if not self.params.get_bool("BluetoothEnabled"):
try:
self._radio.stop()
except Exception:
pass
def _client(self) -> BlueZClient:
with self._lock:
if self._bluez is None:
if not self.params.get_bool("BluetoothEnabled"):
raise RuntimeError("Bluetooth is disabled")
self._radio.start()
self._bluez = self._bluez_factory()
self._bluez.set_powered(True)
return self._bluez
def _reset_client(self) -> None:
with self._lock:
if self._bluez is not None:
try:
self._bluez.close()
except Exception:
pass
self._bluez = None
def _offroad(self) -> bool:
return self.params.get_bool("IsOffroad")
def status(self) -> dict[str, Any]:
result = {
"available": self._radio.available,
"enabled": self.params.get_bool("BluetoothEnabled"),
"powered": False,
"discovering": False,
"offroad": self._offroad(),
"selected_audio": self.params.get("BluetoothAudioAddress", encoding="utf-8") or "",
"devices": [],
"prompt": None,
"error": self._pairing_error,
"pairing_address": self._pairing_address,
}
if not result["enabled"]:
return result
try:
result.update(self._client().status())
result["available"] = True
prompt = result.get("prompt")
if prompt is not None and self._pairing_address:
prompt["address"] = self._pairing_address
device = next((item for item in result["devices"] if item["address"].upper() == self._pairing_address.upper()), None)
prompt["name"] = device["name"] if device else self._pairing_address
except Exception as error:
result["error"] = str(error)
self._reset_client()
return result
def _require_offroad(self, command: str) -> None:
if command in OFFROAD_COMMANDS and not self._offroad():
raise RuntimeError("Bluetooth settings can only be changed offroad")
def _pair_worker(self, address: str) -> None:
try:
self._client().pair(address)
status = self._client().device_for_address(address)
if status.get("audio") and not self.params.get("BluetoothAudioAddress", encoding="utf-8"):
self.params.put("BluetoothAudioAddress", address)
self._pairing_error = ""
except Exception as error:
self._pairing_error = str(error)
cloudlog.exception("Bluetooth pairing failed")
finally:
self._pairing_address = ""
def _test_audio_worker(self, address: str, deadline: float) -> None:
try:
self._sleep(max(0.0, deadline - time.monotonic()))
if (not self._offroad() or not self.params.get_bool("BluetoothEnabled") or
(self.params.get("BluetoothAudioAddress", encoding="utf-8") or "").upper() != address.upper()):
return
device = self._client().device_for_address(address)
if not device.get("connected"):
return
self.params_memory.put("TestAlert", "engage")
self._sleep(AUDIO_TEST_HOLD_TIME)
except Exception:
cloudlog.exception("Bluetooth audio test failed")
finally:
self._audio_test_deadline = 0.0
self.params.remove("BluetoothAudioTestActive")
def handle(self, request: dict[str, Any]) -> dict[str, Any]:
command = str(request.get("command", ""))
if command == "status":
return {"status": self.status()}
self._require_offroad(command)
address = str(request.get("address", ""))
if command == "set_power":
enabled = bool(request.get("enabled", False))
if enabled:
try:
self.params.put_bool("BluetoothEnabled", True)
self._client()
except Exception:
self.params.put_bool("BluetoothEnabled", False)
self._reset_client()
try:
self._radio.stop()
except Exception:
pass
raise
else:
try:
with self._lock:
client = self._bluez
if client is not None:
client.set_powered(False)
finally:
self._reset_client()
self._radio.stop()
self.params.remove("BluetoothAudioAddress")
self.params.put_bool("BluetoothEnabled", False)
self._scan_deadline = 0.0
elif command == "start_scan":
if not self.params.get_bool("BluetoothEnabled"):
raise RuntimeError("Enable Bluetooth before scanning")
self._client().start_discovery()
self._scan_deadline = time.monotonic() + SCAN_DURATION
elif command == "stop_scan":
self._client().stop_discovery()
self._scan_deadline = 0.0
elif command == "pair":
if self._pairing_address:
raise RuntimeError("Another Bluetooth device is already pairing")
self._pairing_address = address
self._pairing_error = ""
threading.Thread(target=self._pair_worker, args=(address,), daemon=True).start()
elif command == "connect":
self._client().connect(address)
elif command == "disconnect":
self._client().disconnect(address)
elif command == "forget":
self._client().remove(address)
if (self.params.get("BluetoothAudioAddress", encoding="utf-8") or "").upper() == address.upper():
self.params.remove("BluetoothAudioAddress")
elif command == "select_audio":
if address:
device = self._client().device_for_address(address)
if not device.get("audio"):
raise RuntimeError("Selected device does not support Bluetooth audio")
self.params.put("BluetoothAudioAddress", address)
else:
self.params.remove("BluetoothAudioAddress")
elif command == "test_audio":
if self.params.get_bool("BluetoothAudioTestActive"):
raise RuntimeError("Bluetooth audio test is already playing")
device = self._client().device_for_address(address)
if not device.get("audio"):
raise RuntimeError("Selected device does not support Bluetooth audio")
if not device.get("paired") or not device.get("connected"):
raise RuntimeError("Connect the Bluetooth audio device before testing")
self.params.put("BluetoothAudioAddress", address)
self.params.put_bool("BluetoothAudioTestActive", True)
deadline = time.monotonic() + AUDIO_TEST_START_DELAY
self._audio_test_deadline = deadline
threading.Thread(target=self._test_audio_worker, args=(address, deadline), daemon=True).start()
return {"audio_test_delay_ms": max(0, round((deadline - time.monotonic()) * 1000))}
elif command == "pairing_response":
if not self._client().agent.respond(str(request.get("prompt_id", "")), bool(request.get("accepted", False)), str(request.get("value", ""))):
raise RuntimeError("Pairing request is no longer active")
else:
raise RuntimeError(f"Unknown Bluetooth command: {command}")
return {}
def _maintain_scan(self, status: dict[str, Any], now: float) -> None:
if not status["discovering"]:
self._scan_deadline = 0.0
elif not status["offroad"] or (self._scan_deadline and now >= self._scan_deadline):
self._client().stop_discovery()
self._scan_deadline = 0.0
def maintain_connections(self) -> None:
while True:
time.sleep(2)
if not self.params.get_bool("BluetoothEnabled"):
continue
try:
status = self.status()
if not status["available"] or not status["powered"]:
continue
now = time.monotonic()
self._maintain_scan(status, now)
if self._pairing_address or now - self._last_reconnect < 15:
continue
self._last_reconnect = now
selected = str(status["selected_audio"])
candidates = [device for device in status["devices"] if device["paired"] and device["trusted"] and not device["connected"]]
candidates.sort(key=lambda device: device["address"].upper() != selected.upper())
for device in candidates:
if device["audio"] or device["controller"]:
try:
self._client().connect(device["address"])
except Exception:
cloudlog.warning(f"Bluetooth reconnect failed for {device['address']}")
except Exception:
cloudlog.exception("Bluetooth connection maintenance failed")
class BluetoothRequestHandler(socketserver.StreamRequestHandler):
def handle(self) -> None:
try:
raw = self.rfile.readline(1024 * 1024)
request = json.loads(raw)
payload = self.server.controller.handle(request)
response = {"ok": True, **payload}
except Exception as error:
response = {"ok": False, "error": str(error)}
self.wfile.write(json.dumps(response, separators=(",", ":")).encode() + b"\n")
class BluetoothServer(socketserver.ThreadingUnixStreamServer):
daemon_threads = True
def __init__(self, socket_path: str, controller: BluetoothController):
self.controller = controller
super().__init__(socket_path, BluetoothRequestHandler)
def main() -> None:
try:
os.unlink(BLUETOOTH_SOCKET_PATH)
except FileNotFoundError:
pass
controller = BluetoothController()
threading.Thread(target=controller.maintain_connections, daemon=True).start()
try:
with BluetoothServer(BLUETOOTH_SOCKET_PATH, controller) as server:
os.chmod(BLUETOOTH_SOCKET_PATH, 0o660)
server.serve_forever()
finally:
controller.close()
try:
os.unlink(BLUETOOTH_SOCKET_PATH)
except FileNotFoundError:
pass
if __name__ == "__main__":
main()
+196
View File
@@ -0,0 +1,196 @@
import json
import os
import socket
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from openpilot.common.params import Params
BLUETOOTH_SOCKET_PATH = "/tmp/starpilot-bluetooth.sock"
BLUETOOTH_RADIO_HELPER = "/usr/comma/bluetooth-radio"
A2DP_SINK_UUID = "0000110b-0000-1000-8000-00805f9b34fb"
HID_UUID = "00001124-0000-1000-8000-00805f9b34fb"
HOG_UUID = "00001812-0000-1000-8000-00805f9b34fb"
COMMAND_TIMEOUTS = {
"set_power": 55.0,
"start_scan": 20.0,
"stop_scan": 20.0,
"connect": 35.0,
"disconnect": 20.0,
"forget": 20.0,
"test_audio": 10.0,
}
@dataclass(frozen=True)
class BluetoothDevice:
address: str
name: str
paired: bool = False
trusted: bool = False
connected: bool = False
blocked: bool = False
rssi: int | None = None
uuids: tuple[str, ...] = ()
audio: bool = False
controller: bool = False
@classmethod
def from_dict(cls, value: dict[str, Any]) -> "BluetoothDevice":
return cls(
address=str(value.get("address", "")),
name=str(value.get("name", value.get("address", "Unknown device"))),
paired=bool(value.get("paired", False)),
trusted=bool(value.get("trusted", False)),
connected=bool(value.get("connected", False)),
blocked=bool(value.get("blocked", False)),
rssi=int(value["rssi"]) if value.get("rssi") is not None else None,
uuids=tuple(str(uuid).lower() for uuid in value.get("uuids", ())),
audio=bool(value.get("audio", False)),
controller=bool(value.get("controller", False)),
)
@dataclass(frozen=True)
class BluetoothStatus:
available: bool = False
enabled: bool = False
powered: bool = False
discovering: bool = False
offroad: bool = False
selected_audio: str = ""
devices: tuple[BluetoothDevice, ...] = ()
prompt: dict[str, Any] | None = None
error: str = ""
@classmethod
def from_dict(cls, value: dict[str, Any]) -> "BluetoothStatus":
return cls(
available=bool(value.get("available", False)),
enabled=bool(value.get("enabled", False)),
powered=bool(value.get("powered", False)),
discovering=bool(value.get("discovering", False)),
offroad=bool(value.get("offroad", False)),
selected_audio=str(value.get("selected_audio", "")),
devices=tuple(BluetoothDevice.from_dict(device) for device in value.get("devices", ())),
prompt=value.get("prompt"),
error=str(value.get("error", "")),
)
def device_capabilities(uuids: list[str] | tuple[str, ...], bluetooth_class: int = 0, icon: str = "") -> tuple[bool, bool]:
normalized = {str(uuid).lower() for uuid in uuids}
major_class = (int(bluetooth_class) >> 8) & 0x1F
audio = A2DP_SINK_UUID in normalized or major_class == 0x04 or icon in {"audio-card", "audio-headphones", "audio-headset"}
controller = HID_UUID in normalized or HOG_UUID in normalized or major_class == 0x05 or icon in {"input-gaming", "input-mouse", "input-keyboard"}
return audio, controller
def show_pairing_device(address: str, name: str, paired: bool, trusted: bool, connected: bool, blocked: bool,
audio: bool, controller: bool) -> bool:
known = paired or trusted or connected
named = bool(name) and name not in {address, "Unknown device"}
return known or (named and not blocked and (audio or controller))
class BluetoothClient:
def __init__(self, socket_path: str = BLUETOOTH_SOCKET_PATH, timeout: float = 5.0):
self.socket_path = socket_path
self.timeout = timeout
def call(self, command: str, **payload: Any) -> dict[str, Any]:
request = json.dumps({"command": command, **payload}, separators=(",", ":")).encode() + b"\n"
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(max(self.timeout, COMMAND_TIMEOUTS.get(command, 0.0)))
sock.connect(self.socket_path)
sock.sendall(request)
response = bytearray()
while not response.endswith(b"\n"):
chunk = sock.recv(65536)
if not chunk:
break
response.extend(chunk)
if not response:
raise RuntimeError("Bluetooth service returned no response")
result = json.loads(response)
if not result.get("ok", False):
raise RuntimeError(str(result.get("error", "Bluetooth operation failed")))
return result
def status(self) -> BluetoothStatus:
if os.getenv("SP_ALLOW_DESKTOP_FAKE_BLUETOOTH", "0") == "1" and not os.path.exists(self.socket_path):
return BluetoothStatus(
available=True,
enabled=True,
powered=True,
discovering=False,
offroad=True,
devices=(
BluetoothDevice("00:11:22:33:44:55", "Bluetooth Speaker", paired=True, connected=True, audio=True),
BluetoothDevice("AA:BB:CC:DD:EE:FF", "Game Controller", controller=True, rssi=-48),
),
)
if not os.path.exists(self.socket_path):
params = Params()
return BluetoothStatus(
available=Path(BLUETOOTH_RADIO_HELPER).is_file(),
enabled=params.get_bool("BluetoothEnabled"),
offroad=params.get_bool("IsOffroad"),
selected_audio=params.get("BluetoothAudioAddress", encoding="utf-8") or "",
)
return BluetoothStatus.from_dict(self.call("status").get("status", {}))
@staticmethod
def serialize_status(status: BluetoothStatus) -> dict[str, Any]:
return asdict(status)
def set_power(self, enabled: bool) -> None:
params = Params()
bootstrap = enabled and not os.path.exists(self.socket_path)
if bootstrap:
params.put_bool("BluetoothEnabled", True)
deadline = time.monotonic() + max(self.timeout, 10.0)
while not os.path.exists(self.socket_path):
if time.monotonic() >= deadline:
params.put_bool("BluetoothEnabled", False)
raise RuntimeError("Bluetooth service did not start")
time.sleep(0.05)
try:
self.call("set_power", enabled=enabled)
except Exception:
if bootstrap:
params.put_bool("BluetoothEnabled", False)
raise
def start_scan(self) -> None:
self.call("start_scan")
def stop_scan(self) -> None:
self.call("stop_scan")
def pair(self, address: str) -> None:
self.call("pair", address=address)
def connect(self, address: str) -> None:
self.call("connect", address=address)
def disconnect(self, address: str) -> None:
self.call("disconnect", address=address)
def forget(self, address: str) -> None:
self.call("forget", address=address)
def select_audio(self, address: str) -> None:
self.call("select_audio", address=address)
def test_audio(self, address: str) -> float:
result = self.call("test_audio", address=address)
return max(0.0, float(result.get("audio_test_delay_ms", 0)) / 1000.0)
def respond(self, prompt_id: str, accepted: bool, value: str = "") -> None:
self.call("pairing_response", prompt_id=prompt_id, accepted=accepted, value=value)
+34
View File
@@ -0,0 +1,34 @@
import subprocess
import time
from pathlib import Path
RADIO_HELPER = "/usr/comma/bluetooth-radio"
class BluetoothRadio:
def __init__(self, helper: str = RADIO_HELPER):
self.helper = helper
@property
def available(self) -> bool:
return Path(self.helper).is_file()
@property
def ready(self) -> bool:
return Path("/sys/class/bluetooth/hci0").exists()
def start(self, timeout: float = 50.0) -> None:
if not self.available:
raise RuntimeError("Bluetooth radio support is not installed")
subprocess.run(["sudo", "-n", "systemctl", "start", "starpilot-bluetooth-radio.service"], check=True, timeout=timeout)
deadline = time.monotonic() + timeout
while not self.ready:
if time.monotonic() >= deadline:
raise RuntimeError("Bluetooth radio did not become ready")
time.sleep(0.1)
def stop(self, timeout: float = 10.0) -> None:
if self.available:
subprocess.run(["sudo", "-n", "systemctl", "stop", "starpilot-bluetooth-radio.service"], check=True, timeout=timeout)
@@ -0,0 +1,275 @@
import io
import threading
import time
import numpy as np
import pytest
from openpilot.starpilot.system.bluetooth.audio import BluetoothAudioSink
from openpilot.starpilot.system.bluetooth.bluez import PairingAgent
from openpilot.starpilot.system.bluetooth.daemon import BluetoothController
from openpilot.starpilot.system.bluetooth.protocol import A2DP_SINK_UUID, HID_UUID, BluetoothDevice, BluetoothStatus, device_capabilities, show_pairing_device
class FakeParams:
def __init__(self, **values):
self.values = values
def get_bool(self, key):
return bool(self.values.get(key, False))
def get(self, key, encoding=None, **_kwargs):
value = self.values.get(key)
return value.decode(encoding) if encoding and isinstance(value, bytes) else value
def put_bool(self, key, value):
self.values[key] = value
def put(self, key, value):
self.values[key] = value
def remove(self, key):
self.values.pop(key, None)
class FakeAgent:
def __init__(self):
self.responses = []
def respond(self, prompt_id, accepted, value):
self.responses.append((prompt_id, accepted, value))
return prompt_id == "prompt"
class FakeBlueZ:
def __init__(self):
self.agent = FakeAgent()
self.powered = False
self.discovering = False
self.closed = False
self.actions = []
self.device = {
"address": "00:11:22:33:44:55",
"name": "Speaker",
"paired": True,
"trusted": True,
"connected": False,
"audio": True,
"controller": False,
}
def close(self):
self.closed = True
def set_powered(self, powered):
self.powered = powered
def status(self):
return {"powered": self.powered, "discovering": self.discovering, "devices": [dict(self.device)], "prompt": None}
def start_discovery(self):
self.discovering = True
def stop_discovery(self):
self.discovering = False
def device_for_address(self, _address):
return dict(self.device)
def pair(self, address):
self.actions.append(("pair", address))
def connect(self, address):
self.actions.append(("connect", address))
def disconnect(self, address):
self.actions.append(("disconnect", address))
def remove(self, address):
self.actions.append(("remove", address))
class FakeRadio:
available = True
ready = True
def __init__(self):
self.starts = 0
self.stops = 0
def start(self):
self.starts += 1
def stop(self):
self.stops += 1
class FakeProcess:
def __init__(self):
self.stdin = io.BytesIO()
self.stopped = False
def poll(self):
return 0 if self.stopped else None
def terminate(self):
self.stopped = True
def wait(self, timeout=None):
return 0
def kill(self):
self.stopped = True
def test_protocol_round_trip_and_capabilities():
audio, controller = device_capabilities([A2DP_SINK_UUID, HID_UUID])
assert audio and controller
status = BluetoothStatus.from_dict({
"available": True,
"enabled": True,
"devices": [{"address": "00:11:22:33:44:55", "name": "Combo", "uuids": [A2DP_SINK_UUID, HID_UUID], "audio": True, "controller": True}],
})
assert status.devices == (BluetoothDevice("00:11:22:33:44:55", "Combo", uuids=(A2DP_SINK_UUID, HID_UUID), audio=True, controller=True),)
def test_pairing_list_filters_anonymous_and_irrelevant_advertisements():
assert not show_pairing_device("00:11:22:33:44:55", "00:11:22:33:44:55", False, False, False, False, False, False)
assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False)
assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True)
assert show_pairing_device("00:11:22:33:44:55", "Known device", True, True, False, False, False, False)
def test_pairing_agent_accept_reject_and_timeout():
agent = PairingAgent()
result = []
worker = threading.Thread(target=lambda: result.append(agent.request("confirmation", "/device", "123456", timeout=1.0)))
worker.start()
deadline = time.monotonic() + 1.0
while agent.prompt is None and time.monotonic() < deadline:
time.sleep(0.01)
assert agent.prompt is not None
assert agent.respond(agent.prompt["id"], True)
worker.join(timeout=1.0)
assert result == [(True, "")]
assert agent.request("pin", "/device", timeout=0.01) == (False, "")
def test_disabled_status_does_not_start_radio_or_bluez():
params = FakeParams(IsOffroad=True, BluetoothEnabled=False)
radio = FakeRadio()
created = []
controller = BluetoothController(params, lambda: created.append(FakeBlueZ()) or created[-1], radio)
status = controller.status()
assert status["available"] and not status["enabled"] and not status["powered"]
assert radio.starts == 0 and created == []
def test_power_pair_audio_and_offroad_enforcement():
params = FakeParams(IsOffroad=True, BluetoothEnabled=False)
radio = FakeRadio()
clients = []
controller = BluetoothController(params, lambda: clients.append(FakeBlueZ()) or clients[-1], radio)
controller.handle({"command": "set_power", "enabled": True})
assert params.get_bool("BluetoothEnabled") and radio.starts == 1 and clients[0].powered
controller.handle({"command": "select_audio", "address": "00:11:22:33:44:55"})
assert params.get("BluetoothAudioAddress") == "00:11:22:33:44:55"
controller.handle({"command": "select_audio", "address": ""})
assert params.get("BluetoothAudioAddress") is None
assert clients[0].actions == []
params.values["IsOffroad"] = False
with pytest.raises(RuntimeError, match="offroad"):
controller.handle({"command": "start_scan"})
controller.handle({"command": "connect", "address": "00:11:22:33:44:55"})
assert clients[0].actions[-1] == ("connect", "00:11:22:33:44:55")
params.values["IsOffroad"] = True
controller.handle({"command": "set_power", "enabled": False})
assert not params.get_bool("BluetoothEnabled") and radio.stops == 1 and clients[0].closed
def test_audio_uses_soundd_engage_alert_and_cleans_up():
params = FakeParams(IsOffroad=True, BluetoothEnabled=True)
params_memory = FakeParams()
client = FakeBlueZ()
client.device["connected"] = True
controller = BluetoothController(params, lambda: client, FakeRadio(), params_memory, sleep=lambda _delay: None)
result = controller.handle({"command": "test_audio", "address": client.device["address"]})
deadline = time.monotonic() + 1.0
while params.get_bool("BluetoothAudioTestActive") and time.monotonic() < deadline:
time.sleep(0.01)
assert params.get("BluetoothAudioAddress") == client.device["address"]
assert 2500 <= result["audio_test_delay_ms"] <= 3000
assert params_memory.get("TestAlert") == "engage"
assert not params.get_bool("BluetoothAudioTestActive")
def test_audio_requires_connected_device_and_offroad():
params = FakeParams(IsOffroad=True, BluetoothEnabled=True)
client = FakeBlueZ()
controller = BluetoothController(params, lambda: client, FakeRadio(), FakeParams())
with pytest.raises(RuntimeError, match="Connect"):
controller.handle({"command": "test_audio", "address": client.device["address"]})
params.values["IsOffroad"] = False
with pytest.raises(RuntimeError, match="offroad"):
controller.handle({"command": "test_audio", "address": client.device["address"]})
def test_scan_stops_after_timeout():
params = FakeParams(IsOffroad=True, BluetoothEnabled=True)
client = FakeBlueZ()
controller = BluetoothController(params, lambda: client, FakeRadio())
controller.handle({"command": "start_scan"})
assert client.discovering and controller._scan_deadline > time.monotonic()
controller._maintain_scan(controller.status(), controller._scan_deadline)
assert not client.discovering and controller._scan_deadline == 0.0
def test_audio_queue_is_nonblocking_and_falls_back():
params = FakeParams(BluetoothEnabled=True, BluetoothAudioAddress="00:11:22:33:44:55")
process = FakeProcess()
sink = BluetoothAudioSink(params, popen_factory=lambda *_args, **_kwargs: process, start_thread=False)
sink._aplay = "/usr/bin/aplay"
sink._thread = threading.Thread(target=sink._run, daemon=True)
sink._thread.start()
samples = np.array([-1.0, 0.0, 1.0], dtype=np.float32)
deadline = time.monotonic() + 1.0
while not sink._address and time.monotonic() < deadline:
time.sleep(0.01)
assert not sink.submit(samples)
deadline = time.monotonic() + 1.0
while not sink.healthy and time.monotonic() < deadline:
time.sleep(0.01)
assert sink.healthy
assert len(process.stdin.getvalue()) == 12
assert sink.submit(samples)
process.stopped = True
assert not sink.healthy
sink.close()
def test_full_audio_queue_immediately_restores_local_output():
params = FakeParams(BluetoothEnabled=True, BluetoothAudioAddress="00:11:22:33:44:55")
process = FakeProcess()
sink = BluetoothAudioSink(params, start_thread=False)
sink._aplay = "/usr/bin/aplay"
sink._address = "00:11:22:33:44:55"
sink._process = process
sink._healthy = True
sink._last_write = time.monotonic()
samples = np.zeros(3, dtype=np.float32)
assert sink.submit(samples)
assert sink.submit(samples)
assert sink.submit(samples)
assert not sink.submit(samples)
assert not sink.healthy
def test_audio_address_decodes_device_params_bytes():
params = FakeParams(BluetoothEnabled=True, BluetoothAudioAddress=b"00:11:22:33:44:55")
sink = BluetoothAudioSink(params, start_thread=False)
assert sink.desired_address() == "00:11:22:33:44:55"
@@ -2,6 +2,8 @@ import { html, reactive } from "/assets/vendor/arrow-core.js"
import { createBrowserHistory, createRouter } from "/assets/vendor/remix-router-1.3.1.js"
import { hideSidebar } from "/assets/js/utils.js"
import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-c4-hint-1"
import { Bluetooth } from "/assets/components/tools/bluetooth.js?v=bluetooth-3"
import { WheelControls } from "/assets/components/tools/wheel_controls.js?v=controllers-2"
import { ErrorLogs } from "/assets/components/tools/error_logs.js"
import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js"
import { GalaxyPairing } from "/assets/components/tools/galaxy.js"
@@ -11,9 +13,9 @@ import { MapsManager } from "/assets/components/tools/maps.js"
import { NavDestination } from "/assets/components/navigation/navigation_destination.js?v=nav-search-context-2"
import { NavKeys } from "/assets/components/navigation/navigation_keys.js?v=app-keys-session-1"
import { RouteRecordings } from "/assets/components/recordings/dashcam_routes.js"
import { SettingsView } from "/assets/components/settings.js?v=router-cycle-fix-1"
import { SettingsView } from "/assets/components/settings.js?v=router-cycle-fix-3"
import { ScreenRecordings } from "/assets/components/recordings/screen_recordings.js"
import { Sidebar } from "/assets/components/sidebar.js?v=lateral-tuning-1"
import { Sidebar } from "/assets/components/sidebar.js?v=controllers-nav-1"
import { SentryMode } from "/assets/components/tools/sentry.js"
import { SpeedLimits } from "/assets/components/tools/speed_limits.js"
import { ModelManager } from "/assets/components/tools/model_manager.js?v=20260825a"
@@ -66,6 +68,8 @@ function SafeHome() {
function Root() {
let routes = [
createRoute("bluetooth", "/bluetooth", Bluetooth),
createRoute("wheel_controls", "/wheel-controls", WheelControls),
createRoute("device_settings", "/device_settings/:section?", DeviceSettings),
createRoute("errorLogs", "/manage_error_logs", ErrorLogs),
createRoute("galaxy", "/galaxy", GalaxyPairing),
@@ -11,10 +11,12 @@ const MENU_ITEMS = {
],
tools: [
{ name: "Toggles", link: "/device_settings", icon: "bi-toggle-on" },
{ name: "Bluetooth", link: "/bluetooth", icon: "bi-bluetooth" },
{ name: "Download Speed Limits", link: "/download_speed_limits", icon: "bi-download" },
{ name: "Error Logs", link: "/manage_error_logs", icon: "bi-exclamation-triangle" },
{ name: "Galaxy", link: "/galaxy", icon: "bi-globe2" },
{ name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation" },
{ name: "Controllers", link: "/wheel-controls", icon: "bi-controller" },
{ name: "Lateral Tuning", link: "/tuning", icon: "bi-sign-turn-right" },
{ name: "Long Maneuvers", link: "/longitudinal_maneuvers", icon: "bi-signpost-split" },
{ name: "Maps", link: "/manage_maps", icon: "bi-map" },
@@ -0,0 +1,139 @@
.bluetoothPage {
display: flex;
flex-direction: column;
gap: 16px;
}
.bluetoothHeader,
.bluetoothTitle,
.bluetoothDeviceHeader,
.bluetoothToolbar,
.bluetoothActions,
.bluetoothBadges {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.bluetoothHeader,
.bluetoothDeviceHeader {
justify-content: space-between;
}
.bluetoothTitle > i {
color: #a98ce5;
font-size: 2.5rem;
}
.bluetoothHeader h2,
.bluetoothHeader p,
.bluetoothCard h3 {
margin: 0;
}
.bluetoothHeader p {
margin-top: 6px;
opacity: 0.8;
}
.bluetoothCard,
.bluetoothNotice,
.bluetoothError,
.bluetoothPrompt {
background: var(--sidebar-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-lg);
padding: 16px;
}
.bluetoothDeviceList {
display: grid;
gap: 12px;
}
.bluetoothNotice,
.bluetoothPrompt {
color: #ffe2a3;
}
.bluetoothAudioCountdown {
display: flex;
align-items: center;
gap: 18px;
padding: 16px 20px;
border: 1px solid rgba(64, 201, 124, 0.45);
border-radius: var(--border-radius-lg);
background: rgba(64, 201, 124, 0.12);
}
.bluetoothAudioCountdown strong {
min-width: 72px;
color: #b8ffd4;
font-size: 2rem;
text-align: center;
}
.bluetoothError {
color: #ff9ab3;
}
.bluetoothAddress {
display: inline-block;
margin-top: 5px;
font-family: monospace;
opacity: 0.65;
}
.bluetoothBadge {
border-radius: 999px;
padding: 4px 9px;
background: rgba(255, 255, 255, 0.08);
font-size: 0.78rem;
font-weight: 700;
}
.bluetoothBadgePaired {
color: #d9c9ff;
background: rgba(139, 108, 197, 0.22);
}
.bluetoothBadgeConnected {
color: #b8ffd4;
background: rgba(64, 201, 124, 0.2);
}
.bluetoothToolbar button,
.bluetoothActions button,
.bluetoothSwitch {
border: 0;
border-radius: var(--border-radius-md);
padding: 10px 14px;
background: linear-gradient(135deg, #7a62b8, #8b6cc5);
color: #fff;
font-weight: 700;
cursor: pointer;
}
.bluetoothActions {
margin-top: 14px;
}
.bluetoothActions button.selected {
background: linear-gradient(135deg, #258b58, #40c97c);
}
.bluetoothActions button.danger {
background: linear-gradient(135deg, #b14a6b, #d95a7b);
}
.bluetoothToolbar button:disabled,
.bluetoothActions button:disabled,
.bluetoothSwitch:has(input:disabled) {
cursor: not-allowed;
opacity: 0.5;
}
.bluetoothSwitch input {
margin: 0;
}
@@ -0,0 +1,216 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
const state = reactive({
loading: true,
busy: "",
available: false,
enabled: false,
powered: false,
discovering: false,
offroad: false,
selectedAudio: "",
devices: [],
prompt: null,
audioTestAddress: "",
audioTestLabel: "",
error: "",
})
let initialized = false
let lastPromptId = ""
let audioTestTimer = null
function startAudioTestCountdown(address, delayMs, requestStartedAt) {
if (audioTestTimer !== null) clearInterval(audioTestTimer)
const halfRoundTripMs = Math.max(0, (performance.now() - requestStartedAt) / 2)
const deadline = performance.now() + Math.max(0, delayMs - halfRoundTripMs)
state.audioTestAddress = address
const update = () => {
const remaining = deadline - performance.now()
if (remaining > 0) {
state.audioTestLabel = String(Math.max(1, Math.ceil(remaining / 1000)))
} else if (remaining > -3000) {
state.audioTestLabel = "NOW"
} else {
state.audioTestLabel = ""
state.audioTestAddress = ""
clearInterval(audioTestTimer)
audioTestTimer = null
}
}
update()
audioTestTimer = setInterval(update, 50)
}
async function request(operation, body = {}) {
const requestStartedAt = performance.now()
state.busy = operation
try {
const response = await fetch(`/api/bluetooth/${operation}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || "Bluetooth operation failed")
if (operation === "test_audio") {
startAudioTestCountdown(String(body.address || ""), Number(payload.audio_test_delay_ms || 3000), requestStartedAt)
}
state.error = ""
await refresh()
} catch (error) {
state.error = error?.message || "Bluetooth operation failed"
} finally {
state.busy = ""
}
}
async function handlePrompt(prompt) {
if (!prompt || prompt.id === lastPromptId) return
lastPromptId = prompt.id
if (prompt.display_only) return
let accepted = true
let value = ""
if (prompt.kind === "pin") {
value = window.prompt(`Enter the PIN for ${prompt.name || "Bluetooth device"}`) ?? ""
accepted = value.length > 0
} else if (prompt.kind === "passkey") {
value = window.prompt(`Enter the passkey for ${prompt.name || "Bluetooth device"}`) ?? ""
accepted = /^\d{1,6}$/.test(value)
} else {
const suffix = prompt.value ? `\n\nPasskey: ${prompt.value}` : ""
accepted = window.confirm(`Allow ${prompt.name || "Bluetooth device"} to pair?${suffix}`)
}
await request("pairing_response", { prompt_id: prompt.id, accepted, value })
}
async function refresh() {
try {
const response = await fetch("/api/bluetooth/status", { cache: "no-store" })
const payload = await response.json()
state.available = !!payload.available
state.enabled = !!payload.enabled
state.powered = !!payload.powered
state.discovering = !!payload.discovering
state.offroad = !!payload.offroad
state.selectedAudio = String(payload.selected_audio || "")
state.devices = Array.isArray(payload.devices) ? payload.devices : []
state.prompt = payload.prompt || null
state.error = payload.error || (response.ok ? "" : "Bluetooth service unavailable")
handlePrompt(state.prompt)
} catch (error) {
state.available = false
state.error = error?.message || "Bluetooth service unavailable"
} finally {
state.loading = false
}
}
function initialize() {
if (initialized) return
initialized = true
refresh()
setInterval(() => {
if (window.location.pathname === "/bluetooth") refresh()
}, 2000)
}
function capabilityBadges(device) {
const badges = []
if (device.audio) badges.push(html`<span class="bluetoothBadge">Audio</span>`)
if (device.controller) badges.push(html`<span class="bluetoothBadge">Controller</span>`)
if (device.paired) badges.push(html`<span class="bluetoothBadge bluetoothBadgePaired">Paired</span>`)
if (device.connected) badges.push(html`<span class="bluetoothBadge bluetoothBadgeConnected">Connected</span>`)
return badges
}
function deviceActions(device) {
const audioSelected = () => state.selectedAudio.toUpperCase() === device.address.toUpperCase()
return html`
<div class="bluetoothActions">
${!device.paired ? html`
<button disabled="${() => !state.offroad || !!state.busy}" @click="${() => request("pair", { address: device.address })}">Pair</button>
` : html`
<button disabled="${() => !!state.busy}" @click="${() => request(device.connected ? "disconnect" : "connect", { address: device.address })}">
${device.connected ? "Disconnect" : "Connect"}
</button>
${device.audio ? html`
<button class="${() => audioSelected() ? "selected" : ""}"
disabled="${() => !!state.busy}" @click="${() => request("select_audio", { address: audioSelected() ? "" : device.address })}">
${() => audioSelected() ? "Stop Using for Audio" : "Use for Audio"}
</button>
${device.connected ? html`
<button disabled="${() => !state.offroad || !!state.busy || !!state.audioTestLabel}" @click="${() => request("test_audio", { address: device.address })}">
${() => state.audioTestAddress === device.address && state.audioTestLabel ? `Test Audio: ${state.audioTestLabel}` : "Test Audio"}
</button>
` : ""}
` : ""}
<button class="danger" disabled="${() => !state.offroad || !!state.busy}" @click="${() => {
if (window.confirm(`Forget ${device.name}?`)) request("forget", { address: device.address })
}}">Forget</button>
`}
</div>
`
}
export function Bluetooth() {
initialize()
return html`
<div class="bluetoothPage">
<div class="bluetoothHeader">
<div class="bluetoothTitle">
<i class="bi bi-bluetooth" aria-hidden="true"></i>
<div>
<h2>Bluetooth</h2>
<p>Connect audio devices and controllers.</p>
</div>
</div>
<label class="bluetoothSwitch">
<input type="checkbox" checked="${() => state.enabled}" disabled="${() => !state.available || !state.offroad || !!state.busy}"
@change="${(event) => request("power", { enabled: event.target.checked })}" />
<span>${() => state.enabled ? "On" : "Off"}</span>
</label>
</div>
${() => !state.offroad ? html`<div class="bluetoothNotice">Scanning, pairing, and forgetting devices are available offroad only.</div>` : ""}
${() => state.error ? html`<div class="bluetoothError">${state.error}</div>` : ""}
${() => state.prompt?.display_only ? html`
<div class="bluetoothPrompt">${state.prompt.name || "Bluetooth device"}: ${state.prompt.value}</div>
` : ""}
${() => state.audioTestLabel ? html`
<div class="bluetoothAudioCountdown">
<strong>${state.audioTestLabel}</strong>
<span>The test sound is sent at NOW. The audible gap is Bluetooth latency.</span>
</div>
` : ""}
<div class="bluetoothToolbar">
<button disabled="${() => !state.offroad || !state.enabled || !!state.busy}"
@click="${() => request(state.discovering ? "stop_scan" : "scan")}">
${() => state.discovering ? "Stop Scanning" : "Scan for Devices"}
</button>
<button disabled="${() => !!state.busy}" @click="${refresh}">Refresh</button>
</div>
<div class="bluetoothDeviceList">
${() => state.loading ? html`<div class="bluetoothCard">Loading...</div>` : ""}
${() => !state.loading && state.devices.length === 0 ? html`
<div class="bluetoothCard">${state.enabled ? "No Bluetooth devices found." : "Enable Bluetooth to find devices."}</div>
` : ""}
${() => state.devices.map((device) => html`
<div class="bluetoothCard">
<div class="bluetoothDeviceHeader">
<div>
<h3>${device.name}</h3>
</div>
<div class="bluetoothBadges">${capabilityBadges(device)}</div>
</div>
${deviceActions(device)}
</div>
`)}
</div>
</div>
`
}
@@ -0,0 +1,217 @@
.wheelControlsPage {
display: flex;
flex-direction: column;
gap: 16px;
}
.wheelHeader,
.wheelHeaderActions,
.wheelCardHeader,
.wheelMapping,
.wheelDeviceSummary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.wheelHeader h2,
.wheelHeader p,
.wheelCard h3,
.wheelCard p {
margin: 0;
}
.wheelHeader p {
margin-top: 6px;
opacity: 0.8;
}
.wheelHeaderActions {
justify-content: flex-end;
}
.wheelCard,
.wheelNotice,
.wheelError,
.wheelDeviceSummary {
background: var(--sidebar-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-lg);
padding: 16px;
}
.wheelSlotGrid,
.wheelMappings {
display: grid;
gap: 12px;
}
.wheelSlotLabel,
.wheelMapping span,
.wheelMuted,
.wheelEmpty,
.wheelHint {
opacity: 0.68;
}
.wheelCard h3 {
margin-top: 4px;
}
.wheelHint {
margin-top: 10px !important;
}
.wheelHint a {
color: #cbb8ff;
}
.wheelMapping {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--sidebar-border-color);
}
.wheelMapping span {
display: block;
margin-top: 3px;
}
.wheelDeviceSummary {
align-items: stretch;
flex-direction: column;
justify-content: flex-start;
}
.wheelDeviceHeading span,
.wheelDeviceRow span {
display: block;
margin-top: 4px;
opacity: 0.68;
}
.wheelDeviceList {
display: grid;
gap: 10px;
}
.wheelDeviceRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 12px;
border-top: 1px solid var(--sidebar-border-color);
}
.wheelControlsPage button.joystickEnabled {
background: linear-gradient(135deg, #258b58, #40c97c);
}
.wheelLearnPrompt {
display: flex;
align-items: center;
gap: 9px;
margin-top: 13px;
padding: 10px 12px;
border-radius: var(--border-radius-md);
color: #dfffea;
background: rgba(64, 201, 124, 0.14);
}
.wheelLearnPrompt span {
width: 9px;
height: 9px;
border-radius: 50%;
background: #40c97c;
animation: wheelPulse 1s infinite alternate;
}
.wheelTestPanel {
border: 1px solid rgba(139, 108, 197, 0.58);
border-radius: var(--border-radius-lg);
padding: 18px;
background: rgba(139, 108, 197, 0.13);
transition: background 140ms ease, border-color 140ms ease;
}
.wheelTestPanel.success {
border-color: rgba(64, 201, 124, 0.72);
background: rgba(64, 201, 124, 0.15);
}
.wheelTestPanel.failure {
border-color: rgba(217, 90, 123, 0.72);
background: rgba(217, 90, 123, 0.14);
}
.wheelTestPanel p {
margin: 8px 0 0;
opacity: 0.82;
}
.wheelTestIndicator {
display: flex;
align-items: center;
gap: 10px;
}
.wheelTestIndicator span {
width: 12px;
height: 12px;
border-radius: 50%;
background: #8b6cc5;
box-shadow: 0 0 14px rgba(139, 108, 197, 0.75);
}
.wheelTestPanel.success .wheelTestIndicator span {
background: #40c97c;
box-shadow: 0 0 16px rgba(64, 201, 124, 0.82);
}
.wheelTestPanel.failure .wheelTestIndicator span {
background: #d95a7b;
box-shadow: 0 0 16px rgba(217, 90, 123, 0.82);
}
.wheelControlsPage button {
border: 0;
border-radius: var(--border-radius-md);
padding: 10px 14px;
background: linear-gradient(135deg, #7a62b8, #8b6cc5);
color: #fff;
font-weight: 700;
cursor: pointer;
}
.wheelControlsPage button.learning {
background: linear-gradient(135deg, #258b58, #40c97c);
}
.wheelControlsPage button.testing {
background: linear-gradient(135deg, #258b58, #40c97c);
}
.wheelControlsPage button.danger {
background: linear-gradient(135deg, #b14a6b, #d95a7b);
}
.wheelControlsPage button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.wheelNotice {
color: #ffe2a3;
}
.wheelError {
color: #ff9ab3;
}
@keyframes wheelPulse {
from { opacity: 0.35; transform: scale(0.8); }
to { opacity: 1; transform: scale(1.15); }
}
@@ -0,0 +1,208 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
const state = reactive({
loading: true,
busy: "",
available: false,
offroad: false,
devices: [],
joystickDevice: "",
mappings: [],
slots: [],
learning: false,
learningSlot: null,
remainingSeconds: 0,
testing: false,
lastTested: null,
error: "",
})
let initialized = false
async function refresh() {
try {
const response = await fetch("/api/wheel-controls/status", { cache: "no-store" })
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || "Wheel controls are unavailable")
state.available = !!payload.available
state.offroad = !!payload.offroad
state.devices = Array.isArray(payload.devices) ? payload.devices : []
state.joystickDevice = typeof payload.joystick_device === "string" ? payload.joystick_device : ""
state.mappings = Array.isArray(payload.mappings) ? payload.mappings : []
state.slots = Array.isArray(payload.slots) ? payload.slots : []
state.learning = !!payload.learning
state.learningSlot = Number.isInteger(payload.learning_slot) ? payload.learning_slot : null
state.remainingSeconds = Number(payload.remaining_seconds || 0)
state.testing = !!payload.testing
state.lastTested = payload.last_tested && typeof payload.last_tested === "object" ? payload.last_tested : null
state.error = ""
} catch (error) {
state.available = false
state.error = error?.message || "Wheel controls are unavailable"
} finally {
state.loading = false
}
}
async function request(operation, body = {}) {
state.busy = operation
try {
const response = await fetch(`/api/wheel-controls/${operation}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || "Wheel control operation failed")
state.error = ""
await refresh()
} catch (error) {
state.error = error?.message || "Wheel control operation failed"
} finally {
state.busy = ""
}
}
function initialize() {
if (initialized) return
initialized = true
refresh()
setInterval(() => {
if (window.location.pathname === "/wheel-controls") refresh()
}, 750)
}
function mappingRow(mapping) {
return html`
<div class="wheelMapping">
<div>
<strong>${mapping.event_name}</strong>
<span>${mapping.device_name}</span>
</div>
<button class="danger" disabled="${() => !state.offroad || !!state.busy}"
@click="${() => request("delete", { id: mapping.id })}">Remove</button>
</div>
`
}
function slotCard(slot, index) {
const configured = !!slot?.enabled && !!slot?.key
const mappings = () => state.mappings.filter(mapping => mapping.slot === index)
const learning = () => state.learning && state.learningSlot === index
return html`
<section class="wheelCard">
<div class="wheelCardHeader">
<div>
<span class="wheelSlotLabel">Favorite #${index + 1}</span>
<h3>${configured ? (slot.label || slot.key) : "Not configured"}</h3>
</div>
<button class="${() => learning() ? "learning" : ""}"
disabled="${() => !configured || !state.offroad || state.testing || !!state.busy}"
@click="${() => request(learning() ? "cancel" : "learn", { slot: index })}">
${() => learning() ? `Listening (${Math.ceil(state.remainingSeconds)}s)` : "Learn Button"}
</button>
</div>
${!configured ? html`
<p class="wheelHint">Choose and enable this slot in <a href="/device_settings/favorites">Toggles → Favorites</a>.</p>
` : html`
<p class="wheelHint">Pressing any mapped button will run this favorite.</p>
`}
${() => learning() ? html`
<div class="wheelLearnPrompt"><span></span>Press one button on your controller, macropad, or keyboard.</div>
` : ""}
<div class="wheelMappings">
${() => mappings().length ? mappings().map(mappingRow) : html`<span class="wheelEmpty">No buttons mapped.</span>`}
</div>
</section>
`
}
function testResultClass() {
if (!state.lastTested) return "waiting"
return state.lastTested.mapped ? "success" : "failure"
}
function testPanel() {
return html`
<section class="${() => `wheelTestPanel ${testResultClass()}`}">
<div class="wheelTestIndicator">
<span></span>
<strong>${() => {
if (!state.lastTested) return "Waiting for a button"
return state.lastTested.mapped ? "Successful" : "Not mapped"
}}</strong>
</div>
<p>${() => {
if (!state.lastTested) return "Press a button to verify its mapping. Controller inputs are temporarily consumed while testing is enabled."
const device = state.lastTested.device_name || "External input"
const button = state.lastTested.event_name || `Button ${state.lastTested.event_code}`
return state.lastTested.mapped
? `${button} on ${device} is mapped to Favorite #${Number(state.lastTested.slot) + 1}.`
: `${button} on ${device} does not have a mapping.`
}}</p>
</section>
`
}
function deviceRow(device) {
const selected = () => state.joystickDevice === device.device_id
return html`
<div class="wheelDeviceRow">
<div>
<strong>${device.name}</strong>
<span>${device.joystick_capable ? "Buttons and joystick axes" : "Buttons only"}</span>
</div>
${device.joystick_capable ? html`
<button class="${() => selected() ? "joystickEnabled" : ""}"
disabled="${() => !state.offroad || !!state.busy}"
@click="${() => request("joystick", { device_id: device.device_id, enabled: !selected() })}">
${() => selected() ? "Enabled for Joystick Mode" : "Enable for Joystick Mode"}
</button>
` : ""}
</div>
`
}
export function WheelControls() {
initialize()
return html`
<div class="wheelControlsPage">
<header class="wheelHeader">
<div>
<h2>Controllers</h2>
<p>Map buttons to favorites, or explicitly select one gamepad for Joystick Mode.</p>
</div>
<div class="wheelHeaderActions">
<button class="${() => state.testing ? "testing" : ""}"
disabled="${() => !state.offroad || !state.mappings.length || !!state.busy}"
@click="${() => request(state.testing ? "test-stop" : "test")}">
${() => state.testing ? "Stop Testing" : "Test Buttons"}
</button>
<button class="danger" disabled="${() => !state.offroad || !state.mappings.length || !!state.busy}"
@click="${() => {
if (window.confirm("Remove every controller mapping?")) request("clear")
}}">Clear All</button>
</div>
</header>
${() => !state.offroad ? html`<div class="wheelNotice">Mappings can only be learned or changed while offroad. Mapped buttons continue working onroad.</div>` : ""}
${() => state.error ? html`<div class="wheelError">${state.error}</div>` : ""}
${() => !state.loading && !state.available && state.mappings.length ? html`<div class="wheelNotice">The wheel control service is starting.</div>` : ""}
${() => state.testing ? testPanel() : ""}
<div class="wheelDeviceSummary">
<div class="wheelDeviceHeading">
<strong>Connected input devices</strong>
<span>Favorite buttons are the default. Only the selected gamepad controls Joystick Mode.</span>
</div>
${() => state.devices.length
? html`<div class="wheelDeviceList">${state.devices.map(deviceRow)}</div>`
: html`<span class="wheelMuted">Connect or pair a controller, macropad, or keyboard.</span>`}
</div>
<div class="wheelSlotGrid">
${() => state.loading ? html`<div class="wheelCard">Loading...</div>` : state.slots.map(slotCard)}
</div>
</div>
`
}
@@ -40,6 +40,8 @@
<link rel="stylesheet" href="/assets/components/tools/toggles.css">
<link rel="stylesheet" href="/assets/components/tools/update_manager.css">
<link rel="stylesheet" href="/assets/components/tools/device_settings.css?v=favorite-actions-1">
<link rel="stylesheet" href="/assets/components/tools/bluetooth.css?v=bluetooth-3">
<link rel="stylesheet" href="/assets/components/tools/wheel_controls.css?v=controllers-2">
<link rel="stylesheet" href="/assets/components/tools/galaxy.css">
<link rel="stylesheet" href="/assets/components/tools/sentry.css">
<link rel="stylesheet" href="/assets/components/tools/longitudinal_maneuvers.css">
@@ -48,7 +50,7 @@
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
<script type="module">
import("/assets/components/router.js?v=router-cycle-fix-1").catch((err) => {
import("/assets/components/router.js?v=router-cycle-fix-3").catch((err) => {
console.error("[the_galaxy] bootstrap failed", err);
const target = document.getElementById("app") || document.body;
const pre = document.createElement("pre");
@@ -318,6 +318,17 @@ def _install_server_import_stubs():
"openpilot.starpilot.system.the_galaxy.flm_workspace",
)
sys.modules["openpilot.starpilot.system.the_galaxy.utilities"] = utilities
sys.modules["openpilot.starpilot.system.wheel_controls"] = _simple_module(
"openpilot.starpilot.system.wheel_controls",
cancel_learning=lambda *args, **kwargs: None,
clear_mappings=lambda *args, **kwargs: None,
delete_mapping=lambda *args, **kwargs: True,
public_status=lambda *args, **kwargs: {"mappings": [], "devices": [], "available": True},
set_joystick_device=lambda *args, **kwargs: None,
start_learning=lambda *args, **kwargs: None,
start_testing=lambda *args, **kwargs: None,
stop_testing=lambda *args, **kwargs: None,
)
class FakeParams:
@@ -5,6 +5,9 @@ REPO_ROOT = Path(__file__).resolve().parents[4]
SETTINGS_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/settings.js"
ROUTER_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/router.js"
INDEX_PATH = REPO_ROOT / "starpilot/system/the_galaxy/templates/index.html"
BLUETOOTH_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/bluetooth.js"
CONTROLLERS_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/wheel_controls.js"
SIDEBAR_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/sidebar.js"
def test_settings_does_not_create_a_second_router_module():
@@ -18,5 +21,45 @@ def test_router_and_settings_cache_bust_is_consistent():
router = ROUTER_PATH.read_text(encoding="utf-8")
index = INDEX_PATH.read_text(encoding="utf-8")
assert "/assets/components/settings.js?v=router-cycle-fix-1" in router
assert "/assets/components/router.js?v=router-cycle-fix-1" in index
assert "/assets/components/settings.js?v=router-cycle-fix-3" in router
assert "/assets/components/router.js?v=router-cycle-fix-3" in index
def test_bluetooth_actions_use_reactive_disabled_bindings():
source = BLUETOOTH_PATH.read_text(encoding="utf-8")
assert 'disabled="${pairingDisabled}"' not in source
assert 'disabled="${disabled}"' not in source
assert 'disabled="${() => !state.offroad || !!state.busy}"' in source
assert "bluetoothAddress" not in source
assert 'address: audioSelected() ? "" : device.address' in source
assert 'audioSelected() ? "Stop Using for Audio" : "Use for Audio"' in source
assert 'request("test_audio", { address: device.address })' in source
assert "startAudioTestCountdown" in source
assert "The test sound is sent at NOW" in source
def test_controller_test_mode_has_explicit_start_and_stop():
source = CONTROLLERS_PATH.read_text(encoding="utf-8")
assert 'state.testing ? "test-stop" : "test"' in source
assert 'state.lastTested.mapped ? "Successful" : "Not mapped"' in source
assert "Controller inputs are temporarily consumed" in source
def test_controller_joystick_mode_requires_explicit_device_selection():
source = CONTROLLERS_PATH.read_text(encoding="utf-8")
assert "Favorite buttons are the default" in source
assert "Enable for Joystick Mode" in source
assert 'request("joystick", { device_id: device.device_id, enabled: !selected() })' in source
def test_bluetooth_and_controllers_sidebar_order():
source = SIDEBAR_PATH.read_text(encoding="utf-8")
toggles = source.index('{ name: "Toggles"')
bluetooth = source.index('{ name: "Bluetooth"')
sentry = source.index('{ name: "Sentry Mode"')
controllers = source.index('{ name: "Controllers"')
assert toggles < bluetooth < sentry < controllers
@@ -1,5 +1,6 @@
import json
import sys
from dataclasses import asdict
from openpilot.common.params import ParamKeyType
@@ -112,6 +113,188 @@ def _params_client(monkeypatch, values, device_type):
return app.test_client(), fake_params
class FakeBluetoothClient:
calls = []
def __init__(self, timeout=0):
self.timeout = timeout
def status(self):
from openpilot.starpilot.system.bluetooth.protocol import BluetoothStatus
return BluetoothStatus(available=True, enabled=True, powered=True, offroad=True)
@staticmethod
def serialize_status(status):
return asdict(status)
def set_power(self, enabled):
self.calls.append(("set_power", {"enabled": enabled}))
def call(self, command, **payload):
self.calls.append((command, payload))
return {"audio_test_delay_ms": 3000} if command == "test_audio" else {}
def test_bluetooth_status_api(monkeypatch):
client, _ = _params_client(monkeypatch, {"IsOffroad": True}, "mici")
monkeypatch.setattr(the_galaxy, "BluetoothClient", FakeBluetoothClient)
response = client.get("/api/bluetooth/status")
assert response.status_code == 200
assert response.get_json() == {
"available": True,
"devices": [],
"discovering": False,
"enabled": True,
"error": "",
"offroad": True,
"powered": True,
"prompt": None,
"selected_audio": "",
}
def test_bluetooth_api_enforces_offroad(monkeypatch):
FakeBluetoothClient.calls = []
client, _ = _params_client(monkeypatch, {"IsOffroad": False}, "mici")
monkeypatch.setattr(the_galaxy, "BluetoothClient", FakeBluetoothClient)
response = client.post("/api/bluetooth/pair", json={"address": "00:11:22:33:44:55"})
assert response.status_code == 409
assert FakeBluetoothClient.calls == []
response = client.post("/api/bluetooth/test_audio", json={"address": "00:11:22:33:44:55"})
assert response.status_code == 409
assert FakeBluetoothClient.calls == []
def test_bluetooth_api_allows_connection_recovery_onroad(monkeypatch):
FakeBluetoothClient.calls = []
client, _ = _params_client(monkeypatch, {"IsOffroad": False}, "mici")
monkeypatch.setattr(the_galaxy, "BluetoothClient", FakeBluetoothClient)
response = client.post("/api/bluetooth/connect", json={"address": "00:11:22:33:44:55"})
assert response.status_code == 200
assert FakeBluetoothClient.calls == [("connect", {"address": "00:11:22:33:44:55"})]
def test_bluetooth_api_dispatches_operations(monkeypatch):
FakeBluetoothClient.calls = []
client, _ = _params_client(monkeypatch, {"IsOffroad": True}, "mici")
monkeypatch.setattr(the_galaxy, "BluetoothClient", FakeBluetoothClient)
assert client.post("/api/bluetooth/power", json={"enabled": True}).status_code == 200
assert client.post("/api/bluetooth/select_audio", json={"address": "00:11:22:33:44:55"}).status_code == 200
assert client.post("/api/bluetooth/select_audio", json={"address": ""}).status_code == 200
audio_response = client.post("/api/bluetooth/test_audio", json={"address": "00:11:22:33:44:55"})
assert audio_response.status_code == 200
assert audio_response.get_json()["audio_test_delay_ms"] == 3000
assert FakeBluetoothClient.calls == [
("set_power", {"enabled": True}),
("select_audio", {"address": "00:11:22:33:44:55"}),
("select_audio", {"address": ""}),
("test_audio", {"address": "00:11:22:33:44:55"}),
]
def test_wheel_controls_status_includes_favorite_slots(monkeypatch):
client, _ = _params_client(monkeypatch, {"IsOffroad": True, "FavoriteSlots": []}, "mici")
monkeypatch.setattr(the_galaxy, "wheel_control_status", lambda *_args: {"available": True, "mappings": [], "devices": []})
monkeypatch.setattr(the_galaxy, "_get_available_favorite_slot_options", lambda: [{"key": "ForceOffroad", "label": "Force Offroad"}])
monkeypatch.setattr(the_galaxy, "normalize_favorite_slots", lambda *_args, **_kwargs: [
{"enabled": True, "key": "ForceOffroad", "label": ""},
{"enabled": False, "key": None, "label": ""},
{"enabled": False, "key": None, "label": ""},
])
response = client.get("/api/wheel-controls/status")
assert response.status_code == 200
assert response.get_json()["slots"][0]["label"] == "Force Offroad"
def test_wheel_controls_learning_requires_offroad(monkeypatch):
client, _ = _params_client(monkeypatch, {"IsOffroad": False}, "mici")
calls = []
monkeypatch.setattr(the_galaxy, "start_wheel_control_learning", lambda *args: calls.append(args))
response = client.post("/api/wheel-controls/learn", json={"slot": 0})
assert response.status_code == 409
assert calls == []
def test_wheel_controls_learning_targets_configured_favorite(monkeypatch):
client, _ = _params_client(monkeypatch, {"IsOffroad": True, "FavoriteSlots": []}, "mici")
calls = []
monkeypatch.setattr(the_galaxy, "start_wheel_control_learning", lambda *args: calls.append(args))
monkeypatch.setattr(the_galaxy, "_get_available_favorite_slot_options", lambda: [{"key": "ForceOffroad", "label": "Force Offroad"}])
monkeypatch.setattr(the_galaxy, "normalize_favorite_slots", lambda *_args, **_kwargs: [
{"enabled": True, "key": "ForceOffroad", "label": "Force Offroad"},
{"enabled": False, "key": None, "label": ""},
{"enabled": False, "key": None, "label": ""},
])
response = client.post("/api/wheel-controls/learn", json={"slot": 0})
assert response.status_code == 200
assert calls == [(0, the_galaxy.params_memory, the_galaxy.params)]
def test_wheel_controls_test_mode_has_explicit_start_and_stop(monkeypatch):
client, _ = _params_client(monkeypatch, {"IsOffroad": True}, "mici")
calls = []
monkeypatch.setattr(the_galaxy, "start_wheel_control_testing", lambda *args: calls.append(("start", args)))
monkeypatch.setattr(the_galaxy, "stop_wheel_control_testing", lambda *args: calls.append(("stop", args)))
assert client.post("/api/wheel-controls/test").status_code == 200
assert client.post("/api/wheel-controls/test-stop").status_code == 200
assert calls == [
("start", (the_galaxy.params_memory, the_galaxy.params)),
("stop", (the_galaxy.params_memory,)),
]
def test_wheel_controls_joystick_selection_is_explicit_and_offroad(monkeypatch):
client, _ = _params_client(monkeypatch, {"IsOffroad": True}, "mici")
calls = []
monkeypatch.setattr(the_galaxy, "wheel_control_status", lambda *_args: {"devices": [
{"device_id": "bt-pad", "joystick_capable": True},
]})
monkeypatch.setattr(the_galaxy, "set_joystick_device", lambda *args: calls.append(args))
response = client.post("/api/wheel-controls/joystick", json={"device_id": "bt-pad", "enabled": True})
assert response.status_code == 200
assert calls == [("bt-pad", True, the_galaxy.params)]
def test_wheel_controls_rejects_button_only_joystick_source(monkeypatch):
client, _ = _params_client(monkeypatch, {"IsOffroad": True}, "mici")
monkeypatch.setattr(the_galaxy, "wheel_control_status", lambda *_args: {"devices": [
{"device_id": "media-remote", "joystick_capable": False},
]})
response = client.post("/api/wheel-controls/joystick", json={"device_id": "media-remote", "enabled": True})
assert response.status_code == 400
def test_wheel_controls_joystick_selection_requires_offroad(monkeypatch):
client, _ = _params_client(monkeypatch, {"IsOffroad": False}, "mici")
calls = []
monkeypatch.setattr(the_galaxy, "set_joystick_device", lambda *args: calls.append(args))
response = client.post("/api/wheel-controls/joystick", json={"device_id": "bt-pad", "enabled": True})
assert response.status_code == 409
assert calls == []
def test_params_compat_accepts_json_strings_for_json_keys():
backend = FakeParamsBackend(
key_types={"FavoriteDestinations": ParamKeyType.JSON},
+154
View File
@@ -102,6 +102,17 @@ from openpilot.starpilot.navigation.destination_store import normalize_destinati
from openpilot.starpilot.system.the_galaxy.factory_reset import remove_path as _run_factory_reset_delete
from openpilot.starpilot.system.the_galaxy import flm_workspace, utilities
from openpilot.starpilot.system.the_galaxy.update_recovery import inspect_interrupted_update, public_recovery_status, recover_interrupted_update
from openpilot.starpilot.system.bluetooth import BluetoothClient
from openpilot.starpilot.system.wheel_controls import (
cancel_learning as cancel_wheel_control_learning,
clear_mappings as clear_wheel_control_mappings,
delete_mapping as delete_wheel_control_mapping,
public_status as wheel_control_status,
set_joystick_device,
start_learning as start_wheel_control_learning,
start_testing as start_wheel_control_testing,
stop_testing as stop_wheel_control_testing,
)
DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL")
# Keep Galaxy independent of opendbc's generated car bindings while matching RivianFlags.ANGLE_HARNESS.
@@ -4853,6 +4864,10 @@ def setup(app):
"/assets/components/tools/pip_sidecam.js",
"/assets/components/tools/pip_sidecam.css",
"/assets/components/tools/toggles.js",
"/assets/components/tools/bluetooth.js",
"/assets/components/tools/bluetooth.css",
"/assets/components/tools/wheel_controls.js",
"/assets/components/tools/wheel_controls.css",
}:
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
@@ -4875,6 +4890,145 @@ def setup(app):
response.headers["Expires"] = "0"
return response
@app.route("/api/bluetooth/status", methods=["GET"])
def bluetooth_status():
try:
status = BluetoothClient(timeout=3.0).status()
return jsonify(BluetoothClient.serialize_status(status)), 200
except Exception as error:
return jsonify({
"available": False,
"enabled": params.get_bool("BluetoothEnabled"),
"offroad": params.get_bool("IsOffroad"),
"selected_audio": params.get("BluetoothAudioAddress", encoding="utf-8") or "",
"devices": [],
"error": str(error),
}), 503
@app.route("/api/bluetooth/<operation>", methods=["POST"])
def bluetooth_operation(operation):
commands = {
"power": "set_power",
"scan": "start_scan",
"stop_scan": "stop_scan",
"pair": "pair",
"connect": "connect",
"disconnect": "disconnect",
"forget": "forget",
"select_audio": "select_audio",
"test_audio": "test_audio",
"pairing_response": "pairing_response",
}
command = commands.get(operation)
if command is None:
return jsonify({"error": "Unknown Bluetooth operation."}), 404
offroad_only = {"power", "scan", "stop_scan", "pair", "forget", "test_audio", "pairing_response"}
if operation in offroad_only and not params.get_bool("IsOffroad"):
return jsonify({"error": "Bluetooth settings can only be changed offroad."}), 409
data = request.get_json(silent=True) or {}
payload = {}
if command == "set_power":
payload["enabled"] = bool(data.get("enabled", False))
elif command == "pairing_response":
payload = {
"prompt_id": str(data.get("prompt_id", "")),
"accepted": bool(data.get("accepted", False)),
"value": str(data.get("value", "")),
}
elif command not in {"start_scan", "stop_scan"}:
payload["address"] = str(data.get("address", ""))
if not payload["address"] and command != "select_audio":
return jsonify({"error": "Bluetooth device address is required."}), 400
try:
client = BluetoothClient(timeout=10.0)
if command == "set_power":
client.set_power(payload["enabled"])
result = {}
else:
result = client.call(command, **payload)
return jsonify({"message": "Bluetooth operation started.", **result}), 200
except Exception as error:
return jsonify({"error": str(error)}), 503
@app.route("/api/wheel-controls/status", methods=["GET"])
def wheel_controls_status():
status = wheel_control_status(params, params_memory)
options = _get_available_favorite_slot_options()
option_by_key = {option["key"]: option for option in options}
slots = normalize_favorite_slots(
params.get(FAVORITE_SLOTS_PARAM),
params=params,
eligible_keys=set(option_by_key),
)
for slot in slots:
key = slot.get("key")
if key in option_by_key:
slot["label"] = option_by_key[key]["label"]
status["slots"] = slots
return jsonify(status), 200
@app.route("/api/wheel-controls/<operation>", methods=["POST"])
def wheel_controls_operation(operation):
if operation not in {"learn", "cancel", "delete", "clear", "test", "test-stop", "joystick"}:
return jsonify({"error": "Unknown wheel control operation."}), 404
if not params.get_bool("IsOffroad"):
return jsonify({"error": "Wheel controls can only be configured offroad."}), 409
data = request.get_json(silent=True) or {}
try:
if operation == "joystick":
device_id = str(data.get("device_id") or "").strip()
enabled = bool(data.get("enabled", False))
if enabled:
devices = wheel_control_status(params, params_memory).get("devices", [])
device = next((item for item in devices if item.get("device_id") == device_id), None)
if device is None:
return jsonify({"error": "Controller is not connected."}), 404
if not device.get("joystick_capable"):
return jsonify({"error": "This device does not expose joystick axes."}), 400
set_joystick_device(device_id, enabled, params)
return jsonify({"message": "Joystick controller updated."}), 200
if operation == "learn":
stop_wheel_control_testing(params_memory)
slot_index = int(data.get("slot", -1))
options = _get_available_favorite_slot_options()
slots = normalize_favorite_slots(
params.get(FAVORITE_SLOTS_PARAM),
params=params,
eligible_keys={option["key"] for option in options},
)
if not 0 <= slot_index < len(slots) or not slots[slot_index].get("enabled") or not slots[slot_index].get("key"):
return jsonify({"error": "Configure and enable that Favorite before learning a button."}), 400
start_wheel_control_learning(slot_index, params_memory, params)
return jsonify({"message": f"Press a button for Favorite #{slot_index + 1}."}), 200
if operation == "cancel":
cancel_wheel_control_learning(params_memory, params)
return jsonify({"message": "Button learning cancelled."}), 200
if operation == "test":
cancel_wheel_control_learning(params_memory, params)
start_wheel_control_testing(params_memory, params)
return jsonify({"message": "Button testing enabled."}), 200
if operation == "test-stop":
stop_wheel_control_testing(params_memory)
return jsonify({"message": "Button testing disabled."}), 200
if operation == "clear":
clear_wheel_control_mappings(params)
cancel_wheel_control_learning(params_memory, params)
stop_wheel_control_testing(params_memory)
return jsonify({"message": "Wheel control mappings cleared."}), 200
identifier = str(data.get("id") or "").strip()
if not identifier:
return jsonify({"error": "Mapping id is required."}), 400
if not delete_wheel_control_mapping(identifier, params):
return jsonify({"error": "Wheel control mapping was not found."}), 404
return jsonify({"message": "Wheel control mapping removed."}), 200
except (TypeError, ValueError) as error:
return jsonify({"error": str(error)}), 400
except Exception as error:
return jsonify({"error": str(error)}), 503
@app.route("/assets/components/tools/device_settings_layout.json", methods=["GET"])
def device_settings_layout_asset():
if not SETTINGS_CATALOG_PATH.is_file():
@@ -0,0 +1,29 @@
from .wheel_controlsd import (
LEARN_TIMEOUT_SECONDS,
cancel_learning,
clear_mappings,
connected_input_sources,
delete_mapping,
load_mappings,
public_status,
selected_joystick_device,
set_joystick_device,
start_learning,
start_testing,
stop_testing,
)
__all__ = [
"LEARN_TIMEOUT_SECONDS",
"cancel_learning",
"clear_mappings",
"connected_input_sources",
"delete_mapping",
"load_mappings",
"public_status",
"selected_joystick_device",
"set_joystick_device",
"start_learning",
"start_testing",
"stop_testing",
]
@@ -0,0 +1,260 @@
import os
from openpilot.starpilot.system.wheel_controls import wheel_controlsd
class FakeParams:
def __init__(self, values=None):
self.values = dict(values or {})
def get(self, key, encoding=None, default=None, block=False):
del encoding, block
return self.values.get(key, default)
def get_bool(self, key):
return bool(self.values.get(key, False))
def get_int(self, key, default=0):
return int(self.values.get(key, default))
def put(self, key, value):
self.values[key] = value
def put_int(self, key, value):
self.values[key] = int(value)
def put_bool(self, key, value):
self.values[key] = bool(value)
def remove(self, key):
self.values.pop(key, None)
def source(name="Macro Pad"):
return wheel_controlsd.InputSource("/dev/input/event9", "stable-device", name, 3, 0x1234, 0x5678)
def test_mapping_round_trip_and_reassignment():
params = FakeParams()
first = wheel_controlsd.upsert_mapping(source(), 30, 0, params)
second = wheel_controlsd.upsert_mapping(source(), 30, 2, params)
assert first["id"] == second["id"]
assert params.get_bool(wheel_controlsd.ENABLED_PARAM)
assert wheel_controlsd.load_mappings(params) == [second]
assert wheel_controlsd.delete_mapping(second["id"], params)
assert wheel_controlsd.load_mappings(params) == []
assert not params.get_bool(wheel_controlsd.ENABLED_PARAM)
def test_joystick_selection_is_explicit_and_exclusive():
params = FakeParams()
assert wheel_controlsd.selected_joystick_device(params) == ""
assert wheel_controlsd.set_joystick_device("bluetooth-pad", True, params) == "bluetooth-pad"
assert wheel_controlsd.selected_joystick_device(params) == "bluetooth-pad"
assert params.get_bool(wheel_controlsd.ENABLED_PARAM)
assert wheel_controlsd.set_joystick_device("usb-pad", True, params) == "usb-pad"
assert wheel_controlsd.selected_joystick_device(params) == "usb-pad"
assert wheel_controlsd.load_mappings(params) == []
assert wheel_controlsd.set_joystick_device("usb-pad", False, params) == ""
assert wheel_controlsd.selected_joystick_device(params) == ""
assert not params.get_bool(wheel_controlsd.ENABLED_PARAM)
def test_favorite_mappings_keep_controller_daemon_enabled_when_joystick_is_disabled():
params = FakeParams()
wheel_controlsd.upsert_mapping(source("Bluetooth Controller"), 304, 0, params)
wheel_controlsd.set_joystick_device("stable-device", True, params)
wheel_controlsd.set_joystick_device("stable-device", False, params)
assert params.get_bool(wheel_controlsd.ENABLED_PARAM)
assert len(wheel_controlsd.load_mappings(params)) == 1
def test_learning_captures_next_key_without_triggering_old_mapping(monkeypatch):
params = FakeParams({"IsOffroad": True})
memory = FakeParams()
daemon = wheel_controlsd.WheelControlsDaemon(params, memory)
triggered = []
monkeypatch.setattr(wheel_controlsd, "execute_favorite_slot", lambda *args: triggered.append(args[0]) or True)
wheel_controlsd.start_learning(1, memory)
daemon._update_learning(10.0)
daemon._handle_key(source("Game Controller"), 304)
assert triggered == []
assert memory.get_int(wheel_controlsd.LEARN_SLOT_PARAM) == 0
assert wheel_controlsd.load_mappings(params)[0] == {
"id": wheel_controlsd.mapping_id("stable-device", 304),
"device_id": "stable-device",
"device_name": "Game Controller",
"event_code": 304,
"event_name": "BTN_SOUTH",
"slot": 1,
}
daemon.close()
def test_mapped_key_triggers_once(monkeypatch):
params = FakeParams({"IsOffroad": False})
memory = FakeParams()
wheel_controlsd.upsert_mapping(source(), 30, 2, params)
daemon = wheel_controlsd.WheelControlsDaemon(params, memory)
triggered = []
monkeypatch.setattr(wheel_controlsd, "execute_favorite_slot", lambda slot, *_args: triggered.append(slot) or True)
daemon._handle_key(source(), 30)
daemon._handle_key(source(), 31)
assert triggered == [2]
daemon.close()
def test_selected_joystick_controller_does_not_trigger_favorites(monkeypatch):
params = FakeParams({"IsOffroad": False})
memory = FakeParams()
wheel_controlsd.upsert_mapping(source("Game Controller"), 304, 0, params)
wheel_controlsd.set_joystick_device("stable-device", True, params)
daemon = wheel_controlsd.WheelControlsDaemon(params, memory)
triggered = []
monkeypatch.setattr(wheel_controlsd, "execute_favorite_slot", lambda slot, *_args: triggered.append(slot) or True)
daemon._handle_key(source("Game Controller"), 304)
assert triggered == []
wheel_controlsd.set_joystick_device("stable-device", False, params)
daemon._handle_key(source("Game Controller"), 304)
assert triggered == [0]
daemon.close()
def test_only_key_down_is_dispatched(monkeypatch):
params = FakeParams({"IsOffroad": False})
memory = FakeParams()
wheel_controlsd.upsert_mapping(source(), 30, 0, params)
daemon = wheel_controlsd.WheelControlsDaemon(params, memory)
triggered = []
monkeypatch.setattr(wheel_controlsd, "execute_favorite_slot", lambda slot, *_args: triggered.append(slot) or True)
read_fd, write_fd = os.pipe()
os.set_blocking(read_fd, False)
daemon.sources[read_fd] = source()
daemon.buffers[read_fd] = bytearray()
for value in (1, 2, 0):
os.write(write_fd, wheel_controlsd.INPUT_EVENT.pack(0, 0, wheel_controlsd.EV_KEY, 30, value))
daemon._read_events(read_fd)
assert triggered == [0]
os.close(write_fd)
daemon.close()
def test_learning_is_cancelled_onroad():
params = FakeParams({"IsOffroad": False})
memory = FakeParams({wheel_controlsd.LEARN_SLOT_PARAM: 1})
daemon = wheel_controlsd.WheelControlsDaemon(params, memory)
daemon._update_learning(10.0)
assert daemon.learning_slot is None
assert memory.get_int(wheel_controlsd.LEARN_SLOT_PARAM) == 0
daemon.close()
def test_only_usb_and_bluetooth_input_buses_are_accepted(monkeypatch):
values = {
"modalias": "input:b0003v1234p5678e0001-e0,1,k110,",
"name": "USB Macro Pad",
"phys": "usb-1/input0",
"uniq": "",
}
monkeypatch.setattr(wheel_controlsd, "_read_text", lambda path: values.get(path.name, ""))
external = wheel_controlsd.inspect_input_source("/dev/input/event9")
assert external is not None
assert external.name == "USB Macro Pad"
values["modalias"] = "input:b0018v0000p0000e0000-e0,1,k74,"
assert wheel_controlsd.inspect_input_source("/dev/input/event2") is None
def test_media_and_gamepad_button_names_are_supported():
assert wheel_controlsd.event_name(115) == "KEY_VOLUMEUP"
assert wheel_controlsd.event_name(164) == "KEY_PLAYPAUSE"
assert wheel_controlsd.event_name(304) == "BTN_SOUTH"
assert wheel_controlsd.event_name(wheel_controlsd.hat_event_code(wheel_controlsd.ABS_HAT0X, -1)) == "DPAD_LEFT"
assert wheel_controlsd.event_name(wheel_controlsd.hat_event_code(wheel_controlsd.ABS_HAT0X, 1)) == "DPAD_RIGHT"
assert wheel_controlsd.event_name(wheel_controlsd.hat_event_code(wheel_controlsd.ABS_HAT0X + 1, -1)) == "DPAD_UP"
assert wheel_controlsd.event_name(wheel_controlsd.hat_event_code(wheel_controlsd.ABS_HAT0X + 1, 1)) == "DPAD_DOWN"
def test_dpad_hat_axes_are_dispatched_once_per_press(monkeypatch):
params = FakeParams({"IsOffroad": False})
memory = FakeParams()
left = wheel_controlsd.hat_event_code(wheel_controlsd.ABS_HAT0X, -1)
wheel_controlsd.upsert_mapping(source("Game Controller"), left, 1, params)
daemon = wheel_controlsd.WheelControlsDaemon(params, memory)
triggered = []
monkeypatch.setattr(wheel_controlsd, "execute_favorite_slot", lambda slot, *_args: triggered.append(slot) or True)
read_fd, write_fd = os.pipe()
os.set_blocking(read_fd, False)
daemon.sources[read_fd] = source("Game Controller")
daemon.buffers[read_fd] = bytearray()
for value in (-1, -1, 0, -1):
os.write(write_fd, wheel_controlsd.INPUT_EVENT.pack(0, 0, wheel_controlsd.EV_ABS, wheel_controlsd.ABS_HAT0X, value))
daemon._read_events(read_fd)
assert triggered == [1, 1]
os.close(write_fd)
daemon.close()
def test_button_test_mode_eats_mapped_and_unmapped_inputs(monkeypatch):
params = FakeParams({"IsOffroad": True})
memory = FakeParams()
wheel_controlsd.upsert_mapping(source(), 164, 0, params)
daemon = wheel_controlsd.WheelControlsDaemon(params, memory)
triggered = []
monkeypatch.setattr(wheel_controlsd, "execute_favorite_slot", lambda slot, *_args: triggered.append(slot) or True)
wheel_controlsd.start_testing(memory, params)
daemon._update_testing()
daemon._handle_key(source(), 164)
assert triggered == []
assert daemon.last_tested == {
"mapped": True,
"device_name": "Macro Pad",
"event_code": 164,
"event_name": "KEY_PLAYPAUSE",
"slot": 0,
}
daemon._handle_key(source(), 165)
assert triggered == []
assert daemon.last_tested["mapped"] is False
wheel_controlsd.stop_testing(memory)
daemon._update_testing()
daemon._handle_key(source(), 164)
assert triggered == [0]
daemon.close()
def test_button_test_mode_stops_onroad():
params = FakeParams({"IsOffroad": True})
memory = FakeParams()
wheel_controlsd.upsert_mapping(source(), 30, 0, params)
daemon = wheel_controlsd.WheelControlsDaemon(params, memory)
wheel_controlsd.start_testing(memory, params)
daemon._update_testing()
assert daemon.testing
params.values["IsOffroad"] = False
daemon._update_testing()
assert not daemon.testing
assert not memory.get_bool(wheel_controlsd.TEST_ACTIVE_PARAM)
daemon.close()
@@ -0,0 +1,486 @@
#!/usr/bin/env python3
from __future__ import annotations
import hashlib
import json
import os
import re
import selectors
import struct
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
MAPPINGS_PARAM = "WheelControlMappings"
LEARN_SLOT_PARAM = "WheelControlLearnSlot"
STATUS_PARAM = "WheelControlStatus"
TEST_ACTIVE_PARAM = "WheelControlTestActive"
ENABLED_PARAM = "WheelControlsEnabled"
JOYSTICK_DEVICE_PARAM = "JoystickControlDevice"
LEARN_TIMEOUT_SECONDS = 20.0
DEVICE_SCAN_INTERVAL_SECONDS = 1.0
STATUS_INTERVAL_SECONDS = 0.5
EV_KEY = 1
EV_ABS = 3
KEY_DOWN = 1
ABS_HAT0X = 16
ABS_HAT3Y = 23
HAT_EVENT_BASE = 0x10000
EXTERNAL_INPUT_BUSES = {0x0003, 0x0005}
INPUT_EVENT = struct.Struct("@llHHi")
MODALIAS_RE = re.compile(r"input:b([0-9a-f]{4})v([0-9a-f]{4})p([0-9a-f]{4})e([0-9a-f]{4})", re.IGNORECASE)
try:
from inputs import KEYS_AND_BUTTONS
KEY_NAMES = dict(KEYS_AND_BUTTONS)
except ImportError:
KEY_NAMES = {}
@dataclass(frozen=True)
class InputSource:
path: str
device_id: str
name: str
bus: int
vendor: int
product: int
phys: str = ""
uniq: str = ""
joystick_capable: bool = False
def serialize(self) -> dict[str, Any]:
return {
"path": self.path,
"device_id": self.device_id,
"name": self.name,
"bus": self.bus,
"vendor": self.vendor,
"product": self.product,
"joystick_capable": self.joystick_capable,
}
def _read_text(path: Path) -> str:
try:
return path.read_text(encoding="utf-8", errors="replace").strip()
except OSError:
return ""
def inspect_input_source(path: str) -> InputSource | None:
event_name = Path(path).name
sysfs = Path("/sys/class/input") / event_name / "device"
match = MODALIAS_RE.match(_read_text(sysfs / "modalias"))
if match is None:
return None
bus, vendor, product, _version = (int(value, 16) for value in match.groups())
if bus not in EXTERNAL_INPUT_BUSES:
return None
name = _read_text(sysfs / "name") or "External input"
phys = _read_text(sysfs / "phys")
uniq = _read_text(sysfs / "uniq")
identity = f"{bus:04x}:{vendor:04x}:{product:04x}:{name.casefold()}:{uniq.casefold()}"
device_id = hashlib.sha256(identity.encode()).hexdigest()[:20]
joystick_capable = any(sysfs.resolve().glob("js*"))
return InputSource(path, device_id, name, bus, vendor, product, phys, uniq, joystick_capable)
def connected_input_sources() -> list[InputSource]:
if not Path("/dev/input").is_dir():
return []
return [source for path in sorted(Path("/dev/input").glob("event*")) if (source := inspect_input_source(str(path))) is not None]
def selected_joystick_device(params: Params | None = None) -> str:
params = params or Params(return_defaults=True)
try:
return (params.get(JOYSTICK_DEVICE_PARAM, encoding="utf-8") or "").strip()
except Exception:
return ""
def set_joystick_device(device_id: str, enabled: bool, params: Params | None = None) -> str:
params = params or Params(return_defaults=True)
selected = device_id.strip() if enabled else ""
if selected:
params.put(JOYSTICK_DEVICE_PARAM, selected)
else:
params.remove(JOYSTICK_DEVICE_PARAM)
params.put_bool(ENABLED_PARAM, bool(load_mappings(params) or selected))
return selected
def event_name(code: int) -> str:
if HAT_EVENT_BASE <= code < HAT_EVENT_BASE + (ABS_HAT3Y - ABS_HAT0X + 1) * 2:
offset = code - HAT_EVENT_BASE
axis = ABS_HAT0X + offset // 2
positive = bool(offset % 2)
hat = (axis - ABS_HAT0X) // 2
vertical = bool((axis - ABS_HAT0X) % 2)
direction = ("DOWN" if positive else "UP") if vertical else ("RIGHT" if positive else "LEFT")
return f"DPAD_{direction}" if hat == 0 else f"HAT_{hat}_{direction}"
return KEY_NAMES.get(code, f"KEY_{code}")
def hat_event_code(axis: int, value: int) -> int:
return HAT_EVENT_BASE + (axis - ABS_HAT0X) * 2 + int(value > 0)
def mapping_id(device_id: str, code: int) -> str:
return hashlib.sha256(f"{device_id}:{code}".encode()).hexdigest()[:16]
def normalize_mappings(value: Any) -> list[dict[str, Any]]:
if isinstance(value, bytes):
value = value.decode("utf-8", errors="replace")
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError:
return []
if not isinstance(value, list):
return []
normalized: list[dict[str, Any]] = []
seen: set[tuple[str, int]] = set()
for raw in value:
if not isinstance(raw, dict):
continue
device_id = str(raw.get("device_id") or "").strip()
name = str(raw.get("device_name") or "External input").strip()[:96]
try:
code = int(raw.get("event_code"))
slot = int(raw.get("slot"))
except (TypeError, ValueError):
continue
if not device_id or code < 0 or not 0 <= slot < 3:
continue
signature = (device_id, code)
if signature in seen:
continue
seen.add(signature)
normalized.append({
"id": mapping_id(device_id, code),
"device_id": device_id,
"device_name": name,
"event_code": code,
"event_name": str(raw.get("event_name") or event_name(code))[:64],
"slot": slot,
})
return normalized
def load_mappings(params: Params | None = None) -> list[dict[str, Any]]:
params = params or Params(return_defaults=True)
try:
return normalize_mappings(params.get(MAPPINGS_PARAM))
except Exception:
return []
def save_mappings(mappings: list[dict[str, Any]], params: Params | None = None) -> list[dict[str, Any]]:
params = params or Params(return_defaults=True)
normalized = normalize_mappings(mappings)
params.put(MAPPINGS_PARAM, normalized)
params.put_bool(ENABLED_PARAM, bool(normalized or selected_joystick_device(params)))
return normalized
def upsert_mapping(source: InputSource, code: int, slot: int, params: Params | None = None) -> dict[str, Any]:
params = params or Params(return_defaults=True)
mappings = [
mapping for mapping in load_mappings(params)
if not (mapping["device_id"] == source.device_id and mapping["event_code"] == code)
]
learned = {
"id": mapping_id(source.device_id, code),
"device_id": source.device_id,
"device_name": source.name,
"event_code": code,
"event_name": event_name(code),
"slot": slot,
}
mappings.append(learned)
save_mappings(mappings, params)
return learned
def delete_mapping(identifier: str, params: Params | None = None) -> bool:
params = params or Params(return_defaults=True)
mappings = load_mappings(params)
kept = [mapping for mapping in mappings if mapping["id"] != identifier]
if len(kept) == len(mappings):
return False
save_mappings(kept, params)
return True
def clear_mappings(params: Params | None = None) -> None:
save_mappings([], params)
def start_learning(slot: int, params_memory: Params | None = None, params: Params | None = None) -> None:
if not 0 <= slot < 3:
raise ValueError("Favorite slot must be between 1 and 3")
params_memory = params_memory or Params(memory=True)
(params or Params()).put_bool(ENABLED_PARAM, True)
params_memory.put_int(LEARN_SLOT_PARAM, slot + 1)
def cancel_learning(params_memory: Params | None = None, params: Params | None = None) -> None:
(params_memory or Params(memory=True)).remove(LEARN_SLOT_PARAM)
if params is not None and not load_mappings(params):
params.put_bool(ENABLED_PARAM, False)
def start_testing(params_memory: Params | None = None, params: Params | None = None) -> None:
params = params or Params(return_defaults=True)
if not load_mappings(params):
raise ValueError("Map at least one button before testing")
params.put_bool(ENABLED_PARAM, True)
(params_memory or Params(memory=True)).put_bool(TEST_ACTIVE_PARAM, True)
def stop_testing(params_memory: Params | None = None) -> None:
(params_memory or Params(memory=True)).remove(TEST_ACTIVE_PARAM)
def _status_value(params_memory: Params) -> dict[str, Any]:
try:
value = params_memory.get(STATUS_PARAM)
except Exception:
return {}
if isinstance(value, bytes):
value = value.decode("utf-8", errors="replace")
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError:
return {}
return value if isinstance(value, dict) else {}
def public_status(params: Params | None = None, params_memory: Params | None = None) -> dict[str, Any]:
params = params or Params(return_defaults=True)
params_memory = params_memory or Params(memory=True)
status = _status_value(params_memory)
updated_at = float(status.get("updated_at", 0.0) or 0.0)
status["available"] = bool(updated_at and time.monotonic() - updated_at < 3.0)
selected = selected_joystick_device(params)
if not status["available"]:
status["devices"] = [source.serialize() for source in connected_input_sources()]
for device in status.get("devices", []):
device["joystick_enabled"] = device.get("device_id") == selected
status["joystick_device"] = selected
status["offroad"] = params.get_bool("IsOffroad")
status["enabled"] = params.get_bool(ENABLED_PARAM)
status["mappings"] = load_mappings(params)
status.setdefault("devices", [])
status.setdefault("learning", False)
status.setdefault("learning_slot", None)
status.setdefault("remaining_seconds", 0)
status.setdefault("last_learned", None)
status.setdefault("testing", False)
status.setdefault("last_tested", None)
return status
def execute_favorite_slot(slot: int, params: Params, params_memory: Params) -> bool:
from openpilot.starpilot.common.favorite_slots import toggle_favorite_slot
return toggle_favorite_slot(slot, params, params_memory)
class WheelControlsDaemon:
def __init__(self, params: Params | None = None, params_memory: Params | None = None):
self.params = params or Params(return_defaults=True)
self.params_memory = params_memory or Params(memory=True)
self.selector = selectors.DefaultSelector()
self.sources: dict[int, InputSource] = {}
self.buffers: dict[int, bytearray] = {}
self.hat_values: dict[tuple[int, int], int] = {}
self.learning_slot: int | None = None
self.learning_deadline = 0.0
self.last_learned: dict[str, Any] | None = None
self.testing = False
self.last_tested: dict[str, Any] | None = None
self.last_scan = 0.0
self.last_status = 0.0
def close(self) -> None:
for fd in list(self.sources):
self._remove(fd)
self.selector.close()
self.params_memory.remove(TEST_ACTIVE_PARAM)
self.params_memory.remove(STATUS_PARAM)
def _remove(self, fd: int) -> None:
try:
self.selector.unregister(fd)
except Exception:
pass
try:
os.close(fd)
except OSError:
pass
self.sources.pop(fd, None)
self.buffers.pop(fd, None)
self.hat_values = {key: value for key, value in self.hat_values.items() if key[0] != fd}
def _scan_devices(self) -> None:
current_paths = {source.path for source in self.sources.values()}
existing_paths = set(Path("/dev/input").glob("event*")) if Path("/dev/input").is_dir() else set()
for fd, source in list(self.sources.items()):
if Path(source.path) not in existing_paths:
self._remove(fd)
for path in sorted(existing_paths):
path_text = str(path)
if path_text in current_paths:
continue
source = inspect_input_source(path_text)
if source is None:
continue
try:
fd = os.open(path_text, os.O_RDONLY | os.O_NONBLOCK)
self.selector.register(fd, selectors.EVENT_READ)
except OSError:
continue
self.sources[fd] = source
self.buffers[fd] = bytearray()
def _update_learning(self, now: float) -> None:
if not self.params.get_bool("IsOffroad"):
cancel_learning(self.params_memory, self.params)
self.learning_slot = None
return
requested = self.params_memory.get_int(LEARN_SLOT_PARAM)
if 1 <= requested <= 3:
slot = requested - 1
if slot != self.learning_slot:
self.learning_slot = slot
self.learning_deadline = now + LEARN_TIMEOUT_SECONDS
elif self.learning_slot is not None:
self.learning_slot = None
if self.learning_slot is not None and now >= self.learning_deadline:
cancel_learning(self.params_memory, self.params)
self.learning_slot = None
def _update_testing(self) -> None:
requested = self.params_memory.get_bool(TEST_ACTIVE_PARAM)
if not self.params.get_bool("IsOffroad"):
stop_testing(self.params_memory)
self.testing = False
return
if requested and not self.testing:
self.testing = True
self.last_tested = None
elif not requested:
self.testing = False
def _handle_key(self, source: InputSource, code: int) -> None:
if source.device_id == selected_joystick_device(self.params):
return
if self.learning_slot is not None:
learned = upsert_mapping(source, code, self.learning_slot, self.params)
self.last_learned = learned
cancel_learning(self.params_memory, self.params)
self.learning_slot = None
return
mappings = load_mappings(self.params)
if self.testing:
mapping = next((item for item in mappings if item["device_id"] == source.device_id and item["event_code"] == code), None)
self.last_tested = {
"mapped": mapping is not None,
"device_name": source.name,
"event_code": code,
"event_name": event_name(code),
"slot": mapping["slot"] if mapping is not None else None,
}
return
for mapping in mappings:
if mapping["device_id"] == source.device_id and mapping["event_code"] == code:
try:
execute_favorite_slot(mapping["slot"], self.params, self.params_memory)
except Exception:
cloudlog.exception("wheel control favorite action failed")
return
def _read_events(self, fd: int) -> None:
try:
chunk = os.read(fd, INPUT_EVENT.size * 32)
except BlockingIOError:
return
except OSError:
self._remove(fd)
return
if not chunk:
self._remove(fd)
return
buffer = self.buffers[fd]
buffer.extend(chunk)
source = self.sources[fd]
while len(buffer) >= INPUT_EVENT.size:
raw = bytes(buffer[:INPUT_EVENT.size])
del buffer[:INPUT_EVENT.size]
_seconds, _microseconds, event_type, code, value = INPUT_EVENT.unpack(raw)
if event_type == EV_KEY and value == KEY_DOWN:
self._handle_key(source, code)
elif event_type == EV_ABS and ABS_HAT0X <= code <= ABS_HAT3Y:
previous = self.hat_values.get((fd, code), 0)
self.hat_values[(fd, code)] = value
if value and value != previous:
self._handle_key(source, hat_event_code(code, value))
def _publish_status(self, now: float) -> None:
remaining = max(0, round(self.learning_deadline - now, 1)) if self.learning_slot is not None else 0
status = {
"updated_at": now,
"devices": [source.serialize() for source in sorted(self.sources.values(), key=lambda item: item.name.casefold())],
"learning": self.learning_slot is not None,
"learning_slot": self.learning_slot,
"remaining_seconds": remaining,
"last_learned": self.last_learned,
"testing": self.testing,
"last_tested": self.last_tested,
}
self.params_memory.put(STATUS_PARAM, status)
def run(self) -> None:
try:
while True:
now = time.monotonic()
self._update_learning(now)
self._update_testing()
if now - self.last_scan >= DEVICE_SCAN_INTERVAL_SECONDS:
self._scan_devices()
self.last_scan = now
for key, _mask in self.selector.select(timeout=0.1):
self._read_events(key.fd)
now = time.monotonic()
if now - self.last_status >= STATUS_INTERVAL_SECONDS:
self._publish_status(now)
self.last_status = now
finally:
self.close()
def main() -> None:
WheelControlsDaemon().run()
if __name__ == "__main__":
main()
+11 -11
View File
@@ -56,30 +56,30 @@
},
{
"name": "boot",
"url": "https://files.firestar.link/x/ugiq4cqx08q7/boot9.img.xz",
"url": "https://files.firestar.link/x/go5upewt27dg/boot21.img.xz",
"fallback_urls": [
"https://files-east.firestar.link/x/npthb0hzvtxx/boot9.img.xz"
"https://files-east.firestar.link/x/cne45vvt5qri/boot21.img.xz"
],
"hash": "ab2eba0f96b2f48efa376330c3eb509158361adf3ad9c20f269ec92457aa841f",
"hash_raw": "ab2eba0f96b2f48efa376330c3eb509158361adf3ad9c20f269ec92457aa841f",
"size": 48343040,
"hash": "623d9bbd0bba1bcb1a007b6f10a79fb44ec09e14fc5438da3f6145809bfd42f8",
"hash_raw": "623d9bbd0bba1bcb1a007b6f10a79fb44ec09e14fc5438da3f6145809bfd42f8",
"size": 48162816,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "c12865a879012d6ad3da538c2ae8aff7d58c308c303fc5d8af309cb1707bc036"
"ondevice_hash": "623d9bbd0bba1bcb1a007b6f10a79fb44ec09e14fc5438da3f6145809bfd42f8"
},
{
"name": "system",
"url": "https://files.firestar.link/x/fwqn0nhycvfk/system20.img.xz",
"url": "https://files.firestar.link/x/ichkjps8r35o/system21.img.xz",
"fallback_urls": [
"https://files-east.firestar.link/x/9d6ro8onq782/system20.img.xz"
"https://files-east.firestar.link/x/upt6onk1dkof/system21.img.xz"
],
"hash": "eb3724cf96107367258fdaeef382a61e36c64116808737eeadb35f8da3fa7ad3",
"hash_raw": "eb3724cf96107367258fdaeef382a61e36c64116808737eeadb35f8da3fa7ad3",
"hash": "b5a7fe920a0c9154dd19ece7a014d56ece40786774952f575509c7291699ad29",
"hash_raw": "b5a7fe920a0c9154dd19ece7a014d56ece40786774952f575509c7291699ad29",
"size": 4718592000,
"sparse": false,
"full_check": false,
"has_ab": true,
"ondevice_hash": "eb3724cf96107367258fdaeef382a61e36c64116808737eeadb35f8da3fa7ad3"
"ondevice_hash": "b5a7fe920a0c9154dd19ece7a014d56ece40786774952f575509c7291699ad29"
}
]
+15 -1
View File
@@ -117,6 +117,18 @@ def run_navigationd(started: bool, params: Params, CP: car.CarParams, starpilot_
return started and params.get("NavDestination") is not None
def bluetooth_enabled(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool:
return params.get_bool("BluetoothEnabled")
def soundd_run(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool:
return driverview(started, params, CP, starpilot_toggles) or params.get_bool("BluetoothAudioTestActive")
def wheel_controls_enabled(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool:
return params.get_bool("WheelControlsEnabled")
def run_v_asm(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool:
return started and getattr(starpilot_toggles, "v_asm_enabled", False)
@@ -152,7 +164,7 @@ procs = [
PythonProcess("sensord", "system.sensord.sensord", sensord_run, enabled=not PC),
PythonProcess("sentryd", "system.sentryd.sentryd", sentry_mode, enabled=not PC),
PythonProcess("soundd", "selfdrive.ui.soundd", driverview),
PythonProcess("soundd", "selfdrive.ui.soundd", soundd_run),
PythonProcess("locationd", "selfdrive.locationd.locationd", only_onroad),
NativeProcess("_pandad", "selfdrive/pandad", ["./pandad"], always_run, enabled=False),
PythonProcess("calibrationd", "selfdrive.locationd.calibrationd", only_onroad),
@@ -189,6 +201,8 @@ procs = [
# StarPilot variables
procs += [
PythonProcess("bluetooth_managerd", "starpilot.system.bluetooth.daemon", bluetooth_enabled, enabled=TICI),
PythonProcess("wheel_controlsd", "starpilot.system.wheel_controls.wheel_controlsd", wheel_controls_enabled, enabled=TICI, nice=19),
PythonProcess("the_galaxy", "starpilot.system.the_galaxy.the_galaxy", always_run, nice=10),
PythonProcess("galaxy", "starpilot.system.galaxy.galaxy", always_run, nice=10),
]
+37 -1
View File
@@ -4,7 +4,16 @@ import pytest
from cereal import car
from opendbc.car.ford.values import CAR as FORD_CAR
from openpilot.system.manager.process_config import allow_uploads, camera_run, managed_processes, sentry_mode, ublox
from openpilot.system.manager.process_config import (
allow_uploads,
bluetooth_enabled,
camera_run,
managed_processes,
sentry_mode,
soundd_run,
ublox,
wheel_controls_enabled,
)
class FakeParams:
@@ -39,6 +48,33 @@ def test_uploader_runs_at_background_priority():
assert managed_processes["uploader"].nice == 19
@pytest.mark.parametrize("enabled", [False, True])
def test_bluetooth_process_is_param_gated(enabled):
params = SimpleNamespace(get_bool=lambda key: enabled if key == "BluetoothEnabled" else False)
assert bluetooth_enabled(False, params, car.CarParams.new_message(), SimpleNamespace()) is enabled
@pytest.mark.parametrize(
"started,driver_view,audio_test,expected",
[(True, False, False, True), (False, True, False, True), (False, False, True, True), (False, False, False, False)],
)
def test_soundd_runs_for_driving_and_bluetooth_audio_test(started, driver_view, audio_test, expected):
values = {"IsDriverViewEnabled": driver_view, "BluetoothAudioTestActive": audio_test}
params = SimpleNamespace(get_bool=lambda key: values.get(key, False))
assert soundd_run(started, params, car.CarParams.new_message(), SimpleNamespace()) is expected
def test_wheel_controls_process_runs_on_supported_devices_at_background_priority():
process = managed_processes["wheel_controlsd"]
assert process.nice == 19
@pytest.mark.parametrize("enabled", [False, True])
def test_wheel_controls_process_is_mapping_gated(enabled):
params = SimpleNamespace(get_bool=lambda key: enabled if key == "WheelControlsEnabled" else False)
assert wheel_controls_enabled(False, params, car.CarParams.new_message(), SimpleNamespace()) is enabled
class CameraParams:
def __init__(self, capture: bool):
self.capture = capture
+104
View File
@@ -0,0 +1,104 @@
import math
import threading
import time
from openpilot.starpilot.system.bluetooth import BluetoothClient, BluetoothStatus
class BluetoothManager:
def __init__(self):
self._client = BluetoothClient(timeout=5.0)
self._lock = threading.Lock()
self._status = BluetoothStatus()
self._active = False
self._exit = False
self._operation_error = ""
self._audio_test_deadline = 0.0
self._thread = threading.Thread(target=self._poll, daemon=True)
self._thread.start()
@property
def status(self) -> BluetoothStatus:
with self._lock:
return self._status
def set_active(self, active: bool) -> None:
self._active = active
def stop(self) -> None:
self._exit = True
def consume_error(self) -> str:
with self._lock:
error = self._operation_error
self._operation_error = ""
return error
def audio_test_phase(self) -> str:
with self._lock:
deadline = self._audio_test_deadline
if deadline <= 0:
return "starting"
remaining = deadline - time.monotonic()
if remaining > 0:
return str(max(1, math.ceil(remaining)))
if remaining > -3.0:
return "NOW"
return "complete"
def _poll(self) -> None:
while not self._exit:
if self._active:
try:
status = self._client.status()
with self._lock:
self._status = status
except Exception as error:
with self._lock:
self._status = BluetoothStatus(error=str(error))
time.sleep(1.0 if self._active else 2.0)
def _run(self, fn, *args) -> None:
def worker():
try:
fn(*args)
except Exception as error:
with self._lock:
self._operation_error = str(error)
threading.Thread(target=worker, daemon=True).start()
def set_power(self, enabled: bool) -> None:
self._run(self._client.set_power, enabled)
def set_scanning(self, scanning: bool) -> None:
self._run(self._client.start_scan if scanning else self._client.stop_scan)
def pair(self, address: str) -> None:
self._run(self._client.pair, address)
def connect(self, address: str) -> None:
self._run(self._client.connect, address)
def disconnect(self, address: str) -> None:
self._run(self._client.disconnect, address)
def forget(self, address: str) -> None:
self._run(self._client.forget, address)
def select_audio(self, address: str) -> None:
self._run(self._client.select_audio, address)
def test_audio(self, address: str) -> None:
def worker():
try:
delay = self._client.test_audio(address)
with self._lock:
self._audio_test_deadline = time.monotonic() + delay
except Exception as error:
with self._lock:
self._operation_error = str(error)
self._audio_test_deadline = 0.0
threading.Thread(target=worker, daemon=True).start()
def respond(self, prompt_id: str, accepted: bool, value: str = "") -> None:
self._run(self._client.respond, prompt_id, accepted, value)
+120 -3
View File
@@ -13,6 +13,7 @@ import hashlib
import json
import lzma
import os
import posixpath
import re
import shutil
import struct
@@ -78,6 +79,61 @@ ALLOWED_IMAGE_MUTATIONS = frozenset({
*LEGACY_RUNTIME_LIBRARY_PATHS,
*FACTORY_INSTALL_PATHS,
})
BLUETOOTH_RUNTIME_PATHS = frozenset({
"/etc/alsa/conf.d/20-bluealsa.conf",
"/etc/bluetooth/input.conf",
"/etc/bluetooth/main.conf",
"/etc/bluetooth/network.conf",
"/etc/default/bluetooth",
"/etc/default/bluez-alsa",
"/etc/init.d/bluetooth",
"/etc/dbus-1/system.d/starpilot-bluetooth.conf",
"/usr/bin/bluealsa",
"/usr/bin/bluealsa-aplay",
"/usr/bin/bluemoon",
"/usr/bin/bluetoothctl",
"/usr/bin/btattach",
"/usr/bin/btmgmt",
"/usr/bin/btmon",
"/usr/bin/ciptool",
"/usr/bin/gatttool",
"/usr/bin/hciattach",
"/usr/bin/hciconfig",
"/usr/bin/hcitool",
"/usr/bin/hex2hcd",
"/usr/bin/l2ping",
"/usr/bin/l2test",
"/usr/bin/mpris-proxy",
"/usr/bin/obexctl",
"/usr/bin/rctest",
"/usr/bin/rfcomm",
"/usr/bin/sdptool",
"/usr/comma/bluetooth-enabled",
"/usr/comma/bluetooth-radio",
"/usr/lib/aarch64-linux-gnu/alsa-lib/libasound_module_ctl_bluealsa.so",
"/usr/lib/aarch64-linux-gnu/alsa-lib/libasound_module_pcm_bluealsa.so",
"/usr/lib/aarch64-linux-gnu/libldacBT_abr.so.2",
"/usr/lib/aarch64-linux-gnu/libldacBT_abr.so.2.0.2",
"/usr/lib/aarch64-linux-gnu/libldacBT_enc.so.2",
"/usr/lib/aarch64-linux-gnu/libldacBT_enc.so.2.0.2",
"/usr/lib/aarch64-linux-gnu/libsbc.so.1",
"/usr/lib/aarch64-linux-gnu/libsbc.so.1.3.1",
"/usr/lib/systemd/system/bluealsa-aplay.service",
"/usr/lib/systemd/system/bluealsa.service",
"/usr/lib/systemd/system/bluetooth.service",
"/usr/lib/systemd/system/starpilot-bluetooth-radio.service",
"/usr/lib/udev/hid2hci",
"/usr/lib/udev/rules.d/97-hid2hci.rules",
"/usr/libexec/bluetooth/bluetoothd",
"/usr/sbin/bluetoothd",
"/usr/sbin/rfkill",
"/usr/share/apport/package-hooks/source_bluez.py",
"/usr/share/dbus-1/system-services/org.bluez.service",
"/usr/share/dbus-1/system.d/bluealsa.conf",
"/usr/share/dbus-1/system.d/bluetooth.conf",
"/usr/share/zsh/site-functions/_bluetoothctl",
})
BLUETOOTH_RUNTIME_DIRECTORIES = frozenset({"/usr/lib/firmware/qca"})
# Exact system partition pinned by ~/openpilot as of the 19.6 AGNOS release.
UPSTREAM_VERSION = "19.6"
@@ -161,6 +217,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--c3-deps-url", default=C3_DEPENDENCY_SOURCE_URL,
help="Exact prior StarPilot image containing the compatibility packages")
parser.add_argument("--c3-deps-image", help="Use a local exact StarPilot dependency source image")
parser.add_argument("--bluetooth-rootfs", help="Validated additive Bluetooth rootfs overlay")
parser.add_argument("--set-version", required=True, help="StarPilot revision, for example 19.6.5")
parser.add_argument("--work-dir", default=".cache/agnos_upstream_system")
parser.add_argument("--output-xz", help="Output .img.xz path")
@@ -746,7 +803,8 @@ def add_path_to_image(debugfs: str, image: Path, source: Path, destination: str)
if source.is_symlink():
ensure_image_directory(debugfs, image, str(Path(destination).parent))
target = os.readlink(source)
if "/" in target or target in ("", ".", ".."):
resolved_target = posixpath.normpath(posixpath.join(posixpath.dirname(destination), target))
if posixpath.isabs(target) or target in ("", ".", "..") or not resolved_target.startswith("/"):
raise RuntimeError(f"Unsafe compatibility-library symlink target: {target!r}")
run_debugfs(debugfs, image, f"symlink {destination} {target}", write=True)
stat = run_debugfs(debugfs, image, f"stat {destination}")
@@ -768,6 +826,55 @@ def add_path_to_image(debugfs: str, image: Path, source: Path, destination: str)
run_debugfs(debugfs, image, f"set_inode_field <{inode}> {field} {value}", write=True)
def validate_bluetooth_rootfs(source: Path) -> dict[str, dict[str, object]]:
if not source.is_dir():
raise RuntimeError(f"Bluetooth rootfs overlay not found: {source}")
paths = {
"/" + path.relative_to(source).as_posix()
for path in source.rglob("*")
if path.is_file() or path.is_symlink()
}
if paths != BLUETOOTH_RUNTIME_PATHS:
missing = sorted(BLUETOOTH_RUNTIME_PATHS - paths)
unexpected = sorted(paths - BLUETOOTH_RUNTIME_PATHS)
raise RuntimeError(f"Bluetooth overlay inventory mismatch: missing={missing}, unexpected={unexpected}")
missing_directories = [path for path in BLUETOOTH_RUNTIME_DIRECTORIES if not (source / path.removeprefix("/")).is_dir()]
if missing_directories:
raise RuntimeError(f"Bluetooth overlay is missing directories: {sorted(missing_directories)}")
manifest: dict[str, dict[str, object]] = {}
for image_path in sorted(paths):
local_path = source / image_path.removeprefix("/")
if local_path.is_symlink():
target = os.readlink(local_path)
manifest[image_path] = {"type": "symlink", "target": target}
else:
manifest[image_path] = {
"type": "file",
"sha256": sha256_file(local_path),
"mode": oct(local_path.stat().st_mode & 0o777),
}
return manifest
def add_bluetooth_rootfs(debugfs: str, image: Path, source: Path) -> dict[str, dict[str, object]]:
manifest = validate_bluetooth_rootfs(source)
conflicts = [
path for path in (*BLUETOOTH_RUNTIME_DIRECTORIES, *BLUETOOTH_RUNTIME_PATHS)
if image_path_exists(debugfs, image, path)
]
if conflicts:
raise RuntimeError(f"Bluetooth overlay overlaps the base image: {sorted(conflicts)}")
for image_path in sorted(BLUETOOTH_RUNTIME_DIRECTORIES):
ensure_image_directory(debugfs, image, image_path)
for image_path in sorted(BLUETOOTH_RUNTIME_PATHS):
add_path_to_image(debugfs, image, source / image_path.removeprefix("/"), image_path)
missing = [path for path in BLUETOOTH_RUNTIME_PATHS if not image_path_exists(debugfs, image, path)]
if missing:
raise RuntimeError(f"Candidate image is missing Bluetooth runtime paths: {sorted(missing)}")
return manifest
def replace_image_file(debugfs: str, image: Path, source: Path, destination: str) -> None:
if destination not in FACTORY_INSTALL_PATHS:
raise RuntimeError(f"Refusing to replace non-factory-install path {destination}")
@@ -852,6 +959,10 @@ def main() -> int:
debugfs, e2fsck = find_debugfs(), find_e2fsck()
work_dir = Path(args.work_dir).resolve()
work_dir.mkdir(parents=True, exist_ok=True)
bluetooth_rootfs = Path(args.bluetooth_rootfs).resolve() if args.bluetooth_rootfs else None
bluetooth_manifest = validate_bluetooth_rootfs(bluetooth_rootfs) if bluetooth_rootfs else {}
bluetooth_mutations = BLUETOOTH_RUNTIME_PATHS | BLUETOOTH_RUNTIME_DIRECTORIES
allowed_image_mutations = ALLOWED_IMAGE_MUTATIONS | (bluetooth_mutations if bluetooth_rootfs else frozenset())
if args.source_image:
source = Path(args.source_image).resolve()
@@ -945,6 +1056,8 @@ def main() -> int:
add_path_to_image(debugfs, candidate_raw, legacy_runtime_dir / Path(image_path).name, image_path)
replace_image_file(debugfs, candidate_raw, customized_setup, SETUP_PATH_IN_IMAGE)
replace_image_file(debugfs, candidate_raw, customized_installer, INSTALLER_PATH_IN_IMAGE)
if bluetooth_rootfs:
bluetooth_manifest = add_bluetooth_rootfs(debugfs, candidate_raw, bluetooth_rootfs)
if read_image_text(debugfs, candidate_raw, VERSION_PATH_IN_IMAGE) != target_version:
raise RuntimeError("Failed to write the StarPilot AGNOS version marker")
@@ -986,7 +1099,7 @@ def main() -> int:
"base_version": UPSTREAM_VERSION,
"base_raw_sha256": UPSTREAM_RAW_SHA256,
"target_version": target_version,
"allowed_image_mutations": sorted(ALLOWED_IMAGE_MUTATIONS),
"allowed_image_mutations": sorted(allowed_image_mutations),
"raw_sha256": raw_hash,
"raw_size": candidate_raw.stat().st_size,
"xz_sha256": sha256_file(output_xz),
@@ -997,6 +1110,7 @@ def main() -> int:
"starpilot_dependency_paths": list(STAR_PILOT_DEPENDENCY_PATHS),
"c3_dependency_paths": list(C3_DEPENDENCY_PATHS),
"legacy_runtime_library_paths": list(LEGACY_RUNTIME_LIBRARY_PATHS),
"bluetooth_runtime": bluetooth_manifest,
"protected_payloads": candidate_payloads,
"factory_install_payloads": candidate_factory_payloads,
"factory_reset_stack": (
@@ -1015,7 +1129,10 @@ def main() -> int:
print(f" raw sha256: {raw_hash}")
print(f" xz sha256: {metadata['xz_sha256']}")
print(f" metadata: {metadata_path}")
print(" only mutations: /VERSION, additive StarPilot runtime/C3 compatibility, and factory setup/installer branding")
mutation_summary = "/VERSION, additive StarPilot runtime/C3 compatibility, and factory setup/installer branding"
if bluetooth_rootfs:
mutation_summary += ", plus the validated additive Bluetooth runtime"
print(f" only mutations: {mutation_summary}")
if args.new_url:
manifest_path = Path(args.manifest).resolve()
+107 -9
View File
@@ -2,6 +2,9 @@
import os
import time
import argparse
import fcntl
import select
import struct
import threading
import numpy as np
import inputs
@@ -12,6 +15,7 @@ from openpilot.common.params import Params
from openpilot.common.realtime import Ratekeeper
from openpilot.common.swaglog import cloudlog
from openpilot.system.hardware import HARDWARE
from openpilot.starpilot.system.wheel_controls import connected_input_sources, selected_joystick_device
from openpilot.tools.lib.kbhit import KBHit
EXPO = 0.4
@@ -25,6 +29,11 @@ CONTROLLER_PROFILES = {
'DualSense': {'name': 'DualSense', 'steer': 'ABS_X', 'accel': 'ABS_RY', 'lo': 0., 'hi': 255.},
}
DEFAULT_PROFILE = 'X-Box'
ABS_CODES = {'ABS_X': 0, 'ABS_Y': 1, 'ABS_Z': 2, 'ABS_RX': 3, 'ABS_RY': 4, 'ABS_RZ': 5}
INPUT_EVENT = struct.Struct('@llHHi')
EV_KEY = 1
EV_ABS = 3
BTN_NORTH = 307
class Keyboard:
@@ -57,10 +66,15 @@ class Joystick:
def __init__(self):
self.cancel_button = 'BTN_NORTH'
self.is_pc = HARDWARE.get_device_type() == 'pc'
self.params = Params(return_defaults=True)
self._last_scan = 0.
self._source_id = ''
self._event_path = ''
self._event_fd = None
self._load_profile()
self._rescan(force=True)
def _load_profile(self):
def _load_profile(self, name=''):
if self.is_pc:
# DualSense over a laptop for development
accel_axis, steer_axis = 'ABS_Z', 'ABS_RX'
@@ -68,7 +82,6 @@ class Joystick:
raw_min, raw_max, self.deadzone = 0., 255., 0.03
name, prof_name = 'pc', 'pc'
else:
name = inputs.devices.gamepads[0].name if inputs.devices.gamepads else ''
prof = next((p for key, p in CONTROLLER_PROFILES.items() if key in name), CONTROLLER_PROFILES[DEFAULT_PROFILE])
accel_axis, steer_axis = prof['accel'], prof['steer']
self.flip_map = {}
@@ -82,18 +95,103 @@ class Joystick:
self.axes_order = [accel_axis, steer_axis]
self.cancel = False
def _rescan(self):
# `inputs` enumerates /dev/input once at import, so a pad that wasn't ready at boot (or was
# hot-swapped) never gets read. Re-scan so it's picked up without a restart. Throttled to 1s.
def _close_event(self):
if self._event_fd is not None:
try:
os.close(self._event_fd)
except OSError:
pass
self._event_fd = None
self._event_path = ''
self._source_id = ''
def _axis_range(self, axis_name, fallback_min, fallback_max):
if self._event_fd is None:
return fallback_min, fallback_max
axis = ABS_CODES[axis_name]
data = bytearray(24)
try:
fcntl.ioctl(self._event_fd, 0x80184540 + axis, data, True)
_value, minimum, maximum, _fuzz, _flat, _resolution = struct.unpack('iiiiii', data)
if maximum > minimum:
return float(minimum), float(maximum)
except OSError:
pass
return fallback_min, fallback_max
def _rescan(self, force=False):
now = time.monotonic()
if now - self._last_scan < 1.0:
if not force and now - self._last_scan < 1.0:
return
self._last_scan = now
inputs.devices = inputs.DeviceManager()
if not self.is_pc and inputs.devices.gamepads:
self._load_profile()
if self.is_pc:
inputs.devices = inputs.DeviceManager()
if inputs.devices.gamepads:
self._load_profile(inputs.devices.gamepads[0].name)
return
selected = selected_joystick_device(self.params)
source = next((item for item in connected_input_sources()
if item.device_id == selected and item.joystick_capable), None)
if source is None:
if self._event_fd is not None:
cloudlog.info('joystick_control: selected controller disconnected')
self._close_event()
self.axes_values = dict.fromkeys(self.axes_values, 0.)
return
if self._source_id == source.device_id and self._event_path == source.path and self._event_fd is not None:
return
self._close_event()
try:
self._event_fd = os.open(source.path, os.O_RDONLY | os.O_NONBLOCK)
except OSError:
return
self._source_id = source.device_id
self._event_path = source.path
self._load_profile(source.name)
for axis in self.axes_order:
self.min_axis_value[axis], self.max_axis_value[axis] = self._axis_range(
axis, self.min_axis_value[axis], self.max_axis_value[axis])
cloudlog.info(f"joystick_control: selected '{source.name}' at {source.path}")
def _handle_event(self, event_type, code, value):
if event_type == EV_KEY and code == BTN_NORTH:
self.cancel = value != 0
return True
if event_type != EV_ABS:
return False
event_name = next((name for name, number in ABS_CODES.items() if number == code), '')
if event_name not in self.axes_values:
return False
norm = -float(np.interp(value, [self.min_axis_value[event_name], self.max_axis_value[event_name]], [-1., 1.]))
norm = norm if abs(norm) > self.deadzone else 0.
self.axes_values[event_name] = EXPO * norm ** 3 + (1 - EXPO) * norm
return True
def update(self):
if not self.is_pc:
self._rescan()
if self._event_fd is None:
time.sleep(0.1)
return False
try:
readable, _, _ = select.select([self._event_fd], [], [], 0.1)
if not readable:
return False
data = os.read(self._event_fd, INPUT_EVENT.size * 64)
except OSError:
self._close_event()
return False
if not data:
self._close_event()
return False
handled = False
for offset in range(0, len(data) - INPUT_EVENT.size + 1, INPUT_EVENT.size):
_seconds, _microseconds, event_type, code, value = INPUT_EVENT.unpack_from(data, offset)
handled = self._handle_event(event_type, code, value) or handled
return handled
try:
joystick_event = get_gamepad()[0]
except (OSError, UnpluggedError):
+32
View File
@@ -0,0 +1,32 @@
from openpilot.tools.joystick import joystick_control
def test_evdev_axes_are_normalized_for_joystick_mode():
joystick = joystick_control.Joystick.__new__(joystick_control.Joystick)
joystick.axes_values = {"ABS_RY": 0.0, "ABS_X": 0.0}
joystick.min_axis_value = {"ABS_RY": 0.0, "ABS_X": 0.0}
joystick.max_axis_value = {"ABS_RY": 255.0, "ABS_X": 255.0}
joystick.deadzone = 0.1
joystick.cancel = False
assert joystick._handle_event(joystick_control.EV_ABS, joystick_control.ABS_CODES["ABS_X"], 0)
assert joystick.axes_values["ABS_X"] == 1.0
assert joystick._handle_event(joystick_control.EV_ABS, joystick_control.ABS_CODES["ABS_RY"], 255)
assert joystick.axes_values["ABS_RY"] == -1.0
assert joystick._handle_event(joystick_control.EV_ABS, joystick_control.ABS_CODES["ABS_X"], 128)
assert joystick.axes_values["ABS_X"] == 0.0
def test_evdev_ignores_unconfigured_axes_and_tracks_cancel_button():
joystick = joystick_control.Joystick.__new__(joystick_control.Joystick)
joystick.axes_values = {"ABS_RY": 0.0, "ABS_X": 0.0}
joystick.min_axis_value = {"ABS_RY": 0.0, "ABS_X": 0.0}
joystick.max_axis_value = {"ABS_RY": 255.0, "ABS_X": 255.0}
joystick.deadzone = 0.1
joystick.cancel = False
assert not joystick._handle_event(joystick_control.EV_ABS, joystick_control.ABS_CODES["ABS_Z"], 255)
assert joystick._handle_event(joystick_control.EV_KEY, joystick_control.BTN_NORTH, 1)
assert joystick.cancel
assert joystick._handle_event(joystick_control.EV_KEY, joystick_control.BTN_NORTH, 0)
assert not joystick.cancel