diff --git a/common/params_keys.h b/common/params_keys.h index 4c5bb81d8f..6004f3578d 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -16,6 +16,9 @@ inline static std::unordered_map 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 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 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}}, diff --git a/launch_env.sh b/launch_env.sh index 8da24eaa2c..3b90370440 100755 --- a/launch_env.sh +++ b/launch_env.sh @@ -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 diff --git a/selfdrive/assets/icons_mici/settings/bluetooth.png b/selfdrive/assets/icons_mici/settings/bluetooth.png new file mode 100644 index 0000000000..dc9b325290 Binary files /dev/null and b/selfdrive/assets/icons_mici/settings/bluetooth.png differ diff --git a/selfdrive/assets/icons_mici/settings/bluetooth.svg b/selfdrive/assets/icons_mici/settings/bluetooth.svg new file mode 100644 index 0000000000..7cb4a9dbb9 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/bluetooth.svg @@ -0,0 +1,3 @@ + + + diff --git a/selfdrive/ui/mici/layouts/home.py b/selfdrive/ui/mici/layouts/home.py index 3b3ca354a1..21ec72d989 100644 --- a/selfdrive/ui/mici/layouts/home.py +++ b/selfdrive/ui/mici/layouts/home.py @@ -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: diff --git a/selfdrive/ui/mici/layouts/settings/bluetooth.py b/selfdrive/ui/mici/layouts/settings/bluetooth.py new file mode 100644 index 0000000000..049c95adc1 --- /dev/null +++ b/selfdrive/ui/mici/layouts/settings/bluetooth.py @@ -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() diff --git a/selfdrive/ui/mici/layouts/settings/device.py b/selfdrive/ui/mici/layouts/settings/device.py index 153383318b..c761be204e 100644 --- a/selfdrive/ui/mici/layouts/settings/device.py +++ b/selfdrive/ui/mici/layouts/settings/device.py @@ -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 diff --git a/selfdrive/ui/mici/layouts/settings/settings.py b/selfdrive/ui/mici/layouts/settings/settings.py index 35307b4ddc..58e409df95 100644 --- a/selfdrive/ui/mici/layouts/settings/settings.py +++ b/selfdrive/ui/mici/layouts/settings/settings.py @@ -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, diff --git a/selfdrive/ui/mici/layouts/settings/tests/test_mici_device_reboot.py b/selfdrive/ui/mici/layouts/settings/tests/test_mici_device_reboot.py new file mode 100644 index 0000000000..abc4e11bed --- /dev/null +++ b/selfdrive/ui/mici/layouts/settings/tests/test_mici_device_reboot.py @@ -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)] diff --git a/selfdrive/ui/soundd.py b/selfdrive/ui/soundd.py index ddb64fa4db..08e2314f8d 100644 --- a/selfdrive/ui/soundd.py +++ b/selfdrive/ui/soundd.py @@ -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 diff --git a/selfdrive/ui/tests/test_soundd.py b/selfdrive/ui/tests/test_soundd.py index ae0b2db2bb..e4f3ccc15b 100644 --- a/selfdrive/ui/tests/test_soundd.py +++ b/selfdrive/ui/tests/test_soundd.py @@ -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']) diff --git a/starpilot/system/bluetooth/__init__.py b/starpilot/system/bluetooth/__init__.py new file mode 100644 index 0000000000..4eb051fa04 --- /dev/null +++ b/starpilot/system/bluetooth/__init__.py @@ -0,0 +1,3 @@ +from .protocol import BluetoothClient, BluetoothDevice, BluetoothStatus + +__all__ = ["BluetoothClient", "BluetoothDevice", "BluetoothStatus"] diff --git a/starpilot/system/bluetooth/audio.py b/starpilot/system/bluetooth/audio.py new file mode 100644 index 0000000000..feb02dfb05 --- /dev/null +++ b/starpilot/system/bluetooth/audio.py @@ -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) diff --git a/starpilot/system/bluetooth/bluez.py b/starpilot/system/bluetooth/bluez.py new file mode 100644 index 0000000000..f897beb09b --- /dev/null +++ b/starpilot/system/bluetooth/bluez.py @@ -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"],)) diff --git a/starpilot/system/bluetooth/daemon.py b/starpilot/system/bluetooth/daemon.py new file mode 100644 index 0000000000..c1f62ad562 --- /dev/null +++ b/starpilot/system/bluetooth/daemon.py @@ -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() diff --git a/starpilot/system/bluetooth/protocol.py b/starpilot/system/bluetooth/protocol.py new file mode 100644 index 0000000000..f28744d75e --- /dev/null +++ b/starpilot/system/bluetooth/protocol.py @@ -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) diff --git a/starpilot/system/bluetooth/radio.py b/starpilot/system/bluetooth/radio.py new file mode 100644 index 0000000000..3b7cc65e83 --- /dev/null +++ b/starpilot/system/bluetooth/radio.py @@ -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) diff --git a/starpilot/system/bluetooth/tests/__init__.py b/starpilot/system/bluetooth/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/starpilot/system/bluetooth/tests/test_bluetooth.py b/starpilot/system/bluetooth/tests/test_bluetooth.py new file mode 100644 index 0000000000..01f5501459 --- /dev/null +++ b/starpilot/system/bluetooth/tests/test_bluetooth.py @@ -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" diff --git a/starpilot/system/the_galaxy/assets/components/router.js b/starpilot/system/the_galaxy/assets/components/router.js index 111c5efc3d..65a05a571c 100644 --- a/starpilot/system/the_galaxy/assets/components/router.js +++ b/starpilot/system/the_galaxy/assets/components/router.js @@ -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), diff --git a/starpilot/system/the_galaxy/assets/components/sidebar.js b/starpilot/system/the_galaxy/assets/components/sidebar.js index f2228d2b4d..f3e0807696 100644 --- a/starpilot/system/the_galaxy/assets/components/sidebar.js +++ b/starpilot/system/the_galaxy/assets/components/sidebar.js @@ -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" }, diff --git a/starpilot/system/the_galaxy/assets/components/tools/bluetooth.css b/starpilot/system/the_galaxy/assets/components/tools/bluetooth.css new file mode 100644 index 0000000000..e6fbf2ab1d --- /dev/null +++ b/starpilot/system/the_galaxy/assets/components/tools/bluetooth.css @@ -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; +} diff --git a/starpilot/system/the_galaxy/assets/components/tools/bluetooth.js b/starpilot/system/the_galaxy/assets/components/tools/bluetooth.js new file mode 100644 index 0000000000..3a311162df --- /dev/null +++ b/starpilot/system/the_galaxy/assets/components/tools/bluetooth.js @@ -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`Audio`) + if (device.controller) badges.push(html`Controller`) + if (device.paired) badges.push(html`Paired`) + if (device.connected) badges.push(html`Connected`) + return badges +} + +function deviceActions(device) { + const audioSelected = () => state.selectedAudio.toUpperCase() === device.address.toUpperCase() + return html` +
+ ${!device.paired ? html` + + ` : html` + + ${device.audio ? html` + + ${device.connected ? html` + + ` : ""} + ` : ""} + + `} +
+ ` +} + +export function Bluetooth() { + initialize() + return html` +
+
+
+ +
+

Bluetooth

+

Connect audio devices and controllers.

+
+
+ +
+ + ${() => !state.offroad ? html`
Scanning, pairing, and forgetting devices are available offroad only.
` : ""} + ${() => state.error ? html`
${state.error}
` : ""} + ${() => state.prompt?.display_only ? html` +
${state.prompt.name || "Bluetooth device"}: ${state.prompt.value}
+ ` : ""} + ${() => state.audioTestLabel ? html` +
+ ${state.audioTestLabel} + The test sound is sent at NOW. The audible gap is Bluetooth latency. +
+ ` : ""} + +
+ + +
+ +
+ ${() => state.loading ? html`
Loading...
` : ""} + ${() => !state.loading && state.devices.length === 0 ? html` +
${state.enabled ? "No Bluetooth devices found." : "Enable Bluetooth to find devices."}
+ ` : ""} + ${() => state.devices.map((device) => html` +
+
+
+

${device.name}

+
+
${capabilityBadges(device)}
+
+ ${deviceActions(device)} +
+ `)} +
+
+ ` +} diff --git a/starpilot/system/the_galaxy/assets/components/tools/wheel_controls.css b/starpilot/system/the_galaxy/assets/components/tools/wheel_controls.css new file mode 100644 index 0000000000..5e1b999a55 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/components/tools/wheel_controls.css @@ -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); } +} diff --git a/starpilot/system/the_galaxy/assets/components/tools/wheel_controls.js b/starpilot/system/the_galaxy/assets/components/tools/wheel_controls.js new file mode 100644 index 0000000000..e8711e8115 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/components/tools/wheel_controls.js @@ -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` +
+
+ ${mapping.event_name} + ${mapping.device_name} +
+ +
+ ` +} + +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` +
+
+
+ Favorite #${index + 1} +

${configured ? (slot.label || slot.key) : "Not configured"}

+
+ +
+ ${!configured ? html` +

Choose and enable this slot in Toggles → Favorites.

+ ` : html` +

Pressing any mapped button will run this favorite.

+ `} + ${() => learning() ? html` +
Press one button on your controller, macropad, or keyboard.
+ ` : ""} +
+ ${() => mappings().length ? mappings().map(mappingRow) : html`No buttons mapped.`} +
+
+ ` +} + +function testResultClass() { + if (!state.lastTested) return "waiting" + return state.lastTested.mapped ? "success" : "failure" +} + +function testPanel() { + return html` +
+
+ + ${() => { + if (!state.lastTested) return "Waiting for a button" + return state.lastTested.mapped ? "Successful" : "Not mapped" + }} +
+

${() => { + 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.` + }}

+
+ ` +} + +function deviceRow(device) { + const selected = () => state.joystickDevice === device.device_id + return html` +
+
+ ${device.name} + ${device.joystick_capable ? "Buttons and joystick axes" : "Buttons only"} +
+ ${device.joystick_capable ? html` + + ` : ""} +
+ ` +} + +export function WheelControls() { + initialize() + return html` +
+
+
+

Controllers

+

Map buttons to favorites, or explicitly select one gamepad for Joystick Mode.

+
+
+ + +
+
+ + ${() => !state.offroad ? html`
Mappings can only be learned or changed while offroad. Mapped buttons continue working onroad.
` : ""} + ${() => state.error ? html`
${state.error}
` : ""} + ${() => !state.loading && !state.available && state.mappings.length ? html`
The wheel control service is starting.
` : ""} + ${() => state.testing ? testPanel() : ""} + +
+
+ Connected input devices + Favorite buttons are the default. Only the selected gamepad controls Joystick Mode. +
+ ${() => state.devices.length + ? html`
${state.devices.map(deviceRow)}
` + : html`Connect or pair a controller, macropad, or keyboard.`} +
+ +
+ ${() => state.loading ? html`
Loading...
` : state.slots.map(slotCard)} +
+
+ ` +} diff --git a/starpilot/system/the_galaxy/templates/index.html b/starpilot/system/the_galaxy/templates/index.html index 403a20a361..92d673081f 100644 --- a/starpilot/system/the_galaxy/templates/index.html +++ b/starpilot/system/the_galaxy/templates/index.html @@ -40,6 +40,8 @@ + + @@ -48,7 +50,7 @@