From c9fe126c27531f5ad8c020789c85ce7a99217cbf Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Mon, 7 Jul 2025 08:47:56 +0200 Subject: [PATCH 1/3] Implement HID++ 2.0 fake device for some basic requests The fake device is a simplified, working implementation of a HID++ 2.0 device, that behaves similar to the real hardware. This allows tests to be closer to the real world. Related #2905 --- lib/logitech_receiver/hidpp20.py | 10 +-- tests/conftest.py | 8 +++ tests/fakes/__init__.py | 0 tests/fakes/hidpp20_device.py | 62 ++++++++++++++++++ tests/logitech_receiver/test_hidpp20.py | 83 +++++++++++++++++++++++++ 5 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/fakes/__init__.py create mode 100644 tests/fakes/hidpp20_device.py create mode 100644 tests/logitech_receiver/test_hidpp20.py diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 49a1c76f..865682c8 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -1576,7 +1576,7 @@ class Hidpp20: except Exception: return None - def get_name(self, device: Device): + def get_name(self, device: Device) -> str: """Reads a device's name. :returns: a string with the device name, or ``None`` if the device is not @@ -1692,7 +1692,7 @@ class Hidpp20: if SupportedFeature.ONBOARD_PROFILES in device.features: return OnboardProfiles.from_device(device) - def get_mouse_pointer_info(self, device: Device): + def get_mouse_pointer_info(self, device: Device) -> dict[str, Any]: pointer_info = device.feature_request(SupportedFeature.MOUSE_POINTER) if pointer_info: dpi, flags = struct.unpack("!HB", pointer_info[:3]) @@ -1706,7 +1706,7 @@ class Hidpp20: "suggest_vertical_orientation": suggest_vertical_orientation, } - def get_vertical_scrolling_info(self, device: Device): + def get_vertical_scrolling_info(self, device: Device) -> dict[str, Any]: vertical_scrolling_info = device.feature_request(SupportedFeature.VERTICAL_SCROLLING) if vertical_scrolling_info: roller, ratchet, lines = struct.unpack("!BBB", vertical_scrolling_info[:3]) @@ -1722,13 +1722,13 @@ class Hidpp20: )[roller] return {"roller": roller_type, "ratchet": ratchet, "lines": lines} - def get_hi_res_scrolling_info(self, device: Device): + def get_hi_res_scrolling_info(self, device: Device) -> tuple[int, int]: hi_res_scrolling_info = device.feature_request(SupportedFeature.HI_RES_SCROLLING) if hi_res_scrolling_info: mode, resolution = struct.unpack("!BB", hi_res_scrolling_info[:2]) return mode, resolution - def get_pointer_speed_info(self, device: Device): + def get_pointer_speed_info(self, device: Device) -> float: pointer_speed_info = device.feature_request(SupportedFeature.POINTER_SPEED) if pointer_speed_info: pointer_speed_hi, pointer_speed_lo = struct.unpack("!BB", pointer_speed_info[:2]) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..7ecc4245 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest + +from .fakes import hidpp20_device + + +@pytest.fixture +def device(): + yield hidpp20_device.Hidpp20Device() diff --git a/tests/fakes/__init__.py b/tests/fakes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/fakes/hidpp20_device.py b/tests/fakes/hidpp20_device.py new file mode 100644 index 00000000..9b1f1faa --- /dev/null +++ b/tests/fakes/hidpp20_device.py @@ -0,0 +1,62 @@ +from logitech_receiver.hidpp20_constants import SupportedFeature + + +class Hidpp20Device: + def __init__(self): + self._iteration = 0 + + def feature_request(self, feature, function: int = 0x00, *params, no_reply: bool = False) -> bytes: + self._iteration += 1 + if feature == SupportedFeature.DEVICE_FW_VERSION: + if function == 0x00: + self._iteration = 1 + return bytes([0x02, 0xFF, 0xFF]) + elif self._iteration == 2 and function == 0x10: + return bytes.fromhex("01414243030401000101000102030405") + elif self._iteration == 3: + self._iteration = 0 + return bytes.fromhex("02414243030401000101000102030405") + elif feature == SupportedFeature.DEVICE_NAME: + if function == 0x00: + self._iteration = 1 + return bytes([0x12]) + elif function == 0x10: + if self._iteration == 2: + return bytes.fromhex("4142434445464748494A4B4C4D4E4F") + elif self._iteration == 3: + return bytes.fromhex("505152530000000000000000000000") + elif function == 0x20: + keyboard = 0x00 + return bytes([keyboard]) + elif feature == SupportedFeature.DEVICE_FRIENDLY_NAME: + if function == 0x00: + self._iteration = 1 + return bytes([0x12]) + elif function == 0x10: + if self._iteration == 2: + return bytes.fromhex("004142434445464748494A4B4C4D4E") + elif self._iteration == 3: + return bytes.fromhex("0E4F50515253000000000000000000") + elif feature == SupportedFeature.BATTERY_STATUS: + if function == 0x00: + return bytes.fromhex("502000FFFF") + elif feature == SupportedFeature.VERTICAL_SCROLLING: + roller_type = 0x01 + num_of_ratchet_by_turn = 0x08 + scroll_lines = 0x0C + return bytes([roller_type, num_of_ratchet_by_turn, scroll_lines]) + elif feature == SupportedFeature.HI_RES_SCROLLING: + mode = 0x01 + resolution = 0x02 + return bytes([mode, resolution]) + elif feature == SupportedFeature.MOUSE_POINTER: + sensor_resolution_msb = 0x01 + sensor_resolution_lsb = 0x00 + flags = 0x0A + return bytes([sensor_resolution_msb, sensor_resolution_lsb, flags]) + elif feature == SupportedFeature.POINTER_SPEED: + pointer_speed_high = 0x01 + pointer_speed_low = 0x03 + return bytes([pointer_speed_high, pointer_speed_low]) + + raise RuntimeError(f"Unsupported feature: {feature.name}, func=0x{function:02X}") diff --git a/tests/logitech_receiver/test_hidpp20.py b/tests/logitech_receiver/test_hidpp20.py new file mode 100644 index 00000000..abd8c20e --- /dev/null +++ b/tests/logitech_receiver/test_hidpp20.py @@ -0,0 +1,83 @@ +from logitech_receiver import common +from logitech_receiver import hidpp20 +from logitech_receiver.common import FirmwareKind +from logitech_receiver.hidpp20_constants import SupportedFeature + +_hidpp20 = hidpp20.Hidpp20() + + +def test_get_firmware(device): + result = _hidpp20.get_firmware(device) + + assert result == ( + common.FirmwareInfo( + kind=FirmwareKind.Bootloader, + name="ABC", + version="03.04.B0100", + extras=b"\x01\x00\x01\x02\x03\x04\x05", + ), + common.FirmwareInfo( + kind=FirmwareKind.Hardware, + name="", + version="65", + extras=None, + ), + ) + + +def test_get_kind(device): + result = _hidpp20.get_kind(device) + + assert result == "keyboard" + assert result == 1 + + +def test_get_name(device): + result = _hidpp20.get_name(device) + + assert result == "ABCDEFGHIJKLMNOPQR" + + +def test_get_friendly_name(device): + result = _hidpp20.get_friendly_name(device) + + assert result == "ABCDEFGHIJKLMNOPQR" + + +def test_get_battery_status(device): + feature, battery = _hidpp20.get_battery_status(device) + + assert feature == SupportedFeature.BATTERY_STATUS + assert battery.level == 80 + assert battery.next_level == 32 + assert battery.status == common.BatteryStatus.DISCHARGING + + +def test_get_vertical_scrolling_info(device): + result = _hidpp20.get_vertical_scrolling_info(device) + + assert result == {"roller": "standard", "ratchet": 8, "lines": 12} + + +def test_get_high_resolution_scrolling_info(device): + mode, resolution = _hidpp20.get_hi_res_scrolling_info(device) + + assert mode == 0x1 + assert resolution == 0x2 + + +def test_get_mouse_pointer_info(device): + result = _hidpp20.get_mouse_pointer_info(device) + + assert result == { + "dpi": 0x100, + "acceleration": "med", + "suggest_os_ballistics": False, + "suggest_vertical_orientation": True, + } + + +def test_get_pointer_speed_info(device): + result = _hidpp20.get_pointer_speed_info(device) + + assert result == 0x0103 / 256 From 2781b4d878d8b80e590a21d70d9f9c5f10b7f1a0 Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Sat, 12 Jul 2025 02:15:19 +0200 Subject: [PATCH 2/3] Extend test module test_hidpp10 with postfix simple Avoid name clash with fake device test. --- .../logitech_receiver/{test_hidpp10.py => test_hidpp10_simple.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/logitech_receiver/{test_hidpp10.py => test_hidpp10_simple.py} (100%) diff --git a/tests/logitech_receiver/test_hidpp10.py b/tests/logitech_receiver/test_hidpp10_simple.py similarity index 100% rename from tests/logitech_receiver/test_hidpp10.py rename to tests/logitech_receiver/test_hidpp10_simple.py From 8e631291c1926223d5c9fb3bbc29ff947d0cada4 Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Sat, 12 Jul 2025 02:16:14 +0200 Subject: [PATCH 3/3] Implement HID++ 1.0 fake device for some basic requests The fake device is a simplified, working implementation of a HID++ 1.0 device, that behaves similar to the real hardware. This allows tests to be closer to the real world. Related #2905 --- lib/logitech_receiver/hidpp10.py | 2 +- tests/conftest.py | 7 +++++++ tests/fakes/hidpp10_device.py | 18 +++++++++++++++++ tests/logitech_receiver/test_hidpp10.py | 27 +++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 tests/fakes/hidpp10_device.py create mode 100644 tests/logitech_receiver/test_hidpp10.py diff --git a/lib/logitech_receiver/hidpp10.py b/lib/logitech_receiver/hidpp10.py index 29399c8c..38a87793 100644 --- a/lib/logitech_receiver/hidpp10.py +++ b/lib/logitech_receiver/hidpp10.py @@ -191,7 +191,7 @@ class Hidpp10: def get_notification_flags(self, device: Device): return self._get_register(device, Registers.NOTIFICATIONS) - def set_notification_flags(self, device: Device, *flag_bits: NotificationFlag): + def set_notification_flags(self, device: Device, *flag_bits: NotificationFlag) -> None: assert device is not None # Avoid a call if the device is not online, diff --git a/tests/conftest.py b/tests/conftest.py index 7ecc4245..4b95e552 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,15 @@ import pytest +from fakes import hidpp10_device + from .fakes import hidpp20_device +@pytest.fixture +def device_hidpp10(): + yield hidpp10_device.Hidpp10Device() + + @pytest.fixture def device(): yield hidpp20_device.Hidpp20Device() diff --git a/tests/fakes/hidpp10_device.py b/tests/fakes/hidpp10_device.py new file mode 100644 index 00000000..a25ffca6 --- /dev/null +++ b/tests/fakes/hidpp10_device.py @@ -0,0 +1,18 @@ +from logitech_receiver.hidpp10_constants import Registers + + +class Hidpp10Device: + def __init__(self): + self._iteration = 0 + + @property + def kind(self): + pass + + def request(self, request_id, *params, no_reply=False): + if request_id == 0x8100 + Registers.BATTERY_STATUS: + return b"fff" + elif request_id == 0x8000: + return bytes([0x10, 0x10, 0x00]) + + raise RuntimeError(f"Unsupported feature: {request_id:04X}") diff --git a/tests/logitech_receiver/test_hidpp10.py b/tests/logitech_receiver/test_hidpp10.py new file mode 100644 index 00000000..51d91878 --- /dev/null +++ b/tests/logitech_receiver/test_hidpp10.py @@ -0,0 +1,27 @@ +from logitech_receiver import hidpp10 +from logitech_receiver import hidpp10_constants +from logitech_receiver.hidpp10_constants import Registers + +_hidpp10 = hidpp10.Hidpp10() + + +def test_read_register(device_hidpp10): + result = hidpp10.read_register( + device_hidpp10, + register=Registers.BATTERY_STATUS, + ) + + assert result == bytes.fromhex("666666") + + +def test_set_notification_flags(mocker, device_hidpp10): + spy_request = mocker.spy(device_hidpp10, "request") + + result = _hidpp10.set_notification_flags( + device_hidpp10, + hidpp10_constants.NotificationFlag.BATTERY_STATUS, + hidpp10_constants.NotificationFlag.WIRELESS, + ) + + spy_request.assert_called_once_with(0x8000 | Registers.NOTIFICATIONS, b"\x10\x01\x00") + assert result is not None