diff --git a/lib/logitech_receiver/device.py b/lib/logitech_receiver/device.py index 99874924..6de205fb 100644 --- a/lib/logitech_receiver/device.py +++ b/lib/logitech_receiver/device.py @@ -184,8 +184,8 @@ class Device: if codename: self._codename = codename elif self.protocol < 2.0: - self._codename = "? (%s)" % (self.wpid or self.product_id) - return self._codename or "?? (%s)" % (self.wpid or self.product_id) + self._codename = "? (%s)" % (self.wpid or hex(self.product_id)[2:].upper()) + return self._codename or "?? (%s)" % (self.wpid or hex(self.product_id)[2:].upper()) @property def name(self): @@ -539,7 +539,7 @@ class Device: def __str__(self): try: - name = self.name or self.codename or "?" + name = self._name or self._codename or "?" except exceptions.NoSuchDevice: name = "name not available" return "" % (self.number, self.wpid or self.product_id, name, self.serial) diff --git a/tests/logitech_receiver/hidpp.py b/tests/logitech_receiver/hidpp.py new file mode 100644 index 00000000..15ea6dcc --- /dev/null +++ b/tests/logitech_receiver/hidpp.py @@ -0,0 +1,120 @@ +## Copyright (C) 2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/ +## +## This program is free software; you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation; either version 2 of the License, or +## (at your option) any later version. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License along +## with this program; if not, write to the Free Software Foundation, Inc., +## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +"""HID++ data and functions common to several logitech_receiver test files""" + +from dataclasses import dataclass +from struct import pack +from typing import Optional + + +def open_path(path: Optional[str]) -> Optional[int]: + return int(path, 16) if path is not None else None + + +@dataclass +class Response: + response: Optional[str] + id: int + params: str = "" + handle: int = 0x11 + devnumber: int = 0xFF + no_reply: bool = False + + +def replace_number(responses, number): # change the devnumber for a list of responses + return [Response(r.response, r.id, r.params, r.handle, number, r.no_reply) for r in responses] + + +def ping(responses, handle, devnumber, long_message=False): + print("PING ", hex(handle), hex(devnumber) if devnumber else devnumber) + print(responses) + for r in responses: + if handle == r.handle and devnumber == r.devnumber and r.id == 0x0010: + print("RESPONSE", hex(r.handle), hex(r.devnumber), r.response) + return r.response + + +def request(responses, handle, devnumber, id, *params, no_reply=False, return_error=False, long_message=False, protocol=1.0): + params = b"".join(pack("B", p) if isinstance(p, int) else p for p in params) + print("REQUEST ", hex(handle), hex(devnumber), hex(id), params.hex()) + for r in responses: + if handle == r.handle and devnumber == r.devnumber and r.id == id and bytes.fromhex(r.params) == params: + print("RESPONSE", hex(r.handle), hex(r.devnumber), hex(r.id), r.params, r.response) + return bytes.fromhex(r.response) if r.response is not None else None + + +r_empty = [ # a HID++ device with no responses except for ping + Response(1.0, 0x0010), # ping +] + +r_keyboard_1 = [ # a HID++ 1.0 keyboard + Response(1.0, 0x0010), # ping + Response("001234", 0x81F1, "01"), # firmware + Response("003412", 0x81F1, "02"), # firmware + Response("002345", 0x81F1, "03"), # firmware + Response("003456", 0x81F1, "04"), # firmware +] + +r_keyboard_2 = [ # a HID++ 2.0 keyboard + Response(4.2, 0x0010), # ping + Response("010001", 0x0000, "0001"), # feature set at 0x01 + Response("020003", 0x0000, "1000"), # battery status at 0x02 + Response("030001", 0x0000, "0003"), # device information at 0x03 + Response("040003", 0x0000, "0100"), # unknown 0100 at 0x04 + Response("050003", 0x0000, "1B04"), # reprogrammable keys V4 at 0x05 + Response("08", 0x0100), # 8 features + Response("00010001", 0x0110, "01"), # feature set at 0x01 + Response("10000001", 0x0110, "02"), # battery status at 0x02 + Response("00030001", 0x0110, "03"), # device information at 0x03 + Response("01000003", 0x0110, "04"), # unknown 0100 at 0x04 + Response("1B040003", 0x0110, "05"), # reprogrammable keys V4 at 0x05 + Response("0212345678000D1234567890ABAA01", 0x0300), # device information + Response("00110012AB010203CD00", 0x0510, "00"), # reprogrammable keys V4 + Response("01110022AB010203CD00", 0x0510, "01"), # reprogrammable keys V4 + Response("00010111AB010203CD00", 0x0510, "02"), # reprogrammable keys V4 + Response("03110032AB010204CD00", 0x0510, "03"), # reprogrammable keys V4 + Response("00030333AB010203CD00", 0x0510, "04"), # reprogrammable keys V4 +] + +r_mouse_1 = [ # a HID++ 1.0 mouse + Response(1.0, 0x0010), # ping +] + +r_mouse_2 = [ # a HID++ 2.0 mouse with few responses except for ping + Response(4.2, 0x0010), # ping +] + +r_mouse_3 = [ # a HID++ 2.0 mouse + Response(4.5, 0x0010), # ping + Response("010001", 0x0000, "0001"), # feature set at 0x01 + Response("020002", 0x0000, "8060"), # report rate at 0x02 + Response("040001", 0x0000, "0003"), # device information at 0x04 + Response("050002", 0x0000, "0005"), # device type and name at 0x05 + Response("08", 0x0100), # 8 features + Response("00010001", 0x0110, "01"), # feature set at 0x01 + Response("80600002", 0x0110, "02"), # report rate at 0x02 + Response("00030001", 0x0110, "04"), # device information at 0x04 + Response("00050002", 0x0110, "05"), # device type and name at 0x05 + Response("09", 0x0210), # report rate - current rate + Response("03123456790008123456780000AA01", 0x0400), # device information + Response("0141424302030100", 0x0410, "00"), # firmware 0 + Response("0241", 0x0410, "01"), # firmware 1 + Response("05", 0x0410, "02"), # firmware 2 + Response("12", 0x0500), # name count - 18 characters + Response("414241424142414241424142414241", 0x0510, "00"), # name - first 15 characters + Response("444544000000000000000000000000", 0x0510, "0F"), # name - last 3 characters +] diff --git a/tests/logitech_receiver/test_device.py b/tests/logitech_receiver/test_device.py new file mode 100644 index 00000000..590f52f6 --- /dev/null +++ b/tests/logitech_receiver/test_device.py @@ -0,0 +1,181 @@ +## Copyright (C) 2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/ +## +## This program is free software; you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation; either version 2 of the License, or +## (at your option) any later version. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License along +## with this program; if not, write to the Free Software Foundation, Inc., +## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from dataclasses import dataclass +from functools import partial +from unittest import mock + +import pytest + +from logitech_receiver import device + +from . import hidpp + + +@dataclass +class DeviceInfo: + path: str + vendor_id: int = 1133 + product_id: int = 4066 + hidpp_short: bool = False + hidpp_long: bool = True + bus_id: int = 0x0003 # USB + + +di_CCCC = DeviceInfo("11", product_id=0xCCCC) +di_C318 = DeviceInfo("11", product_id=0xC318) +di_B530 = DeviceInfo("11", product_id=0xB350, bus_id=0x0005) +di_C068 = DeviceInfo("11", product_id=0xC06B) +di_C08A = DeviceInfo("11", product_id=0xC08A) +di_DDDD = DeviceInfo("11", product_id=0xDDDD) + + +@pytest.fixture +def mock_base(): + with mock.patch("logitech_receiver.base.open_path", return_value=None) as mock_open_path: + with mock.patch("logitech_receiver.base.request", return_value=None) as mock_request: + with mock.patch("logitech_receiver.base.ping", return_value=None) as mock_ping: + yield mock_open_path, mock_request, mock_ping + + +@pytest.mark.parametrize( + "device_info, responses, handle, _name, _codename, number, protocol", + zip( + [di_CCCC, di_C318, di_B530, di_C068, di_C08A, di_DDDD], + [hidpp.r_empty, hidpp.r_keyboard_1, hidpp.r_keyboard_2, hidpp.r_mouse_1, hidpp.r_mouse_2, hidpp.r_mouse_3], + [0x11, 0x11, 0x11, 0x11, 0x11, 0x11], + [None, "Illuminated Keyboard", "Craft Advanced Keyboard", "G700 Gaming Mouse", "MX Vertical Wireless Mouse", None], + [None, "Illuminated", "Craft", "G700", "MX Vertical", None], + [0xFF, 0x0, 0xFF, 0x0, 0xFF, 0xFF], + [1.0, 1.0, 4.5, 1.0, 4.5, 4.5], + ), +) +def test_Device_info(device_info, responses, handle, _name, _codename, number, protocol, mock_base): + mock_base[0].side_effect = hidpp.open_path + mock_base[1].side_effect = partial(hidpp.request, responses) + mock_base[2].side_effect = partial(hidpp.ping, responses) + + test_device = device.Device(None, None, None, handle=handle, device_info=device_info) + + assert test_device.handle == handle + assert test_device._name == _name + assert test_device._codename == _codename + assert test_device.number == number + assert test_device._protocol == protocol + + +@dataclass +class Receiver: + path: str = "1" + handle: int = 0x11 + + def device_codename(self, number): + return None + + def __contains__(self, dev): + return True + + +@pytest.fixture +def mock_hid(): + with mock.patch("hidapi.find_paired_node", return_value=None) as find_paired_node: + yield find_paired_node + + +pi_CCCC = {"wpid": "CCCC", "kind": 0, "serial": None, "polling": "1ms", "power_switch": "top"} +pi_2011 = {"wpid": "2011", "kind": 1, "serial": "1234", "polling": "2ms", "power_switch": "bottom"} +pi_4066 = {"wpid": "4066", "kind": 1, "serial": "5678", "polling": "4ms", "power_switch": "left"} +pi_1023 = {"wpid": "1023", "kind": 2, "serial": "1234", "polling": "8ms", "power_switch": "right"} +pi_407B = {"wpid": "407B", "kind": 2, "serial": "5678", "polling": "1ms", "power_switch": "left"} +pi_DDDD = {"wpid": "DDDD", "kind": 2, "serial": "1234", "polling": "2ms", "power_switch": "top"} + + +@pytest.mark.parametrize( + "number, pairing_info, responses, handle, _name, codename, protocol, name", + zip( + range(1, 7), + [pi_CCCC, pi_2011, pi_4066, pi_1023, pi_407B, pi_DDDD], + [hidpp.r_empty, hidpp.r_keyboard_1, hidpp.r_keyboard_2, hidpp.r_mouse_1, hidpp.r_mouse_2, hidpp.r_mouse_3], + [None, 0x11, 0x11, 0x11, 0x11, 0x11], + [None, "Wireless Keyboard K520", "Craft Advanced Keyboard", "G700 Gaming Mouse", "MX Vertical Wireless Mouse", None], + ["? (CCCC)", "K520", "Craft", "G700", "MX Vertical", "ABABABABABABABADED"], + [1.0, 1.0, 4.5, 1.0, 4.5, 4.5], + [ + "? (CCCC)", + "Wireless Keyboard K520", + "Craft Advanced Keyboard", + "G700 Gaming Mouse", + "MX Vertical Wireless Mouse", + "ABABABABABABABADED", + ], + ), +) +def test_Device_receiver(number, pairing_info, responses, handle, _name, codename, protocol, name, mock_base): + mock_base[0].side_effect = hidpp.open_path + mock_base[1].side_effect = partial(hidpp.request, hidpp.replace_number(responses, number)) + mock_base[2].side_effect = partial(hidpp.ping, hidpp.replace_number(responses, number)) + + test_device = device.Device(Receiver(), number, True, pairing_info, handle=handle) + + assert test_device.handle == handle + assert test_device._name == _name + assert test_device.codename == codename + assert test_device.number == number + assert test_device._protocol == protocol + assert test_device.protocol == (protocol or 0) + assert test_device.codename == codename + assert test_device.name == name + + +@pytest.mark.parametrize( + "number, pairing_info, responses, handle, unitId, modelId, tid_map, kind, firmware, serial, id, psl, rate", + zip( + range(1, 7), + [pi_CCCC, pi_2011, pi_4066, pi_1023, pi_407B, pi_DDDD], + [hidpp.r_empty, hidpp.r_keyboard_1, hidpp.r_keyboard_2, hidpp.r_mouse_1, hidpp.r_mouse_2, hidpp.r_mouse_3], + [None, 0x11, 0x11, 0x11, 0x11, 0x11], + [None, None, "12345678", None, None, "12345679"], # unitId + [None, None, "1234567890AB", None, None, "123456780000"], # modelId + [None, None, {"btid": "1234", "wpid": "5678", "usbid": "90AB"}, None, None, {"usbid": "1234"}], # tid_map + ["?", 1, 1, 2, 2, 2], # kind + [(), True, (), (), (), True], # firmware + [None, "1234", "5678", "1234", "5678", "1234"], # serial + ["", "1234", "12345678", "1234", "5678", "12345679"], # id + ["top", "bottom", "left", "right", "left", "top"], # power switch location + ["1ms", "2ms", "4ms", "8ms", "1ms", "9ms"], # polling rate + ), +) +def test_Device_ids( + number, pairing_info, responses, handle, unitId, modelId, tid_map, kind, firmware, serial, id, psl, rate, mock_base +): + mock_base[0].side_effect = hidpp.open_path + mock_base[1].side_effect = partial(hidpp.request, hidpp.replace_number(responses, number)) + mock_base[2].side_effect = partial(hidpp.ping, hidpp.replace_number(responses, number)) + + test_device = device.Device(Receiver(), number, True, pairing_info, handle=handle) + + assert test_device.unitId == unitId + assert test_device.modelId == modelId + assert test_device.tid_map == tid_map + assert test_device.kind == kind + + assert test_device.firmware == firmware or len(test_device.firmware) > 0 and firmware is True + assert test_device.id == id + assert test_device.power_switch_location == psl + assert test_device.polling_rate == rate + + +# IMPORTANT TODO - battery