reachy_mini_hermes / tests /test_bluetooth.py
Timbo89's picture
Publish public companion experience from c47252b6
b35c494 verified
Raw
History Blame Contribute Delete
23.6 kB
from __future__ import annotations
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import cast
import pytest
from reachy_mini_hermes.bluetooth import BluetoothGamepadService, _parse_devices
@dataclass
class FakeResult:
returncode: int
stdout: str
stderr: str = ""
def test_parse_devices_strips_ansi_and_normalizes_addresses() -> None:
devices = _parse_devices(
"\x1b[0;94mDevice aa:bb:cc:dd:ee:ff Wireless Controller\x1b[0m\n"
"Device 11:22:33:44:55:66 DualSense Wireless Controller\n"
)
assert devices == {
"AA:BB:CC:DD:EE:FF": "Wireless Controller",
"11:22:33:44:55:66": "DualSense Wireless Controller",
}
def test_refresh_combines_known_paired_and_connected_devices() -> None:
def runner(command: list[str], _input: str | None, _timeout: float):
arguments = command[1:]
outputs = {
("show",): "Controller AA:BB\n Powered: yes\n",
("devices", "Paired"): "Device AA:BB:CC:DD:EE:FF Wireless Controller\n",
("devices", "Connected"): "Device AA:BB:CC:DD:EE:FF Wireless Controller\n",
("info", "AA:BB:CC:DD:EE:FF"): "Paired: yes\nBonded: yes\nConnected: yes\n",
("devices",): (
"Device AA:BB:CC:DD:EE:FF Wireless Controller\n"
"Device 11:22:33:44:55:66 Other Gamepad\n"
),
}
return FakeResult(returncode=0, stdout=outputs[tuple(arguments)])
service = BluetoothGamepadService(lambda *_args: None, command_runner=runner, adapter_available=True)
status = service.refresh()
assert status["adapter_available"] is True
assert status["adapter_powered"] is True
device_items = cast(list[dict[str, object]], status["devices"])
devices = {str(item["address"]): item for item in device_items}
assert devices["AA:BB:CC:DD:EE:FF"]["paired"] is True
assert devices["AA:BB:CC:DD:EE:FF"]["bonded"] is True
assert devices["AA:BB:CC:DD:EE:FF"]["connected"] is True
assert devices["11:22:33:44:55:66"]["paired"] is False
def test_pair_uses_one_bounded_bluetoothctl_session_and_validates_mac() -> None:
calls: list[tuple[list[str], str | None, float]] = []
def runner(command: list[str], input_text: str | None, timeout: float):
calls.append((command, input_text, timeout))
arguments = command[1:]
if arguments == ["show"]:
output = "Powered: yes"
elif arguments == ["devices", "Paired"]:
output = "Device AA:BB:CC:DD:EE:FF Wireless Controller"
elif arguments == ["devices", "Connected"]:
output = "Device AA:BB:CC:DD:EE:FF Wireless Controller"
elif arguments == ["devices"]:
output = "Device AA:BB:CC:DD:EE:FF Wireless Controller"
elif arguments == ["info", "AA:BB:CC:DD:EE:FF"]:
output = "Paired: yes\nBonded: yes\nTrusted: yes\nConnected: yes"
else:
output = "Pairing successful\nConnection successful"
return FakeResult(returncode=0, stdout=output)
service = BluetoothGamepadService(lambda *_args: None, command_runner=runner, adapter_available=True)
status = service.pair("aa:bb:cc:dd:ee:ff")
pair_call = next(call for call in calls if "pair" in call[0])
assert pair_call[0] == [
"bluetoothctl", "--timeout", "35", "--agent", "NoInputNoOutput",
"pair", "AA:BB:CC:DD:EE:FF",
]
assert pair_call[1] is None
assert ["bluetoothctl", "trust", "AA:BB:CC:DD:EE:FF"] in [call[0] for call in calls]
assert ["bluetoothctl", "connect", "AA:BB:CC:DD:EE:FF"] in [call[0] for call in calls]
paired_devices = cast(list[dict[str, object]], status["devices"])
assert paired_devices[0]["connected"] is True
assert paired_devices[0]["bonded"] is True
with pytest.raises(ValueError, match="MAC address"):
service.pair("not-a-mac")
def test_pair_requires_positive_bluez_properties_before_trust() -> None:
calls: list[list[str]] = []
def runner(command: list[str], _input: str | None, _timeout: float):
calls.append(command)
if command[1:2] == ["info"]:
return FakeResult(returncode=0, stdout="Paired: no\nTrusted: no\nConnected: no")
return FakeResult(returncode=0, stdout="Pairing request returned")
service = BluetoothGamepadService(lambda *_args: None, command_runner=runner, adapter_available=True)
with pytest.raises(RuntimeError, match="did not confirm pairing"):
service.pair("AA:BB:CC:DD:EE:FF")
assert not any(call[1:2] == ["trust"] for call in calls)
def test_pair_removes_stale_unbonded_device_and_requires_a_real_bond() -> None:
calls: list[list[str]] = []
info_calls = 0
def runner(command: list[str], _input: str | None, _timeout: float):
nonlocal info_calls
calls.append(command)
if command[1:2] == ["info"]:
info_calls += 1
return FakeResult(returncode=0, stdout="Paired: yes\nBonded: no\nConnected: yes")
return FakeResult(returncode=0, stdout="Command successful")
service = BluetoothGamepadService(lambda *_args: None, command_runner=runner, adapter_available=True)
with pytest.raises(RuntimeError, match="did not create a bond"):
service.pair("AA:BB:CC:DD:EE:FF")
assert info_calls == 2
assert calls[1] == ["bluetoothctl", "remove", "AA:BB:CC:DD:EE:FF"]
assert calls[2] == [
"bluetoothctl", "--timeout", "35", "--agent", "NoInputNoOutput",
"pair", "AA:BB:CC:DD:EE:FF",
]
assert not any(call[1:2] == ["trust"] for call in calls)
def test_scan_failure_propagates_and_retains_error() -> None:
def runner(_command: list[str], _input: str | None, _timeout: float):
return FakeResult(returncode=1, stdout="", stderr="adapter blocked")
service = BluetoothGamepadService(lambda *_args: None, command_runner=runner, adapter_available=True)
with pytest.raises(RuntimeError, match="adapter blocked"):
service.scan(seconds=5)
assert "adapter blocked" in str(service.status()["last_error"])
assert service.status()["scan_active"] is False
def successful_runner(command: list[str], _input: str | None, _timeout: float):
arguments = command[1:]
outputs = {
("power", "on"): "Changing power on succeeded",
("--timeout", "5", "scan", "on"): "Device AA:BB:CC:DD:EE:FF Wireless Controller",
("show",): "Powered: yes",
("devices",): "",
("devices", "Paired"): "",
("devices", "Connected"): "",
}
return FakeResult(returncode=0, stdout=outputs[tuple(arguments)])
successful = BluetoothGamepadService(
lambda *_args: None,
command_runner=successful_runner,
adapter_available=True,
)
devices = cast(list[dict[str, object]], successful.scan(seconds=5)["devices"])
assert devices == [
{
"address": "AA:BB:CC:DD:EE:FF",
"name": "Wireless Controller",
"paired": False,
"bonded": False,
"connected": False,
}
]
def refresh_failure_runner(command: list[str], _input: str | None, _timeout: float):
arguments = command[1:]
if arguments == ["power", "on"]:
return FakeResult(returncode=0, stdout="Changing power on succeeded")
if arguments == ["--timeout", "5", "scan", "on"]:
return FakeResult(returncode=0, stdout="Device AA:BB:CC:DD:EE:FF Wireless Controller")
return FakeResult(returncode=1, stdout="", stderr="org.bluez.Error.Failed")
refresh_failure = BluetoothGamepadService(
lambda *_args: None,
command_runner=refresh_failure_runner,
adapter_available=True,
)
with pytest.raises(RuntimeError, match="org.bluez.Error.Failed"):
refresh_failure.scan(seconds=5)
assert "org.bluez.Error.Failed" in str(refresh_failure.status()["last_error"])
def test_unsupported_joystick_fails_closed_and_state_reset_clears_axes(tmp_path, monkeypatch) -> None:
joystick = tmp_path / "js0"
joystick.write_bytes(b"")
identity = {"name": "Unsupported Flight Stick", "vendor": "054c"}
monkeypatch.setattr(
BluetoothGamepadService,
"_joystick_name",
staticmethod(lambda _fd, _path: identity["name"]),
)
monkeypatch.setattr(
BluetoothGamepadService,
"_joystick_vendor",
staticmethod(lambda _path: identity["vendor"]),
)
service = BluetoothGamepadService(
lambda *_args: None,
joystick_glob=lambda: [str(joystick)],
adapter_available=False,
)
assert service._select_joystick() == ""
service.handle_joystick_event(0x02, 0, 32_000)
service.handle_joystick_event(0x02, 2, -32_000)
assert service._axes
assert service._last_base_direction == "left"
with service._lock:
service._reset_gamepad_state_locked()
assert service._axes == {}
assert service._last_direction == ""
assert service._last_base_direction == ""
unsupported = (
("Xbox Wireless Controller", "045e"),
("Generic USB Gamepad", "1234"),
("Nintendo Switch Pro Controller", "057e"),
("DualSense Wireless Controller", "057e"),
)
for name, vendor in unsupported:
identity.update(name=name, vendor=vendor)
assert not service._is_supported_playstation_controller(name, vendor)
assert service._select_joystick() == ""
identity.update(name="Wireless Controller", vendor="054c")
assert service._is_supported_playstation_controller("Wireless Controller", "054c")
assert service._select_joystick() == str(joystick)
assert service._is_supported_playstation_controller(
"Sony Interactive Entertainment DualSense Wireless Controller", "054C"
)
def test_gamepad_mapping_is_allowlisted_debounced_and_stoppable() -> None:
actions: list[tuple[str, str, str]] = []
service = BluetoothGamepadService(
lambda kind, action, value: actions.append((kind, action, value)),
adapter_available=False,
)
service._dispatch_suspended = False
service.handle_joystick_event(0x82, 0, 32_000) # Initial-state event is ignored.
service.handle_joystick_event(0x02, 0, 32_000)
service.handle_joystick_event(0x02, 0, 32_000) # Held direction does not flood the queue.
service.handle_joystick_event(0x02, 1, -32_000)
service.handle_joystick_event(0x02, 0, 0)
service.handle_joystick_event(0x02, 1, 0)
service.handle_joystick_event(0x01, 1, 1)
service.handle_joystick_event(0x01, 0, 1)
service.handle_joystick_event(0x01, 3, 1)
service.handle_joystick_event(0x01, 2, 1)
service.handle_joystick_event(0x01, 4, 1)
service.handle_joystick_event(0x01, 5, 1)
service.handle_joystick_event(0x01, 11, 1)
service.handle_joystick_event(0x01, 12, 1)
service.handle_joystick_event(0x02, 2, -32_000)
service.handle_joystick_event(0x02, 2, -32_000) # Held right stick is debounced.
service.handle_joystick_event(0x02, 2, 0)
service.handle_joystick_event(0x02, 2, 32_000)
service.handle_joystick_event(0x01, 8, 1) # Share has no mapped action.
assert actions == [
("action", "look", "right"),
("action", "look", "up_right"),
("action", "look", "up"),
("action", "look", "center"),
("action", "emotion", "happy"),
("action", "emotion", "surprised"),
("stop", "", ""),
("precision", "body_yaw", "5.0"),
("precision", "body_yaw", "-5.0"),
("precision", "center_head", "0.0"),
("precision", "center_base", "0.0"),
("precision", "body_yaw", "5.0"),
("precision", "body_yaw", "-5.0"),
]
def test_incomplete_evdev_discovery_is_closed_and_retried() -> None:
class FakeFeatures:
def __init__(self, complete: bool) -> None:
self.complete = complete
self.closed = False
def discovery_complete(self) -> bool:
return self.complete
def status(self) -> dict[str, object]:
return {}
def handle_l2(self, _pressed: bool) -> None:
return None
def play_feedback(self, _pattern: str) -> None:
return None
def poll(self) -> bool:
return True
def close(self) -> None:
self.closed = True
attempts = [FakeFeatures(False), FakeFeatures(True)]
service = BluetoothGamepadService(
lambda *_args: None,
feature_factory=lambda _path, _dispatch: attempts.pop(0), # type: ignore[arg-type]
adapter_available=False,
)
first = attempts[0]
service._open_controller_features("/dev/input/js0")
assert first.closed is True
assert "Waiting" in str(service.status()["feature_error"])
service._open_controller_features("/dev/input/js0")
assert service._controller_features is not None
assert service.status()["feature_error"] == ""
def test_extended_feature_failure_does_not_break_legacy_joydev_actions() -> None:
actions: list[tuple[str, str, str]] = []
def broken_features(_path: str, _dispatch: object) -> object:
raise RuntimeError("evdev unavailable")
service = BluetoothGamepadService(
lambda kind, action, value: actions.append((kind, action, value)),
feature_factory=broken_features, # type: ignore[arg-type]
adapter_available=False,
)
service._dispatch_suspended = False
service._open_controller_features("/dev/input/js0")
service.handle_joystick_event(0x01, 1, 1)
assert actions == [("action", "look", "center")]
assert "evdev unavailable" in str(service.status()["feature_error"])
assert service.status()["gyro_available"] is False
def test_extended_features_are_status_visible_and_l2_is_hold_to_enable() -> None:
actions: list[tuple[str, str, str]] = []
class FakeFeatures:
def __init__(self) -> None:
self.l2: list[bool] = []
self.feedback: list[str] = []
self.closed = False
def poll(self) -> bool:
return True
def close(self) -> None:
self.closed = True
def handle_l2(self, pressed: bool) -> None:
self.l2.append(pressed)
def status(self) -> dict[str, bool]:
return {
"rumble_available": True,
"gyro_available": True,
"touchpad_available": True,
"gyro_active": bool(self.l2 and self.l2[-1]),
}
def play_feedback(self, pattern: str) -> None:
self.feedback.append(pattern)
features = FakeFeatures()
service = BluetoothGamepadService(
lambda kind, action, value: actions.append((kind, action, value)),
adapter_available=False,
)
service._controller_features = features # type: ignore[assignment]
service.handle_joystick_event(0x01, 6, 1)
service.handle_joystick_event(0x01, 6, 0)
service.handle_joystick_event(0x01, 2, 1)
assert features.l2 == [True, False]
assert features.feedback == ["stop"]
assert actions == [("stop", "", "")]
status = service.status()
assert status["rumble_available"] is True
assert status["gyro_available"] is True
assert status["touchpad_available"] is True
assert status["gyro_active"] is False
service.set_gamepad_enabled(False)
assert features.closed is True
assert service.status()["gyro_available"] is False
def test_rejected_stop_uses_rejected_feedback_not_stop_confirmation() -> None:
class FakeFeatures:
def status(self) -> dict[str, object]:
return {}
def play_feedback(self, pattern: str) -> None:
feedback.append(pattern)
def reject_stop(_kind: str, _action: str, _value: str) -> None:
raise RuntimeError("stop rejected")
feedback: list[str] = []
service = BluetoothGamepadService(reject_stop, adapter_available=False)
service._controller_features = FakeFeatures() # type: ignore[assignment]
service.handle_joystick_event(0x01, 2, 1)
assert feedback == ["rejected"]
assert service.status()["last_error"] == "stop rejected"
def test_explicit_false_callback_result_is_rejected() -> None:
service = BluetoothGamepadService(lambda *_args: False, adapter_available=False)
assert service._dispatch("stop", "", "") is False
assert service.status()["last_error"] == "Controller command was rejected"
assert service.status()["last_gamepad_action"] == ""
def test_disconnect_and_remove_abort_before_bluez_when_stop_fails() -> None:
calls: list[list[str]] = []
def runner(command: list[str], _input: str | None, _timeout: float) -> FakeResult:
calls.append(command)
return FakeResult(returncode=0, stdout="Command successful")
def reject_stop(_kind: str, _action: str, _value: str) -> None:
raise RuntimeError("motion cancellation failed")
service = BluetoothGamepadService(reject_stop, command_runner=runner, adapter_available=True)
for operation in (service.disconnect, service.remove):
with pytest.raises(RuntimeError, match="Stop failed: motion cancellation failed"):
operation("AA:BB:CC:DD:EE:FF")
assert calls == []
def test_remove_rejects_device_that_bluez_still_reports_connected_or_trusted() -> None:
calls: list[list[str]] = []
def runner(command: list[str], _input: str | None, _timeout: float) -> FakeResult:
calls.append(command)
if command[1:2] == ["info"]:
return FakeResult(returncode=0, stdout="Paired: no\nBonded: no\nTrusted: yes\nConnected: yes")
return FakeResult(returncode=0, stdout="Command successful")
service = BluetoothGamepadService(lambda *_args: None, command_runner=runner, adapter_available=True)
with pytest.raises(RuntimeError, match="paired, bonded, trusted, or connected"):
service.remove("AA:BB:CC:DD:EE:FF")
assert calls[0][1:2] == ["remove"]
assert calls[1][1:2] == ["info"]
def test_disconnect_quiesces_new_controller_input_until_bluez_finishes() -> None:
actions: list[tuple[str, str, str]] = []
disconnect_started = threading.Event()
allow_disconnect = threading.Event()
errors: list[Exception] = []
def runner(command: list[str], _input: str | None, _timeout: float) -> FakeResult:
if command[1:2] == ["disconnect"]:
disconnect_started.set()
assert allow_disconnect.wait(timeout=2.0)
return FakeResult(returncode=0, stdout="Disconnected")
if command[1:2] == ["info"]:
return FakeResult(returncode=0, stdout="Paired: yes\nBonded: yes\nTrusted: yes\nConnected: no")
if command[1:2] == ["show"]:
return FakeResult(returncode=0, stdout="Powered: yes")
return FakeResult(returncode=0, stdout="")
service = BluetoothGamepadService(
lambda kind, action, value: actions.append((kind, action, value)),
command_runner=runner,
adapter_available=True,
)
service._gamepad_enabled = True
service._dispatch_suspended = False
def run_disconnect() -> None:
try:
service.disconnect("AA:BB:CC:DD:EE:FF")
except Exception as exc: # pragma: no cover - assertion below reports it
errors.append(exc)
worker = threading.Thread(target=run_disconnect)
worker.start()
assert disconnect_started.wait(timeout=2.0)
service.handle_joystick_event(0x02, 2, -32_000)
allow_disconnect.set()
worker.join(timeout=2.0)
assert not worker.is_alive()
assert errors == []
assert actions == [("stop", "", "")]
assert service._dispatch_suspended is True
def test_connect_cannot_resume_dispatch_inside_a_newer_disconnect_transition() -> None:
actions: list[tuple[str, str, str]] = []
connect_started = threading.Event()
allow_connect = threading.Event()
disconnect_started = threading.Event()
allow_disconnect = threading.Event()
errors: list[Exception] = []
def runner(command: list[str], _input: str | None, _timeout: float) -> FakeResult:
if command[1:2] == ["connect"]:
connect_started.set()
assert allow_connect.wait(timeout=2.0)
return FakeResult(returncode=0, stdout="Connection successful")
if command[1:2] == ["disconnect"]:
disconnect_started.set()
assert allow_disconnect.wait(timeout=2.0)
return FakeResult(returncode=0, stdout="Disconnected")
if command[1:2] == ["info"]:
connected = "no" if disconnect_started.is_set() else "yes"
return FakeResult(
returncode=0,
stdout=f"Paired: yes\nBonded: yes\nTrusted: yes\nConnected: {connected}",
)
if command[1:2] == ["show"]:
return FakeResult(returncode=0, stdout="Powered: yes")
return FakeResult(returncode=0, stdout="")
service = BluetoothGamepadService(
lambda kind, action, value: actions.append((kind, action, value)),
command_runner=runner,
adapter_available=True,
)
service._gamepad_enabled = True
service._dispatch_suspended = False
def run(operation: str) -> None:
try:
getattr(service, operation)("AA:BB:CC:DD:EE:FF")
except Exception as exc: # pragma: no cover - assertion below reports it
errors.append(exc)
connect_worker = threading.Thread(target=run, args=("connect",))
disconnect_worker = threading.Thread(target=run, args=("disconnect",))
connect_worker.start()
assert connect_started.wait(timeout=2.0)
disconnect_worker.start()
allow_connect.set()
assert disconnect_started.wait(timeout=2.0)
service.handle_joystick_event(0x02, 2, -32_000)
allow_disconnect.set()
connect_worker.join(timeout=2.0)
disconnect_worker.join(timeout=2.0)
assert not connect_worker.is_alive() and not disconnect_worker.is_alive()
assert errors == []
assert actions == [("stop", "", "")]
assert service._dispatch_suspended is True
def test_bluetooth_ui_exposes_pairing_mapping_and_v26_assets() -> None:
static = Path(__file__).resolve().parents[1] / "reachy_mini_hermes" / "static"
html = (static / "index.html").read_text(encoding="utf-8")
script = (static / "main.js").read_text(encoding="utf-8")
worker = (static / "service-worker.js").read_text(encoding="utf-8")
for element_id in (
"bluetooth-device-select",
"bluetooth-scan-button",
"bluetooth-pair-button",
"bluetooth-connect-button",
"bluetooth-disconnect-button",
"bluetooth-remove-button",
"gamepad-enabled",
):
assert f'id="{element_id}"' in html
assert "Share + PS" in html
assert "Create + PS" in html
assert "Right stick rotates the base" in html
assert "L1 / R1" in html
assert "L3 centers the head" in html
assert "R3 centers the base" in html
assert "Touchpad click centers all" in html
assert "Hold L2 still for gyro calibration" in html
assert "Rumble acknowledges selected commands" in html
assert "feature_error" in script
assert "gyro_calibrating" in script
assert "Reachy Mini Wireless only" in html
assert "not supported on Reachy Mini Lite" in html
assert "/api/bluetooth/scan" in script
assert "/api/bluetooth/gamepad" in script
assert "if (body.last_error) throw new Error(body.last_error);" in script
assert "reachy-hermes-shell-v26" in worker