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, BluetoothClient, BluetoothDevice, BluetoothStatus, device_capabilities, show_pairing_device) from openpilot.system import hardware from openpilot.system.ui.lib.bluetooth_manager import BluetoothManager 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 set_auto_accept_incoming(self, _enabled): pass 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.discoverable = False self.discovering = False self.closed = False self.actions = [] self.connect_timeouts = [] self.connect_error = None self.device = { "path": "/fake/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 set_discoverable(self, discoverable): self.discoverable = discoverable 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 self.actions.append(("stop_scan", "")) def device_for_address(self, _address): return dict(self.device) def pair(self, address, _device_path=None): self.actions.append(("pair", address)) def connect(self, address, timeout=30.0): self.actions.append(("connect", address)) self.connect_timeouts.append(timeout) if self.connect_error is not None: raise self.connect_error 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 BlockingStopRadio(FakeRadio): def __init__(self): super().__init__() self.stop_started = threading.Event() self.allow_stop = threading.Event() def stop(self): self.stops += 1 self.stop_started.set() self.allow_stop.wait() class BlockingPowerClient: def __init__(self): self.power_entered = threading.Event() self.allow_power = threading.Event() self.power_finished = threading.Event() self.status_calls = 0 def set_power(self, _enabled): self.power_entered.set() self.allow_power.wait() self.power_finished.set() def status(self): self.status_calls += 1 return BluetoothStatus() 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", "Media Remote", False, False, False, False, False, True, True) assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, 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_desktop_fake_bluetooth_is_stateful_and_interactive(monkeypatch, tmp_path): monkeypatch.setenv("SP_ALLOW_DESKTOP_FAKE_BLUETOOTH", "1") monkeypatch.setenv("SIMULATION", "1") monkeypatch.setenv("NOBOARD", "1") client = BluetoothClient(socket_path=str(tmp_path / "bluetooth.sock")) initial = client.status() speaker, controller = initial.devices[:2] assert initial.available and initial.enabled and speaker.connected client.start_scan() assert client.status().discovering client.pair(controller.address) client.connect(controller.address) paired_controller = next(device for device in client.status().devices if device.address == controller.address) assert paired_controller.paired and paired_controller.trusted and paired_controller.connected client.select_audio(speaker.address) assert client.status().selected_audio == speaker.address assert client.test_audio(speaker.address) == 3.0 client.forget(controller.address) forgotten_controller = next(device for device in client.status().devices if device.address == controller.address) assert not forgotten_controller.paired and not forgotten_controller.connected client.set_power(False) disabled = client.status() assert not disabled.enabled and not disabled.powered and not disabled.discovering assert disabled.selected_audio == speaker.address with pytest.raises(RuntimeError, match="Enable Bluetooth"): client.start_scan() client.set_power(True) enabled = client.status() assert enabled.enabled and enabled.selected_audio == speaker.address def test_desktop_fake_bluetooth_cannot_activate_on_device(monkeypatch, tmp_path): monkeypatch.setenv("SP_ALLOW_DESKTOP_FAKE_BLUETOOTH", "1") monkeypatch.setenv("SIMULATION", "1") monkeypatch.setenv("NOBOARD", "1") monkeypatch.setattr(hardware, "PC", False) client = BluetoothClient(socket_path=str(tmp_path / "bluetooth.sock")) assert client._get_desktop_fake() is None assert client._desktop_fake is None def test_pairing_agent_accept_reject_and_timeout(): agent = PairingAgent() agent.set_auto_accept_incoming(True) assert agent.request("confirmation", "/incoming", "123456") == (True, "") agent.set_auto_accept_incoming(False) 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_enabled_initialization_registers_bluetooth_agent_without_ui_poll(): params = FakeParams(IsOffroad=True, BluetoothEnabled=True) radio = FakeRadio() created = [] controller = BluetoothController(params, lambda: created.append(FakeBlueZ()) or created[-1], radio) controller.initialize() assert radio.starts == 1 and len(created) == 1 assert created[0].powered 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_disconnect_is_idempotent_and_suppresses_auto_reconnect(): params = FakeParams(IsOffroad=False, BluetoothEnabled=True) client = FakeBlueZ() controller = BluetoothController(params, lambda: client, FakeRadio()) controller._policy_disconnected.add(client.device["address"].upper()) controller.handle({"command": "disconnect", "address": client.device["address"]}) address = client.device["address"].upper() assert client.actions == [("disconnect", client.device["address"])] assert address in controller._manual_disconnect_until assert controller._manual_disconnect_until[address] > time.monotonic() assert address not in controller._policy_disconnected def test_power_off_preserves_saved_audio_selection(): params = FakeParams(IsOffroad=True, BluetoothEnabled=False, BluetoothAudioAddress="00:11:22:33:44:55") controller = BluetoothController(params, FakeBlueZ, FakeRadio()) controller.handle({"command": "set_power", "enabled": True}) controller.handle({"command": "set_power", "enabled": False}) assert params.get("BluetoothAudioAddress") == "00:11:22:33:44:55" def test_status_does_not_restart_radio_during_disable(): params = FakeParams(IsOffroad=True, BluetoothEnabled=True) radio = BlockingStopRadio() client = FakeBlueZ() controller = BluetoothController(params, lambda: client, radio) controller._bluez = client errors = [] def disable(): try: controller.handle({"command": "set_power", "enabled": False}) except Exception as error: errors.append(error) status_started = threading.Event() status_done = threading.Event() status_result = [] def read_status(): status_started.set() status_result.append(controller.status()) status_done.set() worker = threading.Thread(target=disable, daemon=True) worker.start() assert radio.stop_started.wait(timeout=1.0) status_worker = threading.Thread(target=read_status, daemon=True) status_worker.start() try: assert status_started.wait(timeout=1.0) assert not status_done.wait(timeout=0.1) finally: radio.allow_stop.set() worker.join(timeout=1.0) status_worker.join(timeout=1.0) assert not worker.is_alive() assert not status_worker.is_alive() assert errors == [] assert radio.starts == 0 assert radio.stops == 1 status = status_result[0] assert not status["enabled"] assert not params.get_bool("BluetoothEnabled") def test_status_poll_does_not_overlap_power_transition(): client = BlockingPowerClient() manager = object.__new__(BluetoothManager) manager._client = client manager._lock = threading.Lock() manager._client_lock = threading.Lock() manager._status = BluetoothStatus() manager._active = True manager._exit = False manager._operation_error = "" manager._operations = {} manager._power_pending = False manager._audio_test_deadline = 0.0 manager.set_power(True) assert client.power_entered.wait(timeout=1.0) poller = threading.Thread(target=manager._poll_status) poller.start() poller.join(timeout=1.0) client.allow_power.set() assert client.power_finished.wait(timeout=1.0) assert not poller.is_alive() assert client.status_calls == 0 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_controller_offroad_disconnect_policy_is_opt_in_and_delayed(): params = FakeParams(IsOffroad=True, BluetoothEnabled=True, BluetoothDisconnectControllersOffroad=False) client = FakeBlueZ() controller = BluetoothController(params, lambda: client, FakeRadio()) controller._bluez = client controller_status = { "offroad": True, "devices": [ {**client.device, "name": "Controller", "audio": False, "controller": True, "connected": True}, {**client.device, "address": "AA:BB:CC:DD:EE:FF", "audio": True, "controller": False, "connected": True}, ], } assert not controller._maintain_controller_offroad_policy(controller_status, 100.0) params.put_bool("BluetoothDisconnectControllersOffroad", True) assert not controller._maintain_controller_offroad_policy(controller_status, 219.9) assert client.actions == [] assert controller._maintain_controller_offroad_policy(controller_status, 220.0) assert client.actions == [("disconnect", client.device["address"])] assert client.device["address"].upper() in controller._policy_disconnected assert controller._maintain_controller_offroad_policy(controller_status, 221.0) assert client.actions == [("disconnect", client.device["address"])] def test_controller_offroad_disconnect_policy_reconnects_onroad(): params = FakeParams(IsOffroad=True, BluetoothEnabled=True, BluetoothDisconnectControllersOffroad=True) client = FakeBlueZ() controller = BluetoothController(params, lambda: client, FakeRadio()) controller._bluez = client address = "00:11:22:33:44:55" controller._offroad_since = 100.0 controller._policy_disconnected.add(address) controller._reconnect_backoff[address] = (3, 500.0) controller._last_reconnect = 210.0 disconnected_status = { "offroad": False, "selected_audio": "", "devices": [{**client.device, "controller": False, "connected": False}], } assert not controller._maintain_controller_offroad_policy(disconnected_status, 220.0) assert controller._offroad_since is None assert controller._policy_disconnected == {address} assert controller._policy_disconnect_retry_after == {} assert address not in controller._reconnect_backoff assert controller._last_reconnect == 0.0 controller._maintain_reconnects(disconnected_status, 220.0, False) assert client.actions == [("connect", address)] assert client.connect_timeouts == [5.0] connected_status = { **disconnected_status, "devices": [{**client.device, "controller": False, "connected": True}], } controller._maintain_reconnects(connected_status, 221.0, False) assert controller._policy_disconnected == set() def test_controller_auto_reconnect_uses_fixed_short_retry(): params = FakeParams(IsOffroad=False, BluetoothEnabled=True) client = FakeBlueZ() client.connect_error = RuntimeError("Host is down") controller = BluetoothController(params, lambda: client, FakeRadio()) controller._bluez = client status = { "offroad": False, "selected_audio": "", "devices": [{**client.device, "audio": False, "controller": True, "connected": False}], } controller._maintain_reconnects(status, 100.0, False) assert controller._reconnect_backoff[client.device["address"]] == (1, 105.0) controller._maintain_reconnects(status, 105.0, False) assert controller._reconnect_backoff[client.device["address"]] == (2, 110.0) assert client.connect_timeouts == [5.0, 5.0] def test_pair_keeps_discovery_until_pair_starts(): params = FakeParams(IsOffroad=True, BluetoothEnabled=True) client = FakeBlueZ() controller = BluetoothController(params, lambda: client, FakeRadio()) controller.handle({"command": "start_scan"}) controller.handle({"command": "pair", "address": client.device["address"]}) deadline = time.monotonic() + 1.0 while not any(action[0] == "pair" for action in client.actions) and time.monotonic() < deadline: time.sleep(0.01) pair_index = client.actions.index(("pair", client.device["address"])) assert client.actions[-1] == ("stop_scan", "") assert pair_index < len(client.actions) - 1 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"