Files
firestarsdog 6170955afc Tickle Me ELMo
2026-09-06 00:54:36 -04:00

670 lines
23 KiB
Python

import io
import threading
import time
import numpy as np
import pytest
from openpilot.starpilot.system.bluetooth.audio import BluetoothAudioSink
import openpilot.starpilot.system.bluetooth.daemon as bluetooth_daemon
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,
SPP_UUID, 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.device = {
"path": "/fake/device",
"address": "00:11:22:33:44:55",
"name": "Speaker",
"paired": True,
"trusted": True,
"connected": False,
"audio": True,
"controller": False,
"serial": 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):
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 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
class FakeELM:
instances = []
def __init__(self, address):
self.address = address
self.adapter_name = "Fake ELM327"
self.closed = False
self.commands = []
self.opened = False
FakeELM.instances.append(self)
def open(self):
self.opened = True
return self.adapter_name
def close(self):
self.closed = True
def command(self, value):
self.commands.append(value)
if value == "fail":
raise RuntimeError("transport failed")
return f"response for {value}"
def read_dtcs(self):
return {"codes": ["P0133"], "raw": "43 01 33"}
def test_protocol_round_trip_and_capabilities():
audio, controller, serial = device_capabilities([A2DP_SINK_UUID, HID_UUID])
assert audio and controller
assert not serial
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_serial_capability_round_trips_and_is_discoverable():
audio, controller, serial = device_capabilities([SPP_UUID.upper()])
assert not audio and not controller and serial
status = BluetoothStatus.from_dict({
"devices": [{"address": "00:11:22:33:44:55", "name": "OBDII", "serial": True}],
})
assert status.devices[0].serial
assert show_pairing_device("00:11:22:33:44:55", "OBDII", False, False, False, False,
audio=False, controller=False, serial=True)
def test_serial_device_is_not_auto_reconnected_but_audio_device_is(monkeypatch):
class StopMaintenance(Exception):
pass
params = FakeParams(IsOffroad=True, BluetoothEnabled=True)
client = FakeBlueZ()
client.powered = True
client.device.update(paired=True, trusted=True, connected=False, audio=False, controller=False, serial=True)
sleeps = 0
def sleep(_delay):
nonlocal sleeps
sleeps += 1
if sleeps > 1:
raise StopMaintenance
monkeypatch.setattr(bluetooth_daemon.time, "sleep", sleep)
controller = BluetoothController(params, lambda: client, FakeRadio(), sleep=sleep, elm_factory=FakeELM)
controller._bluez = client
controller._last_reconnect = -100.0
with pytest.raises(StopMaintenance):
controller.maintain_connections()
assert client.actions == []
client.device.update(audio=True, serial=False)
sleeps = 0
controller._last_reconnect = -100.0
with pytest.raises(StopMaintenance):
controller.maintain_connections()
assert client.actions == [("connect", client.device["address"])]
def make_elm_controller(elm_factory=FakeELM):
params = FakeParams(IsOffroad=True, BluetoothEnabled=True)
client = FakeBlueZ()
client.device.update(paired=True, trusted=True, serial=True)
controller = BluetoothController(params, lambda: client, FakeRadio(), elm_factory=elm_factory)
return controller, client, params
def test_elm_is_lazy_and_requires_a_paired_serial_device():
FakeELM.instances = []
controller, client, params = make_elm_controller()
assert FakeELM.instances == []
controller.status()
assert FakeELM.instances == []
result = controller.handle({"command": "elm_open", "address": client.device["address"]})
assert result == {"adapter": "Fake ELM327"}
assert len(FakeELM.instances) == 1
params.values["IsOffroad"] = False
with pytest.raises(RuntimeError, match="offroad"):
controller.handle({"command": "elm_command", "address": client.device["address"], "value": "ATI"})
params.values["IsOffroad"] = True
client.device["paired"] = False
controller.handle({"command": "elm_close", "address": client.device["address"]})
with pytest.raises(RuntimeError, match="Pair"):
controller.handle({"command": "elm_open", "address": client.device["address"]})
client.device.update(paired=True, serial=False)
with pytest.raises(RuntimeError, match="Serial Port Profile"):
controller.handle({"command": "elm_open", "address": client.device["address"]})
def test_elm_commands_use_one_session_and_close_on_transport_failure():
FakeELM.instances = []
controller, client, _ = make_elm_controller()
address = client.device["address"]
controller.handle({"command": "elm_open", "address": address})
assert controller.handle({"command": "elm_open", "address": address}) == {"adapter": "Fake ELM327"}
assert controller.handle({"command": "elm_command", "address": address, "value": "ATI"}) == {"response": "response for ATI"}
assert controller.handle({"command": "elm_read_dtcs", "address": address}) == {"codes": ["P0133"], "raw": "43 01 33"}
with pytest.raises(RuntimeError, match="transport"):
controller.handle({"command": "elm_command", "address": address, "value": "fail"})
assert controller._elm is None
assert FakeELM.instances[0].closed
def test_elm_open_replaces_a_different_session_and_close_is_allowed_onroad():
FakeELM.instances = []
controller, client, params = make_elm_controller()
first = client.device["address"]
second = "AA:BB:CC:DD:EE:FF"
controller.handle({"command": "elm_open", "address": first})
controller.handle({"command": "elm_open", "address": second})
assert len(FakeELM.instances) == 2
assert FakeELM.instances[0].closed
assert not FakeELM.instances[1].closed
params.values["IsOffroad"] = False
controller.handle({"command": "elm_close", "address": second})
assert FakeELM.instances[1].closed and controller._elm is None
def test_elm_cleanup_happens_before_poweroff_forget_shutdown_and_onroad(monkeypatch):
class StopMaintenance(Exception):
pass
for cleanup in ("power", "forget", "shutdown", "onroad"):
FakeELM.instances = []
controller, client, params = make_elm_controller()
address = client.device["address"]
controller.handle({"command": "elm_open", "address": address})
session = FakeELM.instances[0]
if cleanup == "power":
controller.handle({"command": "set_power", "enabled": False})
elif cleanup == "forget":
controller.handle({"command": "forget", "address": address})
elif cleanup == "shutdown":
controller.close()
else:
params.values["IsOffroad"] = False
sleeps = 0
def sleep(_delay):
nonlocal sleeps
sleeps += 1
if sleeps > 1:
raise StopMaintenance
monkeypatch.setattr(bluetooth_daemon.time, "sleep", sleep)
controller._sleep = sleep
with pytest.raises(StopMaintenance):
controller.maintain_connections()
assert session.closed and controller._elm is None
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, discovering=True)
assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False, discovering=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.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()
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_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"