reachy_mini_hermes / tests /test_controller_features.py
Timbo89's picture
Publish public companion experience from c47252b6
b35c494 verified
Raw
History Blame Contribute Delete
18.7 kB
from __future__ import annotations
import os
from pathlib import Path
from types import SimpleNamespace
import evdev
from reachy_mini_hermes.controller_features import ControllerFeatureInterpreter, PlayStationEvdevFeatures
def test_gyro_requires_l2_calibration_and_emits_bounded_debounced_head_nudges() -> None:
actions: list[tuple[str, str, str]] = []
feedback: list[str] = []
clock = [10.0]
controller = ControllerFeatureInterpreter(
lambda kind, action, value: actions.append((kind, action, value)),
feedback.append,
monotonic=lambda: clock[0],
)
controller.set_capabilities(rumble=True, gyro=True, touchpad=False)
controller.handle_motion_frame({"ABS_RX": 0, "ABS_RY": -30_000, "ABS_RZ": 0})
assert actions == []
controller.handle_l2(True)
assert controller.status()["gyro_calibrating"] is True
for _ in range(24):
clock[0] += 0.005
controller.handle_motion_frame({"ABS_RX": 100, "ABS_RY": -50, "ABS_RZ": 25})
assert controller.status()["gyro_calibrating"] is True
clock[0] += 0.14
controller.handle_motion_frame({"ABS_RX": 100, "ABS_RY": -50, "ABS_RZ": 25})
assert controller.status()["gyro_active"] is True
controller.handle_motion_frame({"ABS_RX": 100, "ABS_RY": -30_770, "ABS_RZ": 25})
controller.handle_motion_frame({"ABS_RX": 100, "ABS_RY": -35_000, "ABS_RZ": 25})
controller.handle_motion_frame({"ABS_RX": 100, "ABS_RY": -50, "ABS_RZ": 25})
controller.handle_motion_frame({"ABS_RX": 100, "ABS_RY": 30_670, "ABS_RZ": 25})
controller.handle_motion_frame({"ABS_RX": 100, "ABS_RY": -50, "ABS_RZ": 25})
controller.handle_motion_frame({"ABS_RX": 30_820, "ABS_RY": -50, "ABS_RZ": 25})
controller.handle_motion_frame({"ABS_RX": 100, "ABS_RY": -50, "ABS_RZ": 25})
controller.handle_motion_frame({"ABS_RX": -30_620, "ABS_RY": -50, "ABS_RZ": 25})
controller.handle_motion_frame({"ABS_RX": 100, "ABS_RY": -50, "ABS_RZ": 25})
controller.handle_motion_frame({"ABS_RX": 100, "ABS_RY": -50, "ABS_RZ": 30_745})
assert actions == [
("precision", "yaw", "2.5"),
("precision", "yaw", "-2.5"),
("precision", "pitch", "-2.5"),
("precision", "pitch", "2.5"),
("precision", "roll", "-2.5"),
]
assert "gyro_on" in feedback
controller.handle_l2(False)
controller.handle_motion_frame({"ABS_RX": 0, "ABS_RY": -30_000, "ABS_RZ": 0})
assert len(actions) == 5
assert controller.status()["gyro_active"] is False
def test_touchpad_click_centers_all_and_single_finger_swipes_are_bounded() -> None:
actions: list[tuple[str, str, str]] = []
feedback: list[str] = []
clock = [20.0]
controller = ControllerFeatureInterpreter(
lambda kind, action, value: actions.append((kind, action, value)),
feedback.append,
monotonic=lambda: clock[0],
)
controller.set_capabilities(rumble=True, gyro=False, touchpad=True)
controller.touch_click()
controller.touch_down(0, 0.85, 0.50)
controller.touch_move(0, 0.25, 0.50)
clock[0] += 0.2
controller.touch_up(0)
controller.touch_down(0, 0.20, 0.50)
controller.touch_move(0, 0.80, 0.50)
clock[0] += 0.2
controller.touch_up(0)
controller.touch_down(0, 0.50, 0.80)
controller.touch_move(0, 0.50, 0.20)
clock[0] += 0.2
controller.touch_up(0)
controller.touch_down(0, 0.50, 0.20)
controller.touch_move(0, 0.50, 0.80)
clock[0] += 0.2
controller.touch_up(0)
assert actions == [
("precision", "center_all", "0.0"),
("precision", "body_yaw", "5.0"),
("precision", "body_yaw", "-5.0"),
("action", "look", "up"),
("action", "look", "down"),
]
assert feedback == ["accepted"] * 5
def test_touchpad_rejects_short_and_multitouch_gestures_and_resets_cleanly() -> None:
actions: list[tuple[str, str, str]] = []
controller = ControllerFeatureInterpreter(
lambda kind, action, value: actions.append((kind, action, value)),
lambda _pattern: None,
)
controller.set_capabilities(rumble=False, gyro=True, touchpad=True)
controller.touch_down(0, 0.50, 0.50)
controller.touch_move(0, 0.55, 0.52)
controller.touch_up(0)
controller.touch_down(0, 0.90, 0.50)
controller.touch_down(1, 0.10, 0.50)
controller.touch_move(0, 0.10, 0.50)
controller.touch_up(0)
controller.touch_up(1)
assert actions == []
controller.handle_l2(True)
controller.handle_motion_frame({"ABS_RX": 0, "ABS_RY": -30_000, "ABS_RZ": 0})
controller.reset()
controller.handle_motion_frame({"ABS_RX": 0, "ABS_RY": 30_000, "ABS_RZ": 0})
assert actions == []
assert controller.status() == {
"rumble_available": False,
"gyro_available": False,
"touchpad_available": False,
"gyro_active": False,
"gyro_calibrating": False,
"feature_input_error": "",
}
def test_noisy_gyro_calibration_fails_closed_and_cannot_move_the_base() -> None:
actions: list[tuple[str, str, str]] = []
clock = [30.0]
controller = ControllerFeatureInterpreter(
lambda kind, action, value: actions.append((kind, action, value)),
lambda _pattern: None,
monotonic=lambda: clock[0],
)
controller.set_capabilities(rumble=False, gyro=True, touchpad=False)
controller.handle_l2(True)
for index in range(26):
clock[0] += 0.01
value = 30_000 if index % 2 else -30_000
controller.handle_motion_frame({"ABS_RX": 0, "ABS_RY": value, "ABS_RZ": 0})
assert actions == []
assert controller.status()["gyro_active"] is False
assert controller.status()["gyro_calibrating"] is False
assert "moving" in controller.last_error
def test_constant_rotation_is_not_absorbed_as_gyro_bias() -> None:
clock = [35.0]
controller = ControllerFeatureInterpreter(
lambda *_args: None,
lambda _pattern: None,
monotonic=lambda: clock[0],
)
controller.set_capabilities(rumble=False, gyro=True, touchpad=False)
controller.handle_l2(True)
for _ in range(26):
clock[0] += 0.01
controller.handle_motion_frame({"ABS_RX": 0, "ABS_RY": 30_000, "ABS_RZ": 0})
assert controller.status()["gyro_active"] is False
assert "moving" in controller.last_error
def test_touchpad_click_cancels_a_click_drag_swipe() -> None:
actions: list[tuple[str, str, str]] = []
clock = [40.0]
controller = ControllerFeatureInterpreter(
lambda kind, action, value: actions.append((kind, action, value)),
lambda _pattern: None,
monotonic=lambda: clock[0],
)
controller.set_capabilities(rumble=False, gyro=False, touchpad=True)
controller.touch_down(0, 0.9, 0.5)
controller.touch_click()
controller.touch_move(0, 0.1, 0.5)
clock[0] += 0.2
controller.touch_up(0)
assert actions == [("precision", "center_all", "0.0")]
def test_capability_status_and_rumble_failures_are_fail_closed() -> None:
actions: list[tuple[str, str, str]] = []
def broken_feedback(_pattern: str) -> None:
raise OSError("write denied")
controller = ControllerFeatureInterpreter(
lambda kind, action, value: actions.append((kind, action, value)),
broken_feedback,
)
controller.set_capabilities(rumble=True, gyro=True, touchpad=True)
controller.touch_click()
assert actions == [("precision", "center_all", "0.0")]
assert controller.status() == {
"rumble_available": False,
"gyro_available": True,
"touchpad_available": True,
"gyro_active": False,
"gyro_calibrating": False,
"feature_input_error": "write denied",
}
assert "write denied" in controller.last_error
class FakeDevice:
def __init__(self, capabilities: dict[int, list[int]], *, maxima: dict[int, int] | None = None) -> None:
self.fd = os.open("/dev/null", os.O_RDWR)
self.ff_effects_count = 16
self._capabilities = capabilities
self._maxima = maxima or {}
self.events: list[SimpleNamespace] = []
self.uploaded: list[object] = []
self.writes: list[tuple[int, int, int]] = []
self.erased: list[int] = []
self.closed = False
def capabilities(self, *, absinfo: bool = True) -> dict[int, list[int]]:
return self._capabilities
def absinfo(self, code: int) -> SimpleNamespace:
return SimpleNamespace(max=self._maxima.get(code, 1), resolution=1_024)
def read(self) -> list[SimpleNamespace]:
events, self.events = self.events, []
return events
def upload_effect(self, effect: object) -> int:
self.uploaded.append(effect)
return 7
def write(self, event_type: int, code: int, value: int) -> None:
self.writes.append((event_type, code, value))
def erase_effect(self, effect_id: int) -> None:
self.erased.append(effect_id)
def close(self) -> None:
if not self.closed:
os.close(self.fd)
self.closed = True
def test_discovery_requires_exact_ds4_hid_parent_and_detects_capabilities(tmp_path: Path) -> None:
codes = evdev.ecodes
sys_input = tmp_path / "sys-class-input"
hid = tmp_path / "devices" / "0005:054C:09CC.0001"
other_hid = tmp_path / "devices" / "0005:054C:09CC.0002"
def link_input(class_name: str, parent: Path, input_name: str) -> Path:
device = parent / "input" / input_name
device.mkdir(parents=True)
class_dir = sys_input / class_name
class_dir.mkdir(parents=True)
(class_dir / "device").symlink_to(device)
return device
js_device = link_input("js0", hid, "input0")
(js_device / "id").mkdir()
(js_device / "id" / "vendor").write_text("054c\n", encoding="ascii")
(js_device / "id" / "product").write_text("09cc\n", encoding="ascii")
(js_device / "uniq").write_text("a4:ae:12:a6:9f:be\n", encoding="ascii")
link_input("event0", hid, "input1")
link_input("event1", hid, "input2")
link_input("event2", hid, "input3")
link_input("event9", other_hid, "input9")
capabilities = {
"event0": {codes.EV_KEY: [codes.BTN_TL2], codes.EV_FF: [codes.FF_RUMBLE]},
"event1": {codes.EV_ABS: [codes.ABS_RX, codes.ABS_RY, codes.ABS_RZ]},
"event2": {
codes.EV_KEY: [codes.BTN_LEFT],
codes.EV_ABS: [
codes.ABS_MT_SLOT,
codes.ABS_MT_TRACKING_ID,
codes.ABS_MT_POSITION_X,
codes.ABS_MT_POSITION_Y,
],
},
"event9": {codes.EV_KEY: [codes.BTN_TL2], codes.EV_FF: [codes.FF_RUMBLE]},
}
names = {
"event0": "Wireless Controller",
"event1": "Wireless Controller Motion Sensors",
"event2": "Wireless Controller Touchpad",
"event9": "Wireless Controller",
}
opened: list[str] = []
def input_device(path: str) -> FakeDevice:
event_name = Path(path).name
opened.append(event_name)
device = FakeDevice(
capabilities[event_name],
maxima={codes.ABS_MT_POSITION_X: 1_920, codes.ABS_MT_POSITION_Y: 942},
)
device.name = names[event_name] # type: ignore[attr-defined]
device.uniq = "a4:ae:12:a6:9f:be" # type: ignore[attr-defined]
device.info = SimpleNamespace(bustype=0x0005, vendor=0x054C, product=0x09CC) # type: ignore[attr-defined]
return device
evdev_module = SimpleNamespace(ecodes=codes, ff=evdev.ff, InputDevice=input_device)
interpreter = ControllerFeatureInterpreter(lambda *_args: None, lambda _pattern: None)
features = PlayStationEvdevFeatures.discover(
"/dev/input/js0",
interpreter,
evdev_module=evdev_module,
event_glob=lambda: [f"/dev/input/event{number}" for number in (0, 1, 2, 9)],
sys_class_input=sys_input,
)
assert opened == ["event0", "event1", "event2"]
assert features.status() == {
"rumble_available": True,
"gyro_available": True,
"touchpad_available": True,
"gyro_active": False,
"gyro_calibrating": False,
"feature_input_error": "",
}
features.close()
def test_rumble_cleanup_erases_effect_even_if_stop_write_fails() -> None:
codes = evdev.ecodes
class StopFailDevice(FakeDevice):
def write(self, event_type: int, code: int, value: int) -> None:
if event_type == codes.EV_FF and value == 0:
raise OSError("controller disconnected while stopping effect")
super().write(event_type, code, value)
gamepad = StopFailDevice({codes.EV_KEY: [codes.BTN_TL2], codes.EV_FF: [codes.FF_RUMBLE]})
holder: dict[str, PlayStationEvdevFeatures] = {}
interpreter = ControllerFeatureInterpreter(
lambda *_args: None,
lambda pattern: holder["features"].rumble(pattern),
)
features = PlayStationEvdevFeatures(interpreter, gamepad=gamepad, evdev_module=evdev)
holder["features"] = features
interpreter.feedback("accepted")
features.close()
assert gamepad.erased == [7]
def test_syn_dropped_discards_all_events_through_recovery_report() -> None:
codes = evdev.ecodes
actions: list[tuple[str, str, str]] = []
gamepad = FakeDevice({codes.EV_KEY: [codes.BTN_TL2]})
sensors = FakeDevice({codes.EV_ABS: [codes.ABS_RX, codes.ABS_RY, codes.ABS_RZ]})
touchpad = FakeDevice(
{
codes.EV_KEY: [codes.BTN_LEFT],
codes.EV_ABS: [
codes.ABS_MT_SLOT,
codes.ABS_MT_TRACKING_ID,
codes.ABS_MT_POSITION_X,
codes.ABS_MT_POSITION_Y,
],
}
)
interpreter = ControllerFeatureInterpreter(
lambda kind, action, value: actions.append((kind, action, value)),
lambda _pattern: None,
)
features = PlayStationEvdevFeatures(
interpreter,
gamepad=gamepad,
sensors=sensors,
touchpad=touchpad,
evdev_module=evdev,
)
gamepad.events = [
SimpleNamespace(type=codes.EV_SYN, code=codes.SYN_DROPPED, value=0),
SimpleNamespace(type=codes.EV_KEY, code=codes.BTN_TL2, value=1),
SimpleNamespace(type=codes.EV_SYN, code=codes.SYN_REPORT, value=0),
]
sensors.events = [
SimpleNamespace(type=codes.EV_SYN, code=codes.SYN_DROPPED, value=0),
SimpleNamespace(type=codes.EV_ABS, code=codes.ABS_RX, value=30_000),
SimpleNamespace(type=codes.EV_ABS, code=codes.ABS_RY, value=30_000),
SimpleNamespace(type=codes.EV_ABS, code=codes.ABS_RZ, value=30_000),
SimpleNamespace(type=codes.EV_SYN, code=codes.SYN_REPORT, value=0),
]
touchpad.events = [
SimpleNamespace(type=codes.EV_SYN, code=codes.SYN_DROPPED, value=0),
SimpleNamespace(type=codes.EV_KEY, code=codes.BTN_LEFT, value=1),
SimpleNamespace(type=codes.EV_SYN, code=codes.SYN_REPORT, value=0),
]
assert features.poll() is True
assert actions == []
assert interpreter.status()["gyro_calibrating"] is False
touchpad.events = [SimpleNamespace(type=codes.EV_KEY, code=codes.BTN_LEFT, value=1)]
assert features.poll() is True
assert actions == [("precision", "center_all", "0.0")]
features.close()
def test_node_failure_discards_sibling_events_from_same_poll_cycle() -> None:
codes = evdev.ecodes
actions: list[tuple[str, str, str]] = []
class FailedDevice(FakeDevice):
def read(self) -> list[SimpleNamespace]:
raise OSError("device disconnected")
gamepad = FakeDevice({codes.EV_KEY: [codes.BTN_TL2]})
sensors = FailedDevice({codes.EV_ABS: [codes.ABS_RX, codes.ABS_RY, codes.ABS_RZ]})
touchpad = FakeDevice({codes.EV_KEY: [codes.BTN_LEFT]})
interpreter = ControllerFeatureInterpreter(
lambda kind, action, value: actions.append((kind, action, value)),
lambda _pattern: None,
)
features = PlayStationEvdevFeatures(
interpreter,
gamepad=gamepad,
sensors=sensors,
touchpad=touchpad,
evdev_module=evdev,
)
gamepad.events = [SimpleNamespace(type=codes.EV_KEY, code=codes.BTN_TL2, value=1)]
touchpad.events = [SimpleNamespace(type=codes.EV_KEY, code=codes.BTN_LEFT, value=1)]
assert features.poll() is False
assert actions == []
assert interpreter.status()["gyro_calibrating"] is False
features.close()
def test_evdev_adapter_polls_motion_touch_and_cleans_rumble_effects() -> None:
codes = evdev.ecodes
actions: list[tuple[str, str, str]] = []
clock = [10.0]
gamepad = FakeDevice(
{
codes.EV_KEY: [codes.BTN_TL2],
codes.EV_FF: [codes.FF_RUMBLE],
}
)
sensors = FakeDevice({codes.EV_ABS: [codes.ABS_RX, codes.ABS_RY, codes.ABS_RZ]})
touchpad = FakeDevice(
{
codes.EV_KEY: [codes.BTN_LEFT],
codes.EV_ABS: [
codes.ABS_MT_SLOT,
codes.ABS_MT_TRACKING_ID,
codes.ABS_MT_POSITION_X,
codes.ABS_MT_POSITION_Y,
],
},
maxima={codes.ABS_MT_POSITION_X: 1_920, codes.ABS_MT_POSITION_Y: 942},
)
holder: dict[str, PlayStationEvdevFeatures] = {}
interpreter = ControllerFeatureInterpreter(
lambda kind, action, value: actions.append((kind, action, value)),
lambda pattern: holder["features"].rumble(pattern),
)
features = PlayStationEvdevFeatures(
interpreter,
gamepad=gamepad,
sensors=sensors,
touchpad=touchpad,
evdev_module=evdev,
monotonic=lambda: clock[0],
)
holder["features"] = features
gamepad.events = [SimpleNamespace(type=codes.EV_KEY, code=codes.BTN_TL2, value=1)]
sensors.events = [
SimpleNamespace(type=codes.EV_ABS, code=codes.ABS_RX, value=0),
SimpleNamespace(type=codes.EV_ABS, code=codes.ABS_RY, value=0),
SimpleNamespace(type=codes.EV_ABS, code=codes.ABS_RZ, value=0),
SimpleNamespace(type=codes.EV_SYN, code=codes.SYN_REPORT, value=0),
]
touchpad.events = [SimpleNamespace(type=codes.EV_KEY, code=codes.BTN_LEFT, value=1)]
assert features.poll() is True
assert actions == [("precision", "center_all", "0.0")]
assert interpreter.status()["gyro_calibrating"] is True
assert gamepad.uploaded
assert gamepad.writes[-1] == (codes.EV_FF, 7, 1)
clock[0] = 11.0
assert features.poll() is True
assert gamepad.erased[-1] == 7
features.close()
assert gamepad.closed and sensors.closed and touchpad.closed
assert interpreter.status()["gyro_active"] is False