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