mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-10 18:23:44 +08:00
bluey
This commit is contained in:
@@ -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
@@ -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 |
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .protocol import BluetoothClient, BluetoothDevice, BluetoothStatus
|
||||
|
||||
__all__ = ["BluetoothClient", "BluetoothDevice", "BluetoothStatus"]
|
||||
@@ -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)
|
||||
@@ -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"],))
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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},
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user