From a2021bacfb4a736c81f737364850704967413634 Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Sun, 3 Nov 2024 18:42:41 +0100 Subject: [PATCH 01/28] Split up huge settings module - Move validators into their own module. - Convert Kind to IntEnum Related #2273 --- lib/logitech_receiver/settings.py | 738 +---------------- lib/logitech_receiver/settings_templates.py | 96 +-- lib/logitech_receiver/settings_validator.py | 744 ++++++++++++++++++ lib/solaar/cli/config.py | 22 +- lib/solaar/ui/config_panel.py | 16 +- lib/solaar/ui/diversion_rules.py | 36 +- ...settings.py => test_settings_validator.py} | 4 +- 7 files changed, 847 insertions(+), 809 deletions(-) create mode 100644 lib/logitech_receiver/settings_validator.py rename tests/logitech_receiver/{test_settings.py => test_settings_validator.py} (82%) diff --git a/lib/logitech_receiver/settings.py b/lib/logitech_receiver/settings.py index f6674eb8cd..cb1a6e6f10 100644 --- a/lib/logitech_receiver/settings.py +++ b/lib/logitech_receiver/settings.py @@ -16,14 +16,16 @@ from __future__ import annotations import logging -import math import struct import time +from enum import IntEnum + from solaar.i18n import _ from . import common from . import hidpp20_constants +from . import settings_validator from .common import NamedInt from .common import NamedInts @@ -43,22 +45,15 @@ ) -def bool_or_toggle(current: bool | str, new: bool | str) -> bool: - if isinstance(new, bool): - return new - - try: - return bool(int(new)) - except (TypeError, ValueError): - new = str(new).lower() - - if new in ("true", "yes", "on", "t", "y"): - return True - if new in ("false", "no", "off", "f", "n"): - return False - if new in ("~", "toggle"): - return not current - return None +class Kind(IntEnum): + TOGGLE = 0x01 + CHOICE = 0x02 + RANGE = 0x04 + MAP_CHOICE = 0x0A + MULTIPLE_TOGGLE = 0x10 + PACKED_RANGE = 0x20 + MULTIPLE_RANGE = 0x40 + HETERO = 0x80 class Setting: @@ -103,14 +98,14 @@ def choices(self): assert hasattr(self, "_value") assert hasattr(self, "_device") - return self._validator.choices if self._validator and self._validator.kind & KIND.choice else None + return self._validator.choices if self._validator and self._validator.kind & Kind.CHOICE else None @property def range(self): assert hasattr(self, "_value") assert hasattr(self, "_device") - if self._validator.kind == KIND.range: + if self._validator.kind == Kind.RANGE: return self._validator.min_value, self._validator.max_value def _pre_read(self, cached, key=None): @@ -692,709 +687,6 @@ def write(self, device, key, data_bytes): return reply if not self.no_reply else True -class Validator: - @classmethod - def build(cls, setting_class, device, **kwargs): - return cls(**kwargs) - - @classmethod - def to_string(cls, value): - return str(value) - - def compare(self, args, current): - if len(args) != 1: - return False - return args[0] == current - - -class BooleanValidator(Validator): - __slots__ = ("true_value", "false_value", "read_skip_byte_count", "write_prefix_bytes", "mask", "needs_current_value") - - kind = KIND.toggle - default_true = 0x01 - default_false = 0x00 - # mask specifies all the affected bits in the value - default_mask = 0xFF - - def __init__( - self, - true_value=default_true, - false_value=default_false, - mask=default_mask, - read_skip_byte_count=0, - write_prefix_bytes=b"", - ): - if isinstance(true_value, int): - assert isinstance(false_value, int) - if mask is None: - mask = self.default_mask - else: - assert isinstance(mask, int) - assert true_value & false_value == 0 - assert true_value & mask == true_value - assert false_value & mask == false_value - self.needs_current_value = mask != self.default_mask - elif isinstance(true_value, bytes): - if false_value is None or false_value == self.default_false: - false_value = b"\x00" * len(true_value) - else: - assert isinstance(false_value, bytes) - if mask is None or mask == self.default_mask: - mask = b"\xff" * len(true_value) - else: - assert isinstance(mask, bytes) - assert len(mask) == len(true_value) == len(false_value) - tv = common.bytes2int(true_value) - fv = common.bytes2int(false_value) - mv = common.bytes2int(mask) - assert tv != fv # true and false might be something other than bit values - assert tv & mv == tv - assert fv & mv == fv - self.needs_current_value = any(m != 0xFF for m in mask) - else: - raise Exception(f"invalid mask '{mask!r}', type {type(mask)}") - - self.true_value = true_value - self.false_value = false_value - self.mask = mask - self.read_skip_byte_count = read_skip_byte_count - self.write_prefix_bytes = write_prefix_bytes - - def validate_read(self, reply_bytes): - reply_bytes = reply_bytes[self.read_skip_byte_count :] - if isinstance(self.mask, int): - reply_value = ord(reply_bytes[:1]) & self.mask - if logger.isEnabledFor(logging.DEBUG): - logger.debug("BooleanValidator: validate read %r => %02X", reply_bytes, reply_value) - if reply_value == self.true_value: - return True - if reply_value == self.false_value: - return False - logger.warning( - "BooleanValidator: reply %02X mismatched %02X/%02X/%02X", - reply_value, - self.true_value, - self.false_value, - self.mask, - ) - return False - - count = len(self.mask) - mask = common.bytes2int(self.mask) - reply_value = common.bytes2int(reply_bytes[:count]) & mask - - true_value = common.bytes2int(self.true_value) - if reply_value == true_value: - return True - - false_value = common.bytes2int(self.false_value) - if reply_value == false_value: - return False - - logger.warning( - "BooleanValidator: reply %r mismatched %r/%r/%r", reply_bytes, self.true_value, self.false_value, self.mask - ) - return False - - def prepare_write(self, new_value, current_value=None): - if new_value is None: - new_value = False - else: - assert isinstance(new_value, bool), f"New value {new_value} for boolean setting is not a boolean" - - to_write = self.true_value if new_value else self.false_value - - if isinstance(self.mask, int): - if current_value is not None and self.needs_current_value: - to_write |= ord(current_value[:1]) & (0xFF ^ self.mask) - if current_value is not None and to_write == ord(current_value[:1]): - return None - to_write = bytes([to_write]) - else: - to_write = bytearray(to_write) - count = len(self.mask) - for i in range(0, count): - b = ord(to_write[i : i + 1]) - m = ord(self.mask[i : i + 1]) - assert b & m == b - # b &= m - if current_value is not None and self.needs_current_value: - b |= ord(current_value[i : i + 1]) & (0xFF ^ m) - to_write[i] = b - to_write = bytes(to_write) - - if current_value is not None and to_write == current_value[: len(to_write)]: - return None - - if logger.isEnabledFor(logging.DEBUG): - logger.debug("BooleanValidator: prepare_write(%s, %s) => %r", new_value, current_value, to_write) - - return self.write_prefix_bytes + to_write - - def acceptable(self, args, current): - if len(args) != 1: - return None - val = bool_or_toggle(current, args[0]) - return [val] if val is not None else None - - -class BitFieldValidator(Validator): - __slots__ = ("byte_count", "options") - - kind = KIND.multiple_toggle - - def __init__(self, options, byte_count=None): - assert isinstance(options, list) - self.options = options - self.byte_count = (max(x.bit_length() for x in options) + 7) // 8 - if byte_count: - assert isinstance(byte_count, int) and byte_count >= self.byte_count - self.byte_count = byte_count - - def to_string(self, value): - def element_to_string(key, val): - k = next((k for k in self.options if int(key) == k), None) - return str(k) + ":" + str(val) if k is not None else "?" - - return "{" + ", ".join([element_to_string(k, value[k]) for k in value]) + "}" - - def validate_read(self, reply_bytes): - r = common.bytes2int(reply_bytes[: self.byte_count]) - value = {int(k): False for k in self.options} - m = 1 - for _ignore in range(8 * self.byte_count): - if m in self.options: - value[int(m)] = bool(r & m) - m <<= 1 - return value - - def prepare_write(self, new_value): - assert isinstance(new_value, dict) - w = 0 - for k, v in new_value.items(): - if v: - w |= int(k) - return common.int2bytes(w, self.byte_count) - - def get_options(self): - return self.options - - def acceptable(self, args, current): - if len(args) != 2: - return None - key = next((key for key in self.options if key == args[0]), None) - if key is None: - return None - val = bool_or_toggle(current[int(key)], args[1]) - return None if val is None else [int(key), val] - - def compare(self, args, current): - if len(args) != 2: - return False - key = next((key for key in self.options if key == args[0]), None) - if key is None: - return False - return args[1] == current[int(key)] - - -class BitFieldWithOffsetAndMaskValidator(Validator): - __slots__ = ("byte_count", "options", "_option_from_key", "_mask_from_offset", "_option_from_offset_mask") - - kind = KIND.multiple_toggle - sep = 0x01 - - def __init__(self, options, om_method=None, byte_count=None): - assert isinstance(options, list) - # each element of options is an instance of a class - # that has an id (which is used as an index in other dictionaries) - # and where om_method is a method that returns a byte offset and byte mask - # that says how to access and modify the bit toggle for the option - self.options = options - self.om_method = om_method - # to retrieve the options efficiently: - self._option_from_key = {} - self._mask_from_offset = {} - self._option_from_offset_mask = {} - for opt in options: - offset, mask = om_method(opt) - self._option_from_key[int(opt)] = opt - try: - self._mask_from_offset[offset] |= mask - except KeyError: - self._mask_from_offset[offset] = mask - try: - mask_to_opt = self._option_from_offset_mask[offset] - except KeyError: - mask_to_opt = {} - self._option_from_offset_mask[offset] = mask_to_opt - mask_to_opt[mask] = opt - self.byte_count = (max(om_method(x)[1].bit_length() for x in options) + 7) // 8 # is this correct?? - if byte_count: - assert isinstance(byte_count, int) and byte_count >= self.byte_count - self.byte_count = byte_count - - def prepare_read(self): - r = [] - for offset, mask in self._mask_from_offset.items(): - b = offset << (8 * (self.byte_count + 1)) - b |= (self.sep << (8 * self.byte_count)) | mask - r.append(common.int2bytes(b, self.byte_count + 2)) - return r - - def prepare_read_key(self, key): - option = self._option_from_key.get(key, None) - if option is None: - return None - offset, mask = option.om_method(option) - b = offset << (8 * (self.byte_count + 1)) - b |= (self.sep << (8 * self.byte_count)) | mask - return common.int2bytes(b, self.byte_count + 2) - - def validate_read(self, reply_bytes_dict): - values = {int(k): False for k in self.options} - for query, b in reply_bytes_dict.items(): - offset = common.bytes2int(query[0:1]) - b += (self.byte_count - len(b)) * b"\x00" - value = common.bytes2int(b[: self.byte_count]) - mask_to_opt = self._option_from_offset_mask.get(offset, {}) - m = 1 - for _ignore in range(8 * self.byte_count): - if m in mask_to_opt: - values[int(mask_to_opt[m])] = bool(value & m) - m <<= 1 - return values - - def prepare_write(self, new_value): - assert isinstance(new_value, dict) - w = {} - for k, v in new_value.items(): - option = self._option_from_key[int(k)] - offset, mask = self.om_method(option) - if offset not in w: - w[offset] = 0 - if v: - w[offset] |= mask - return [ - common.int2bytes( - (offset << (8 * (2 * self.byte_count + 1))) - | (self.sep << (16 * self.byte_count)) - | (self._mask_from_offset[offset] << (8 * self.byte_count)) - | value, - 2 * self.byte_count + 2, - ) - for offset, value in w.items() - ] - - def get_options(self): - return [int(opt) if isinstance(opt, int) else opt.as_int() for opt in self.options] - - def acceptable(self, args, current): - if len(args) != 2: - return None - key = next((option.id for option in self.options if option.as_int() == args[0]), None) - if key is None: - return None - val = bool_or_toggle(current[int(key)], args[1]) - return None if val is None else [int(key), val] - - def compare(self, args, current): - if len(args) != 2: - return False - key = next((option.id for option in self.options if option.as_int() == args[0]), None) - if key is None: - return False - return args[1] == current[int(key)] - - -class ChoicesValidator(Validator): - """Translates between NamedInts and a byte sequence. - :param choices: a list of NamedInts - :param byte_count: the size of the derived byte sequence. If None, it - will be calculated from the choices.""" - - kind = KIND.choice - - def __init__(self, choices=None, byte_count=None, read_skip_byte_count=0, write_prefix_bytes=b""): - assert choices is not None - assert isinstance(choices, NamedInts) - assert len(choices) > 1 - self.choices = choices - self.needs_current_value = False - - max_bits = max(x.bit_length() for x in choices) - self._byte_count = (max_bits // 8) + (1 if max_bits % 8 else 0) - if byte_count: - assert self._byte_count <= byte_count - self._byte_count = byte_count - assert self._byte_count < 8 - self._read_skip_byte_count = read_skip_byte_count - self._write_prefix_bytes = write_prefix_bytes if write_prefix_bytes else b"" - assert self._byte_count + self._read_skip_byte_count <= 14 - assert self._byte_count + len(self._write_prefix_bytes) <= 14 - - def to_string(self, value): - return str(self.choices[value]) if isinstance(value, int) else str(value) - - def validate_read(self, reply_bytes): - reply_value = common.bytes2int(reply_bytes[self._read_skip_byte_count : self._read_skip_byte_count + self._byte_count]) - valid_value = self.choices[reply_value] - assert valid_value is not None, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}" - return valid_value - - def prepare_write(self, new_value, current_value=None): - if new_value is None: - value = self.choices[:][0] - else: - value = self.choice(new_value) - if value is None: - raise ValueError(f"invalid choice {new_value!r}") - assert isinstance(value, NamedInt) - return self._write_prefix_bytes + value.bytes(self._byte_count) - - def choice(self, value): - if isinstance(value, int): - return self.choices[value] - try: - int(value) - if int(value) in self.choices: - return self.choices[int(value)] - except Exception: - pass - if value in self.choices: - return self.choices[value] - else: - return None - - def acceptable(self, args, current): - choice = self.choice(args[0]) if len(args) == 1 else None - return None if choice is None else [choice] - - -class ChoicesMapValidator(ChoicesValidator): - kind = KIND.map_choice - - def __init__( - self, - choices_map, - key_byte_count=0, - key_postfix_bytes=b"", - byte_count=0, - read_skip_byte_count=0, - write_prefix_bytes=b"", - extra_default=None, - mask=-1, - activate=0, - ): - assert choices_map is not None - assert isinstance(choices_map, dict) - max_key_bits = 0 - max_value_bits = 0 - for key, choices in choices_map.items(): - assert isinstance(key, NamedInt) - assert isinstance(choices, NamedInts) - max_key_bits = max(max_key_bits, key.bit_length()) - for key_value in choices: - assert isinstance(key_value, NamedInt) - max_value_bits = max(max_value_bits, key_value.bit_length()) - self._key_byte_count = (max_key_bits + 7) // 8 - if key_byte_count: - assert self._key_byte_count <= key_byte_count - self._key_byte_count = key_byte_count - self._byte_count = (max_value_bits + 7) // 8 - if byte_count: - assert self._byte_count <= byte_count - self._byte_count = byte_count - - self.choices = choices_map - self.needs_current_value = False - self.extra_default = extra_default - self._key_postfix_bytes = key_postfix_bytes - self._read_skip_byte_count = read_skip_byte_count if read_skip_byte_count else 0 - self._write_prefix_bytes = write_prefix_bytes if write_prefix_bytes else b"" - self.activate = activate - self.mask = mask - assert self._byte_count + self._read_skip_byte_count + self._key_byte_count <= 14 - assert self._byte_count + len(self._write_prefix_bytes) + self._key_byte_count <= 14 - - def to_string(self, value): - def element_to_string(key, val): - k, c = next(((k, c) for k, c in self.choices.items() if int(key) == k), (None, None)) - return str(k) + ":" + str(c[val]) if k is not None else "?" - - return "{" + ", ".join([element_to_string(k, value[k]) for k in sorted(value)]) + "}" - - def validate_read(self, reply_bytes, key): - start = self._key_byte_count + self._read_skip_byte_count - end = start + self._byte_count - reply_value = common.bytes2int(reply_bytes[start:end]) & self.mask - # reprogrammable keys starts out as 0, which is not a choice, so don't use assert here - if self.extra_default is not None and self.extra_default == reply_value: - return int(self.choices[key][0]) - if reply_value not in self.choices[key]: - assert reply_value in self.choices[key], "%s: failed to validate read value %02X" % ( - self.__class__.__name__, - reply_value, - ) - return reply_value - - def prepare_key(self, key): - return key.to_bytes(self._key_byte_count, "big") + self._key_postfix_bytes - - def prepare_write(self, key, new_value): - choices = self.choices.get(key) - if choices is None or (new_value not in choices and new_value != self.extra_default): - logger.error("invalid choice %r for %s", new_value, key) - return None - new_value = new_value | self.activate - return self._write_prefix_bytes + new_value.to_bytes(self._byte_count, "big") - - def acceptable(self, args, current): - if len(args) != 2: - return None - key, choices = next(((key, item) for key, item in self.choices.items() if key == args[0]), (None, None)) - if choices is None or args[1] not in choices: - return None - choice = next((item for item in choices if item == args[1]), None) - return [int(key), int(choice)] if choice is not None else None - - def compare(self, args, current): - if len(args) != 2: - return False - key = next((key for key in self.choices if key == int(args[0])), None) - if key is None: - return False - return args[1] == current[int(key)] - - -class RangeValidator(Validator): - kind = KIND.range - """Translates between integers and a byte sequence. - :param min_value: minimum accepted value (inclusive) - :param max_value: maximum accepted value (inclusive) - :param byte_count: the size of the derived byte sequence. If None, it - will be calculated from the range.""" - min_value = 0 - max_value = 255 - - @classmethod - def build(cls, setting_class, device, **kwargs): - kwargs["min_value"] = setting_class.min_value - kwargs["max_value"] = setting_class.max_value - return cls(**kwargs) - - def __init__(self, min_value=0, max_value=255, byte_count=1): - assert max_value > min_value - self.min_value = min_value - self.max_value = max_value - self.needs_current_value = True # read and check before write (needed for ADC power and probably a good idea anyway) - - self._byte_count = math.ceil(math.log(max_value + 1, 256)) - if byte_count: - assert self._byte_count <= byte_count - self._byte_count = byte_count - assert self._byte_count < 8 - - def validate_read(self, reply_bytes): - reply_value = common.bytes2int(reply_bytes[: self._byte_count]) - assert reply_value >= self.min_value, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}" - assert reply_value <= self.max_value, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}" - return reply_value - - def prepare_write(self, new_value, current_value=None): - if new_value < self.min_value or new_value > self.max_value: - raise ValueError(f"invalid choice {new_value!r}") - current_value = self.validate_read(current_value) if current_value is not None else None - to_write = common.int2bytes(new_value, self._byte_count) - # current value is known and same as value to be written return None to signal not to write it - return None if current_value is not None and current_value == new_value else to_write - - def acceptable(self, args, current): - arg = args[0] - # None if len(args) != 1 or type(arg) != int or arg < self.min_value or arg > self.max_value else args) - return None if len(args) != 1 or isinstance(arg, int) or arg < self.min_value or arg > self.max_value else args - - def compare(self, args, current): - if len(args) == 1: - return args[0] == current - elif len(args) == 2: - return args[0] <= current <= args[1] - else: - return False - - -class HeteroValidator(Validator): - kind = KIND.hetero - - @classmethod - def build(cls, setting_class, device, **kwargs): - return cls(**kwargs) - - def __init__(self, data_class=None, options=None, readable=True): - assert data_class is not None and options is not None - self.data_class = data_class - self.options = options - self.readable = readable - self.needs_current_value = False - - def validate_read(self, reply_bytes): - if self.readable: - reply_value = self.data_class.from_bytes(reply_bytes, options=self.options) - return reply_value - - def prepare_write(self, new_value, current_value=None): - to_write = new_value.to_bytes(options=self.options) - return to_write - - def acceptable(self, args, current): # should this actually do some checking? - return True - - -class PackedRangeValidator(Validator): - kind = KIND.packed_range - """Several range values, all the same size, all the same min and max""" - min_value = 0 - max_value = 255 - count = 1 - rsbc = 0 - write_prefix_bytes = b"" - - def __init__( - self, keys, min_value=0, max_value=255, count=1, byte_count=1, read_skip_byte_count=0, write_prefix_bytes=b"" - ): - assert max_value > min_value - self.needs_current_value = True - self.keys = keys - self.min_value = min_value - self.max_value = max_value - self.count = count - self.bc = math.ceil(math.log(max_value + 1 - min(0, min_value), 256)) - if byte_count: - assert self.bc <= byte_count - self.bc = byte_count - assert self.bc * self.count - self.rsbc = read_skip_byte_count - self.write_prefix_bytes = write_prefix_bytes - - def validate_read(self, reply_bytes): - rvs = { - n: common.bytes2int(reply_bytes[self.rsbc + n * self.bc : self.rsbc + (n + 1) * self.bc], signed=True) - for n in range(self.count) - } - for n in range(self.count): - assert rvs[n] >= self.min_value, f"{self.__class__.__name__}: failed to validate read value {rvs[n]:02X}" - assert rvs[n] <= self.max_value, f"{self.__class__.__name__}: failed to validate read value {rvs[n]:02X}" - return rvs - - def prepare_write(self, new_values): - if len(new_values) != self.count: - raise ValueError(f"wrong number of values {new_values!r}") - for new_value in new_values.values(): - if new_value < self.min_value or new_value > self.max_value: - raise ValueError(f"invalid value {new_value!r}") - bytes = self.write_prefix_bytes + b"".join( - common.int2bytes(new_values[n], self.bc, signed=True) for n in range(self.count) - ) - return bytes - - def acceptable(self, args, current): - if len(args) != 2 or int(args[0]) < 0 or int(args[0]) >= self.count: - return None - return None if not isinstance(args[1], int) or args[1] < self.min_value or args[1] > self.max_value else args - - def compare(self, args, current): - logger.warning("compare not implemented for packed range settings") - return False - - -class MultipleRangeValidator(Validator): - kind = KIND.multiple_range - - def __init__(self, items, sub_items): - assert isinstance(items, list) # each element must have .index and its __int__ must return its id (not its index) - assert isinstance(sub_items, dict) - # sub_items: items -> class with .minimum, .maximum, .length (in bytes), .id (a string) and .widget (e.g. 'Scale') - self.items = items - self.keys = NamedInts(**{str(item): int(item) for item in items}) - self._item_from_id = {int(k): k for k in items} - self.sub_items = sub_items - - def prepare_read_item(self, item): - return common.int2bytes((self._item_from_id[int(item)].index << 1) | 0xFF, 2) - - def validate_read_item(self, reply_bytes, item): - item = self._item_from_id[int(item)] - start = 0 - value = {} - for sub_item in self.sub_items[item]: - r = reply_bytes[start : start + sub_item.length] - if len(r) < sub_item.length: - r += b"\x00" * (sub_item.length - len(value)) - v = common.bytes2int(r) - if not (sub_item.minimum < v < sub_item.maximum): - logger.warning( - f"{self.__class__.__name__}: failed to validate read value for {item}.{sub_item}: " - + f"{v} not in [{sub_item.minimum}..{sub_item.maximum}]" - ) - value[str(sub_item)] = v - start += sub_item.length - return value - - def prepare_write(self, value): - seq = [] - w = b"" - for item in value.keys(): - _item = self._item_from_id[int(item)] - b = common.int2bytes(_item.index, 1) - for sub_item in self.sub_items[_item]: - try: - v = value[int(item)][str(sub_item)] - except KeyError: - return None - if not (sub_item.minimum <= v <= sub_item.maximum): - raise ValueError( - f"invalid choice for {item}.{sub_item}: {v} not in [{sub_item.minimum}..{sub_item.maximum}]" - ) - b += common.int2bytes(v, sub_item.length) - if len(w) + len(b) > 15: - seq.append(b + b"\xff") - w = b"" - w += b - seq.append(w + b"\xff") - return seq - - def prepare_write_item(self, item, value): - _item = self._item_from_id[int(item)] - w = common.int2bytes(_item.index, 1) - for sub_item in self.sub_items[_item]: - try: - v = value[str(sub_item)] - except KeyError: - return None - if not (sub_item.minimum <= v <= sub_item.maximum): - raise ValueError(f"invalid choice for {item}.{sub_item}: {v} not in [{sub_item.minimum}..{sub_item.maximum}]") - w += common.int2bytes(v, sub_item.length) - return w + b"\xff" - - def acceptable(self, args, current): - # just one item, with at least one sub-item - if not isinstance(args, list) or len(args) != 2 or not isinstance(args[1], dict): - return None - item = next((p for p in self.items if p.id == args[0] or str(p) == args[0]), None) - if not item: - return None - for sub_key, value in args[1].items(): - sub_item = next((it for it in self.sub_items[item] if it.id == sub_key), None) - if not sub_item: - return None - if not isinstance(value, int) or not (sub_item.minimum <= value <= sub_item.maximum): - return None - return [int(item), {**args[1]}] - - def compare(self, args, current): - logger.warning("compare not implemented for multiple range settings") - return False - - class ActionSettingRW: """Special RW class for settings that turn on and off special processing when a key or button is depressed""" @@ -1578,4 +870,4 @@ def apply_all_settings(device): s.apply() -Setting.validator_class = BooleanValidator +Setting.validator_class = settings_validator.BooleanValidator diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index 5553dc6691..a93294af7d 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -36,6 +36,7 @@ from . import hidpp20 from . import hidpp20_constants from . import settings +from . import settings_validator from . import special_keys from .hidpp10_constants import Registers from .hidpp20_constants import GestureId @@ -177,7 +178,7 @@ class RegisterDpi(settings.Setting): description = _("Mouse movement sensitivity") register = Registers.MOUSE_DPI choices_universe = common.NamedInts.range(0x81, 0x8F, lambda x: str((x - 0x80) * 100)) - validator_class = settings.ChoicesValidator + validator_class = settings_validator.ChoicesValidator validator_options = {"choices": choices_universe} @@ -251,7 +252,7 @@ class Backlight(settings.Setting): description = _("Set illumination time for keyboard.") feature = _F.BACKLIGHT choices_universe = common.NamedInts(Off=0, Varying=2, VeryShort=5, Short=10, Medium=20, Long=60, VeryLong=180) - validator_class = settings.ChoicesValidator + validator_class = settings_validator.ChoicesValidator validator_options = {"choices": choices_universe} @@ -285,7 +286,7 @@ def write(self, device, data_bytes): backlight.write() return True - class validator_class(settings.ChoicesValidator): + class validator_class(settings_validator.ChoicesValidator): @classmethod def build(cls, setting_class, device): backlight = device.backlight @@ -322,7 +323,7 @@ def write(self, device, data_bytes): device.backlight.write() return True - class validator_class(settings.RangeValidator): + class validator_class(settings_validator.RangeValidator): @classmethod def build(cls, setting_class, device): reply = device.feature_request(_F.BACKLIGHT2, 0x20) @@ -334,7 +335,7 @@ def build(cls, setting_class, device): class Backlight2Duration(settings.Setting): feature = _F.BACKLIGHT2 min_version = 3 - validator_class = settings.RangeValidator + validator_class = settings_validator.RangeValidator min_value = 1 max_value = 600 # 10 minutes - actual maximum is 2 hours validator_options = {"byte_count": 2} @@ -363,7 +364,7 @@ class Backlight2DurationHandsOut(Backlight2Duration): label = _("Backlight Delay Hands Out") description = _("Delay in seconds until backlight fades out with hands away from keyboard.") feature = _F.BACKLIGHT2 - validator_class = settings.RangeValidator + validator_class = settings_validator.RangeValidator rw_options = {"field": "dho"} @@ -372,7 +373,7 @@ class Backlight2DurationHandsIn(Backlight2Duration): label = _("Backlight Delay Hands In") description = _("Delay in seconds until backlight fades out with hands near keyboard.") feature = _F.BACKLIGHT2 - validator_class = settings.RangeValidator + validator_class = settings_validator.RangeValidator rw_options = {"field": "dhi"} @@ -381,7 +382,7 @@ class Backlight2DurationPowered(Backlight2Duration): label = _("Backlight Delay Powered") description = _("Delay in seconds until backlight fades out with external power.") feature = _F.BACKLIGHT2 - validator_class = settings.RangeValidator + validator_class = settings_validator.RangeValidator rw_options = {"field": "dpow"} @@ -391,7 +392,7 @@ class Backlight3(settings.Setting): description = _("Set illumination time for keyboard.") feature = _F.BACKLIGHT3 rw_options = {"read_fnid": 0x10, "write_fnid": 0x20, "suffix": b"\x09"} - validator_class = settings.RangeValidator + validator_class = settings_validator.RangeValidator min_value = 0 max_value = 1000 validator_options = {"byte_count": 2} @@ -455,7 +456,7 @@ class PointerSpeed(settings.Setting): label = _("Sensitivity (Pointer Speed)") description = _("Speed multiplier for mouse (256 is normal multiplier).") feature = _F.POINTER_SPEED - validator_class = settings.RangeValidator + validator_class = settings_validator.RangeValidator min_value = 0x002E max_value = 0x01FF validator_options = {"byte_count": 2} @@ -502,7 +503,7 @@ class OnboardProfiles(settings.Setting): for i in range(1, 16): choices_universe[i] = f"Profile {i}" choices_universe[i + 0x100] = f"Read-Only Profile {i}" - validator_class = settings.ChoicesValidator + validator_class = settings_validator.ChoicesValidator class rw_class: def __init__(self, feature): @@ -526,7 +527,7 @@ def write(self, device, data_bytes): profile_change(device, common.bytes2int(data_bytes)) return result - class validator_class(settings.ChoicesValidator): + class validator_class(settings_validator.ChoicesValidator): @classmethod def build(cls, setting_class, device): headers = hidpp20.OnboardProfiles.get_profile_headers(device) @@ -556,7 +557,7 @@ class ReportRate(settings.Setting): choices_universe[7] = "7ms" choices_universe[8] = "8ms" - class validator_class(settings.ChoicesValidator): + class validator_class(settings_validator.ChoicesValidator): @classmethod def build(cls, setting_class, device): # if device.wpid == '408E': @@ -588,7 +589,7 @@ class ExtendedReportRate(settings.Setting): choices_universe[5] = "250us" choices_universe[6] = "125us" - class validator_class(settings.ChoicesValidator): + class validator_class(settings_validator.ChoicesValidator): @classmethod def build(cls, setting_class, device): reply = device.feature_request(_F.EXTENDED_ADJUSTABLE_REPORT_RATE, 0x10) @@ -640,7 +641,7 @@ class ScrollRatchet(settings.Setting): description = _("Switch the mouse wheel between speed-controlled ratcheting and always freespin.") feature = _F.SMART_SHIFT choices_universe = common.NamedInts(**{_("Freespinning"): 1, _("Ratcheted"): 2}) - validator_class = settings.ChoicesValidator + validator_class = settings_validator.ChoicesValidator validator_options = {"choices": choices_universe} @@ -683,7 +684,7 @@ def write(self, device, data_bytes): min_value = rw_class.MIN_VALUE max_value = rw_class.MAX_VALUE - validator_class = settings.RangeValidator + validator_class = settings_validator.RangeValidator class SmartShiftEnhanced(SmartShift): @@ -730,7 +731,7 @@ def write(self, device, key, data_bytes): key_struct.remap(special_keys.CONTROL[common.bytes2int(data_bytes)]) return True - class validator_class(settings.ChoicesMapValidator): + class validator_class(settings_validator.ChoicesMapValidator): @classmethod def build(cls, setting_class, device): choices = {} @@ -907,7 +908,7 @@ def write(self, device, key, data_bytes): key_struct.set_diverted(common.bytes2int(data_bytes) != 0) # not regular return True - class validator_class(settings.ChoicesMapValidator): + class validator_class(settings_validator.ChoicesMapValidator): def __init__(self, choices, key_byte_count=2, byte_count=1, mask=0x01): super().__init__(choices, key_byte_count, byte_count, mask) @@ -983,7 +984,7 @@ class AdjustableDpi(settings.Setting): rw_options = {"read_fnid": 0x20, "write_fnid": 0x30} choices_universe = common.NamedInts.range(100, 4000, str, 50) - class validator_class(settings.ChoicesValidator): + class validator_class(settings_validator.ChoicesValidator): @classmethod def build(cls, setting_class, device): dpilist = produce_dpi_list(setting_class.feature, 0x10, 1, device, 0) @@ -1024,7 +1025,7 @@ def write_key_value(self, key, value, save=True): result = self.write(self._value, save) return result[key] if isinstance(result, dict) else result - class validator_class(settings.ChoicesMapValidator): + class validator_class(settings_validator.ChoicesMapValidator): @classmethod def build(cls, setting_class, device): reply = device.feature_request(setting_class.feature, 0x10, 0x00) @@ -1107,7 +1108,7 @@ def press_action(self): # switch sensitivity if self.device.persister: self.device.persister["_speed-change"] = currentSpeed - class validator_class(settings.ChoicesValidator): + class validator_class(settings_validator.ChoicesValidator): @classmethod def build(cls, setting_class, device): key_index = device.keys.index(special_keys.CONTROL.DPI_Change) @@ -1126,7 +1127,7 @@ class DisableKeyboardKeys(settings.BitFieldSetting): _labels = {k: (None, _("Disables the %s key.") % k) for k in special_keys.DISABLE} choices_universe = special_keys.DISABLE - class validator_class(settings.BitFieldValidator): + class validator_class(settings_validator.BitFieldValidator): @classmethod def build(cls, setting_class, device): mask = device.feature_request(_F.KEYBOARD_DISABLE_KEYS, 0x00)[0] @@ -1158,7 +1159,7 @@ class Multiplatform(settings.Setting): # the problem here is how to construct the right values for the rules Set GUI, # as, for example, the integer value for 'Windows' can be different on different devices - class validator_class(settings.ChoicesValidator): + class validator_class(settings_validator.ChoicesValidator): @classmethod def build(cls, setting_class, device): def _str_os_versions(low, high): @@ -1200,7 +1201,7 @@ class DualPlatform(settings.Setting): choices_universe[0x01] = "Android, Windows" feature = _F.DUALPLATFORM rw_options = {"read_fnid": 0x00, "write_fnid": 0x20} - validator_class = settings.ChoicesValidator + validator_class = settings_validator.ChoicesValidator validator_options = {"choices": choices_universe} @@ -1213,7 +1214,7 @@ class ChangeHost(settings.Setting): rw_options = {"read_fnid": 0x00, "write_fnid": 0x10, "no_reply": True} choices_universe = common.NamedInts(**{"Host " + str(i + 1): i for i in range(3)}) - class validator_class(settings.ChoicesValidator): + class validator_class(settings_validator.ChoicesValidator): @classmethod def build(cls, setting_class, device): infos = device.feature_request(_F.CHANGE_HOST) @@ -1325,7 +1326,7 @@ class Gesture2Gestures(settings.BitFieldWithOffsetAndMaskSetting): choices_universe = hidpp20_constants.GestureId _labels = _GESTURE2_GESTURES_LABELS - class validator_class(settings.BitFieldWithOffsetAndMaskValidator): + class validator_class(settings_validator.BitFieldWithOffsetAndMaskValidator): @classmethod def build(cls, setting_class, device, om_method=None): options = [g for g in device.gestures.gestures.values() if g.can_be_enabled or g.default_enabled] @@ -1342,7 +1343,7 @@ class Gesture2Divert(settings.BitFieldWithOffsetAndMaskSetting): choices_universe = hidpp20_constants.GestureId _labels = _GESTURE2_GESTURES_LABELS - class validator_class(settings.BitFieldWithOffsetAndMaskValidator): + class validator_class(settings_validator.BitFieldWithOffsetAndMaskValidator): @classmethod def build(cls, setting_class, device, om_method=None): options = [g for g in device.gestures.gestures.values() if g.can_be_diverted] @@ -1363,7 +1364,7 @@ class Gesture2Params(settings.LongSettings): _labels = _GESTURE2_PARAMS_LABELS _labels_sub = _GESTURE2_PARAMS_LABELS_SUB - class validator_class(settings.MultipleRangeValidator): + class validator_class(settings_validator.MultipleRangeValidator): @classmethod def build(cls, setting_class, device): params = _hidpp20.get_gestures(device).params.values() @@ -1397,7 +1398,7 @@ def __init__(self, feature): def read(self, device): # no way to read, so just assume off return b"\x00" - class validator_class(settings.BitFieldValidator): + class validator_class(settings_validator.BitFieldValidator): @classmethod def build(cls, setting_class, device): number = device.feature_request(setting_class.feature, 0x00)[0] @@ -1455,7 +1456,7 @@ def write(self, device, key, data_bytes): v = ks.remap(data_bytes) return v - class validator_class(settings.ChoicesMapValidator): + class validator_class(settings_validator.ChoicesMapValidator): @classmethod def build(cls, setting_class, device): remap_keys = device.remap_keys @@ -1494,7 +1495,7 @@ class Sidetone(settings.Setting): label = _("Sidetone") description = _("Set sidetone level.") feature = _F.SIDETONE - validator_class = settings.RangeValidator + validator_class = settings_validator.RangeValidator min_value = 0 max_value = 100 @@ -1507,7 +1508,7 @@ class Equalizer(settings.RangeFieldSetting): rw_options = {"read_fnid": 0x20, "write_fnid": 0x30, "read_prefix": b"\x00"} keys_universe = [] - class validator_class(settings.PackedRangeValidator): + class validator_class(settings_validator.PackedRangeValidator): @classmethod def build(cls, setting_class, device): data = device.feature_request(_F.EQUALIZER, 0x00) @@ -1534,7 +1535,7 @@ class ADCPower(settings.Setting): description = _("Power off in minutes (0 for never).") feature = _F.ADC_MEASUREMENT rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} - validator_class = settings.RangeValidator + validator_class = settings_validator.RangeValidator min_value = 0x00 max_value = 0xFF validator_options = {"byte_count": 1} @@ -1546,7 +1547,7 @@ class BrightnessControl(settings.Setting): description = _("Control overall brightness") feature = _F.BRIGHTNESS_CONTROL rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} - validator_class = settings.RangeValidator + validator_class = settings_validator.RangeValidator def __init__(self, device, rw, validator): super().__init__(device, rw, validator) @@ -1570,7 +1571,7 @@ def write(self, device, data_bytes): return reply return super().write(device, data_bytes) - class validator_class(settings.RangeValidator): + class validator_class(settings_validator.RangeValidator): @classmethod def build(cls, setting_class, device): reply = device.feature_request(_F.BRIGHTNESS_CONTROL) @@ -1591,7 +1592,7 @@ class LEDControl(settings.Setting): feature = _F.COLOR_LED_EFFECTS rw_options = {"read_fnid": 0x70, "write_fnid": 0x80} choices_universe = common.NamedInts(Device=0, Solaar=1) - validator_class = settings.ChoicesValidator + validator_class = settings_validator.ChoicesValidator validator_options = {"choices": choices_universe} @@ -1605,12 +1606,13 @@ class LEDZoneSetting(settings.Setting): label = _("LED Zone Effects") description = _("Set effect for LED Zone") + "\n" + _("LED Control needs to be set to Solaar to be effective.") feature = _F.COLOR_LED_EFFECTS - color_field = {"name": _LEDP.color, "kind": settings.KIND.choice, "label": None, "choices": colors} - speed_field = {"name": _LEDP.speed, "kind": settings.KIND.range, "label": _("Speed"), "min": 0, "max": 255} - period_field = {"name": _LEDP.period, "kind": settings.KIND.range, "label": _("Period"), "min": 100, "max": 5000} - intensity_field = {"name": _LEDP.intensity, "kind": settings.KIND.range, "label": _("Intensity"), "min": 0, "max": 100} - ramp_field = {"name": _LEDP.ramp, "kind": settings.KIND.choice, "label": _("Ramp"), "choices": hidpp20.LEDRampChoices} - # form_field = {"name": _LEDP.form, "kind": settings.KIND.choice, "label": _("Form"), "choices": _hidpp20.LEDFormChoices} + color_field = {"name": _LEDP.color, "kind": settings.Kind.CHOICE, "label": None, "choices": colors} + speed_field = {"name": _LEDP.speed, "kind": settings.Kind.RANGE, "label": _("Speed"), "min": 0, "max": 255} + period_field = {"name": _LEDP.period, "kind": settings.Kind.RANGE, "label": _("Period"), "min": 100, "max": 5000} + intensity_field = {"name": _LEDP.intensity, "kind": settings.Kind.RANGE, "label": _("Intensity"), "min": 0, "max": 100} + ramp_field = {"name": _LEDP.ramp, "kind": settings.Kind.CHOICE, "label": _("Ramp"), "choices": hidpp20.LEDRampChoices} + # form_field = {"name": _LEDP.form, "kind": settings.Kind.CHOICE, "label": _("Form"), "choices": + # _hidpp20.LEDFormChoices} possible_fields = [color_field, speed_field, period_field, intensity_field, ramp_field] @classmethod @@ -1620,14 +1622,14 @@ def setup(cls, device, read_fnid, write_fnid, suffix): for zone in infos.zones: prefix = common.int2bytes(zone.index, 1) rw = settings.FeatureRW(cls.feature, read_fnid, write_fnid, prefix=prefix, suffix=suffix) - validator = settings.HeteroValidator( + validator = settings_validator.HeteroValidator( data_class=hidpp20.LEDEffectSetting, options=zone.effects, readable=infos.readable ) setting = cls(device, rw, validator) setting.name = cls.name + str(int(zone.location)) setting.label = _("LEDs") + " " + str(hidpp20.LEDZoneLocations[zone.location]) choices = [hidpp20.LEDEffects[e.ID][0] for e in zone.effects if e.ID in hidpp20.LEDEffects] - ID_field = {"name": "ID", "kind": settings.KIND.choice, "label": None, "choices": choices} + ID_field = {"name": "ID", "kind": settings.Kind.CHOICE, "label": None, "choices": choices} setting.possible_fields = [ID_field] + cls.possible_fields setting.fields_map = hidpp20.LEDEffects settings_.append(setting) @@ -1645,7 +1647,7 @@ class RGBControl(settings.Setting): feature = _F.RGB_EFFECTS rw_options = {"read_fnid": 0x50, "write_fnid": 0x50} choices_universe = common.NamedInts(Device=0, Solaar=1) - validator_class = settings.ChoicesValidator + validator_class = settings_validator.ChoicesValidator validator_options = {"choices": choices_universe, "write_prefix_bytes": b"\x01", "read_skip_byte_count": 1} @@ -1723,7 +1725,7 @@ def write_key_value(self, key, value, save=True): class rw_class(settings.FeatureRWMap): pass - class validator_class(settings.ChoicesMapValidator): + class validator_class(settings_validator.ChoicesMapValidator): @classmethod def build(cls, setting_class, device): choices_map = {} @@ -1742,7 +1744,7 @@ def build(cls, setting_class, device): return result -SETTINGS = [ +SETTINGS: list[settings.Setting] = [ RegisterHandDetection, # simple RegisterSmoothScroll, # simple RegisterSideScroll, # simple diff --git a/lib/logitech_receiver/settings_validator.py b/lib/logitech_receiver/settings_validator.py new file mode 100644 index 0000000000..b2102d0438 --- /dev/null +++ b/lib/logitech_receiver/settings_validator.py @@ -0,0 +1,744 @@ +from __future__ import annotations + +import logging +import math + +from enum import IntEnum + +from logitech_receiver import common +from logitech_receiver.common import NamedInt +from logitech_receiver.common import NamedInts + +logger = logging.getLogger(__name__) + + +def bool_or_toggle(current: bool | str, new: bool | str) -> bool: + if isinstance(new, bool): + return new + + try: + return bool(int(new)) + except (TypeError, ValueError): + new = str(new).lower() + + if new in ("true", "yes", "on", "t", "y"): + return True + if new in ("false", "no", "off", "f", "n"): + return False + if new in ("~", "toggle"): + return not current + return None + + +class Kind(IntEnum): + TOGGLE = 0x01 + CHOICE = 0x02 + RANGE = 0x04 + MAP_CHOICE = 0x0A + MULTIPLE_TOGGLE = 0x10 + PACKED_RANGE = 0x20 + MULTIPLE_RANGE = 0x40 + HETERO = 0x80 + + +class Validator: + @classmethod + def build(cls, setting_class, device, **kwargs): + return cls(**kwargs) + + @classmethod + def to_string(cls, value): + return str(value) + + def compare(self, args, current): + if len(args) != 1: + return False + return args[0] == current + + +class BooleanValidator(Validator): + __slots__ = ("true_value", "false_value", "read_skip_byte_count", "write_prefix_bytes", "mask", "needs_current_value") + + kind = Kind.TOGGLE + default_true = 0x01 + default_false = 0x00 + # mask specifies all the affected bits in the value + default_mask = 0xFF + + def __init__( + self, + true_value=default_true, + false_value=default_false, + mask=default_mask, + read_skip_byte_count=0, + write_prefix_bytes=b"", + ): + if isinstance(true_value, int): + assert isinstance(false_value, int) + if mask is None: + mask = self.default_mask + else: + assert isinstance(mask, int) + assert true_value & false_value == 0 + assert true_value & mask == true_value + assert false_value & mask == false_value + self.needs_current_value = mask != self.default_mask + elif isinstance(true_value, bytes): + if false_value is None or false_value == self.default_false: + false_value = b"\x00" * len(true_value) + else: + assert isinstance(false_value, bytes) + if mask is None or mask == self.default_mask: + mask = b"\xff" * len(true_value) + else: + assert isinstance(mask, bytes) + assert len(mask) == len(true_value) == len(false_value) + tv = common.bytes2int(true_value) + fv = common.bytes2int(false_value) + mv = common.bytes2int(mask) + assert tv != fv # true and false might be something other than bit values + assert tv & mv == tv + assert fv & mv == fv + self.needs_current_value = any(m != 0xFF for m in mask) + else: + raise Exception(f"invalid mask '{mask!r}', type {type(mask)}") + + self.true_value = true_value + self.false_value = false_value + self.mask = mask + self.read_skip_byte_count = read_skip_byte_count + self.write_prefix_bytes = write_prefix_bytes + + def validate_read(self, reply_bytes): + reply_bytes = reply_bytes[self.read_skip_byte_count :] + if isinstance(self.mask, int): + reply_value = ord(reply_bytes[:1]) & self.mask + if logger.isEnabledFor(logging.DEBUG): + logger.debug("BooleanValidator: validate read %r => %02X", reply_bytes, reply_value) + if reply_value == self.true_value: + return True + if reply_value == self.false_value: + return False + logger.warning( + "BooleanValidator: reply %02X mismatched %02X/%02X/%02X", + reply_value, + self.true_value, + self.false_value, + self.mask, + ) + return False + + count = len(self.mask) + mask = common.bytes2int(self.mask) + reply_value = common.bytes2int(reply_bytes[:count]) & mask + + true_value = common.bytes2int(self.true_value) + if reply_value == true_value: + return True + + false_value = common.bytes2int(self.false_value) + if reply_value == false_value: + return False + + logger.warning( + "BooleanValidator: reply %r mismatched %r/%r/%r", reply_bytes, self.true_value, self.false_value, self.mask + ) + return False + + def prepare_write(self, new_value, current_value=None): + if new_value is None: + new_value = False + else: + assert isinstance(new_value, bool), f"New value {new_value} for boolean setting is not a boolean" + + to_write = self.true_value if new_value else self.false_value + + if isinstance(self.mask, int): + if current_value is not None and self.needs_current_value: + to_write |= ord(current_value[:1]) & (0xFF ^ self.mask) + if current_value is not None and to_write == ord(current_value[:1]): + return None + to_write = bytes([to_write]) + else: + to_write = bytearray(to_write) + count = len(self.mask) + for i in range(0, count): + b = ord(to_write[i : i + 1]) + m = ord(self.mask[i : i + 1]) + assert b & m == b + # b &= m + if current_value is not None and self.needs_current_value: + b |= ord(current_value[i : i + 1]) & (0xFF ^ m) + to_write[i] = b + to_write = bytes(to_write) + + if current_value is not None and to_write == current_value[: len(to_write)]: + return None + + if logger.isEnabledFor(logging.DEBUG): + logger.debug("BooleanValidator: prepare_write(%s, %s) => %r", new_value, current_value, to_write) + + return self.write_prefix_bytes + to_write + + def acceptable(self, args, current): + if len(args) != 1: + return None + val = bool_or_toggle(current, args[0]) + return [val] if val is not None else None + + +class BitFieldValidator(Validator): + __slots__ = ("byte_count", "options") + + kind = Kind.MULTIPLE_TOGGLE + + def __init__(self, options, byte_count=None): + assert isinstance(options, list) + self.options = options + self.byte_count = (max(x.bit_length() for x in options) + 7) // 8 + if byte_count: + assert isinstance(byte_count, int) and byte_count >= self.byte_count + self.byte_count = byte_count + + def to_string(self, value): + def element_to_string(key, val): + k = next((k for k in self.options if int(key) == k), None) + return str(k) + ":" + str(val) if k is not None else "?" + + return "{" + ", ".join([element_to_string(k, value[k]) for k in value]) + "}" + + def validate_read(self, reply_bytes): + r = common.bytes2int(reply_bytes[: self.byte_count]) + value = {int(k): False for k in self.options} + m = 1 + for _ignore in range(8 * self.byte_count): + if m in self.options: + value[int(m)] = bool(r & m) + m <<= 1 + return value + + def prepare_write(self, new_value): + assert isinstance(new_value, dict) + w = 0 + for k, v in new_value.items(): + if v: + w |= int(k) + return common.int2bytes(w, self.byte_count) + + def get_options(self): + return self.options + + def acceptable(self, args, current): + if len(args) != 2: + return None + key = next((key for key in self.options if key == args[0]), None) + if key is None: + return None + val = bool_or_toggle(current[int(key)], args[1]) + return None if val is None else [int(key), val] + + def compare(self, args, current): + if len(args) != 2: + return False + key = next((key for key in self.options if key == args[0]), None) + if key is None: + return False + return args[1] == current[int(key)] + + +class BitFieldWithOffsetAndMaskValidator(Validator): + __slots__ = ("byte_count", "options", "_option_from_key", "_mask_from_offset", "_option_from_offset_mask") + + kind = Kind.MULTIPLE_TOGGLE + sep = 0x01 + + def __init__(self, options, om_method=None, byte_count=None): + assert isinstance(options, list) + # each element of options is an instance of a class + # that has an id (which is used as an index in other dictionaries) + # and where om_method is a method that returns a byte offset and byte mask + # that says how to access and modify the bit toggle for the option + self.options = options + self.om_method = om_method + # to retrieve the options efficiently: + self._option_from_key = {} + self._mask_from_offset = {} + self._option_from_offset_mask = {} + for opt in options: + offset, mask = om_method(opt) + self._option_from_key[int(opt)] = opt + try: + self._mask_from_offset[offset] |= mask + except KeyError: + self._mask_from_offset[offset] = mask + try: + mask_to_opt = self._option_from_offset_mask[offset] + except KeyError: + mask_to_opt = {} + self._option_from_offset_mask[offset] = mask_to_opt + mask_to_opt[mask] = opt + self.byte_count = (max(om_method(x)[1].bit_length() for x in options) + 7) // 8 # is this correct?? + if byte_count: + assert isinstance(byte_count, int) and byte_count >= self.byte_count + self.byte_count = byte_count + + def prepare_read(self): + r = [] + for offset, mask in self._mask_from_offset.items(): + b = offset << (8 * (self.byte_count + 1)) + b |= (self.sep << (8 * self.byte_count)) | mask + r.append(common.int2bytes(b, self.byte_count + 2)) + return r + + def prepare_read_key(self, key): + option = self._option_from_key.get(key, None) + if option is None: + return None + offset, mask = option.om_method(option) + b = offset << (8 * (self.byte_count + 1)) + b |= (self.sep << (8 * self.byte_count)) | mask + return common.int2bytes(b, self.byte_count + 2) + + def validate_read(self, reply_bytes_dict): + values = {int(k): False for k in self.options} + for query, b in reply_bytes_dict.items(): + offset = common.bytes2int(query[0:1]) + b += (self.byte_count - len(b)) * b"\x00" + value = common.bytes2int(b[: self.byte_count]) + mask_to_opt = self._option_from_offset_mask.get(offset, {}) + m = 1 + for _ignore in range(8 * self.byte_count): + if m in mask_to_opt: + values[int(mask_to_opt[m])] = bool(value & m) + m <<= 1 + return values + + def prepare_write(self, new_value): + assert isinstance(new_value, dict) + w = {} + for k, v in new_value.items(): + option = self._option_from_key[int(k)] + offset, mask = self.om_method(option) + if offset not in w: + w[offset] = 0 + if v: + w[offset] |= mask + return [ + common.int2bytes( + (offset << (8 * (2 * self.byte_count + 1))) + | (self.sep << (16 * self.byte_count)) + | (self._mask_from_offset[offset] << (8 * self.byte_count)) + | value, + 2 * self.byte_count + 2, + ) + for offset, value in w.items() + ] + + def get_options(self): + return [int(opt) if isinstance(opt, int) else opt.as_int() for opt in self.options] + + def acceptable(self, args, current): + if len(args) != 2: + return None + key = next((option.id for option in self.options if option.as_int() == args[0]), None) + if key is None: + return None + val = bool_or_toggle(current[int(key)], args[1]) + return None if val is None else [int(key), val] + + def compare(self, args, current): + if len(args) != 2: + return False + key = next((option.id for option in self.options if option.as_int() == args[0]), None) + if key is None: + return False + return args[1] == current[int(key)] + + +class ChoicesValidator(Validator): + """Translates between NamedInts and a byte sequence. + :param choices: a list of NamedInts + :param byte_count: the size of the derived byte sequence. If None, it + will be calculated from the choices.""" + + kind = Kind.CHOICE + + def __init__(self, choices=None, byte_count=None, read_skip_byte_count=0, write_prefix_bytes=b""): + assert choices is not None + assert isinstance(choices, NamedInts) + assert len(choices) > 1 + self.choices = choices + self.needs_current_value = False + + max_bits = max(x.bit_length() for x in choices) + self._byte_count = (max_bits // 8) + (1 if max_bits % 8 else 0) + if byte_count: + assert self._byte_count <= byte_count + self._byte_count = byte_count + assert self._byte_count < 8 + self._read_skip_byte_count = read_skip_byte_count + self._write_prefix_bytes = write_prefix_bytes if write_prefix_bytes else b"" + assert self._byte_count + self._read_skip_byte_count <= 14 + assert self._byte_count + len(self._write_prefix_bytes) <= 14 + + def to_string(self, value): + return str(self.choices[value]) if isinstance(value, int) else str(value) + + def validate_read(self, reply_bytes): + reply_value = common.bytes2int(reply_bytes[self._read_skip_byte_count : self._read_skip_byte_count + self._byte_count]) + valid_value = self.choices[reply_value] + assert valid_value is not None, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}" + return valid_value + + def prepare_write(self, new_value, current_value=None): + if new_value is None: + value = self.choices[:][0] + else: + value = self.choice(new_value) + if value is None: + raise ValueError(f"invalid choice {new_value!r}") + assert isinstance(value, NamedInt) + return self._write_prefix_bytes + value.bytes(self._byte_count) + + def choice(self, value): + if isinstance(value, int): + return self.choices[value] + try: + int(value) + if int(value) in self.choices: + return self.choices[int(value)] + except Exception: + pass + if value in self.choices: + return self.choices[value] + else: + return None + + def acceptable(self, args, current): + choice = self.choice(args[0]) if len(args) == 1 else None + return None if choice is None else [choice] + + +class ChoicesMapValidator(ChoicesValidator): + kind = Kind.MAP_CHOICE + + def __init__( + self, + choices_map, + key_byte_count=0, + key_postfix_bytes=b"", + byte_count=0, + read_skip_byte_count=0, + write_prefix_bytes=b"", + extra_default=None, + mask=-1, + activate=0, + ): + assert choices_map is not None + assert isinstance(choices_map, dict) + max_key_bits = 0 + max_value_bits = 0 + for key, choices in choices_map.items(): + assert isinstance(key, NamedInt) + assert isinstance(choices, NamedInts) + max_key_bits = max(max_key_bits, key.bit_length()) + for key_value in choices: + assert isinstance(key_value, NamedInt) + max_value_bits = max(max_value_bits, key_value.bit_length()) + self._key_byte_count = (max_key_bits + 7) // 8 + if key_byte_count: + assert self._key_byte_count <= key_byte_count + self._key_byte_count = key_byte_count + self._byte_count = (max_value_bits + 7) // 8 + if byte_count: + assert self._byte_count <= byte_count + self._byte_count = byte_count + + self.choices = choices_map + self.needs_current_value = False + self.extra_default = extra_default + self._key_postfix_bytes = key_postfix_bytes + self._read_skip_byte_count = read_skip_byte_count if read_skip_byte_count else 0 + self._write_prefix_bytes = write_prefix_bytes if write_prefix_bytes else b"" + self.activate = activate + self.mask = mask + assert self._byte_count + self._read_skip_byte_count + self._key_byte_count <= 14 + assert self._byte_count + len(self._write_prefix_bytes) + self._key_byte_count <= 14 + + def to_string(self, value): + def element_to_string(key, val): + k, c = next(((k, c) for k, c in self.choices.items() if int(key) == k), (None, None)) + return str(k) + ":" + str(c[val]) if k is not None else "?" + + return "{" + ", ".join([element_to_string(k, value[k]) for k in sorted(value)]) + "}" + + def validate_read(self, reply_bytes, key): + start = self._key_byte_count + self._read_skip_byte_count + end = start + self._byte_count + reply_value = common.bytes2int(reply_bytes[start:end]) & self.mask + # reprogrammable keys starts out as 0, which is not a choice, so don't use assert here + if self.extra_default is not None and self.extra_default == reply_value: + return int(self.choices[key][0]) + if reply_value not in self.choices[key]: + assert reply_value in self.choices[key], "%s: failed to validate read value %02X" % ( + self.__class__.__name__, + reply_value, + ) + return reply_value + + def prepare_key(self, key): + return key.to_bytes(self._key_byte_count, "big") + self._key_postfix_bytes + + def prepare_write(self, key, new_value): + choices = self.choices.get(key) + if choices is None or (new_value not in choices and new_value != self.extra_default): + logger.error("invalid choice %r for %s", new_value, key) + return None + new_value = new_value | self.activate + return self._write_prefix_bytes + new_value.to_bytes(self._byte_count, "big") + + def acceptable(self, args, current): + if len(args) != 2: + return None + key, choices = next(((key, item) for key, item in self.choices.items() if key == args[0]), (None, None)) + if choices is None or args[1] not in choices: + return None + choice = next((item for item in choices if item == args[1]), None) + return [int(key), int(choice)] if choice is not None else None + + def compare(self, args, current): + if len(args) != 2: + return False + key = next((key for key in self.choices if key == int(args[0])), None) + if key is None: + return False + return args[1] == current[int(key)] + + +class RangeValidator(Validator): + kind = Kind.RANGE + """Translates between integers and a byte sequence. + :param min_value: minimum accepted value (inclusive) + :param max_value: maximum accepted value (inclusive) + :param byte_count: the size of the derived byte sequence. If None, it + will be calculated from the range.""" + min_value = 0 + max_value = 255 + + @classmethod + def build(cls, setting_class, device, **kwargs): + kwargs["min_value"] = setting_class.min_value + kwargs["max_value"] = setting_class.max_value + return cls(**kwargs) + + def __init__(self, min_value=0, max_value=255, byte_count=1): + assert max_value > min_value + self.min_value = min_value + self.max_value = max_value + self.needs_current_value = True # read and check before write (needed for ADC power and probably a good idea anyway) + + self._byte_count = math.ceil(math.log(max_value + 1, 256)) + if byte_count: + assert self._byte_count <= byte_count + self._byte_count = byte_count + assert self._byte_count < 8 + + def validate_read(self, reply_bytes): + reply_value = common.bytes2int(reply_bytes[: self._byte_count]) + assert reply_value >= self.min_value, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}" + assert reply_value <= self.max_value, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}" + return reply_value + + def prepare_write(self, new_value, current_value=None): + if new_value < self.min_value or new_value > self.max_value: + raise ValueError(f"invalid choice {new_value!r}") + current_value = self.validate_read(current_value) if current_value is not None else None + to_write = common.int2bytes(new_value, self._byte_count) + # current value is known and same as value to be written return None to signal not to write it + return None if current_value is not None and current_value == new_value else to_write + + def acceptable(self, args, current): + arg = args[0] + # None if len(args) != 1 or type(arg) != int or arg < self.min_value or arg > self.max_value else args) + return None if len(args) != 1 or isinstance(arg, int) or arg < self.min_value or arg > self.max_value else args + + def compare(self, args, current): + if len(args) == 1: + return args[0] == current + elif len(args) == 2: + return args[0] <= current <= args[1] + else: + return False + + +class HeteroValidator(Validator): + kind = Kind.HETERO + + @classmethod + def build(cls, setting_class, device, **kwargs): + return cls(**kwargs) + + def __init__(self, data_class=None, options=None, readable=True): + assert data_class is not None and options is not None + self.data_class = data_class + self.options = options + self.readable = readable + self.needs_current_value = False + + def validate_read(self, reply_bytes): + if self.readable: + reply_value = self.data_class.from_bytes(reply_bytes, options=self.options) + return reply_value + + def prepare_write(self, new_value, current_value=None): + to_write = new_value.to_bytes(options=self.options) + return to_write + + def acceptable(self, args, current): # should this actually do some checking? + return True + + +class PackedRangeValidator(Validator): + kind = Kind.PACKED_RANGE + """Several range values, all the same size, all the same min and max""" + min_value = 0 + max_value = 255 + count = 1 + rsbc = 0 + write_prefix_bytes = b"" + + def __init__( + self, keys, min_value=0, max_value=255, count=1, byte_count=1, read_skip_byte_count=0, write_prefix_bytes=b"" + ): + assert max_value > min_value + self.needs_current_value = True + self.keys = keys + self.min_value = min_value + self.max_value = max_value + self.count = count + self.bc = math.ceil(math.log(max_value + 1 - min(0, min_value), 256)) + if byte_count: + assert self.bc <= byte_count + self.bc = byte_count + assert self.bc * self.count + self.rsbc = read_skip_byte_count + self.write_prefix_bytes = write_prefix_bytes + + def validate_read(self, reply_bytes): + rvs = { + n: common.bytes2int(reply_bytes[self.rsbc + n * self.bc : self.rsbc + (n + 1) * self.bc], signed=True) + for n in range(self.count) + } + for n in range(self.count): + assert rvs[n] >= self.min_value, f"{self.__class__.__name__}: failed to validate read value {rvs[n]:02X}" + assert rvs[n] <= self.max_value, f"{self.__class__.__name__}: failed to validate read value {rvs[n]:02X}" + return rvs + + def prepare_write(self, new_values): + if len(new_values) != self.count: + raise ValueError(f"wrong number of values {new_values!r}") + for new_value in new_values.values(): + if new_value < self.min_value or new_value > self.max_value: + raise ValueError(f"invalid value {new_value!r}") + bytes = self.write_prefix_bytes + b"".join( + common.int2bytes(new_values[n], self.bc, signed=True) for n in range(self.count) + ) + return bytes + + def acceptable(self, args, current): + if len(args) != 2 or int(args[0]) < 0 or int(args[0]) >= self.count: + return None + return None if not isinstance(args[1], int) or args[1] < self.min_value or args[1] > self.max_value else args + + def compare(self, args, current): + logger.warning("compare not implemented for packed range settings") + return False + + +class MultipleRangeValidator(Validator): + kind = Kind.MULTIPLE_RANGE + + def __init__(self, items, sub_items): + assert isinstance(items, list) # each element must have .index and its __int__ must return its id (not its index) + assert isinstance(sub_items, dict) + # sub_items: items -> class with .minimum, .maximum, .length (in bytes), .id (a string) and .widget (e.g. 'Scale') + self.items = items + self.keys = NamedInts(**{str(item): int(item) for item in items}) + self._item_from_id = {int(k): k for k in items} + self.sub_items = sub_items + + def prepare_read_item(self, item): + return common.int2bytes((self._item_from_id[int(item)].index << 1) | 0xFF, 2) + + def validate_read_item(self, reply_bytes, item): + item = self._item_from_id[int(item)] + start = 0 + value = {} + for sub_item in self.sub_items[item]: + r = reply_bytes[start : start + sub_item.length] + if len(r) < sub_item.length: + r += b"\x00" * (sub_item.length - len(value)) + v = common.bytes2int(r) + if not (sub_item.minimum < v < sub_item.maximum): + logger.warning( + f"{self.__class__.__name__}: failed to validate read value for {item}.{sub_item}: " + + f"{v} not in [{sub_item.minimum}..{sub_item.maximum}]" + ) + value[str(sub_item)] = v + start += sub_item.length + return value + + def prepare_write(self, value): + seq = [] + w = b"" + for item in value.keys(): + _item = self._item_from_id[int(item)] + b = common.int2bytes(_item.index, 1) + for sub_item in self.sub_items[_item]: + try: + v = value[int(item)][str(sub_item)] + except KeyError: + return None + if not (sub_item.minimum <= v <= sub_item.maximum): + raise ValueError( + f"invalid choice for {item}.{sub_item}: {v} not in [{sub_item.minimum}..{sub_item.maximum}]" + ) + b += common.int2bytes(v, sub_item.length) + if len(w) + len(b) > 15: + seq.append(b + b"\xff") + w = b"" + w += b + seq.append(w + b"\xff") + return seq + + def prepare_write_item(self, item, value): + _item = self._item_from_id[int(item)] + w = common.int2bytes(_item.index, 1) + for sub_item in self.sub_items[_item]: + try: + v = value[str(sub_item)] + except KeyError: + return None + if not (sub_item.minimum <= v <= sub_item.maximum): + raise ValueError(f"invalid choice for {item}.{sub_item}: {v} not in [{sub_item.minimum}..{sub_item.maximum}]") + w += common.int2bytes(v, sub_item.length) + return w + b"\xff" + + def acceptable(self, args, current): + # just one item, with at least one sub-item + if not isinstance(args, list) or len(args) != 2 or not isinstance(args[1], dict): + return None + item = next((p for p in self.items if p.id == args[0] or str(p) == args[0]), None) + if not item: + return None + for sub_key, value in args[1].items(): + sub_item = next((it for it in self.sub_items[item] if it.id == sub_key), None) + if not sub_item: + return None + if not isinstance(value, int) or not (sub_item.minimum <= value <= sub_item.maximum): + return None + return [int(item), {**args[1]}] + + def compare(self, args, current): + logger.warning("compare not implemented for multiple range settings") + return False diff --git a/lib/solaar/cli/config.py b/lib/solaar/cli/config.py index 1e26a43ef2..914a2aa75c 100644 --- a/lib/solaar/cli/config.py +++ b/lib/solaar/cli/config.py @@ -30,9 +30,9 @@ def _print_setting(s, verbose=True): if verbose: if s.description: print("#", s.description.replace("\n", " ")) - if s.kind == settings.KIND.toggle: + if s.kind == settings.Kind.TOGGLE: print("# possible values: on/true/t/yes/y/1 or off/false/f/no/n/0 or Toggle/~") - elif s.kind == settings.KIND.choice: + elif s.kind == settings.Kind.CHOICE: print( "# possible values: one of [", ", ".join(str(v) for v in s.choices), @@ -53,7 +53,7 @@ def _print_setting_keyed(s, key, verbose=True): if verbose: if s.description: print("#", s.description.replace("\n", " ")) - if s.kind == settings.KIND.multiple_toggle: + if s.kind == settings.Kind.MULTIPLE_TOGGLE: k = next((k for k in s._labels if key == k), None) if k is None: print(s.name, "=? (key not found)") @@ -64,7 +64,7 @@ def _print_setting_keyed(s, key, verbose=True): print(s.name, "= ? (failed to read from device)") else: print(s.name, s.val_to_string({k: value[str(int(k))]})) - elif s.kind == settings.KIND.map_choice: + elif s.kind == settings.Kind.MAP_CHOICE: k = next((k for k in s.choices.keys() if key == k), None) if k is None: print(s.name, "=? (key not found)") @@ -215,26 +215,26 @@ def run(receivers, args, _find_receiver, find_device): dev.persister[setting.name] = setting._value -def set(dev, setting, args, save): - if setting.kind == settings.KIND.toggle: +def set(dev, setting: settings.Setting, args, save): + if setting.kind == settings.Kind.TOGGLE: value = select_toggle(args.value_key, setting) args.value_key = value message = f"Setting {setting.name} of {dev.name} to {value}" result = setting.write(value, save=save) - elif setting.kind == settings.KIND.range: + elif setting.kind == settings.Kind.RANGE: value = select_range(args.value_key, setting) args.value_key = value message = f"Setting {setting.name} of {dev.name} to {value}" result = setting.write(value, save=save) - elif setting.kind == settings.KIND.choice: + elif setting.kind == settings.Kind.CHOICE: value = select_choice(args.value_key, setting.choices, setting, None) args.value_key = int(value) message = f"Setting {setting.name} of {dev.name} to {value}" result = setting.write(value, save=save) - elif setting.kind == settings.KIND.map_choice: + elif setting.kind == settings.Kind.MAP_CHOICE: if args.extra_subkey is None: _print_setting_keyed(setting, args.value_key) return None, None, None @@ -252,7 +252,7 @@ def set(dev, setting, args, save): message = f"Setting {setting.name} of {dev.name} key {k!r} to {value!r}" result = setting.write_key_value(int(k), value, save=save) - elif setting.kind == settings.KIND.multiple_toggle: + elif setting.kind == settings.Kind.MULTIPLE_TOGGLE: if args.extra_subkey is None: _print_setting_keyed(setting, args.value_key) return None, None, None @@ -271,7 +271,7 @@ def set(dev, setting, args, save): message = f"Setting {setting.name} key {k!r} to {value!r}" result = setting.write_key_value(str(int(k)), value, save=save) - elif setting.kind == settings.KIND.multiple_range: + elif setting.kind == settings.Kind.MULTIPLE_RANGE: if args.extra_subkey is None: raise Exception(f"{setting.name}: setting needs both key and value to set") key = args.value_key diff --git a/lib/solaar/ui/config_panel.py b/lib/solaar/ui/config_panel.py index e8f99d5627..b5450b7fa2 100644 --- a/lib/solaar/ui/config_panel.py +++ b/lib/solaar/ui/config_panel.py @@ -666,21 +666,21 @@ def _create_sbox(s, _device): change.set_sensitive(True) change.connect("clicked", _change_click, sbox) - if s.kind == settings.KIND.toggle: + if s.kind == settings.Kind.TOGGLE: control = ToggleControl(sbox) - elif s.kind == settings.KIND.range: + elif s.kind == settings.Kind.RANGE: control = SliderControl(sbox) - elif s.kind == settings.KIND.choice: + elif s.kind == settings.Kind.CHOICE: control = _create_choice_control(sbox) - elif s.kind == settings.KIND.map_choice: + elif s.kind == settings.Kind.MAP_CHOICE: control = MapChoiceControl(sbox) - elif s.kind == settings.KIND.multiple_toggle: + elif s.kind == settings.Kind.MULTIPLE_TOGGLE: control = MultipleToggleControl(sbox, change) - elif s.kind == settings.KIND.multiple_range: + elif s.kind == settings.Kind.MULTIPLE_RANGE: control = MultipleRangeControl(sbox, change) - elif s.kind == settings.KIND.packed_range: + elif s.kind == settings.Kind.PACKED_RANGE: control = PackedRangeControl(sbox, change) - elif s.kind == settings.KIND.hetero: + elif s.kind == settings.Kind.HETERO: control = HeteroKeyControl(sbox, change) else: if logger.isEnabledFor(logging.WARNING): diff --git a/lib/solaar/ui/diversion_rules.py b/lib/solaar/ui/diversion_rules.py index f502d7eab5..669b182dc1 100644 --- a/lib/solaar/ui/diversion_rules.py +++ b/lib/solaar/ui/diversion_rules.py @@ -39,7 +39,7 @@ from logitech_receiver.common import NamedInt from logitech_receiver.common import NamedInts from logitech_receiver.common import UnsortedNamedInts -from logitech_receiver.settings import KIND as _SKIND +from logitech_receiver.settings import Kind from logitech_receiver.settings import Setting from logitech_receiver.settings_templates import SETTINGS @@ -1455,7 +1455,7 @@ def right_label(cls, component): class _SettingWithValueUI: ALL_SETTINGS = _all_settings() - MULTIPLE = [_SKIND.multiple_toggle, _SKIND.map_choice, _SKIND.multiple_range] + MULTIPLE = [Kind.MULTIPLE_TOGGLE, Kind.MAP_CHOICE, Kind.MULTIPLE_RANGE] ACCEPT_TOGGLE = True label_text = "" @@ -1569,7 +1569,7 @@ def _setting_attributes(cls, setting_name, device=None): if kind in cls.MULTIPLE: keys = UnsortedNamedInts() for s in settings: - universe = getattr(s, "keys_universe" if kind == _SKIND.map_choice else "choices_universe", None) + universe = getattr(s, "keys_universe" if kind == Kind.MAP_CHOICE else "choices_universe", None) if universe: keys |= universe # only one key per number is used @@ -1641,12 +1641,12 @@ def item(k): supported_keys = None if device_setting: val = device_setting._validator - if device_setting.kind == _SKIND.multiple_toggle: + if device_setting.kind == Kind.MULTIPLE_TOGGLE: supported_keys = val.get_options() or None - elif device_setting.kind == _SKIND.map_choice: + elif device_setting.kind == Kind.MAP_CHOICE: choices = val.choices or None supported_keys = choices.keys() if choices else None - elif device_setting.kind == _SKIND.multiple_range: + elif device_setting.kind == Kind.MULTIPLE_RANGE: supported_keys = val.keys self.key_field.show_only(supported_keys, include_new=True) self._update_validation() @@ -1655,24 +1655,24 @@ def _update_value_list(self, setting_name, device=None, key=None): setting, val_class, kind, keys = self._setting_attributes(setting_name, device) ds = device.settings if device else {} device_setting = ds.get(setting_name, None) - if kind in (_SKIND.toggle, _SKIND.multiple_toggle): + if kind in (Kind.TOGGLE, Kind.MULTIPLE_TOGGLE): self.value_field.make_toggle() - elif kind in (_SKIND.choice, _SKIND.map_choice): + elif kind in (Kind.CHOICE, Kind.MAP_CHOICE): all_values, extra = self._all_choices(device_setting or setting_name) self.value_field.make_choice(all_values, extra) supported_values = None if device_setting: val = device_setting._validator choices = getattr(val, "choices", None) or None - if kind == _SKIND.choice: + if kind == Kind.CHOICE: supported_values = choices - elif kind == _SKIND.map_choice and isinstance(choices, dict): + elif kind == Kind.MAP_CHOICE and isinstance(choices, dict): supported_values = choices.get(key, None) or None self.value_field.choice_widget.show_only(supported_values, include_new=True) self._update_validation() - elif kind == _SKIND.range: + elif kind == Kind.RANGE: self.value_field.make_range(val_class.min_value, val_class.max_value) - elif kind == _SKIND.multiple_range: + elif kind == Kind.MULTIPLE_RANGE: self.value_field.make_range_with_key( getattr(setting, "sub_items_universe", {}).get(key, {}) if setting else {}, getattr(setting, "_labels_sub", None) if setting else None, @@ -1703,7 +1703,7 @@ def _update_validation(self): key = self.key_field.get_value(invalid_as_str=False, accept_hidden=False) icon = "dialog-warning" if key is None else "" self.key_field.get_child().set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) - if kind in (_SKIND.choice, _SKIND.map_choice): + if kind in (Kind.CHOICE, Kind.MAP_CHOICE): value = self.value_field.choice_widget.get_value(invalid_as_str=False, accept_hidden=False) icon = "dialog-warning" if value is None else "" self.value_field.choice_widget.get_child().set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) @@ -1758,26 +1758,26 @@ def right_label(cls, component): key_label = getattr(setting, "_labels", {}).get(key, [None])[0] if setting else None disp.append(key_label or key) value = next(a, None) - if setting and (kind in (_SKIND.choice, _SKIND.map_choice)): + if setting and (kind in (Kind.CHOICE, Kind.MAP_CHOICE)): all_values = cls._all_choices(setting or setting_name)[0] supported_values = None if device_setting: val = device_setting._validator choices = getattr(val, "choices", None) or None - if kind == _SKIND.choice: + if kind == Kind.CHOICE: supported_values = choices - elif kind == _SKIND.map_choice and isinstance(choices, dict): + elif kind == Kind.MAP_CHOICE and isinstance(choices, dict): supported_values = choices.get(key, None) or None if supported_values and isinstance(supported_values, NamedInts): value = supported_values[value] if not supported_values and all_values and isinstance(all_values, NamedInts): value = all_values[value] disp.append(value) - elif kind == _SKIND.multiple_range and isinstance(value, dict) and len(value) == 1: + elif kind == Kind.MULTIPLE_RANGE and isinstance(value, dict) and len(value) == 1: k, v = next(iter(value.items())) k = (getattr(setting, "_labels_sub", {}).get(k, (None,))[0] if setting else None) or k disp.append(f"{k}={v}") - elif kind in (_SKIND.toggle, _SKIND.multiple_toggle): + elif kind in (Kind.TOGGLE, Kind.MULTIPLE_TOGGLE): disp.append(_(str(value))) else: disp.append(value) diff --git a/tests/logitech_receiver/test_settings.py b/tests/logitech_receiver/test_settings_validator.py similarity index 82% rename from tests/logitech_receiver/test_settings.py rename to tests/logitech_receiver/test_settings_validator.py index 5d50a3a4bf..383fae22c9 100644 --- a/tests/logitech_receiver/test_settings.py +++ b/tests/logitech_receiver/test_settings_validator.py @@ -1,6 +1,6 @@ import pytest -from logitech_receiver import settings +from logitech_receiver import settings_validator @pytest.mark.parametrize( @@ -20,6 +20,6 @@ ], ) def test_bool_or_toggle(current, new, expected): - result = settings.bool_or_toggle(current=current, new=new) + result = settings_validator.bool_or_toggle(current=current, new=new) assert result == expected From b0599e70a40a2d05c5034bd8c94698af8bd21524 Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Sun, 3 Nov 2024 21:13:07 +0100 Subject: [PATCH 02/28] Refactor: Convert Kind to IntEnum Related #2273 --- lib/logitech_receiver/settings.py | 12 ------------ lib/solaar/ui/config_panel.py | 6 +++--- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/lib/logitech_receiver/settings.py b/lib/logitech_receiver/settings.py index cb1a6e6f10..2acb04b953 100644 --- a/lib/logitech_receiver/settings.py +++ b/lib/logitech_receiver/settings.py @@ -27,22 +27,10 @@ from . import hidpp20_constants from . import settings_validator from .common import NamedInt -from .common import NamedInts logger = logging.getLogger(__name__) - SENSITIVITY_IGNORE = "ignore" -KIND = NamedInts( - toggle=0x01, - choice=0x02, - range=0x04, - map_choice=0x0A, - multiple_toggle=0x10, - packed_range=0x20, - multiple_range=0x40, - hetero=0x80, -) class Kind(IntEnum): diff --git a/lib/solaar/ui/config_panel.py b/lib/solaar/ui/config_panel.py index b5450b7fa2..d4a88f874f 100644 --- a/lib/solaar/ui/config_panel.py +++ b/lib/solaar/ui/config_panel.py @@ -105,7 +105,7 @@ def update(self): def layout(self, sbox, label, change, spinner, failed): sbox.pack_start(label, False, False, 0) sbox.pack_end(change, False, False, 0) - fill = sbox.setting.kind == settings.KIND.range or sbox.setting.kind == settings.KIND.hetero + fill = sbox.setting.kind == settings.Kind.RANGE or sbox.setting.kind == settings.Kind.HETERO sbox.pack_end(self, fill, fill, 0) sbox.pack_end(spinner, False, False, 0) sbox.pack_end(failed, False, False, 0) @@ -544,13 +544,13 @@ def __init__(self, sbox, delegate=None): item_lblbox = None item_box = ComboBoxText() - if item["kind"] == settings.KIND.choice: + if item["kind"] == settings.Kind.CHOICE: for entry in item["choices"]: item_box.append(str(int(entry)), str(entry)) item_box.set_active(0) item_box.connect("changed", self.changed) self.pack_start(item_box, False, False, 0) - elif item["kind"] == settings.KIND.range: + elif item["kind"] == settings.Kind.RANGE: item_box = Scale() item_box.set_range(item["min"], item["max"]) item_box.set_round_digits(0) From cc743b8d8254b83701ab070ed81d256283b70b77 Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Sun, 3 Nov 2024 21:14:16 +0100 Subject: [PATCH 03/28] type hints: Introduce settings protocol Related #2273 --- lib/logitech_receiver/settings_templates.py | 91 ++++++++++++++++++++- lib/solaar/cli/config.py | 3 +- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index a93294af7d..cfff83d3f4 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -24,6 +24,7 @@ from time import time from typing import Any from typing import Callable +from typing import Protocol from solaar.i18n import _ @@ -1805,7 +1806,95 @@ def build(cls, setting_class, device): ] -def check_feature(device, settings_class: settings.Setting) -> None | bool | Any: +class SettingsProtocol(Protocol): + @property + def name(self): + ... + + @property + def label(self): + ... + + @property + def description(self): + ... + + @property + def feature(self): + ... + + @property + def register(self): + ... + + @property + def kind(self): + ... + + @property + def min_version(self): + ... + + @property + def persist(self): + ... + + @property + def rw_options(self): + ... + + @property + def validator_class(self): + ... + + @property + def validator_options(self): + ... + + @classmethod + def build(cls, device): + ... + + def val_to_string(self, value): + ... + + @property + def choices(self): + ... + + @property + def range(self): + ... + + def _pre_read(self, cached, key=None): + ... + + def read(self, cached=True): + ... + + def _pre_write(self, save=True): + ... + + def update(self, value, save=True): + ... + + def write(self, value, save=True): + ... + + def acceptable(self, args, current): + ... + + def compare(self, args, current): + ... + + def apply(self): + ... + + def __str__(self): + ... + + +def check_feature(device, settings_class: SettingsProtocol) -> None | bool | Any: if settings_class.feature not in device.features: return if settings_class.min_version > device.features.get_feature_version(settings_class.feature): diff --git a/lib/solaar/cli/config.py b/lib/solaar/cli/config.py index 914a2aa75c..c1b71f3997 100644 --- a/lib/solaar/cli/config.py +++ b/lib/solaar/cli/config.py @@ -19,6 +19,7 @@ from logitech_receiver import settings from logitech_receiver import settings_templates from logitech_receiver.common import NamedInts +from logitech_receiver.settings_templates import SettingsProtocol from solaar import configuration @@ -215,7 +216,7 @@ def run(receivers, args, _find_receiver, find_device): dev.persister[setting.name] = setting._value -def set(dev, setting: settings.Setting, args, save): +def set(dev, setting: SettingsProtocol, args, save): if setting.kind == settings.Kind.TOGGLE: value = select_toggle(args.value_key, setting) args.value_key = value From 9dbd3688936361fe838fa19ee6edf130ab3592be Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Sun, 3 Nov 2024 21:37:11 +0100 Subject: [PATCH 04/28] Refactor: Remove diversion alias Related #2273 --- lib/solaar/ui/diversion_rules.py | 196 +++++++++++++++---------------- 1 file changed, 98 insertions(+), 98 deletions(-) diff --git a/lib/solaar/ui/diversion_rules.py b/lib/solaar/ui/diversion_rules.py index 669b182dc1..b7557cb340 100644 --- a/lib/solaar/ui/diversion_rules.py +++ b/lib/solaar/ui/diversion_rules.py @@ -35,7 +35,7 @@ from gi.repository import Gdk from gi.repository import GObject from gi.repository import Gtk -from logitech_receiver import diversion as _DIV +from logitech_receiver import diversion from logitech_receiver.common import NamedInt from logitech_receiver.common import NamedInts from logitech_receiver.common import UnsortedNamedInts @@ -64,7 +64,7 @@ def __init__(self, component, level=0, editable=False): GObject.GObject.__init__(self) def display_left(self): - if isinstance(self.component, _DIV.Rule): + if isinstance(self.component, diversion.Rule): if self.level == 0: return _("Built-in rules") if not self.editable else _("User-defined rules") if self.level == 1: @@ -82,7 +82,7 @@ def display_right(self): def display_icon(self): if self.component is None: return "" - if isinstance(self.component, _DIV.Rule) and self.level == 0: + if isinstance(self.component, diversion.Rule) and self.level == 0: return "emblem-system" if not self.editable else "avatar-default" return self.__component_ui().icon_name() @@ -143,17 +143,17 @@ def _populate_model( return if editable is None: editable = model[it][0].editable if it is not None else False - if isinstance(rule_component, _DIV.Rule): + if isinstance(rule_component, diversion.Rule): editable = editable or (rule_component.source is not None) wrapped = RuleComponentWrapper(rule_component, level, editable=editable) piter = model.insert(it, pos, (wrapped,)) - if isinstance(rule_component, (_DIV.Rule, _DIV.And, _DIV.Or, _DIV.Later)): + if isinstance(rule_component, (diversion.Rule, diversion.And, diversion.Or, diversion.Later)): for c in rule_component.components: - ed = editable or (isinstance(c, _DIV.Rule) and c.source is not None) + ed = editable or (isinstance(c, diversion.Rule) and c.source is not None) _populate_model(model, piter, c, level + 1, editable=ed) if len(rule_component.components) == 0: _populate_model(model, piter, None, level + 1, editable=editable) - elif isinstance(rule_component, _DIV.Not): + elif isinstance(rule_component, diversion.Not): _populate_model(model, piter, rule_component.component, level + 1, editable=editable) @@ -177,13 +177,13 @@ def allowed_actions(m: Gtk.TreeStore, it: Gtk.TreeIter) -> AllowedActions: parent_c = m[parent_it][0].component if wrapped.level > 0 else None can_wrap = wrapped.editable and wrapped.component is not None and wrapped.level >= 2 - can_delete = wrapped.editable and not isinstance(parent_c, _DIV.Not) and c is not None and wrapped.level >= 1 - can_insert = wrapped.editable and not isinstance(parent_c, _DIV.Not) and wrapped.level >= 2 + can_delete = wrapped.editable and not isinstance(parent_c, diversion.Not) and c is not None and wrapped.level >= 1 + can_insert = wrapped.editable and not isinstance(parent_c, diversion.Not) and wrapped.level >= 2 can_insert_only_rule = wrapped.editable and wrapped.level == 1 can_flatten = ( wrapped.editable - and not isinstance(parent_c, _DIV.Not) - and isinstance(c, (_DIV.Rule, _DIV.And, _DIV.Or)) + and not isinstance(parent_c, diversion.Not) + and isinstance(c, (diversion.Rule, diversion.And, diversion.Or)) and wrapped.level >= 2 and len(c.components) ) @@ -242,7 +242,7 @@ def create_menu_event_button_released(self, v, e): p2 = self._menu_paste(m, it, below=True) p2.set_label(_("Paste below")) menu.append(p2) - elif enabled_actions.insert_only_rule and isinstance(_rule_component_clipboard, _DIV.Rule): + elif enabled_actions.insert_only_rule and isinstance(_rule_component_clipboard, diversion.Rule): p = self._menu_paste(m, it) menu.append(p) if enabled_actions.c is None: @@ -252,7 +252,7 @@ def create_menu_event_button_released(self, v, e): p2 = self._menu_paste(m, it, below=True) p2.set_label(_("Paste rule below")) menu.append(p2) - elif enabled_actions.insert_root and isinstance(_rule_component_clipboard, _DIV.Rule): + elif enabled_actions.insert_root and isinstance(_rule_component_clipboard, diversion.Rule): p = self._menu_paste(m, m.iter_nth_child(it, 0)) p.set_label(_("Paste rule")) menu.append(p) @@ -296,7 +296,7 @@ def menu_do_flatten(self, _mitem, m, it): parent_it = m.iter_parent(it) parent_c = m[parent_it][0].component idx = parent_c.components.index(c) - if isinstance(c, _DIV.Not): + if isinstance(c, diversion.Not): parent_c.components = [*parent_c.components[:idx], c.component, *parent_c.components[idx + 1 :]] children = [next(m[it].iterchildren())[0].component] else: @@ -324,8 +324,8 @@ def _menu_do_insert(self, _mitem, m, it, new_c, below=False): idx = 0 else: idx = parent_c.components.index(c) - if isinstance(new_c, _DIV.Rule) and wrapped.level == 1: - new_c.source = _DIV._file_path # new rules will be saved to the YAML file + if isinstance(new_c, diversion.Rule) and wrapped.level == 1: + new_c.source = diversion._file_path # new rules will be saved to the YAML file idx += int(below) parent_c.components.insert(idx, new_c) self._populate_model_func(m, parent_it, new_c, level=wrapped.level, pos=idx) @@ -334,7 +334,7 @@ def _menu_do_insert(self, _mitem, m, it, new_c, below=False): m.remove(it) # remove placeholder in the end new_iter = m.iter_nth_child(parent_it, idx) self.tree_view.get_selection().select_iter(new_iter) - if isinstance(new_c, (_DIV.Rule, _DIV.And, _DIV.Or, _DIV.Not)): + if isinstance(new_c, (diversion.Rule, diversion.And, diversion.Or, diversion.Not)): self.tree_view.expand_row(m.get_path(new_iter), True) def _menu_do_insert_new(self, _mitem, m, it, cls, initial_value, below=False): @@ -345,37 +345,37 @@ def _menu_insert(self, m, it, below=False): elements = [ _("Insert"), [ - (_("Sub-rule"), _DIV.Rule, []), - (_("Or"), _DIV.Or, []), - (_("And"), _DIV.And, []), + (_("Sub-rule"), diversion.Rule, []), + (_("Or"), diversion.Or, []), + (_("And"), diversion.And, []), [ _("Condition"), [ - (_("Feature"), _DIV.Feature, rule_conditions.FeatureUI.FEATURES_WITH_DIVERSION[0]), - (_("Report"), _DIV.Report, 0), - (_("Process"), _DIV.Process, ""), - (_("Mouse process"), _DIV.MouseProcess, ""), - (_("Modifiers"), _DIV.Modifiers, []), - (_("Key"), _DIV.Key, ""), - (_("KeyIsDown"), _DIV.KeyIsDown, ""), - (_("Active"), _DIV.Active, ""), - (_("Device"), _DIV.Device, ""), - (_("Host"), _DIV.Host, ""), - (_("Setting"), _DIV.Setting, [None, "", None]), - (_("Test"), _DIV.Test, next(iter(_DIV.TESTS))), - (_("Test bytes"), _DIV.TestBytes, [0, 1, 0]), - (_("Mouse Gesture"), _DIV.MouseGesture, ""), + (_("Feature"), diversion.Feature, rule_conditions.FeatureUI.FEATURES_WITH_DIVERSION[0]), + (_("Report"), diversion.Report, 0), + (_("Process"), diversion.Process, ""), + (_("Mouse process"), diversion.MouseProcess, ""), + (_("Modifiers"), diversion.Modifiers, []), + (_("Key"), diversion.Key, ""), + (_("KeyIsDown"), diversion.KeyIsDown, ""), + (_("Active"), diversion.Active, ""), + (_("Device"), diversion.Device, ""), + (_("Host"), diversion.Host, ""), + (_("Setting"), diversion.Setting, [None, "", None]), + (_("Test"), diversion.Test, next(iter(diversion.TESTS))), + (_("Test bytes"), diversion.TestBytes, [0, 1, 0]), + (_("Mouse Gesture"), diversion.MouseGesture, ""), ], ], [ _("Action"), [ - (_("Key press"), _DIV.KeyPress, "space"), - (_("Mouse scroll"), _DIV.MouseScroll, [0, 0]), - (_("Mouse click"), _DIV.MouseClick, ["left", 1]), - (_("Set"), _DIV.Set, [None, "", None]), - (_("Execute"), _DIV.Execute, [""]), - (_("Later"), _DIV.Later, [1]), + (_("Key press"), diversion.KeyPress, "space"), + (_("Mouse scroll"), diversion.MouseScroll, [0, 0]), + (_("Mouse click"), diversion.MouseClick, ["left", 1]), + (_("Set"), diversion.Set, [None, "", None]), + (_("Execute"), diversion.Execute, [""]), + (_("Later"), diversion.Later, [1]), ], ], ], @@ -405,7 +405,7 @@ def build(spec): def _menu_create_rule(self, m, it, below=False) -> Gtk.MenuItem: menu_create_rule = Gtk.MenuItem(_("Insert new rule")) - menu_create_rule.connect("activate", self._menu_do_insert_new, m, it, _DIV.Rule, [], below) + menu_create_rule.connect("activate", self._menu_do_insert_new, m, it, diversion.Rule, [], below) menu_create_rule.show() return menu_create_rule @@ -434,14 +434,14 @@ def menu_do_negate(self, _mitem, m, it): c = wrapped.component parent_it = m.iter_parent(it) parent_c = m[parent_it][0].component - if isinstance(c, _DIV.Not): # avoid double negation + if isinstance(c, diversion.Not): # avoid double negation self.menu_do_flatten(_mitem, m, it) self.tree_view.expand_row(m.get_path(parent_it), True) - elif isinstance(parent_c, _DIV.Not): # avoid double negation + elif isinstance(parent_c, diversion.Not): # avoid double negation self.menu_do_flatten(_mitem, m, parent_it) else: idx = parent_c.components.index(c) - self._menu_do_insert_new(_mitem, m, it, _DIV.Not, c, below=True) + self._menu_do_insert_new(_mitem, m, it, diversion.Not, c, below=True) self.menu_do_delete(_mitem, m, m.iter_nth_child(parent_it, idx)) self._on_update() @@ -456,7 +456,7 @@ def menu_do_wrap(self, _mitem, m, it, cls): c = wrapped.component parent_it = m.iter_parent(it) parent_c = m[parent_it][0].component - if isinstance(parent_c, _DIV.Not): + if isinstance(parent_c, diversion.Not): new_c = cls([c], warn=False) parent_c.component = new_c m.remove(it) @@ -475,9 +475,9 @@ def _menu_wrap(self, m, it) -> Gtk.MenuItem: menu_sub_rule = Gtk.MenuItem(_("Sub-rule")) menu_and = Gtk.MenuItem(_("And")) menu_or = Gtk.MenuItem(_("Or")) - menu_sub_rule.connect("activate", self.menu_do_wrap, m, it, _DIV.Rule) - menu_and.connect("activate", self.menu_do_wrap, m, it, _DIV.And) - menu_or.connect("activate", self.menu_do_wrap, m, it, _DIV.Or) + menu_sub_rule.connect("activate", self.menu_do_wrap, m, it, diversion.Rule) + menu_and.connect("activate", self.menu_do_wrap, m, it, diversion.And) + menu_or.connect("activate", self.menu_do_wrap, m, it, diversion.Or) submenu_wrap.append(menu_sub_rule) submenu_wrap.append(menu_and) submenu_wrap.append(menu_or) @@ -490,7 +490,7 @@ def menu_do_copy(self, _mitem: Gtk.MenuItem, m: Gtk.TreeStore, it: Gtk.TreeIter) wrapped = m[it][0] c = wrapped.component - _rule_component_clipboard = _DIV.RuleComponent().compile(c.data()) + _rule_component_clipboard = diversion.RuleComponent().compile(c.data()) def menu_do_cut(self, _mitem, m, it): global _rule_component_clipboard @@ -511,7 +511,7 @@ def menu_do_paste(self, _mitem, m, it, below=False): c = _rule_component_clipboard _rule_component_clipboard = None if c: - _rule_component_clipboard = _DIV.RuleComponent().compile(c.data()) + _rule_component_clipboard = diversion.RuleComponent().compile(c.data()) self._menu_do_insert(_mitem, m, it, new_c=c, below=below) self._on_update() @@ -604,13 +604,13 @@ def _reload_yaml_file(self): self.dirty = False for c in self.selected_rule_edit_panel.get_children(): self.selected_rule_edit_panel.remove(c) - _DIV.load_config_rule_file() + diversion.load_config_rule_file() self.model = self._create_model() self.view.set_model(self.model) self.view.expand_all() def _save_yaml_file(self): - if _DIV._save_config_rule_file(): + if diversion._save_config_rule_file(): self.dirty = False self.save_btn.set_sensitive(False) self.discard_btn.set_sensitive(False) @@ -656,10 +656,10 @@ def _create_top_panel(self): def _create_model(self): model = Gtk.TreeStore(RuleComponentWrapper) - if len(_DIV.rules.components) == 1: + if len(diversion.rules.components) == 1: # only built-in rules - add empty user rule list - _DIV.rules.components.insert(0, _DIV.Rule([], source=_DIV._file_path)) - _populate_model(model, None, _DIV.rules.components) + diversion.rules.components.insert(0, diversion.Rule([], source=diversion._file_path)) + _populate_model(model, None, diversion.rules.components) return model def _create_view_columns(self): @@ -725,7 +725,7 @@ def _event_key_pressed(self, v, e): ) elif ( enabled_actions.insert_only_rule - and isinstance(_rule_component_clipboard, _DIV.Rule) + and isinstance(_rule_component_clipboard, diversion.Rule) and e.keyval in [Gdk.KEY_v, Gdk.KEY_V] ): self._action_menu.menu_do_paste( @@ -733,7 +733,7 @@ def _event_key_pressed(self, v, e): ) elif ( enabled_actions.insert_root - and isinstance(_rule_component_clipboard, _DIV.Rule) + and isinstance(_rule_component_clipboard, diversion.Rule) and e.keyval in [Gdk.KEY_v, Gdk.KEY_V] ): self._action_menu.menu_do_paste(None, m, m.iter_nth_child(it, 0)) @@ -760,11 +760,11 @@ def _event_key_pressed(self, v, e): if e.keyval == Gdk.KEY_exclam: self._action_menu.menu_do_negate(None, m, it) elif e.keyval == Gdk.KEY_ampersand: - self._action_menu.menu_do_wrap(None, m, it, _DIV.And) + self._action_menu.menu_do_wrap(None, m, it, diversion.And) elif e.keyval == Gdk.KEY_bar: - self._action_menu.menu_do_wrap(None, m, it, _DIV.Or) + self._action_menu.menu_do_wrap(None, m, it, diversion.Or) elif e.keyval in [Gdk.KEY_r, Gdk.KEY_R] and (state & Gdk.ModifierType.SHIFT_MASK): - self._action_menu.menu_do_wrap(None, m, it, _DIV.Rule) + self._action_menu.menu_do_wrap(None, m, it, diversion.Rule) if enabled_actions.flatten and e.keyval in [Gdk.KEY_asterisk, Gdk.KEY_KP_Multiply]: self._action_menu.menu_do_flatten(None, m, it) @@ -1076,7 +1076,7 @@ def right_label(cls, component): class RuleUI(RuleComponentUI): - CLASS = _DIV.Rule + CLASS = diversion.Rule def create_widgets(self): self.widgets = {} @@ -1094,7 +1094,7 @@ def icon_name(cls): class AndUI(RuleComponentUI): - CLASS = _DIV.And + CLASS = diversion.And def create_widgets(self): self.widgets = {} @@ -1108,7 +1108,7 @@ def left_label(cls, component): class OrUI(RuleComponentUI): - CLASS = _DIV.Or + CLASS = diversion.Or def create_widgets(self): self.widgets = {} @@ -1122,7 +1122,7 @@ def left_label(cls, component): class LaterUI(RuleComponentUI): - CLASS = _DIV.Later + CLASS = diversion.Later MIN_VALUE = 0.01 MAX_VALUE = 100 @@ -1157,7 +1157,7 @@ def right_label(cls, component): class NotUI(RuleComponentUI): - CLASS = _DIV.Not + CLASS = diversion.Not def create_widgets(self): self.widgets = {} @@ -1171,7 +1171,7 @@ def left_label(cls, component): class ActionUI(RuleComponentUI): - CLASS = _DIV.Action + CLASS = diversion.Action @classmethod def icon_name(cls): @@ -1335,9 +1335,9 @@ def make_unsupported(self): self.unsupported_label.show() -def _all_settings(): +def create_all_settings(all_settings: list[Setting]) -> dict[str, Setting]: settings = {} - for s in sorted(SETTINGS, key=lambda setting: setting.label): + for s in sorted(all_settings, key=lambda setting: setting.label): if s.name not in settings: settings[s.name] = [s] else: @@ -1406,7 +1406,7 @@ def right_label(cls, component): class ActiveUI(_DeviceUI, ConditionUI): - CLASS = _DIV.Active + CLASS = diversion.Active label_text = _("Device is active and its settings can be changed.") @classmethod @@ -1415,7 +1415,7 @@ def left_label(cls, component): class DeviceUI(_DeviceUI, ConditionUI): - CLASS = _DIV.Device + CLASS = diversion.Device label_text = _("Device that originated the current notification.") @classmethod @@ -1424,7 +1424,7 @@ def left_label(cls, component): class HostUI(ConditionUI): - CLASS = _DIV.Host + CLASS = diversion.Host def create_widgets(self): self.widgets = {} @@ -1454,7 +1454,7 @@ def right_label(cls, component): class _SettingWithValueUI: - ALL_SETTINGS = _all_settings() + ALL_SETTINGS = create_all_settings(SETTINGS) MULTIPLE = [Kind.MULTIPLE_TOGGLE, Kind.MAP_CHOICE, Kind.MULTIPLE_RANGE] ACCEPT_TOGGLE = True @@ -1785,7 +1785,7 @@ def right_label(cls, component): class SetUI(_SettingWithValueUI, ActionUI): - CLASS = _DIV.Set + CLASS = diversion.Set ACCEPT_TOGGLE = True label_text = _("Change setting on device") @@ -1801,7 +1801,7 @@ def _on_update(self, *_args): class SettingUI(_SettingWithValueUI, ConditionUI): - CLASS = _DIV.Setting + CLASS = diversion.Setting ACCEPT_TOGGLE = False label_text = _("Setting on device") @@ -1817,30 +1817,30 @@ def _on_update(self, *_args): COMPONENT_UI = { - _DIV.Rule: RuleUI, - _DIV.Not: NotUI, - _DIV.Or: OrUI, - _DIV.And: AndUI, - _DIV.Later: LaterUI, - _DIV.Process: rule_conditions.ProcessUI, - _DIV.MouseProcess: rule_conditions.MouseProcessUI, - _DIV.Active: ActiveUI, - _DIV.Device: DeviceUI, - _DIV.Host: HostUI, - _DIV.Feature: rule_conditions.FeatureUI, - _DIV.Report: rule_conditions.ReportUI, - _DIV.Modifiers: rule_conditions.ModifiersUI, - _DIV.Key: rule_conditions.KeyUI, - _DIV.KeyIsDown: rule_conditions.KeyIsDownUI, - _DIV.Test: rule_conditions.TestUI, - _DIV.TestBytes: rule_conditions.TestBytesUI, - _DIV.Setting: SettingUI, - _DIV.MouseGesture: rule_conditions.MouseGestureUI, - _DIV.KeyPress: rule_actions.KeyPressUI, - _DIV.MouseScroll: rule_actions.MouseScrollUI, - _DIV.MouseClick: rule_actions.MouseClickUI, - _DIV.Execute: rule_actions.ExecuteUI, - _DIV.Set: SetUI, + diversion.Rule: RuleUI, + diversion.Not: NotUI, + diversion.Or: OrUI, + diversion.And: AndUI, + diversion.Later: LaterUI, + diversion.Process: rule_conditions.ProcessUI, + diversion.MouseProcess: rule_conditions.MouseProcessUI, + diversion.Active: ActiveUI, + diversion.Device: DeviceUI, + diversion.Host: HostUI, + diversion.Feature: rule_conditions.FeatureUI, + diversion.Report: rule_conditions.ReportUI, + diversion.Modifiers: rule_conditions.ModifiersUI, + diversion.Key: rule_conditions.KeyUI, + diversion.KeyIsDown: rule_conditions.KeyIsDownUI, + diversion.Test: rule_conditions.TestUI, + diversion.TestBytes: rule_conditions.TestBytesUI, + diversion.Setting: SettingUI, + diversion.MouseGesture: rule_conditions.MouseGestureUI, + diversion.KeyPress: rule_actions.KeyPressUI, + diversion.MouseScroll: rule_actions.MouseScrollUI, + diversion.MouseClick: rule_actions.MouseClickUI, + diversion.Execute: rule_actions.ExecuteUI, + diversion.Set: SetUI, type(None): RuleComponentUI, # placeholders for empty rule/And/Or } From 6869f1927fe59de3a82df82d3d300aa5d1052838 Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Sun, 3 Nov 2024 21:45:46 +0100 Subject: [PATCH 05/28] Simplify settings UI class Classes shouldn't don't need to know about other settings classes. Related #2273 --- lib/solaar/ui/diversion_rules.py | 48 +++++++++++++++++--------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/lib/solaar/ui/diversion_rules.py b/lib/solaar/ui/diversion_rules.py index b7557cb340..f6660cda0d 100644 --- a/lib/solaar/ui/diversion_rules.py +++ b/lib/solaar/ui/diversion_rules.py @@ -56,6 +56,28 @@ _rule_component_clipboard = None +def create_all_settings(all_settings: list[Setting]) -> dict[str, list[Setting]]: + settings = {} + for s in sorted(all_settings, key=lambda setting: setting.label): + if s.name not in settings: + settings[s.name] = [s] + else: + prev_setting = settings[s.name][0] + prev_kind = prev_setting.validator_class.kind + if prev_kind != s.validator_class.kind: + logger.warning( + "ignoring setting {} - same name of {}, but different kind ({} != {})".format( + s.__name__, prev_setting.__name__, prev_kind, s.validator_class.kind + ) + ) + continue + settings[s.name].append(s) + return settings + + +ALL_SETTINGS = create_all_settings(SETTINGS) + + class RuleComponentWrapper(GObject.GObject): def __init__(self, component, level=0, editable=False): self.component = component @@ -1335,25 +1357,6 @@ def make_unsupported(self): self.unsupported_label.show() -def create_all_settings(all_settings: list[Setting]) -> dict[str, Setting]: - settings = {} - for s in sorted(all_settings, key=lambda setting: setting.label): - if s.name not in settings: - settings[s.name] = [s] - else: - prev_setting = settings[s.name][0] - prev_kind = prev_setting.validator_class.kind - if prev_kind != s.validator_class.kind: - logger.warning( - "ignoring setting {} - same name of {}, but different kind ({} != {})".format( - s.__name__, prev_setting.__name__, prev_kind, s.validator_class.kind - ) - ) - continue - settings[s.name].append(s) - return settings - - class _DeviceUI: label_text = "" @@ -1454,7 +1457,6 @@ def right_label(cls, component): class _SettingWithValueUI: - ALL_SETTINGS = create_all_settings(SETTINGS) MULTIPLE = [Kind.MULTIPLE_TOGGLE, Kind.MAP_CHOICE, Kind.MULTIPLE_RANGE] ACCEPT_TOGGLE = True @@ -1493,7 +1495,7 @@ def create_widgets(self): vexpand=False, ) self.widgets[lbl] = (0, 2, 1, 1) - self.setting_field = SmartComboBox([(s[0].name, s[0].label) for s in self.ALL_SETTINGS.values()]) + self.setting_field = SmartComboBox([(s[0].name, s[0].label) for s in ALL_SETTINGS.values()]) self.setting_field.set_valign(Gtk.Align.CENTER) self.setting_field.connect("changed", self._changed_setting) self.setting_field.connect("changed", self._on_update) @@ -1546,7 +1548,7 @@ def _all_choices(cls, setting): # choice and map-choice if extra is not None: choices |= NamedInts(**{str(extra): int(extra)}) return choices, extra - settings = cls.ALL_SETTINGS.get(setting, []) + settings = ALL_SETTINGS.get(setting, []) choices = UnsortedNamedInts() extra = None for s in settings: @@ -1562,7 +1564,7 @@ def _setting_attributes(cls, setting_name, device=None): setting = device.settings.get(setting_name, None) settings = [type(setting)] if setting else None else: - settings = cls.ALL_SETTINGS.get(setting_name, [None]) + settings = ALL_SETTINGS.get(setting_name, [None]) setting = settings[0] # if settings have the same name, use the first one to get the basic data val_class = setting.validator_class if setting else None kind = val_class.kind if val_class else None From aff28720882f498e7f6da5298a9a9449baea1ab8 Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Sun, 3 Nov 2024 22:53:50 +0100 Subject: [PATCH 06/28] Enforce rules on RuleComponentUI subclasses Enforce create_widgets and collect_values. Related #2273 --- lib/solaar/ui/diversion_rules.py | 4 ++-- lib/solaar/ui/rule_base.py | 14 +++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/solaar/ui/diversion_rules.py b/lib/solaar/ui/diversion_rules.py index f6660cda0d..788eab241b 100644 --- a/lib/solaar/ui/diversion_rules.py +++ b/lib/solaar/ui/diversion_rules.py @@ -1818,7 +1818,7 @@ def _on_update(self, *_args): _SettingWithValueUI._on_update(self, *_args) -COMPONENT_UI = { +COMPONENT_UI: dict[Any, RuleComponentUI] = { diversion.Rule: RuleUI, diversion.Not: NotUI, diversion.Or: OrUI, @@ -1843,7 +1843,7 @@ def _on_update(self, *_args): diversion.MouseClick: rule_actions.MouseClickUI, diversion.Execute: rule_actions.ExecuteUI, diversion.Set: SetUI, - type(None): RuleComponentUI, # placeholders for empty rule/And/Or + # type(None): RuleComponentUI, # placeholders for empty rule/And/Or } _all_devices = AllDevicesInfo() diff --git a/lib/solaar/ui/rule_base.py b/lib/solaar/ui/rule_base.py index d70967272c..1fcc5dcd12 100644 --- a/lib/solaar/ui/rule_base.py +++ b/lib/solaar/ui/rule_base.py @@ -13,8 +13,10 @@ ## 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. +import abc from contextlib import contextmanager as contextlib_contextmanager +from typing import Any from typing import Callable from gi.repository import Gtk @@ -47,7 +49,7 @@ def add_completion_to_entry(cls, entry, values): liststore.append((v,)) -class RuleComponentUI: +class RuleComponentUI(abc.ABC): CLASS = diversion.RuleComponent def __init__(self, panel, on_update: Callable = None): @@ -58,15 +60,17 @@ def __init__(self, panel, on_update: Callable = None): self._on_update_callback = (lambda: None) if on_update is None else on_update self.create_widgets() - def create_widgets(self): + @abc.abstractmethod + def create_widgets(self) -> dict: pass def show(self, component, editable=True): self._show_widgets(editable) self.component = component - def collect_value(self): - return None + @abc.abstractmethod + def collect_value(self) -> Any: + pass @contextlib_contextmanager def ignore_changes(self): @@ -105,5 +109,5 @@ def _remove_panel_items(self): for c in self.panel.get_children(): self.panel.remove(c) - def update_devices(self): + def update_devices(self): # noqa: B027 pass From 4213a1a19f1e7dba116620bad31824aa9a3ef50e Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Sun, 3 Nov 2024 23:45:11 +0100 Subject: [PATCH 07/28] settings: Add docstrings and type hint Related #2273 --- lib/logitech_receiver/settings_templates.py | 31 ++++++++++++--------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index cfff83d3f4..7a574c36fc 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -1911,15 +1911,20 @@ def check_feature(device, settings_class: SettingsProtocol) -> None | bool | Any return False # differentiate from an error-free determination that the setting is not supported -# Returns True if device was queried to find features, False otherwise -def check_feature_settings(device, already_known): - """Auto-detect device settings by the HID++ 2.0 features they have.""" +def check_feature_settings(device, already_known) -> bool: + """Auto-detect device settings by the HID++ 2.0 features they have. + + Returns + ------- + bool + True, if device was queried to find features, False otherwise. + """ if not device.features or not device.online: return False if device.protocol and device.protocol < 2.0: return False absent = device.persister.get("_absent", []) if device.persister else [] - newAbsent = [] + new_absent = [] for sclass in SETTINGS: if sclass.feature: known_present = device.persister and sclass.name in device.persister @@ -1928,22 +1933,22 @@ def check_feature_settings(device, already_known): if isinstance(setting, list): for s in setting: already_known.append(s) - if sclass.name in newAbsent: - newAbsent.remove(sclass.name) + if sclass.name in new_absent: + new_absent.remove(sclass.name) elif setting: already_known.append(setting) - if sclass.name in newAbsent: - newAbsent.remove(sclass.name) + if sclass.name in new_absent: + new_absent.remove(sclass.name) elif setting is None: - if sclass.name not in newAbsent and sclass.name not in absent and sclass.name not in device.persister: - newAbsent.append(sclass.name) - if device.persister and newAbsent: - absent.extend(newAbsent) + if sclass.name not in new_absent and sclass.name not in absent and sclass.name not in device.persister: + new_absent.append(sclass.name) + if device.persister and new_absent: + absent.extend(new_absent) device.persister["_absent"] = absent return True -def check_feature_setting(device, setting_name): +def check_feature_setting(device, setting_name) -> settings.Setting | None: for sclass in SETTINGS: if sclass.feature and sclass.name == setting_name and device.features: setting = check_feature(device, sclass) From 6207ff110c2a02f0c240a7bd34e7a712648f21dc Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Mon, 4 Nov 2024 22:52:08 +0100 Subject: [PATCH 08/28] Remove NamedInts: Convert Column to enum Related #2273 --- lib/solaar/ui/window.py | 73 ++++++++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/lib/solaar/ui/window.py b/lib/solaar/ui/window.py index 06ad70bc86..b76a717de5 100644 --- a/lib/solaar/ui/window.py +++ b/lib/solaar/ui/window.py @@ -17,13 +17,14 @@ import logging +from enum import IntEnum + import gi from gi.repository.GObject import TYPE_PYOBJECT from logitech_receiver import hidpp10_constants from logitech_receiver.common import LOGITECH_VENDOR_ID from logitech_receiver.common import NamedInt -from logitech_receiver.common import NamedInts from solaar import NAME from solaar.i18n import _ @@ -54,12 +55,24 @@ except (ValueError, AttributeError): _CAN_SET_ROW_NONE = "" -# tree model columns -_COLUMN = NamedInts(PATH=0, NUMBER=1, ACTIVE=2, NAME=3, ICON=4, STATUS_TEXT=5, STATUS_ICON=6, DEVICE=7) + +class Column(IntEnum): + """Columns of tree model.""" + + PATH = 0 + NUMBER = 1 + ACTIVE = 2 + NAME = 3 + ICON = 4 + STATUS_TEXT = 5 + STATUS_ICON = 6 + DEVICE = 7 + + _COLUMN_TYPES = (str, int, bool, str, str, str, str, TYPE_PYOBJECT) _TREE_SEPATATOR = (None, 0, False, None, None, None, None, None) assert len(_TREE_SEPATATOR) == len(_COLUMN_TYPES) -assert len(_COLUMN_TYPES) == len(_COLUMN) +assert len(_COLUMN_TYPES) == len(Column) def _new_button(label, icon_name=None, icon_size=_NORMAL_BUTTON_ICON_SIZE, tooltip=None, toggle=False, clicked=None): @@ -238,21 +251,21 @@ def _create_tree(model): tree.set_model(model) def _is_separator(model, item, _ignore=None): - return model.get_value(item, _COLUMN.PATH) is None + return model.get_value(item, Column.PATH) is None tree.set_row_separator_func(_is_separator, None) icon_cell_renderer = Gtk.CellRendererPixbuf() icon_cell_renderer.set_property("stock-size", _TREE_ICON_SIZE) icon_column = Gtk.TreeViewColumn("Icon", icon_cell_renderer) - icon_column.add_attribute(icon_cell_renderer, "sensitive", _COLUMN.ACTIVE) - icon_column.add_attribute(icon_cell_renderer, "icon-name", _COLUMN.ICON) + icon_column.add_attribute(icon_cell_renderer, "sensitive", Column.ACTIVE) + icon_column.add_attribute(icon_cell_renderer, "icon-name", Column.ICON) tree.append_column(icon_column) name_cell_renderer = Gtk.CellRendererText() name_column = Gtk.TreeViewColumn("device name", name_cell_renderer) - name_column.add_attribute(name_cell_renderer, "sensitive", _COLUMN.ACTIVE) - name_column.add_attribute(name_cell_renderer, "text", _COLUMN.NAME) + name_column.add_attribute(name_cell_renderer, "sensitive", Column.ACTIVE) + name_column.add_attribute(name_cell_renderer, "text", Column.NAME) name_column.set_expand(True) tree.append_column(name_column) tree.set_expander_column(name_column) @@ -261,16 +274,16 @@ def _is_separator(model, item, _ignore=None): status_cell_renderer.set_property("scale", 0.85) status_cell_renderer.set_property("xalign", 1) status_column = Gtk.TreeViewColumn("status text", status_cell_renderer) - status_column.add_attribute(status_cell_renderer, "sensitive", _COLUMN.ACTIVE) - status_column.add_attribute(status_cell_renderer, "text", _COLUMN.STATUS_TEXT) + status_column.add_attribute(status_cell_renderer, "sensitive", Column.ACTIVE) + status_column.add_attribute(status_cell_renderer, "text", Column.STATUS_TEXT) status_column.set_expand(True) tree.append_column(status_column) battery_cell_renderer = Gtk.CellRendererPixbuf() battery_cell_renderer.set_property("stock-size", _TREE_ICON_SIZE) battery_column = Gtk.TreeViewColumn("status icon", battery_cell_renderer) - battery_column.add_attribute(battery_cell_renderer, "sensitive", _COLUMN.ACTIVE) - battery_column.add_attribute(battery_cell_renderer, "icon-name", _COLUMN.STATUS_ICON) + battery_column.add_attribute(battery_cell_renderer, "sensitive", Column.ACTIVE) + battery_column.add_attribute(battery_cell_renderer, "icon-name", Column.STATUS_ICON) tree.append_column(battery_column) return tree @@ -348,20 +361,20 @@ def _create(delete_action): def _find_selected_device(): selection = _tree.get_selection() model, item = selection.get_selected() - return model.get_value(item, _COLUMN.DEVICE) if item else None + return model.get_value(item, Column.DEVICE) if item else None def _find_selected_device_id(): selection = _tree.get_selection() model, item = selection.get_selected() if item: - return _model.get_value(item, _COLUMN.PATH), _model.get_value(item, _COLUMN.NUMBER) + return _model.get_value(item, Column.PATH), _model.get_value(item, Column.NUMBER) # triggered by changing selection in the tree def _device_selected(selection): model, item = selection.get_selected() - device = model.get_value(item, _COLUMN.DEVICE) if item else None + device = model.get_value(item, Column.DEVICE) if item else None if device: _update_info_panel(device, full=True) else: @@ -381,7 +394,7 @@ def _receiver_row(receiver_path, receiver=None): item = _model.get_iter_first() while item: # first row matching the path must be the receiver one - if _model.get_value(item, _COLUMN.PATH) == receiver_path: + if _model.get_value(item, Column.PATH) == receiver_path: return item item = _model.iter_next(item) @@ -415,13 +428,13 @@ def _device_row(receiver_path, device_number, device=None): item = _model.iter_children(receiver_row) new_child_index = 0 while item: - if _model.get_value(item, _COLUMN.PATH) != receiver_path: + if _model.get_value(item, Column.PATH) != receiver_path: logger.warning( "path for device row %s different from path for receiver %s", - _model.get_value(item, _COLUMN.PATH), + _model.get_value(item, Column.PATH), receiver_path, ) - item_number = _model.get_value(item, _COLUMN.NUMBER) + item_number = _model.get_value(item, Column.NUMBER) if item_number == device_number: return item if item_number > device_number: @@ -833,9 +846,9 @@ def update(device, need_popup=False, refresh=False): item = _receiver_row(device.path, device if is_alive else None) if is_alive and item: - was_pairing = bool(_model.get_value(item, _COLUMN.STATUS_ICON)) + was_pairing = bool(_model.get_value(item, Column.STATUS_ICON)) is_pairing = (not device.isDevice) and bool(device.pairing.lock_open) - _model.set_value(item, _COLUMN.STATUS_ICON, "network-wireless" if is_pairing else _CAN_SET_ROW_NONE) + _model.set_value(item, Column.STATUS_ICON, "network-wireless" if is_pairing else _CAN_SET_ROW_NONE) if selected_device_id == (device.path, 0): full_update = need_popup or was_pairing != is_pairing @@ -864,15 +877,15 @@ def update(device, need_popup=False, refresh=False): def update_device(device, item, selected_device_id, need_popup, full=False): - was_online = _model.get_value(item, _COLUMN.ACTIVE) + was_online = _model.get_value(item, Column.ACTIVE) is_online = bool(device.online) - _model.set_value(item, _COLUMN.ACTIVE, is_online) + _model.set_value(item, Column.ACTIVE, is_online) battery_level = device.battery_info.level if device.battery_info is not None else None battery_voltage = device.battery_info.voltage if device.battery_info is not None else None if battery_level is None: - _model.set_value(item, _COLUMN.STATUS_TEXT, _CAN_SET_ROW_NONE) - _model.set_value(item, _COLUMN.STATUS_ICON, _CAN_SET_ROW_NONE) + _model.set_value(item, Column.STATUS_TEXT, _CAN_SET_ROW_NONE) + _model.set_value(item, Column.STATUS_ICON, _CAN_SET_ROW_NONE) else: if battery_voltage is not None and False: # Use levels instead of voltage here status_text = f"{int(battery_voltage)}mV" @@ -880,13 +893,13 @@ def update_device(device, item, selected_device_id, need_popup, full=False): status_text = _(str(battery_level)) else: status_text = f"{int(battery_level)}%" - _model.set_value(item, _COLUMN.STATUS_TEXT, status_text) + _model.set_value(item, Column.STATUS_TEXT, status_text) charging = device.battery_info.charging() if device.battery_info is not None else None icon_name = icons.battery(battery_level, charging) - _model.set_value(item, _COLUMN.STATUS_ICON, icon_name) + _model.set_value(item, Column.STATUS_ICON, icon_name) - _model.set_value(item, _COLUMN.NAME, device.codename) + _model.set_value(item, Column.NAME, device.codename) if selected_device_id is None or need_popup: select(device.receiver.path if device.receiver else device.path, device.number) @@ -901,7 +914,7 @@ def find_device(serial): def check(_store, _treepath, row): nonlocal result - device = _model.get_value(row, _COLUMN.DEVICE) + device = _model.get_value(row, Column.DEVICE) if device and device.kind and (device.unitId == serial or device.serial == serial): result = device return True From 9a60d65d77f652bd194ee1de296e6aa5c527b826 Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Mon, 4 Nov 2024 23:27:50 +0100 Subject: [PATCH 09/28] Remove NamedInts: Convert Task to enum Refactor code related to task and task ID. Related #2273 --- lib/logitech_receiver/hidpp20.py | 33 +- lib/logitech_receiver/special_keys.py | 494 +++++++++--------- tests/logitech_receiver/test_device.py | 6 +- .../logitech_receiver/test_hidpp20_complex.py | 28 +- 4 files changed, 289 insertions(+), 272 deletions(-) diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index b2fa67dd44..3f14292777 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -192,11 +192,11 @@ class ReprogrammableKey: - flags {List[str]} -- capabilities and desired software handling of the control """ - def __init__(self, device: Device, index, cid, tid, flags): + def __init__(self, device: Device, index, cid, task_id, flags): self._device = device self.index = index self._cid = cid - self._tid = tid + self._tid = task_id self._flags = flags @property @@ -209,7 +209,10 @@ def default_task(self) -> NamedInt: while the name is the Control ID's native task. But this makes more sense than presenting details of controls vs tasks in the interface. The same convention applies to `mapped_to`, `remappable_to`, `remap` in `ReprogrammableKeyV4`.""" - task = str(special_keys.TASK[self._tid]) + try: + task = str(special_keys.Task(self._tid)) + except ValueError: + task = f"unknown:{self._tid:04X}" return NamedInt(self._cid, task) @property @@ -234,8 +237,8 @@ class ReprogrammableKeyV4(ReprogrammableKey): - mapping_flags {List[str]} -- mapping flags set on the control """ - def __init__(self, device: Device, index, cid, tid, flags, pos, group, gmask): - ReprogrammableKey.__init__(self, device, index, cid, tid, flags) + def __init__(self, device: Device, index, cid, task_id, flags, pos, group, gmask): + ReprogrammableKey.__init__(self, device, index, cid, task_id, flags) self.pos = pos self.group = group self._gmask = gmask @@ -251,7 +254,7 @@ def mapped_to(self) -> NamedInt: if self._mapped_to is None: self._getCidReporting() self._device.keys._ensure_all_keys_queried() - task = str(special_keys.TASK[self._device.keys.cid_to_tid[self._mapped_to]]) + task = str(special_keys.Task(self._device.keys.cid_to_tid[self._mapped_to])) return NamedInt(self._mapped_to, task) @property @@ -263,7 +266,11 @@ def remappable_to(self) -> common.NamedInts: for g in self.group_mask: g = special_keys.CidGroup[str(g)] for tgt_cid in self._device.keys.group_cids[g]: - tgt_task = str(special_keys.TASK[self._device.keys.cid_to_tid[tgt_cid]]) + cid = self._device.keys.cid_to_tid[tgt_cid] + try: + tgt_task = str(special_keys.Task(cid)) + except ValueError: + tgt_task = f"unknown:{cid:04X}" tgt_task = NamedInt(tgt_cid, tgt_task) if tgt_task != self.default_task: # don't put itself in twice ret[tgt_task] = tgt_task @@ -524,9 +531,9 @@ def _query_key(self, index: int): raise IndexError(index) keydata = self.device.feature_request(SupportedFeature.REPROG_CONTROLS, 0x10, index) if keydata: - cid, tid, flags = struct.unpack("!HHB", keydata[:5]) - self.keys[index] = ReprogrammableKey(self.device, index, cid, tid, flags) - self.cid_to_tid[cid] = tid + cid, task_id, flags = struct.unpack("!HHB", keydata[:5]) + self.keys[index] = ReprogrammableKey(self.device, index, cid, task_id, flags) + self.cid_to_tid[cid] = task_id elif logger.isEnabledFor(logging.WARNING): logger.warning(f"Key with index {index} was expected to exist but device doesn't report it.") @@ -540,10 +547,10 @@ def _query_key(self, index: int): raise IndexError(index) keydata = self.device.feature_request(SupportedFeature.REPROG_CONTROLS_V4, 0x10, index) if keydata: - cid, tid, flags1, pos, group, gmask, flags2 = struct.unpack("!HHBBBBB", keydata[:9]) + cid, task_id, flags1, pos, group, gmask, flags2 = struct.unpack("!HHBBBBB", keydata[:9]) flags = flags1 | (flags2 << 8) - self.keys[index] = ReprogrammableKeyV4(self.device, index, cid, tid, flags, pos, group, gmask) - self.cid_to_tid[cid] = tid + self.keys[index] = ReprogrammableKeyV4(self.device, index, cid, task_id, flags, pos, group, gmask) + self.cid_to_tid[cid] = task_id if group != 0: # 0 = does not belong to a group self.group_cids[special_keys.CidGroup(group)].append(cid) elif logger.isEnabledFor(logging.WARNING): diff --git a/lib/logitech_receiver/special_keys.py b/lib/logitech_receiver/special_keys.py index 281512ec52..9547ec3446 100644 --- a/lib/logitech_receiver/special_keys.py +++ b/lib/logitech_receiver/special_keys.py @@ -323,255 +323,263 @@ CONTROL._fallback = lambda x: f"unknown:{x:04X}" -# tasks.py - Switch_Presentation__Switch_Screen=0x0093, # on K400 Plus - Minimize_Window=0x0094, - Maximize_Window=0x0095, # on K400 Plus - MultiPlatform_App_Switch=0x0096, - MultiPlatform_Home=0x0097, - MultiPlatform_Menu=0x0098, - MultiPlatform_Back=0x0099, - Switch_Language=0x009A, # Mac_switch_language - Screen_Capture=0x009B, # Mac_screen_Capture, on Craft Keyboard - Gesture_Button=0x009C, - Smart_Shift=0x009D, - AppExpose=0x009E, - Smart_Zoom=0x009F, - Lookup=0x00A0, - Microphone_on__off=0x00A1, - Wifi_on__off=0x00A2, - Brightness_Down=0x00A3, - Brightness_Up=0x00A4, - Display_Out=0x00A5, - View_Open_Apps=0x00A6, - View_All_Open_Apps=0x00A7, - AppSwitch=0x00A8, - Gesture_Button_Navigation=0x00A9, # Mouse_Thumb_Button on MX Master - Fn_inversion=0x00AA, - Multiplatform_Back=0x00AB, - Multiplatform_Forward=0x00AC, - Multiplatform_Gesture_Button=0x00AD, - HostSwitch_Channel_1=0x00AE, - HostSwitch_Channel_2=0x00AF, - HostSwitch_Channel_3=0x00B0, - Multiplatform_Search=0x00B1, - Multiplatform_Home__Mission_Control=0x00B2, - Multiplatform_Menu__Launchpad=0x00B3, - Virtual_Gesture_Button=0x00B4, - Cursor=0x00B5, - Keyboard_Right_Arrow=0x00B6, - SW_Custom_Highlight=0x00B7, - Keyboard_Left_Arrow=0x00B8, - TBD=0x00B9, - Multiplatform_Language_Switch=0x00BA, - SW_Custom_Highlight_2=0x00BB, - Fast_Forward=0x00BC, - Fast_Backward=0x00BD, - Switch_Highlighting=0x00BE, - Mission_Control__Task_View=0x00BF, # Switch_Workspace on Craft Keyboard - Dashboard_Launchpad__Action_Center=0x00C0, # Application_Launcher on Craft Keyboard - Backlight_Down=0x00C1, # Backlight_Down_FW_internal_function - Backlight_Up=0x00C2, # Backlight_Up_FW_internal_function - Right_Click__App_Contextual_Menu=0x00C3, # Context_Menu on Craft Keyboard - DPI_Change=0x00C4, - New_Tab=0x00C5, - F2=0x00C6, - F3=0x00C7, - F4=0x00C8, - F5=0x00C9, - F6=0x00CA, - F7=0x00CB, - F8=0x00CC, - F1=0x00CD, - Laser_Button=0x00CE, - Laser_Button_Long_Press=0x00CF, - Start_Presentation=0x00D0, - Blank_Screen=0x00D1, - DPI_Switch=0x00D2, # AdjustDPI on MX Vertical - Home__Show_Desktop=0x00D3, - App_Switch__Dashboard=0x00D4, - App_Switch=0x00D5, - Fn_Inversion=0x00D6, - LeftAndRightClick=0x00D7, - Voice_Dictation=0x00D8, - Emoji_Smiling_Face_With_Heart_Shaped_Eyes=0x00D9, - Emoji_Loudly_Crying_Face=0x00DA, - Emoji_Smiley=0x00DB, - Emoji_Smiley_With_Tears=0x00DC, - Open_Emoji_Panel=0x00DD, - Multiplatform_App_Switch__Launchpad=0x00DE, - Snipping_Tool=0x00DF, - Grave_Accent=0x00E0, - Standard_Tab_Key=0x00E1, - Caps_Lock=0x00E2, - Left_Shift=0x00E3, - Left_Control=0x00E4, - Left_Option__Start=0x00E5, - Left_Command__Alt=0x00E6, - Right_Command__Alt=0x00E7, - Right_Option__Start=0x00E8, - Right_Control=0x00E9, - Right_Shift=0x0EA, - Insert=0x00EB, - Delete=0x00EC, - Home=0x00ED, - End=0x00EE, - Page_Up=0x00EF, - Page_Down=0x00F0, - Mute_Microphone=0x00F1, - Do_Not_Disturb=0x00F2, - Backslash=0x00F3, - Refresh=0x00F4, - Close_Tab=0x00F5, - Lang_Switch=0x00F6, - Standard_Alphabetical_Key=0x00F7, - Right_Option__Start__2=0x00F8, - Left_Option=0x00F9, - Right_Option=0x00FA, - Left_Cmd=0x00FB, - Right_Cmd=0x00FC, -) -TASK._fallback = lambda x: f"unknown:{x:04X}" + SWITCH_PRESENTATION_SWITCH_SCREEN = 0x0093 # on K400 Plus + MINIMIZE_WINDOW = 0x0094 + MAXIMIZE_WINDOW = 0x0095 # on K400 Plus + MULTI_PLATFORM_APP_SWITCH = 0x0096 + MULTI_PLATFORM_HOME = 0x0097 + MULTI_PLATFORM_MENU = 0x0098 + MULTI_PLATFORM_BACK = 0x0099 + SWITCH_LANGUAGE = 0x009A # Mac_switch_language + SCREEN_CAPTURE = 0x009B # Mac_screen_Capture, on Craft Keyboard + GESTURE_BUTTON = 0x009C + SMART_SHIFT = 0x009D + APP_EXPOSE = 0x009E + SMART_ZOOM = 0x009F + LOOKUP = 0x00A0 + MICROPHEON_ON_OFF = 0x00A1 + WIFI_ON_OFF = 0x00A2 + BRIGHTNESS_DOWN = 0x00A3 + BRIGHTNESS_UP = 0x00A4 + DISPLAY_OUT = 0x00A5 + VIEW_OPEN_APPS = 0x00A6 + VIEW_ALL_OPEN_APPS = 0x00A7 + APP_SWITCH = 0x00A8 + GESTURE_BUTTON_NAVIGATION = 0x00A9 # Mouse_Thumb_Button on MX Master + FN_INVERSION = 0x00AA + MULTI_PLATFORM_BACK_2 = 0x00AB # Alternative + MULTI_PLATFORM_FORWARD = 0x00AC + MULTI_PLATFORM_Gesture_Button = 0x00AD + HostSwitch_Channel_1 = 0x00AE + HostSwitch_Channel_2 = 0x00AF + HostSwitch_Channel_3 = 0x00B0 + MULTI_PLATFORM_SEARCH = 0x00B1 + MULTI_PLATFORM_HOME_MISSION_CONTROL = 0x00B2 + MULTI_PLATFORM_MENU_LAUNCHPAD = 0x00B3 + VIRTUAL_GESTURE_BUTTON = 0x00B4 + CURSOR = 0x00B5 + KEYBOARD_RIGHT_ARROW = 0x00B6 + SW_CUSTOM_HIGHLIGHT = 0x00B7 + KEYBOARD_LEFT_ARROW = 0x00B8 + TBD = 0x00B9 + MULTI_PLATFORM_Language_Switch = 0x00BA + SW_CUSTOM_HIGHLIGHT_2 = 0x00BB + FAST_FORWARD = 0x00BC + FAST_BACKWARD = 0x00BD + SWITCH_HIGHLIGHTING = 0x00BE + MISSION_CONTROL_TASK_VIEW = 0x00BF # Switch_Workspace on Craft Keyboard + DASHBOARD_LAUNCHPAD_ACTION_CENTER = 0x00C0 # Application_Launcher on Craft + # Keyboard + BACKLIGHT_DOWN = 0x00C1 # Backlight_Down_FW_internal_function + BACKLIGHT_UP = 0x00C2 # Backlight_Up_FW_internal_function + RIGHT_CLICK_APP_CONTEXT_MENU = 0x00C3 # Context_Menu on Craft Keyboard + DPI_Change = 0x00C4 + NEW_TAB = 0x00C5 + F2 = 0x00C6 + F3 = 0x00C7 + F4 = 0x00C8 + F5 = 0x00C9 + F6 = 0x00CA + F7 = 0x00CB + F8 = 0x00CC + F1 = 0x00CD + LASER_BUTTON = 0x00CE + LASER_BUTTON_LONG_PRESS = 0x00CF + START_PRESENTATION = 0x00D0 + BLANK_SCREEN = 0x00D1 + DPI_Switch = 0x00D2 # AdjustDPI on MX Vertical + HOME_SHOW_DESKTOP = 0x00D3 + APP_SWITCH_DASHBOARD = 0x00D4 + APP_SWITCH_2 = 0x00D5 # Alternative + FN_INVERSION_2 = 0x00D6 # Alternative + LEFT_AND_RIGHT_CLICK = 0x00D7 + VOICE_DICTATION = 0x00D8 + EMOJI_SMILING_FACE_WITH_HEART_SHAPED_EYES = 0x00D9 + EMOJI_LOUDLY_CRYING_FACE = 0x00DA + EMOJI_SMILEY = 0x00DB + EMOJI_SMILE_WITH_TEARS = 0x00DC + OPEN_EMOJI_PANEL = 0x00DD + MULTI_PLATFORM_APP_SWITCH_LAUNCHPAD = 0x00DE + SNIPPING_TOOL = 0x00DF + GRAVE_ACCENT = 0x00E0 + STANDARD_TAB_KEY = 0x00E1 + CAPS_LOCK = 0x00E2 + LEFT_SHIFT = 0x00E3 + LEFT_CONTROL = 0x00E4 + LEFT_OPTION_START = 0x00E5 + LEFT_COMMAND_ALT = 0x00E6 + RIGHT_COMMAND_ALT = 0x00E7 + RIGHT_OPTION_START = 0x00E8 + RIGHT_CONTROL = 0x00E9 + RIGHT_SHIFT = 0x0EA + INSERT = 0x00EB + DELETE = 0x00EC + HOME = 0x00ED + END = 0x00EE + PAGE_UP_2 = 0x00EF # Alternative + PAGE_DOWN_2 = 0x00F0 # Alternative + MUTE_MICROPHONE = 0x00F1 + DO_NOT_DISTURB = 0x00F2 + BACKSLASH = 0x00F3 + REFRESH = 0x00F4 + CLOSE_TAB = 0x00F5 + LANG_SWITCH = 0x00F6 + STANDARD_ALPHABETICAL_KEY = 0x00F7 + RRIGH_OPTION_START_2 = 0x00F8 + LEFT_OPTION = 0x00F9 + RIGHT_OPTION = 0x00FA + LEFT_CMD = 0x00FB + RIGHT_CMD = 0x00FC + + def __str__(self): + return self.name.replace("_", " ").title() + + # Capabilities and desired software handling for a control # Ref: https://drive.google.com/file/d/10imcbmoxTJ1N510poGdsviEhoFfB_Ua4/view # We treat bytes 4 and 8 of `getCidInfo` as a single bitfield diff --git a/tests/logitech_receiver/test_device.py b/tests/logitech_receiver/test_device.py index 2dd0dc02a6..f419b05146 100644 --- a/tests/logitech_receiver/test_device.py +++ b/tests/logitech_receiver/test_device.py @@ -215,7 +215,7 @@ def test_device_receiver(number, pairing_info, responses, handle, _name, codenam @pytest.mark.parametrize( - "number, info, responses, handle, unitId, modelId, tid, kind, firmware, serial, id, psl, rate", + "number, info, responses, handle, unitId, modelId, task_id, kind, firmware, serial, id, psl, rate", zip( range(1, 7), [pi_CCCC, pi_2011, pi_4066, pi_1007, pi_407B, pi_DDDD], @@ -239,7 +239,7 @@ def test_device_receiver(number, pairing_info, responses, handle, _name, codenam ["1ms", "2ms", "4ms", "8ms", "1ms", "9ms"], # polling rate ), ) -def test_device_ids(number, info, responses, handle, unitId, modelId, tid, kind, firmware, serial, id, psl, rate): +def test_device_ids(number, info, responses, handle, unitId, modelId, task_id, kind, firmware, serial, id, psl, rate): low_level = LowLevelInterfaceFake(responses) low_level.request = partial(fake_hidpp.request, fake_hidpp.replace_number(responses, number)) low_level.ping = partial(fake_hidpp.ping, fake_hidpp.replace_number(responses, number)) @@ -248,7 +248,7 @@ def test_device_ids(number, info, responses, handle, unitId, modelId, tid, kind, assert test_device.unitId == unitId assert test_device.modelId == modelId - assert test_device.tid_map == tid + assert test_device.tid_map == task_id 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 diff --git a/tests/logitech_receiver/test_hidpp20_complex.py b/tests/logitech_receiver/test_hidpp20_complex.py index 8ebcbec958..3a79d71f38 100644 --- a/tests/logitech_receiver/test_hidpp20_complex.py +++ b/tests/logitech_receiver/test_hidpp20_complex.py @@ -154,19 +154,19 @@ def test_FeaturesArray_getitem(device, expected0, expected1, expected2, expected @pytest.mark.parametrize( - "device, index, cid, tid, flags, default_task, flag_names", + "device, index, cid, task_id, flags, default_task, flag_names", [ (device_standard, 2, 1, 1, 0x30, "Volume Up", ["reprogrammable", "divertable"]), (device_standard, 1, 2, 2, 0x20, "Volume Down", ["divertable"]), ], ) -def test_ReprogrammableKey_key(device, index, cid, tid, flags, default_task, flag_names): - key = hidpp20.ReprogrammableKey(device, index, cid, tid, flags) +def test_reprogrammable_key_key(device, index, cid, task_id, flags, default_task, flag_names): + key = hidpp20.ReprogrammableKey(device, index, cid, task_id, flags) assert key._device == device assert key.index == index assert key._cid == cid - assert key._tid == tid + assert key._tid == task_id assert key._flags == flags assert key.key == special_keys.CONTROL[cid] assert key.default_task == common.NamedInt(cid, default_task) @@ -174,7 +174,7 @@ def test_ReprogrammableKey_key(device, index, cid, tid, flags, default_task, fla @pytest.mark.parametrize( - "device, index, cid, tid, flags, pos, group, gmask, default_task, flag_names, group_names", + "device, index, cid, task_id, flags, pos, group, gmask, default_task, flag_names, group_names", [ (device_standard, 1, 0x51, 0x39, 0x60, 0, 1, 1, "Right Click", ["divertable", "persistently divertable"], ["g1"]), (device_standard, 2, 0x52, 0x3A, 0x11, 1, 2, 3, "Mouse Middle Button", ["mse", "reprogrammable"], ["g1", "g2"]), @@ -193,13 +193,15 @@ def test_ReprogrammableKey_key(device, index, cid, tid, flags, default_task, fla ), ], ) -def test_reprogrammable_key_v4_key(device, index, cid, tid, flags, pos, group, gmask, default_task, flag_names, group_names): - key = hidpp20.ReprogrammableKeyV4(device, index, cid, tid, flags, pos, group, gmask) +def test_reprogrammable_key_v4_key( + device, index, cid, task_id, flags, pos, group, gmask, default_task, flag_names, group_names +): + key = hidpp20.ReprogrammableKeyV4(device, index, cid, task_id, flags, pos, group, gmask) assert key._device == device assert key.index == index assert key._cid == cid - assert key._tid == tid + assert key._tid == task_id assert key._flags == flags assert key.pos == pos assert key.group == group @@ -220,7 +222,7 @@ def test_reprogrammable_key_v4_key(device, index, cid, tid, flags, pos, group, g ], ) # these fields need access all the key data, so start by setting up a device and its key data -def test_ReprogrammableKeyV4_query(responses, index, mapped_to, remappable_to, mapping_flags): +def test_reprogrammable_key_v4_query(responses, index, mapped_to, remappable_to, mapping_flags): device = fake_hidpp.Device( "KEY", responses=responses, feature=hidpp20_constants.SupportedFeature.REPROG_CONTROLS_V4, offset=5 ) @@ -360,14 +362,14 @@ def test_KeysArrayV4_query_key(device, index, top, cid): @pytest.mark.parametrize( - "device, count, index, cid, tid, flags, pos, group, gmask", + "device, count, index, cid, task_id, flags, pos, group, gmask", [ (device_standard, 4, 0, 0x0011, 0x0012, 0xCDAB, 1, 2, 3), (device_standard, 6, 1, 0x0111, 0x0022, 0xCDAB, 1, 2, 3), (device_standard, 8, 3, 0x0311, 0x0032, 0xCDAB, 1, 2, 4), ], ) -def test_KeysArrayV4__getitem(device, count, index, cid, tid, flags, pos, group, gmask): +def test_KeysArrayV4__getitem(device, count, index, cid, task_id, flags, pos, group, gmask): keysarray = hidpp20.KeysArrayV4(device, count) result = keysarray[index] @@ -375,7 +377,7 @@ def test_KeysArrayV4__getitem(device, count, index, cid, tid, flags, pos, group, assert result._device == device assert result.index == index assert result._cid == cid - assert result._tid == tid + assert result._tid == task_id assert result._flags == flags assert result.pos == pos assert result.group == group @@ -421,7 +423,7 @@ def test_KeysArrayV4_index(key, index): (special_keys.CONTROL.Virtual_Gesture_Button, 7, common.NamedInt(0x51, "Right Click"), None), ], ) -def test_KeysArrayV4_key(key, expected_index, expected_mapped_to, expected_remappable_to): +def test_keys_array_v4_key(key, expected_index, expected_mapped_to, expected_remappable_to): device_key._keys = _hidpp20.get_keys(device_key) device_key._keys._ensure_all_keys_queried() From e4122797dbc24136b9ae579874f92698e463bc44 Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Tue, 5 Nov 2024 00:24:50 +0100 Subject: [PATCH 10/28] Add type hints Related #2273 --- lib/logitech_receiver/common.py | 3 +-- lib/logitech_receiver/settings.py | 3 ++- lib/logitech_receiver/settings_templates.py | 6 +----- lib/logitech_receiver/settings_validator.py | 10 +++++----- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/lib/logitech_receiver/common.py b/lib/logitech_receiver/common.py index a55f8df169..f7e59676ac 100644 --- a/lib/logitech_receiver/common.py +++ b/lib/logitech_receiver/common.py @@ -649,8 +649,7 @@ def to_str(self) -> str: elif isinstance(self.level, int): status = self.status.name.lower().replace("_", " ") if self.status is not None else "Unknown" return _("Battery: %(percent)d%% (%(status)s)") % {"percent": self.level, "status": _(status)} - else: - return "" + return "" class Alert(IntEnum): diff --git a/lib/logitech_receiver/settings.py b/lib/logitech_receiver/settings.py index 2acb04b953..904718f162 100644 --- a/lib/logitech_receiver/settings.py +++ b/lib/logitech_receiver/settings.py @@ -20,6 +20,7 @@ import time from enum import IntEnum +from typing import Any from solaar.i18n import _ @@ -276,7 +277,7 @@ def update_key_value(self, key, value, save=True): self._value[int(key)] = value self._pre_write(save) - def write_key_value(self, key, value, save=True): + def write_key_value(self, key, value, save=True) -> Any | None: assert hasattr(self, "_value") assert hasattr(self, "_device") assert key is not None diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index 7a574c36fc..8cc96d5b36 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -33,7 +33,6 @@ from . import descriptors from . import desktop_notifications from . import diversion -from . import hidpp10_constants from . import hidpp20 from . import hidpp20_constants from . import settings @@ -46,7 +45,6 @@ logger = logging.getLogger(__name__) _hidpp20 = hidpp20.Hidpp20() -_DK = hidpp10_constants.DEVICE_KIND _F = hidpp20_constants.SupportedFeature @@ -1612,8 +1610,6 @@ class LEDZoneSetting(settings.Setting): period_field = {"name": _LEDP.period, "kind": settings.Kind.RANGE, "label": _("Period"), "min": 100, "max": 5000} intensity_field = {"name": _LEDP.intensity, "kind": settings.Kind.RANGE, "label": _("Intensity"), "min": 0, "max": 100} ramp_field = {"name": _LEDP.ramp, "kind": settings.Kind.CHOICE, "label": _("Ramp"), "choices": hidpp20.LEDRampChoices} - # form_field = {"name": _LEDP.form, "kind": settings.Kind.CHOICE, "label": _("Form"), "choices": - # _hidpp20.LEDFormChoices} possible_fields = [color_field, speed_field, period_field, intensity_field, ramp_field] @classmethod @@ -1948,7 +1944,7 @@ def check_feature_settings(device, already_known) -> bool: return True -def check_feature_setting(device, setting_name) -> settings.Setting | None: +def check_feature_setting(device, setting_name: str) -> settings.Setting | None: for sclass in SETTINGS: if sclass.feature and sclass.name == setting_name and device.features: setting = check_feature(device, sclass) diff --git a/lib/logitech_receiver/settings_validator.py b/lib/logitech_receiver/settings_validator.py index b2102d0438..cd241d4d4c 100644 --- a/lib/logitech_receiver/settings_validator.py +++ b/lib/logitech_receiver/settings_validator.py @@ -43,11 +43,11 @@ class Kind(IntEnum): class Validator: @classmethod - def build(cls, setting_class, device, **kwargs): + def build(cls, setting_class, device, **kwargs) -> Validator: return cls(**kwargs) @classmethod - def to_string(cls, value): + def to_string(cls, value) -> str: return str(value) def compare(self, args, current): @@ -200,7 +200,7 @@ def __init__(self, options, byte_count=None): assert isinstance(byte_count, int) and byte_count >= self.byte_count self.byte_count = byte_count - def to_string(self, value): + def to_string(self, value) -> str: def element_to_string(key, val): k = next((k for k in self.options if int(key) == k), None) return str(k) + ":" + str(val) if k is not None else "?" @@ -381,7 +381,7 @@ def __init__(self, choices=None, byte_count=None, read_skip_byte_count=0, write_ assert self._byte_count + self._read_skip_byte_count <= 14 assert self._byte_count + len(self._write_prefix_bytes) <= 14 - def to_string(self, value): + def to_string(self, value) -> str: return str(self.choices[value]) if isinstance(value, int) else str(value) def validate_read(self, reply_bytes): @@ -465,7 +465,7 @@ def __init__( assert self._byte_count + self._read_skip_byte_count + self._key_byte_count <= 14 assert self._byte_count + len(self._write_prefix_bytes) + self._key_byte_count <= 14 - def to_string(self, value): + def to_string(self, value) -> str: def element_to_string(key, val): k, c = next(((k, c) for k, c in self.choices.items() if int(key) == k), (None, None)) return str(k) + ":" + str(c[val]) if k is not None else "?" From 8d8d54070cc415b1f76597ebc6140b0ec32d6e9e Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Tue, 5 Nov 2024 00:31:36 +0100 Subject: [PATCH 11/28] key flags: Move to module of use The key flags are solely used in hiddpp20 module, thus put them into the module. Related #2273 --- lib/logitech_receiver/hidpp20.py | 31 +++++++++++++++++++++------ lib/logitech_receiver/special_keys.py | 16 -------------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 3f14292777..8298acdb2c 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -42,6 +42,7 @@ from .common import BatteryStatus from .common import FirmwareKind from .common import NamedInt +from .common import NamedInts from .hidpp20_constants import CHARGE_STATUS from .hidpp20_constants import DEVICE_KIND from .hidpp20_constants import ChargeLevel @@ -79,6 +80,24 @@ def _profiles(self) -> Any: ... +# Capabilities and desired software handling for a control +# Ref: https://drive.google.com/file/d/10imcbmoxTJ1N510poGdsviEhoFfB_Ua4/view +# We treat bytes 4 and 8 of `getCidInfo` as a single bitfield +KEY_FLAG = NamedInts( + analytics_key_events=0x400, + force_raw_XY=0x200, + raw_XY=0x100, + virtual=0x80, + persistently_divertable=0x40, + divertable=0x20, + reprogrammable=0x10, + FN_sensitive=0x08, + nonstandard=0x04, + is_FN=0x02, + mse=0x01, +) + + class FeaturesArray(dict): def __init__(self, device): assert device is not None @@ -217,7 +236,7 @@ def default_task(self) -> NamedInt: @property def flags(self) -> List[str]: - return special_keys.KEY_FLAG.flag_names(self._flags) + return KEY_FLAG.flag_names(self._flags) class ReprogrammableKeyV4(ReprogrammableKey): @@ -354,11 +373,11 @@ def _setCidReporting(self, flags: Dict[NamedInt, bool] = None, remap: int = 0): # The capability required to set a given reporting flag. FLAG_TO_CAPABILITY = { - special_keys.MAPPING_FLAG.diverted: special_keys.KEY_FLAG.divertable, - special_keys.MAPPING_FLAG.persistently_diverted: special_keys.KEY_FLAG.persistently_divertable, - special_keys.MAPPING_FLAG.analytics_key_events_reporting: special_keys.KEY_FLAG.analytics_key_events, - special_keys.MAPPING_FLAG.force_raw_XY_diverted: special_keys.KEY_FLAG.force_raw_XY, - special_keys.MAPPING_FLAG.raw_XY_diverted: special_keys.KEY_FLAG.raw_XY, + special_keys.MAPPING_FLAG.diverted: KEY_FLAG.divertable, + special_keys.MAPPING_FLAG.persistently_diverted: KEY_FLAG.persistently_divertable, + special_keys.MAPPING_FLAG.analytics_key_events_reporting: KEY_FLAG.analytics_key_events, + special_keys.MAPPING_FLAG.force_raw_XY_diverted: KEY_FLAG.force_raw_XY, + special_keys.MAPPING_FLAG.raw_XY_diverted: KEY_FLAG.raw_XY, } bfield = 0 diff --git a/lib/logitech_receiver/special_keys.py b/lib/logitech_receiver/special_keys.py index 9547ec3446..8daa5790f4 100644 --- a/lib/logitech_receiver/special_keys.py +++ b/lib/logitech_receiver/special_keys.py @@ -580,22 +580,6 @@ def __str__(self): return self.name.replace("_", " ").title() -# Capabilities and desired software handling for a control -# Ref: https://drive.google.com/file/d/10imcbmoxTJ1N510poGdsviEhoFfB_Ua4/view -# We treat bytes 4 and 8 of `getCidInfo` as a single bitfield -KEY_FLAG = NamedInts( - analytics_key_events=0x400, - force_raw_XY=0x200, - raw_XY=0x100, - virtual=0x80, - persistently_divertable=0x40, - divertable=0x20, - reprogrammable=0x10, - FN_sensitive=0x08, - nonstandard=0x04, - is_FN=0x02, - mse=0x01, -) # Flags describing the reporting method of a control # We treat bytes 2 and 5 of `get/setCidReporting` as a single bitfield MAPPING_FLAG = NamedInts( From 6c4612f86bc4305ad13ff5974f1ef2d62f476503 Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Tue, 5 Nov 2024 00:24:50 +0100 Subject: [PATCH 12/28] Add type hints Related #2273 --- lib/hidapi/hidapi_impl.py | 4 ++-- lib/logitech_receiver/base.py | 6 +++--- lib/logitech_receiver/device.py | 3 +-- lib/logitech_receiver/settings_templates.py | 3 +-- lib/solaar/listener.py | 21 +++++++++++++++------ tests/logitech_receiver/test_device.py | 2 +- 6 files changed, 23 insertions(+), 16 deletions(-) diff --git a/lib/hidapi/hidapi_impl.py b/lib/hidapi/hidapi_impl.py index 9e04153a21..09bb3ab182 100644 --- a/lib/hidapi/hidapi_impl.py +++ b/lib/hidapi/hidapi_impl.py @@ -228,7 +228,7 @@ def run(self): def _match( action: str, - device, + device: dict[str, Any], filter_func: Callable[[int, int, int, bool, bool], dict[str, Any]], ): """ @@ -393,7 +393,7 @@ def open(vendor_id, product_id, serial=None): return device_handle -def open_path(device_path) -> Any: +def open_path(device_path: str) -> int: """Open a HID device by its path name. :param device_path: the path of a ``DeviceInfo`` tuple returned by enumerate(). diff --git a/lib/logitech_receiver/base.py b/lib/logitech_receiver/base.py index cc8b3221da..de70a71304 100644 --- a/lib/logitech_receiver/base.py +++ b/lib/logitech_receiver/base.py @@ -66,7 +66,7 @@ def find_paired_node(self, receiver_path: str, index: int, timeout: int): def open(self, vendor_id, product_id, serial=None): ... - def open_path(self, path): + def open_path(self, path) -> int: ... def enumerate(self, filter_func: Callable[[int, int, int, bool, bool], dict[str, typing.Any]]) -> DeviceInfo: @@ -233,7 +233,7 @@ def notify_on_receivers_glib(glib: GLib, callback: Callable): return hidapi.monitor_glib(glib, callback, _filter_products_of_interest) -def open_path(path): +def open_path(path) -> int: """Checks if the given Linux device path points to the right UR device. :param path: the Linux device path. @@ -356,7 +356,7 @@ def _is_relevant_message(data: bytes) -> bool: return False -def _read(handle, timeout): +def _read(handle, timeout) -> tuple[int, int, bytes]: """Read an incoming packet from the receiver. :returns: a tuple of (report_id, devnumber, data), or `None`. diff --git a/lib/logitech_receiver/device.py b/lib/logitech_receiver/device.py index 812433cf41..4cd5edc16e 100644 --- a/lib/logitech_receiver/device.py +++ b/lib/logitech_receiver/device.py @@ -23,7 +23,6 @@ import time import typing -from typing import Any from typing import Callable from typing import Optional from typing import Protocol @@ -51,7 +50,7 @@ class LowLevelInterface(Protocol): - def open_path(self, path) -> Any: + def open_path(self, path) -> int: ... def find_paired_node(self, receiver_path: str, index: int, timeout: int): diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index 8cc96d5b36..d69b2a4af2 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -22,7 +22,6 @@ import traceback from time import time -from typing import Any from typing import Callable from typing import Protocol @@ -1890,7 +1889,7 @@ def __str__(self): ... -def check_feature(device, settings_class: SettingsProtocol) -> None | bool | Any: +def check_feature(device, settings_class: SettingsProtocol) -> None | bool | SettingsProtocol: if settings_class.feature not in device.features: return if settings_class.min_version > device.features.get_feature_version(settings_class.feature): diff --git a/lib/solaar/listener.py b/lib/solaar/listener.py index 76a4b1afde..06602931ce 100644 --- a/lib/solaar/listener.py +++ b/lib/solaar/listener.py @@ -15,10 +15,13 @@ ## with this program; if not, write to the Free Software Foundation, Inc., ## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +from __future__ import annotations + import errno import logging import subprocess import time +import typing from collections import namedtuple from functools import partial @@ -36,9 +39,15 @@ from . import dbus from . import i18n +if typing.TYPE_CHECKING: + from hidapi.common import DeviceInfo + gi.require_version("Gtk", "3.0") # NOQA: E402 from gi.repository import GLib # NOQA: E402 # isort:skip +if typing.TYPE_CHECKING: + from logitech_receiver.device import Device + logger = logging.getLogger(__name__) ACTION_ADD = "add" @@ -235,7 +244,7 @@ def __str__(self): return f"" -def _process_bluez_dbus(device, path, dictionary, signature): +def _process_bluez_dbus(device: Device, path, dictionary: dict, signature): """Process bluez dbus property changed signals for device status changes to discover disconnections and connections. """ @@ -251,7 +260,7 @@ def _process_bluez_dbus(device, path, dictionary, signature): _cleanup_bluez_dbus(device) -def _cleanup_bluez_dbus(device): +def _cleanup_bluez_dbus(device: Device): """Remove dbus signal receiver for device""" if logger.isEnabledFor(logging.INFO): logger.info("bluez cleanup for %s", device) @@ -261,10 +270,10 @@ def _cleanup_bluez_dbus(device): _all_listeners = {} # all known receiver listeners, listeners that stop on their own may remain here -def _start(device_info): +def _start(device_info: DeviceInfo): assert _status_callback and _setting_callback - isDevice = device_info.isDevice - if not isDevice: + + if not device_info.isDevice: receiver_ = logitech_receiver.receiver.create_receiver(base, device_info, _setting_callback) else: receiver_ = logitech_receiver.device.create_device(base, device_info, _setting_callback) @@ -345,7 +354,7 @@ def setup_scanner(status_changed_callback, setting_changed_callback, error_callb base.notify_on_receivers_glib(GLib, _process_receiver_event) -def _process_add(device_info, retry): +def _process_add(device_info: DeviceInfo, retry): try: _start(device_info) except OSError as e: diff --git a/tests/logitech_receiver/test_device.py b/tests/logitech_receiver/test_device.py index f419b05146..eda6788443 100644 --- a/tests/logitech_receiver/test_device.py +++ b/tests/logitech_receiver/test_device.py @@ -31,7 +31,7 @@ class LowLevelInterfaceFake: def __init__(self, responses=None): self.responses = responses - def open_path(self, path): + def open_path(self, path) -> int: return fake_hidpp.open_path(path) def find_paired_node(self, receiver_path: str, index: int, timeout: int): From f1ee1c4cae93ef50bfe35db083d62f90cdae8c78 Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Tue, 5 Nov 2024 01:37:42 +0100 Subject: [PATCH 13/28] Remove NamedInts: Convert KeyFlag to Flag Related #2273 --- lib/logitech_receiver/hidpp20.py | 58 ++++++++++--------- .../logitech_receiver/test_hidpp20_complex.py | 20 ++++--- 2 files changed, 43 insertions(+), 35 deletions(-) diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 8298acdb2c..71baca1847 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -21,6 +21,7 @@ import struct import threading +from enum import Flag from typing import Any from typing import Dict from typing import Generator @@ -42,7 +43,6 @@ from .common import BatteryStatus from .common import FirmwareKind from .common import NamedInt -from .common import NamedInts from .hidpp20_constants import CHARGE_STATUS from .hidpp20_constants import DEVICE_KIND from .hidpp20_constants import ChargeLevel @@ -80,22 +80,27 @@ def _profiles(self) -> Any: ... -# Capabilities and desired software handling for a control -# Ref: https://drive.google.com/file/d/10imcbmoxTJ1N510poGdsviEhoFfB_Ua4/view -# We treat bytes 4 and 8 of `getCidInfo` as a single bitfield -KEY_FLAG = NamedInts( - analytics_key_events=0x400, - force_raw_XY=0x200, - raw_XY=0x100, - virtual=0x80, - persistently_divertable=0x40, - divertable=0x20, - reprogrammable=0x10, - FN_sensitive=0x08, - nonstandard=0x04, - is_FN=0x02, - mse=0x01, -) +class KeyFlag(Flag): + """Capabilities and desired software handling for a control. + + Ref: https://drive.google.com/file/d/10imcbmoxTJ1N510poGdsviEhoFfB_Ua4/view + We treat bytes 4 and 8 of `getCidInfo` as a single bitfield + """ + + ANALYTICS_KEY_EVENTS = 0x400 + FORCE_RAW_XY = 0x200 + RAW_XY = 0x100 + VIRTUAL = 0x80 + PERSISTENTLY_DIVERTABLE = 0x40 + DIVERTABLE = 0x20 + REPROGRAMMABLE = 0x10 + FN_SENSITIVE = 0x08 + NONSTANDARD = 0x04 + IS_FN = 0x02 + MSE = 0x01 + + def __str__(self): + return self.name.replace("_", " ") class FeaturesArray(dict): @@ -211,7 +216,7 @@ class ReprogrammableKey: - flags {List[str]} -- capabilities and desired software handling of the control """ - def __init__(self, device: Device, index, cid, task_id, flags): + def __init__(self, device: Device, index: int, cid: int, task_id: int, flags): self._device = device self.index = index self._cid = cid @@ -236,7 +241,7 @@ def default_task(self) -> NamedInt: @property def flags(self) -> List[str]: - return KEY_FLAG.flag_names(self._flags) + return list(common.flag_names(KeyFlag, self._flags)) class ReprogrammableKeyV4(ReprogrammableKey): @@ -373,19 +378,20 @@ def _setCidReporting(self, flags: Dict[NamedInt, bool] = None, remap: int = 0): # The capability required to set a given reporting flag. FLAG_TO_CAPABILITY = { - special_keys.MAPPING_FLAG.diverted: KEY_FLAG.divertable, - special_keys.MAPPING_FLAG.persistently_diverted: KEY_FLAG.persistently_divertable, - special_keys.MAPPING_FLAG.analytics_key_events_reporting: KEY_FLAG.analytics_key_events, - special_keys.MAPPING_FLAG.force_raw_XY_diverted: KEY_FLAG.force_raw_XY, - special_keys.MAPPING_FLAG.raw_XY_diverted: KEY_FLAG.raw_XY, + special_keys.MAPPING_FLAG.diverted: KeyFlag.DIVERTABLE, + special_keys.MAPPING_FLAG.persistently_diverted: KeyFlag.PERSISTENTLY_DIVERTABLE, + special_keys.MAPPING_FLAG.analytics_key_events_reporting: KeyFlag.ANALYTICS_KEY_EVENTS, + special_keys.MAPPING_FLAG.force_raw_XY_diverted: KeyFlag.FORCE_RAW_XY, + special_keys.MAPPING_FLAG.raw_XY_diverted: KeyFlag.RAW_XY, } bfield = 0 for f, v in flags.items(): - if v and FLAG_TO_CAPABILITY[f] not in self.flags: + key_flag = FLAG_TO_CAPABILITY[f].name.lower() + if v and key_flag not in self.flags: raise exceptions.FeatureNotSupported( msg=f'Tried to set mapping flag "{f}" on control "{self.key}" ' - + f'which does not support "{FLAG_TO_CAPABILITY[f]}" on device {self._device}.' + + f'which does not support "{key_flag}" on device {self._device}.' ) bfield |= int(f) if v else 0 bfield |= int(f) << 1 # The 'Xvalid' bit diff --git a/tests/logitech_receiver/test_hidpp20_complex.py b/tests/logitech_receiver/test_hidpp20_complex.py index 3a79d71f38..eb46e446f7 100644 --- a/tests/logitech_receiver/test_hidpp20_complex.py +++ b/tests/logitech_receiver/test_hidpp20_complex.py @@ -170,7 +170,7 @@ def test_reprogrammable_key_key(device, index, cid, task_id, flags, default_task assert key._flags == flags assert key.key == special_keys.CONTROL[cid] assert key.default_task == common.NamedInt(cid, default_task) - assert list(key.flags) == flag_names + assert sorted(list(key.flags)) == sorted(flag_names) @pytest.mark.parametrize( @@ -188,7 +188,7 @@ def test_reprogrammable_key_key(device, index, cid, task_id, flags, default_task 2, 7, "Mouse Back Button", - ["reprogrammable", "raw XY"], + ["reprogrammable", "raw_xy"], ["g1", "g2", "g3"], ), ], @@ -208,7 +208,7 @@ def test_reprogrammable_key_v4_key( assert key._gmask == gmask assert key.key == special_keys.CONTROL[cid] assert key.default_task == common.NamedInt(cid, default_task) - assert list(key.flags) == flag_names + assert sorted(list(key.flags)) == sorted(flag_names) assert list(key.group_mask) == group_names @@ -256,26 +256,28 @@ def test_ReprogrammableKeyV4_set(responses, index, diverted, persistently_divert key = device.keys[index] _mapping_flags = list(key.mapping_flags) - if "divertable" in key.flags or not diverted: + if hidpp20.KeyFlag.DIVERTABLE in key.flags or not diverted: key.set_diverted(diverted) else: with pytest.raises(exceptions.FeatureNotSupported): key.set_diverted(diverted) - assert ("diverted" in list(key.mapping_flags)) == (diverted and "divertable" in key.flags) + assert ("diverted" in list(key.mapping_flags)) == (diverted and hidpp20.KeyFlag.DIVERTABLE in key.flags) - if "persistently divertable" in key.flags or not persistently_diverted: + if hidpp20.KeyFlag.PERSISTENTLY_DIVERTABLE in key.flags or not persistently_diverted: key.set_persistently_diverted(persistently_diverted) else: with pytest.raises(exceptions.FeatureNotSupported): key.set_persistently_diverted(persistently_diverted) - assert ("persistently diverted" in key.mapping_flags) == (persistently_diverted and "persistently divertable" in key.flags) + assert (hidpp20.KeyFlag.PERSISTENTLY_DIVERTABLE in key.mapping_flags) == ( + persistently_diverted and hidpp20.KeyFlag.PERSISTENTLY_DIVERTABLE in key.flags + ) - if "raw XY" in key.flags or not rawXY_reporting: + if hidpp20.KeyFlag.RAW_XY in key.flags or not rawXY_reporting: key.set_rawXY_reporting(rawXY_reporting) else: with pytest.raises(exceptions.FeatureNotSupported): key.set_rawXY_reporting(rawXY_reporting) - assert ("raw XY diverted" in list(key.mapping_flags)) == (rawXY_reporting and "raw XY" in key.flags) + assert ("raw XY diverted" in list(key.mapping_flags)) == (rawXY_reporting and hidpp20.KeyFlag.RAW_XY in key.flags) if remap in key.remappable_to or remap == 0: key.remap(remap) From 3f0d3ed2f848bafef4d0a2194cc8522171579cb1 Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Tue, 5 Nov 2024 02:06:51 +0100 Subject: [PATCH 14/28] Remove NamedInts: Convert Spec to enum Related #2273 --- lib/logitech_receiver/hidpp20.py | 40 +++++++++++-------- .../logitech_receiver/test_hidpp20_complex.py | 14 +++---- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 71baca1847..4dd8ec795b 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -22,6 +22,7 @@ import threading from enum import Flag +from enum import IntEnum from typing import Any from typing import Dict from typing import Generator @@ -671,20 +672,24 @@ def __repr__(self): ParamId.SCALE_FACTOR: (SubParam("scale", 2, 0x002E, 0x01FF, "Scale"),), } -# Spec Ids for feature GESTURE_2 -SPEC = common.NamedInts( - DVI_field_width=1, - field_widths=2, - period_unit=3, - resolution=4, - multiplier=5, - sensor_size=6, - finger_width_and_height=7, - finger_major_minor_axis=8, - finger_force=9, - zone=10, -) -SPEC._fallback = lambda x: f"unknown:{x:04X}" + +class SpecGesture(IntEnum): + """Spec IDs for feature GESTURE_2.""" + + DVI_FIELD_WIDTH = 1 + FIELD_WIDTHS = 2 + PERIOD_UNIT = 3 + RESOLUTION = 4 + MULTIPLIER = 5 + SENSOR_SIZE = 6 + FINGER_WIDTH_AND_HEIGHT = 7 + FINGER_MAJOR_MINOR_AXIS = 8 + FINGER_FORCE = 9 + ZONE = 10 + + def __str__(self): + return f"{self.name.replace('_', ' ').lower()}" + # Action Ids for feature GESTURE_2 ACTION_ID = common.NamedInts( @@ -836,10 +841,13 @@ def __int__(self): class Spec: - def __init__(self, device, low, high): + def __init__(self, device, low: int, high): self._device = device self.id = low - self.spec = SPEC[low] + try: + self.spec = SpecGesture(low) + except ValueError: + self.spec = f"unknown:{low:04X}" self.byte_count = high & 0x0F self._value = None diff --git a/tests/logitech_receiver/test_hidpp20_complex.py b/tests/logitech_receiver/test_hidpp20_complex.py index eb46e446f7..f8e9e34009 100644 --- a/tests/logitech_receiver/test_hidpp20_complex.py +++ b/tests/logitech_receiver/test_hidpp20_complex.py @@ -560,14 +560,14 @@ def test_param(responses, prm, id, index, size, value, default_value, write1, wr @pytest.mark.parametrize( - "responses, id, s, byte_count, value, string", + "responses, id, s, byte_count, expected_value, expected_string", [ - (fake_hidpp.responses_gestures, 1, "DVI field width", 1, 8, "[DVI field width=8]"), - (fake_hidpp.responses_gestures, 2, "field widths", 1, 8, "[field widths=8]"), - (fake_hidpp.responses_gestures, 3, "period unit", 2, 2048, "[period unit=2048]"), + (fake_hidpp.responses_gestures, 1, hidpp20.SpecGesture.DVI_FIELD_WIDTH, 1, 8, "[dvi field width=8]"), + (fake_hidpp.responses_gestures, 2, hidpp20.SpecGesture.FIELD_WIDTHS, 1, 8, "[field widths=8]"), + (fake_hidpp.responses_gestures, 3, hidpp20.SpecGesture.PERIOD_UNIT, 2, 2048, "[period unit=2048]"), ], ) -def test_Spec(responses, id, s, byte_count, value, string): +def test_spec(responses, id, s, byte_count, expected_value, expected_string): device = fake_hidpp.Device("GESTURE", responses=responses, feature=hidpp20_constants.SupportedFeature.GESTURE_2) gestures = _hidpp20.get_gestures(device) @@ -576,8 +576,8 @@ def test_Spec(responses, id, s, byte_count, value, string): assert spec.id == id assert spec.spec == s assert spec.byte_count == byte_count - assert spec.value == value - assert repr(spec) == string + assert spec.value == expected_value + assert repr(spec) == expected_string def test_Gestures(): From 41ac048686dca9e4d3e10b6a3f6ab2eeda654bb6 Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Tue, 5 Nov 2024 02:13:12 +0100 Subject: [PATCH 15/28] Remove NamedInts: Convert ActionId to enum This data is not in use currently. Related #2273 --- lib/logitech_receiver/hidpp20.py | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 4dd8ec795b..89904480d6 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -691,22 +691,21 @@ def __str__(self): return f"{self.name.replace('_', ' ').lower()}" -# Action Ids for feature GESTURE_2 -ACTION_ID = common.NamedInts( - MovePointer=1, - ScrollHorizontal=2, - WheelScrolling=3, - ScrollVertial=4, - ScrollOrPageXY=5, - ScrollOrPageHorizontal=6, - PageScreen=7, - Drag=8, - SecondaryDrag=9, - Zoom=10, - ScrollHorizontalOnly=11, - ScrollVerticalOnly=12, -) -ACTION_ID._fallback = lambda x: f"unknown:{x:04X}" +class ActionId(IntEnum): + """Action IDs for feature GESTURE_2.""" + + MOVE_POINTER = 1 + SCROLL_HORIZONTAL = 2 + WHEEL_SCROLLING = 3 + SCROLL_VERTICAL = 4 + SCROLL_OR_PAGE_XY = 5 + SCROLL_OR_PAGE_HORIZONTAL = 6 + PAGE_SCREEN = 7 + DRAG = 8 + SECONDARY_DRAG = 9 + ZOOM = 10 + SCROLL_HORIZONTAL_ONLY = 11 + SCROLL_VERTICAL_ONLY = 12 class Gesture: From 729c713834fe22949f87c4bdd8683d417db8e1d4 Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Tue, 5 Nov 2024 02:37:58 +0100 Subject: [PATCH 16/28] mapping flag: Move to module of use The mapping flags are solely used in hiddpp20 module, thus put them into this module. Related #2273 --- lib/logitech_receiver/hidpp20.py | 38 ++++++++++++++++++--------- lib/logitech_receiver/special_keys.py | 11 -------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 89904480d6..796e79092d 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -44,6 +44,7 @@ from .common import BatteryStatus from .common import FirmwareKind from .common import NamedInt +from .common import NamedInts from .hidpp20_constants import CHARGE_STATUS from .hidpp20_constants import DEVICE_KIND from .hidpp20_constants import ChargeLevel @@ -104,6 +105,17 @@ def __str__(self): return self.name.replace("_", " ") +# Flags describing the reporting method of a control +# We treat bytes 2 and 5 of `get/setCidReporting` as a single bitfield +MAPPING_FLAG = NamedInts( + analytics_key_events_reporting=0x100, + force_raw_XY_diverted=0x40, + raw_XY_diverted=0x10, + persistently_diverted=0x04, + diverted=0x01, +) + + class FeaturesArray(dict): def __init__(self, device): assert device is not None @@ -305,21 +317,21 @@ def remappable_to(self) -> common.NamedInts: def mapping_flags(self) -> List[str]: if self._mapping_flags is None: self._getCidReporting() - return special_keys.MAPPING_FLAG.flag_names(self._mapping_flags) + return MAPPING_FLAG.flag_names(self._mapping_flags) def set_diverted(self, value: bool): """If set, the control is diverted temporarily and reports presses as HID++ events.""" - flags = {special_keys.MAPPING_FLAG.diverted: value} + flags = {MAPPING_FLAG.diverted: value} self._setCidReporting(flags=flags) def set_persistently_diverted(self, value: bool): """If set, the control is diverted permanently and reports presses as HID++ events.""" - flags = {special_keys.MAPPING_FLAG.persistently_diverted: value} + flags = {MAPPING_FLAG.persistently_diverted: value} self._setCidReporting(flags=flags) def set_rawXY_reporting(self, value: bool): """If set, the mouse temporarily reports all its raw XY events while this control is pressed as HID++ events.""" - flags = {special_keys.MAPPING_FLAG.raw_XY_diverted: value} + flags = {MAPPING_FLAG.raw_XY_diverted: value} self._setCidReporting(flags=flags) def remap(self, to: NamedInt): @@ -371,19 +383,19 @@ def _setCidReporting(self, flags: Dict[NamedInt, bool] = None, remap: int = 0): """ flags = flags if flags else {} # See flake8 B006 - # if special_keys.MAPPING_FLAG.raw_XY_diverted in flags and flags[special_keys.MAPPING_FLAG.raw_XY_diverted]: + # if MAPPING_FLAG.raw_XY_diverted in flags and flags[MAPPING_FLAG.raw_XY_diverted]: # We need diversion to report raw XY, so divert temporarily (since XY reporting is also temporary) - # flags[special_keys.MAPPING_FLAG.diverted] = True - # if special_keys.MAPPING_FLAG.diverted in flags and not flags[special_keys.MAPPING_FLAG.diverted]: - # flags[special_keys.MAPPING_FLAG.raw_XY_diverted] = False + # flags[MAPPING_FLAG.diverted] = True + # if MAPPING_FLAG.diverted in flags and not flags[MAPPING_FLAG.diverted]: + # flags[MAPPING_FLAG.raw_XY_diverted] = False # The capability required to set a given reporting flag. FLAG_TO_CAPABILITY = { - special_keys.MAPPING_FLAG.diverted: KeyFlag.DIVERTABLE, - special_keys.MAPPING_FLAG.persistently_diverted: KeyFlag.PERSISTENTLY_DIVERTABLE, - special_keys.MAPPING_FLAG.analytics_key_events_reporting: KeyFlag.ANALYTICS_KEY_EVENTS, - special_keys.MAPPING_FLAG.force_raw_XY_diverted: KeyFlag.FORCE_RAW_XY, - special_keys.MAPPING_FLAG.raw_XY_diverted: KeyFlag.RAW_XY, + MAPPING_FLAG.diverted: KeyFlag.DIVERTABLE, + MAPPING_FLAG.persistently_diverted: KeyFlag.PERSISTENTLY_DIVERTABLE, + MAPPING_FLAG.analytics_key_events_reporting: KeyFlag.ANALYTICS_KEY_EVENTS, + MAPPING_FLAG.force_raw_XY_diverted: KeyFlag.FORCE_RAW_XY, + MAPPING_FLAG.raw_XY_diverted: KeyFlag.RAW_XY, } bfield = 0 diff --git a/lib/logitech_receiver/special_keys.py b/lib/logitech_receiver/special_keys.py index 8daa5790f4..b8be4a22d0 100644 --- a/lib/logitech_receiver/special_keys.py +++ b/lib/logitech_receiver/special_keys.py @@ -580,17 +580,6 @@ def __str__(self): return self.name.replace("_", " ").title() -# Flags describing the reporting method of a control -# We treat bytes 2 and 5 of `get/setCidReporting` as a single bitfield -MAPPING_FLAG = NamedInts( - analytics_key_events_reporting=0x100, - force_raw_XY_diverted=0x40, - raw_XY_diverted=0x10, - persistently_diverted=0x04, - diverted=0x01, -) - - class CIDGroupBit(IntEnum): g1 = 0x01 g2 = 0x02 From 3bee2b1dec058879a6120cc5b89e2c1db91e6eba Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Tue, 5 Nov 2024 02:41:21 +0100 Subject: [PATCH 17/28] Remove NamedInts: Convert MappingFlag to flag Related #2273 --- lib/logitech_receiver/hidpp20.py | 47 ++++++++++++++++---------------- lib/solaar/cli/show.py | 12 +++++--- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 796e79092d..777b831266 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -44,7 +44,6 @@ from .common import BatteryStatus from .common import FirmwareKind from .common import NamedInt -from .common import NamedInts from .hidpp20_constants import CHARGE_STATUS from .hidpp20_constants import DEVICE_KIND from .hidpp20_constants import ChargeLevel @@ -105,15 +104,17 @@ def __str__(self): return self.name.replace("_", " ") -# Flags describing the reporting method of a control -# We treat bytes 2 and 5 of `get/setCidReporting` as a single bitfield -MAPPING_FLAG = NamedInts( - analytics_key_events_reporting=0x100, - force_raw_XY_diverted=0x40, - raw_XY_diverted=0x10, - persistently_diverted=0x04, - diverted=0x01, -) +class MappingFlag(Flag): + """Flags describing the reporting method of a control. + + We treat bytes 2 and 5 of `get/setCidReporting` as a single bitfield + """ + + ANALYTICS_KEY_EVENTS_REPORTING = 0x100 + FORCE_RAW_XY_DIVERTED = 0x40 + RAW_XY_DIVERTED = 0x10 + PERSISTENTLY_DIVERTED = 0x04 + DIVERTED = 0x01 class FeaturesArray(dict): @@ -317,21 +318,21 @@ def remappable_to(self) -> common.NamedInts: def mapping_flags(self) -> List[str]: if self._mapping_flags is None: self._getCidReporting() - return MAPPING_FLAG.flag_names(self._mapping_flags) + return list(common.flag_names(MappingFlag, self._mapping_flags)) def set_diverted(self, value: bool): """If set, the control is diverted temporarily and reports presses as HID++ events.""" - flags = {MAPPING_FLAG.diverted: value} + flags = {MappingFlag.DIVERTED: value} self._setCidReporting(flags=flags) def set_persistently_diverted(self, value: bool): """If set, the control is diverted permanently and reports presses as HID++ events.""" - flags = {MAPPING_FLAG.persistently_diverted: value} + flags = {MappingFlag.PERSISTENTLY_DIVERTED: value} self._setCidReporting(flags=flags) def set_rawXY_reporting(self, value: bool): """If set, the mouse temporarily reports all its raw XY events while this control is pressed as HID++ events.""" - flags = {MAPPING_FLAG.raw_XY_diverted: value} + flags = {MappingFlag.RAW_XY_DIVERTED: value} self._setCidReporting(flags=flags) def remap(self, to: NamedInt): @@ -383,19 +384,19 @@ def _setCidReporting(self, flags: Dict[NamedInt, bool] = None, remap: int = 0): """ flags = flags if flags else {} # See flake8 B006 - # if MAPPING_FLAG.raw_XY_diverted in flags and flags[MAPPING_FLAG.raw_XY_diverted]: + # if MappingFlag.RAW_XY_DIVERTED in flags and flags[MappingFlag.RAW_XY_DIVERTED]: # We need diversion to report raw XY, so divert temporarily (since XY reporting is also temporary) - # flags[MAPPING_FLAG.diverted] = True - # if MAPPING_FLAG.diverted in flags and not flags[MAPPING_FLAG.diverted]: - # flags[MAPPING_FLAG.raw_XY_diverted] = False + # flags[MappingFlag.DIVERTED] = True + # if MappingFlag.DIVERTED in flags and not flags[MappingFlag.DIVERTED]: + # flags[MappingFlag.RAW_XY_DIVERTED] = False # The capability required to set a given reporting flag. FLAG_TO_CAPABILITY = { - MAPPING_FLAG.diverted: KeyFlag.DIVERTABLE, - MAPPING_FLAG.persistently_diverted: KeyFlag.PERSISTENTLY_DIVERTABLE, - MAPPING_FLAG.analytics_key_events_reporting: KeyFlag.ANALYTICS_KEY_EVENTS, - MAPPING_FLAG.force_raw_XY_diverted: KeyFlag.FORCE_RAW_XY, - MAPPING_FLAG.raw_XY_diverted: KeyFlag.RAW_XY, + MappingFlag.DIVERTED: KeyFlag.DIVERTABLE, + MappingFlag.PERSISTENTLY_DIVERTED: KeyFlag.PERSISTENTLY_DIVERTABLE, + MappingFlag.ANALYTICS_KEY_EVENTS_REPORTING: KeyFlag.ANALYTICS_KEY_EVENTS, + MappingFlag.FORCE_RAW_XY_DIVERTED: KeyFlag.FORCE_RAW_XY, + MappingFlag.RAW_XY_DIVERTED: KeyFlag.RAW_XY, } bfield = 0 diff --git a/lib/solaar/cli/show.py b/lib/solaar/cli/show.py index f9c2310d0c..99a914133d 100644 --- a/lib/solaar/cli/show.py +++ b/lib/solaar/cli/show.py @@ -55,7 +55,7 @@ def _print_receiver(receiver): notification_flags = _hidpp10.get_notification_flags(receiver) if notification_flags is not None: if notification_flags: - notification_names = hidpp10_constants.NOTIFICATION_FLAG.flag_names(notification_flags) + notification_names = hidpp10_constants.NotificationFlag.flag_names(notification_flags) print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags:06X})") else: print(" Notifications: (none)") @@ -151,8 +151,8 @@ def _print_device(dev, num=None): if isinstance(feature, str): feature_bytes = bytes.fromhex(feature[-4:]) else: - feature_bytes = feature.to_bytes(2) - feature_int = int.from_bytes(feature_bytes) + feature_bytes = feature.to_bytes(2, byteorder="big") + feature_int = int.from_bytes(feature_bytes, byteorder="big") flags = dev.request(0x0000, feature_bytes) flags = 0 if flags is None else ord(flags[1:2]) flags = common.flag_names(hidpp20_constants.FeatureFlag, flags) @@ -278,7 +278,11 @@ def _print_device(dev, num=None): gmask_fmt = ",".join(k.group_mask) gmask_fmt = gmask_fmt if gmask_fmt else "empty" print(f" {', '.join(k.flags)}, pos:{int(k.pos)}, group:{int(k.group):1}, group mask:{gmask_fmt}") - report_fmt = ", ".join(k.mapping_flags) + flag_names = list(common.flag_names(hidpp20.KeyFlag, k.flags.value)) + print( + f" {', '.join(flag_names)}, pos:{int(k.pos)}, group:{int(k.group):1}, group mask:{gmask_fmt}" + ) + report_fmt = list(common.flag_names(hidpp20.MappingFlag, k.mapping_flags.value)) report_fmt = report_fmt if report_fmt else "default" print(f" reporting: {report_fmt}") if dev.online and dev.remap_keys: From 6d51b22b8964bbb846d790e7184684912e33f47e Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Tue, 5 Nov 2024 02:49:53 +0100 Subject: [PATCH 18/28] Remove NamedInts: Convert PowerSwitchLocation to flag Related #2273 --- lib/logitech_receiver/hidpp10_constants.py | 27 +++++++++++----------- lib/logitech_receiver/receiver.py | 2 +- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/lib/logitech_receiver/hidpp10_constants.py b/lib/logitech_receiver/hidpp10_constants.py index f006caa06e..6541014a6c 100644 --- a/lib/logitech_receiver/hidpp10_constants.py +++ b/lib/logitech_receiver/hidpp10_constants.py @@ -41,19 +41,20 @@ receiver=0x0F, # for compatibility with HID++ 2.0 ) -POWER_SWITCH_LOCATION = NamedInts( - base=0x01, - top_case=0x02, - edge_of_top_right_corner=0x03, - top_left_corner=0x05, - bottom_left_corner=0x06, - top_right_corner=0x07, - bottom_right_corner=0x08, - top_edge=0x09, - right_edge=0x0A, - left_edge=0x0B, - bottom_edge=0x0C, -) + +class PowerSwitchLocation(IntEnum): + BASE = 0x01 + TOP_CASE = 0x02 + EDGE_OF_TOP_RIGHT_CORNER = 0x03 + TOP_LEFT_CORNER = 0x05 + BOTTOM_LEFT_CORNER = 0x06 + TOP_RIGHT_CORNER = 0x07 + BOTTOM_RIGHT_CORNER = 0x08 + TOP_EDGE = 0x09 + RIGHT_EDGE = 0x0A + LEFT_EDGE = 0x0B + BOTTOM_EDGE = 0x0C + # Some flags are used both by devices and receivers. The Logitech documentation # mentions that the first and last (third) byte are used for devices while the diff --git a/lib/logitech_receiver/receiver.py b/lib/logitech_receiver/receiver.py index 97f391fe17..29f2abb2bc 100644 --- a/lib/logitech_receiver/receiver.py +++ b/lib/logitech_receiver/receiver.py @@ -229,7 +229,7 @@ def device_pairing_information(self, n: int) -> dict: raise exceptions.NoSuchDevice(number=n, receiver=self, error="read pairing information") pair_info = self.read_register(Registers.RECEIVER_INFO, _IR.extended_pairing_information + n - 1) if pair_info: - power_switch = hidpp10_constants.POWER_SWITCH_LOCATION[pair_info[9] & 0x0F] + power_switch = hidpp10_constants.PowerSwitchLocation(pair_info[9] & 0x0F) serial = pair_info[1:5].hex().upper() else: # some Nano receivers? pair_info = self.read_register(0x2D5) # undocumented and questionable From b9154bf0beb206e47a60da230e5a8193b10fe19e Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Tue, 5 Nov 2024 03:08:45 +0100 Subject: [PATCH 19/28] Remove NamedInts: Convert HorizontalScroll to enum Related #2273 --- lib/logitech_receiver/hidpp20.py | 13 ++++++++----- lib/logitech_receiver/special_keys.py | 12 ++++++------ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 777b831266..7321852374 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -407,13 +407,13 @@ def _setCidReporting(self, flags: Dict[NamedInt, bool] = None, remap: int = 0): msg=f'Tried to set mapping flag "{f}" on control "{self.key}" ' + f'which does not support "{key_flag}" on device {self._device}.' ) - bfield |= int(f) if v else 0 - bfield |= int(f) << 1 # The 'Xvalid' bit + bfield |= int(f.value) if v else 0 + bfield |= int(f.value) << 1 # The 'Xvalid' bit if self._mapping_flags: # update flags if already read if v: - self._mapping_flags |= int(f) + self._mapping_flags |= int(f.value) else: - self._mapping_flags &= ~int(f) + self._mapping_flags &= ~int(f.value) if remap != 0 and remap not in self.remappable_to: raise exceptions.FeatureNotSupported( @@ -633,7 +633,10 @@ def _query_key(self, index: int): elif actionId == special_keys.ACTIONID.Mouse: remapped = special_keys.MOUSE_BUTTONS[remapped] elif actionId == special_keys.ACTIONID.Hscroll: - remapped = special_keys.HORIZONTAL_SCROLL[remapped] + try: + remapped = special_keys.HorizontalScroll(remapped) + except ValueError: + remapped = f"unknown horizontal scroll:{remapped:04X}" elif actionId == special_keys.ACTIONID.Consumer: remapped = special_keys.HID_CONSUMERCODES[remapped] elif actionId == special_keys.ACTIONID.Empty: # purge data from empty value diff --git a/lib/logitech_receiver/special_keys.py b/lib/logitech_receiver/special_keys.py index b8be4a22d0..09ac97cb0f 100644 --- a/lib/logitech_receiver/special_keys.py +++ b/lib/logitech_receiver/special_keys.py @@ -1207,11 +1207,11 @@ class CidGroup(IntEnum): ) MOUSE_BUTTONS._fallback = lambda x: f"unknown mouse button:{x:04X}" -HORIZONTAL_SCROLL = NamedInts( - Horizontal_Scroll_Left=0x4000, - Horizontal_Scroll_Right=0x8000, -) -HORIZONTAL_SCROLL._fallback = lambda x: f"unknown horizontal scroll:{x:04X}" + +class HorizontalScroll(IntEnum): + Left = 0x4000 + Right = 0x8000 + # Construct universe for Persistent Remappable Keys setting (only for supported values) KEYS = UnsortedNamedInts() @@ -1246,7 +1246,7 @@ class CidGroup(IntEnum): KEYS[(ACTIONID.Mouse << 24) + (int(code) << 8)] = str(code) # Add Horizontal Scroll -for code in HORIZONTAL_SCROLL: +for code in HorizontalScroll: KEYS[(ACTIONID.Hscroll << 24) + (int(code) << 8)] = str(code) From 55b5c08d9d7285a5e37e663f560e310106a2356b Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Tue, 5 Nov 2024 03:14:46 +0100 Subject: [PATCH 20/28] Remove NamedInts: Convert LedRampChoice to flag Related #2273 --- lib/logitech_receiver/hidpp20.py | 7 ++++++- lib/logitech_receiver/settings_templates.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 7321852374..3713d9a15d 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -990,7 +990,12 @@ class LEDParam: saturation = "saturation" -LEDRampChoices = common.NamedInts(default=0, yes=1, no=2) +class LedRampChoice(IntEnum): + DEFAULT = 0 + YES = 1 + NO = 2 + + LEDFormChoices = common.NamedInts(default=0, sine=1, square=2, triangle=3, sawtooth=4, sharkfin=5, exponential=6) LEDParamSize = { LEDParam.color: 3, diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index d69b2a4af2..c67c9473d6 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -1608,7 +1608,7 @@ class LEDZoneSetting(settings.Setting): speed_field = {"name": _LEDP.speed, "kind": settings.Kind.RANGE, "label": _("Speed"), "min": 0, "max": 255} period_field = {"name": _LEDP.period, "kind": settings.Kind.RANGE, "label": _("Period"), "min": 100, "max": 5000} intensity_field = {"name": _LEDP.intensity, "kind": settings.Kind.RANGE, "label": _("Intensity"), "min": 0, "max": 100} - ramp_field = {"name": _LEDP.ramp, "kind": settings.Kind.CHOICE, "label": _("Ramp"), "choices": hidpp20.LEDRampChoices} + ramp_field = {"name": _LEDP.ramp, "kind": settings.Kind.CHOICE, "label": _("Ramp"), "choices": hidpp20.LedRampChoice} possible_fields = [color_field, speed_field, period_field, intensity_field, ramp_field] @classmethod From 1aadc6caef0c2d432b54a8dc8080f33edcc16227 Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Tue, 5 Nov 2024 21:01:02 +0100 Subject: [PATCH 21/28] charge status: Refactor to enum and move to module of use The charge status is solely used in the hiddpp20 module, thus put it into this module. Related #2273 --- lib/logitech_receiver/hidpp20.py | 12 +++++++++--- lib/logitech_receiver/hidpp20_constants.py | 10 ---------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 3713d9a15d..0b9430b59f 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -44,7 +44,6 @@ from .common import BatteryStatus from .common import FirmwareKind from .common import NamedInt -from .hidpp20_constants import CHARGE_STATUS from .hidpp20_constants import DEVICE_KIND from .hidpp20_constants import ChargeLevel from .hidpp20_constants import ChargeType @@ -117,6 +116,13 @@ class MappingFlag(Flag): DIVERTED = 0x01 +class ChargeStatus(Flag): + CHARGING = 0x00 + FULL = 0x01 + NOT_CHARGING = 0x02 + ERROR = 0x07 + + class FeaturesArray(dict): def __init__(self, device): assert device is not None @@ -1879,10 +1885,10 @@ def decipher_battery_voltage(report: bytes): charge_type = ChargeType.STANDARD if flags & (1 << 7): status = BatteryStatus.RECHARGING - charge_sts = CHARGE_STATUS[flags & 0x03] + charge_sts = ChargeStatus(flags & 0x03) if charge_sts is None: charge_sts = ErrorCode.UNKNOWN - elif charge_sts == CHARGE_STATUS.full: + elif ChargeStatus.FULL in charge_sts: charge_lvl = ChargeLevel.FULL status = BatteryStatus.FULL if flags & (1 << 3): diff --git a/lib/logitech_receiver/hidpp20_constants.py b/lib/logitech_receiver/hidpp20_constants.py index 9071dfb80a..bb345218bd 100644 --- a/lib/logitech_receiver/hidpp20_constants.py +++ b/lib/logitech_receiver/hidpp20_constants.py @@ -179,16 +179,6 @@ class OnboardMode(IntEnum): MODE_HOST = 0x02 -CHARGE_STATUS = NamedInts(charging=0x00, full=0x01, not_charging=0x02, error=0x07) - - -class ChargeStatus(IntEnum): - CHARGING = 0x00 - FULL = 0x01 - NOT_CHARGING = 0x02 - ERROR = 0x07 - - class ChargeLevel(IntEnum): AVERAGE = 50 FULL = 90 From 8d516584d040ae7aeb6e9eda3734081fc1c1fb5f Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Tue, 5 Nov 2024 21:06:08 +0100 Subject: [PATCH 22/28] Remove NamedInts: Convert LedFormChoices to enum Related #2273 --- lib/logitech_receiver/hidpp20.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 0b9430b59f..6d5c868cc3 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -1002,7 +1002,16 @@ class LedRampChoice(IntEnum): NO = 2 -LEDFormChoices = common.NamedInts(default=0, sine=1, square=2, triangle=3, sawtooth=4, sharkfin=5, exponential=6) +class LedFormChoices(IntEnum): + DEFAULT = 0 + SINE = 1 + SQUARE = 2 + TRIANGLE = 3 + SAWTOOTH = 4 + SHARKFIN = 5 + EXPONENTIAL = 6 + + LEDParamSize = { LEDParam.color: 3, LEDParam.speed: 1, From 726eedc38458c94174ea8263c6a4835112aff402 Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Tue, 5 Nov 2024 22:01:36 +0100 Subject: [PATCH 23/28] Fix KeyFlag conversion --- lib/logitech_receiver/hidpp20.py | 10 +-- lib/logitech_receiver/settings_templates.py | 18 ++--- lib/solaar/cli/show.py | 1 - .../logitech_receiver/test_hidpp20_complex.py | 71 +++++++++++++------ 4 files changed, 63 insertions(+), 37 deletions(-) diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 6d5c868cc3..f545012954 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -260,8 +260,8 @@ def default_task(self) -> NamedInt: return NamedInt(self._cid, task) @property - def flags(self) -> List[str]: - return list(common.flag_names(KeyFlag, self._flags)) + def flags(self) -> KeyFlag: + return KeyFlag(self._flags) class ReprogrammableKeyV4(ReprogrammableKey): @@ -321,10 +321,10 @@ def remappable_to(self) -> common.NamedInts: return ret @property - def mapping_flags(self) -> List[str]: + def mapping_flags(self) -> MappingFlag: if self._mapping_flags is None: self._getCidReporting() - return list(common.flag_names(MappingFlag, self._mapping_flags)) + return MappingFlag(self._mapping_flags) def set_diverted(self, value: bool): """If set, the control is diverted temporarily and reports presses as HID++ events.""" @@ -407,7 +407,7 @@ def _setCidReporting(self, flags: Dict[NamedInt, bool] = None, remap: int = 0): bfield = 0 for f, v in flags.items(): - key_flag = FLAG_TO_CAPABILITY[f].name.lower() + key_flag = FLAG_TO_CAPABILITY[f] if v and key_flag not in self.flags: raise exceptions.FeatureNotSupported( msg=f'Tried to set mapping flag "{f}" on control "{self.key}" ' diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index c67c9473d6..0800b435ed 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -38,6 +38,8 @@ from . import settings_validator from . import special_keys from .hidpp10_constants import Registers +from .hidpp20 import KeyFlag +from .hidpp20 import MappingFlag from .hidpp20_constants import GestureId from .hidpp20_constants import ParamId @@ -898,7 +900,7 @@ def __init__(self, feature): def read(self, device, key): key_index = device.keys.index(key) key_struct = device.keys[key_index] - return b"\x00\x00\x01" if "diverted" in key_struct.mapping_flags else b"\x00\x00\x00" + return b"\x00\x00\x01" if MappingFlag.DIVERTED in key_struct.mapping_flags else b"\x00\x00\x00" def write(self, device, key, data_bytes): key_index = device.keys.index(key) @@ -926,20 +928,20 @@ def build(cls, setting_class, device): sliding = gestures = None choices = {} if device.keys: - for k in device.keys: - if "divertable" in k.flags and "virtual" not in k.flags: - if "raw XY" in k.flags: - choices[k.key] = setting_class.choices_gesture + for key in device.keys: + if KeyFlag.DIVERTABLE in key.flags and KeyFlag.VIRTUAL not in key.flags: + if KeyFlag.RAW_XY in key.flags: + choices[key.key] = setting_class.choices_gesture if gestures is None: gestures = MouseGesturesXY(device, name="MouseGestures") if _F.ADJUSTABLE_DPI in device.features: - choices[k.key] = setting_class.choices_universe + choices[key.key] = setting_class.choices_universe if sliding is None: sliding = DpiSlidingXY( device, name="DpiSliding", show_notification=desktop_notifications.show ) else: - choices[k.key] = setting_class.choices_divert + choices[key.key] = setting_class.choices_divert if not choices: return None validator = cls(choices, key_byte_count=2, byte_count=1, mask=0x01) @@ -1111,7 +1113,7 @@ class validator_class(settings_validator.ChoicesValidator): def build(cls, setting_class, device): key_index = device.keys.index(special_keys.CONTROL.DPI_Change) key = device.keys[key_index] if key_index is not None else None - if key is not None and "divertable" in key.flags: + if key is not None and KeyFlag.DIVERTABLE in key.flags: keys = [setting_class.choices_extra, key.key] return cls(choices=common.NamedInts.list(keys), byte_count=2) diff --git a/lib/solaar/cli/show.py b/lib/solaar/cli/show.py index 99a914133d..e26a617fd0 100644 --- a/lib/solaar/cli/show.py +++ b/lib/solaar/cli/show.py @@ -277,7 +277,6 @@ def _print_device(dev, num=None): print(" %2d: %-26s, default: %-27s => %-26s" % (k.index, k.key, k.default_task, k.mapped_to)) gmask_fmt = ",".join(k.group_mask) gmask_fmt = gmask_fmt if gmask_fmt else "empty" - print(f" {', '.join(k.flags)}, pos:{int(k.pos)}, group:{int(k.group):1}, group mask:{gmask_fmt}") flag_names = list(common.flag_names(hidpp20.KeyFlag, k.flags.value)) print( f" {', '.join(flag_names)}, pos:{int(k.pos)}, group:{int(k.group):1}, group mask:{gmask_fmt}" diff --git a/tests/logitech_receiver/test_hidpp20_complex.py b/tests/logitech_receiver/test_hidpp20_complex.py index f8e9e34009..c0f7046736 100644 --- a/tests/logitech_receiver/test_hidpp20_complex.py +++ b/tests/logitech_receiver/test_hidpp20_complex.py @@ -22,6 +22,7 @@ from logitech_receiver import hidpp20 from logitech_receiver import hidpp20_constants from logitech_receiver import special_keys +from logitech_receiver.hidpp20 import KeyFlag from logitech_receiver.hidpp20_constants import GestureId from . import fake_hidpp @@ -154,13 +155,13 @@ def test_FeaturesArray_getitem(device, expected0, expected1, expected2, expected @pytest.mark.parametrize( - "device, index, cid, task_id, flags, default_task, flag_names", + "device, index, cid, task_id, flags, default_task, expected_flags", [ - (device_standard, 2, 1, 1, 0x30, "Volume Up", ["reprogrammable", "divertable"]), - (device_standard, 1, 2, 2, 0x20, "Volume Down", ["divertable"]), + (device_standard, 2, 1, 1, 0x30, "Volume Up", KeyFlag.REPROGRAMMABLE | KeyFlag.DIVERTABLE), + (device_standard, 1, 2, 2, 0x20, "Volume Down", KeyFlag.DIVERTABLE), ], ) -def test_reprogrammable_key_key(device, index, cid, task_id, flags, default_task, flag_names): +def test_reprogrammable_key_key(device, index, cid, task_id, flags, default_task, expected_flags): key = hidpp20.ReprogrammableKey(device, index, cid, task_id, flags) assert key._device == device @@ -170,14 +171,38 @@ def test_reprogrammable_key_key(device, index, cid, task_id, flags, default_task assert key._flags == flags assert key.key == special_keys.CONTROL[cid] assert key.default_task == common.NamedInt(cid, default_task) - assert sorted(list(key.flags)) == sorted(flag_names) + assert key.flags == expected_flags @pytest.mark.parametrize( - "device, index, cid, task_id, flags, pos, group, gmask, default_task, flag_names, group_names", + "device, index, cid, task_id, flags, pos, group, gmask, default_task, expected_flags, group_names", [ - (device_standard, 1, 0x51, 0x39, 0x60, 0, 1, 1, "Right Click", ["divertable", "persistently divertable"], ["g1"]), - (device_standard, 2, 0x52, 0x3A, 0x11, 1, 2, 3, "Mouse Middle Button", ["mse", "reprogrammable"], ["g1", "g2"]), + ( + device_standard, + 1, + 0x51, + 0x39, + 0x60, + 0, + 1, + 1, + "Right Click", + KeyFlag.DIVERTABLE | KeyFlag.PERSISTENTLY_DIVERTABLE, + ["g1"], + ), + ( + device_standard, + 2, + 0x52, + 0x3A, + 0x11, + 1, + 2, + 3, + "Mouse Middle Button", + KeyFlag.MSE | KeyFlag.REPROGRAMMABLE, + ["g1", "g2"], + ), ( device_standard, 3, @@ -188,13 +213,13 @@ def test_reprogrammable_key_key(device, index, cid, task_id, flags, default_task 2, 7, "Mouse Back Button", - ["reprogrammable", "raw_xy"], + KeyFlag.REPROGRAMMABLE | KeyFlag.RAW_XY, ["g1", "g2", "g3"], ), ], ) def test_reprogrammable_key_v4_key( - device, index, cid, task_id, flags, pos, group, gmask, default_task, flag_names, group_names + device, index, cid, task_id, flags, pos, group, gmask, default_task, expected_flags, group_names ): key = hidpp20.ReprogrammableKeyV4(device, index, cid, task_id, flags, pos, group, gmask) @@ -208,21 +233,21 @@ def test_reprogrammable_key_v4_key( assert key._gmask == gmask assert key.key == special_keys.CONTROL[cid] assert key.default_task == common.NamedInt(cid, default_task) - assert sorted(list(key.flags)) == sorted(flag_names) + assert key.flags == expected_flags assert list(key.group_mask) == group_names @pytest.mark.parametrize( - "responses, index, mapped_to, remappable_to, mapping_flags", + "responses, index, mapped_to, remappable_to, expected_mapping_flags", [ - (fake_hidpp.responses_key, 1, "Right Click", common.UnsortedNamedInts(Right_Click=81, Left_Click=80), []), - (fake_hidpp.responses_key, 2, "Left Click", None, ["diverted"]), - (fake_hidpp.responses_key, 3, "Mouse Back Button", None, ["diverted", "persistently diverted"]), - (fake_hidpp.responses_key, 4, "Mouse Forward Button", None, ["diverted", "raw XY diverted"]), + (fake_hidpp.responses_key, 1, "Right Click", common.UnsortedNamedInts(Right_Click=81, Left_Click=80), MappingFlag(0)), + (fake_hidpp.responses_key, 2, "Left Click", None, MappingFlag.DIVERTED), + (fake_hidpp.responses_key, 3, "Mouse Back Button", None, MappingFlag.DIVERTED | MappingFlag.PERSISTENTLY_DIVERTED), + (fake_hidpp.responses_key, 4, "Mouse Forward Button", None, MappingFlag.DIVERTED | MappingFlag.RAW_XY_DIVERTED), ], ) # these fields need access all the key data, so start by setting up a device and its key data -def test_reprogrammable_key_v4_query(responses, index, mapped_to, remappable_to, mapping_flags): +def test_reprogrammable_key_v4_query(responses, index, mapped_to, remappable_to, expected_mapping_flags): device = fake_hidpp.Device( "KEY", responses=responses, feature=hidpp20_constants.SupportedFeature.REPROG_CONTROLS_V4, offset=5 ) @@ -232,7 +257,7 @@ def test_reprogrammable_key_v4_query(responses, index, mapped_to, remappable_to, assert key.mapped_to == mapped_to assert (key.remappable_to == remappable_to) or remappable_to is None - assert list(key.mapping_flags) == mapping_flags + assert key.mapping_flags == expected_mapping_flags @pytest.mark.parametrize( @@ -244,7 +269,7 @@ def test_reprogrammable_key_v4_query(responses, index, mapped_to, remappable_to, (fake_hidpp.responses_key, 4, False, False, False, 0x50, ["0056020000", "0056080000", "0056200000", "0056000050"]), ], ) -def test_ReprogrammableKeyV4_set(responses, index, diverted, persistently_diverted, rawXY_reporting, remap, sets, mocker): +def test_reprogrammable_key_v4_set(responses, index, diverted, persistently_diverted, rawXY_reporting, remap, sets, mocker): responses += [fake_hidpp.Response(r, 0x530, r) for r in sets] device = fake_hidpp.Device( "KEY", responses=responses, feature=hidpp20_constants.SupportedFeature.REPROG_CONTROLS_V4, offset=5 @@ -254,21 +279,21 @@ def test_ReprogrammableKeyV4_set(responses, index, diverted, persistently_divert spy_request = mocker.spy(device, "request") key = device.keys[index] - _mapping_flags = list(key.mapping_flags) + _mapping_flags = key.mapping_flags if hidpp20.KeyFlag.DIVERTABLE in key.flags or not diverted: key.set_diverted(diverted) else: with pytest.raises(exceptions.FeatureNotSupported): key.set_diverted(diverted) - assert ("diverted" in list(key.mapping_flags)) == (diverted and hidpp20.KeyFlag.DIVERTABLE in key.flags) + assert (MappingFlag.DIVERTED in key.mapping_flags) == (diverted and hidpp20.KeyFlag.DIVERTABLE in key.flags) if hidpp20.KeyFlag.PERSISTENTLY_DIVERTABLE in key.flags or not persistently_diverted: key.set_persistently_diverted(persistently_diverted) else: with pytest.raises(exceptions.FeatureNotSupported): key.set_persistently_diverted(persistently_diverted) - assert (hidpp20.KeyFlag.PERSISTENTLY_DIVERTABLE in key.mapping_flags) == ( + assert (hidpp20.MappingFlag.PERSISTENTLY_DIVERTED in key.mapping_flags) == ( persistently_diverted and hidpp20.KeyFlag.PERSISTENTLY_DIVERTABLE in key.flags ) @@ -277,7 +302,7 @@ def test_ReprogrammableKeyV4_set(responses, index, diverted, persistently_divert else: with pytest.raises(exceptions.FeatureNotSupported): key.set_rawXY_reporting(rawXY_reporting) - assert ("raw XY diverted" in list(key.mapping_flags)) == (rawXY_reporting and hidpp20.KeyFlag.RAW_XY in key.flags) + assert (MappingFlag.RAW_XY_DIVERTED in key.mapping_flags) == (rawXY_reporting and hidpp20.KeyFlag.RAW_XY in key.flags) if remap in key.remappable_to or remap == 0: key.remap(remap) From de6d8b46ccfc26eea5f3837bc732925f632f3c97 Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Wed, 6 Nov 2024 00:44:32 +0100 Subject: [PATCH 24/28] Fixes on top of refactoring --- lib/logitech_receiver/common.py | 3 +- lib/logitech_receiver/hidpp20.py | 141 +++++++++--------- tests/logitech_receiver/test_device.py | 10 +- .../logitech_receiver/test_hidpp20_complex.py | 14 +- .../logitech_receiver/test_hidpp20_simple.py | 15 +- 5 files changed, 102 insertions(+), 81 deletions(-) diff --git a/lib/logitech_receiver/common.py b/lib/logitech_receiver/common.py index f7e59676ac..50f4147bdb 100644 --- a/lib/logitech_receiver/common.py +++ b/lib/logitech_receiver/common.py @@ -20,6 +20,7 @@ import dataclasses import typing +from enum import Flag from enum import IntEnum from typing import Generator from typing import Iterable @@ -589,7 +590,7 @@ class FirmwareInfo: extras: str | None -class BatteryStatus(IntEnum): +class BatteryStatus(Flag): DISCHARGING = 0x00 RECHARGING = 0x01 ALMOST_FULL = 0x02 diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index f545012954..fcdaf3ca61 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -26,7 +26,6 @@ from typing import Any from typing import Dict from typing import Generator -from typing import List from typing import Optional from typing import Tuple @@ -84,7 +83,7 @@ class KeyFlag(Flag): """Capabilities and desired software handling for a control. Ref: https://drive.google.com/file/d/10imcbmoxTJ1N510poGdsviEhoFfB_Ua4/view - We treat bytes 4 and 8 of `getCidInfo` as a single bitfield + We treat bytes 4 and 8 of `getCidInfo` as a single bitfield. """ ANALYTICS_KEY_EVENTS = 0x400 @@ -99,9 +98,6 @@ class KeyFlag(Flag): IS_FN = 0x02 MSE = 0x01 - def __str__(self): - return self.name.replace("_", " ") - class MappingFlag(Flag): """Flags describing the reporting method of a control. @@ -228,15 +224,17 @@ def __len__(self) -> int: class ReprogrammableKey: """Information about a control present on a device with the `REPROG_CONTROLS` feature. - Ref: https://drive.google.com/file/d/0BxbRzx7vEV7eU3VfMnRuRXktZ3M/view + Read-only properties: - - index {int} -- index in the control ID table - - key {NamedInt} -- the name of this control - - default_task {NamedInt} -- the native function of this control - - flags {List[str]} -- capabilities and desired software handling of the control + - index -- index in the control ID table + - key -- the name of this control + - default_task -- the native function of this control + - flags -- capabilities and desired software handling of the control + + Ref: https://drive.google.com/file/d/0BxbRzx7vEV7eU3VfMnRuRXktZ3M/view """ - def __init__(self, device: Device, index: int, cid: int, task_id: int, flags): + def __init__(self, device: Device, index: int, cid: int, task_id: int, flags: int): self._device = device self.index = index self._cid = cid @@ -302,7 +300,7 @@ def mapped_to(self) -> NamedInt: return NamedInt(self._mapped_to, task) @property - def remappable_to(self) -> common.NamedInts: + def remappable_to(self): self._device.keys._ensure_all_keys_queried() ret = common.UnsortedNamedInts() if self.group_mask: # only keys with a non-zero gmask are remappable @@ -326,17 +324,17 @@ def mapping_flags(self) -> MappingFlag: self._getCidReporting() return MappingFlag(self._mapping_flags) - def set_diverted(self, value: bool): + def set_diverted(self, value: bool) -> None: """If set, the control is diverted temporarily and reports presses as HID++ events.""" flags = {MappingFlag.DIVERTED: value} self._setCidReporting(flags=flags) - def set_persistently_diverted(self, value: bool): + def set_persistently_diverted(self, value: bool) -> None: """If set, the control is diverted permanently and reports presses as HID++ events.""" flags = {MappingFlag.PERSISTENTLY_DIVERTED: value} self._setCidReporting(flags=flags) - def set_rawXY_reporting(self, value: bool): + def set_rawXY_reporting(self, value: bool) -> None: """If set, the mouse temporarily reports all its raw XY events while this control is pressed as HID++ events.""" flags = {MappingFlag.RAW_XY_DIVERTED: value} self._setCidReporting(flags=flags) @@ -390,12 +388,6 @@ def _setCidReporting(self, flags: Dict[NamedInt, bool] = None, remap: int = 0): """ flags = flags if flags else {} # See flake8 B006 - # if MappingFlag.RAW_XY_DIVERTED in flags and flags[MappingFlag.RAW_XY_DIVERTED]: - # We need diversion to report raw XY, so divert temporarily (since XY reporting is also temporary) - # flags[MappingFlag.DIVERTED] = True - # if MappingFlag.DIVERTED in flags and not flags[MappingFlag.DIVERTED]: - # flags[MappingFlag.RAW_XY_DIVERTED] = False - # The capability required to set a given reporting flag. FLAG_TO_CAPABILITY = { MappingFlag.DIVERTED: KeyFlag.DIVERTABLE, @@ -406,20 +398,20 @@ def _setCidReporting(self, flags: Dict[NamedInt, bool] = None, remap: int = 0): } bfield = 0 - for f, v in flags.items(): - key_flag = FLAG_TO_CAPABILITY[f] - if v and key_flag not in self.flags: + for mapping_flag, activated in flags.items(): + key_flag = FLAG_TO_CAPABILITY[mapping_flag] + if activated and key_flag not in self.flags: raise exceptions.FeatureNotSupported( - msg=f'Tried to set mapping flag "{f}" on control "{self.key}" ' + msg=f'Tried to set mapping flag "{mapping_flag}" on control "{self.key}" ' + f'which does not support "{key_flag}" on device {self._device}.' ) - bfield |= int(f.value) if v else 0 - bfield |= int(f.value) << 1 # The 'Xvalid' bit + bfield |= mapping_flag.value if activated else 0 + bfield |= mapping_flag.value << 1 # The 'Xvalid' bit if self._mapping_flags: # update flags if already read - if v: - self._mapping_flags |= int(f.value) + if activated: + self._mapping_flags |= mapping_flag.value else: - self._mapping_flags &= ~int(f.value) + self._mapping_flags &= ~mapping_flag.value if remap != 0 and remap not in self.remappable_to: raise exceptions.FeatureNotSupported( @@ -1169,26 +1161,29 @@ def __init__(self, device): ButtonBehaviors = common.NamedInts(MacroExecute=0x0, MacroStop=0x1, MacroStopAll=0x2, Send=0x8, Function=0x9) ButtonMappingTypes = common.NamedInts(No_Action=0x0, Button=0x1, Modifier_And_Key=0x2, Consumer_Key=0x3) -ButtonFunctions = common.NamedInts( - No_Action=0x0, - Tilt_Left=0x1, - Tilt_Right=0x2, - Next_DPI=0x3, - Previous_DPI=0x4, - Cycle_DPI=0x5, - Default_DPI=0x6, - Shift_DPI=0x7, - Next_Profile=0x8, - Previous_Profile=0x9, - Cycle_Profile=0xA, - G_Shift=0xB, - Battery_Status=0xC, - Profile_Select=0xD, - Mode_Switch=0xE, - Host_Button=0xF, - Scroll_Down=0x10, - Scroll_Up=0x11, -) + + +class ButtonFunctions(IntEnum): + NO_ACTION = 0x0 + TILT_LEFT = 0x1 + TILT_RIGHT = 0x2 + NEXT_DPI = 0x3 + PREVIOUS_DPI = 0x4 + CYCLE_DPI = 0x5 + DEFAULT_DPI = 0x6 + SHIFT_DPI = 0x7 + NEXT_PROFILE = 0x8 + PREVIOUS_PROFILE = 0x9 + CYCLE_PROFILE = 0xA + G_SHIFT = 0xB + BATTERY_STATUS = 0xC + PROFILE_SELECT = 0xD + MODE_SWITCH = 0xE + HOST_BUTTON = 0xF + SCROLL_DOWN = 0x10 + SCROLL_UP = 0x11 + + ButtonButtons = special_keys.MOUSE_BUTTONS ButtonModifiers = special_keys.modifiers ButtonKeys = special_keys.USB_HID_KEYCODES @@ -1213,32 +1208,37 @@ def to_yaml(cls, dumper, data): return dumper.represent_mapping("!Button", data.__dict__, flow_style=True) @classmethod - def from_bytes(cls, bytes): - behavior = ButtonBehaviors[bytes[0] >> 4] + def from_bytes(cls, bytes_) -> Button: + behavior_id = bytes_[0] >> 4 + behavior = ButtonBehaviors[behavior_id] if behavior == ButtonBehaviors.MacroExecute or behavior == ButtonBehaviors.MacroStop: - sector = ((bytes[0] & 0x0F) << 8) + bytes[1] - address = (bytes[2] << 8) + bytes[3] + sector = ((bytes_[0] & 0x0F) << 8) + bytes_[1] + address = (bytes_[2] << 8) + bytes_[3] result = cls(behavior=behavior, sector=sector, address=address) elif behavior == ButtonBehaviors.Send: - mapping_type = ButtonMappingTypes[bytes[1]] + mapping_type = ButtonMappingTypes[bytes_[1]] if mapping_type == ButtonMappingTypes.Button: - value = ButtonButtons[(bytes[2] << 8) + bytes[3]] + value = ButtonButtons[(bytes_[2] << 8) + bytes_[3]] result = cls(behavior=behavior, type=mapping_type, value=value) elif mapping_type == ButtonMappingTypes.Modifier_And_Key: - modifiers = bytes[2] - value = ButtonKeys[bytes[3]] + modifiers = bytes_[2] + value = ButtonKeys[bytes_[3]] result = cls(behavior=behavior, type=mapping_type, modifiers=modifiers, value=value) elif mapping_type == ButtonMappingTypes.Consumer_Key: - value = ButtonConsumerKeys[(bytes[2] << 8) + bytes[3]] + value = ButtonConsumerKeys[(bytes_[2] << 8) + bytes_[3]] result = cls(behavior=behavior, type=mapping_type, value=value) elif mapping_type == ButtonMappingTypes.No_Action: result = cls(behavior=behavior, type=mapping_type) elif behavior == ButtonBehaviors.Function: - value = ButtonFunctions[bytes[1]] if ButtonFunctions[bytes[1]] is not None else bytes[1] - data = bytes[3] - result = cls(behavior=behavior, value=value, data=data) + second_byte = bytes_[1] + try: + btn_func = ButtonFunctions(second_byte).value + except ValueError: + btn_func = second_byte + data = bytes_[3] + result = cls(behavior=behavior, value=btn_func, data=data) else: - result = cls(behavior=bytes[0] >> 4, bytes=bytes) + result = cls(behavior=bytes_[0] >> 4, bytes=bytes_) return result def to_bytes(self): @@ -1381,7 +1381,14 @@ def to_yaml(cls, dumper, data): return dumper.represent_mapping("!OnboardProfiles", data.__dict__) @classmethod - def get_profile_headers(cls, device): + def get_profile_headers(cls, device) -> list[tuple[int, int]]: + """Returns profile headers. + + Returns + ------- + list[tuple[int, int]] + Tuples contain (sector, enabled). + """ i = 0 headers = [] chunk = device.feature_request(SupportedFeature.ONBOARD_PROFILES, 0x50, 0, 0, 0, i) @@ -1408,10 +1415,8 @@ def from_device(cls, device): gbuttons = buttons if (shift & 0x3 == 0x2) else 0 headers = OnboardProfiles.get_profile_headers(device) profiles = {} - i = 0 - for sector, enabled in headers: - profiles[i + 1] = OnboardProfile.from_dev(device, i, sector, size, enabled, buttons, gbuttons) - i += 1 + for i, (sector, enabled) in enumerate(headers, start=1): + profiles[i] = OnboardProfile.from_dev(device, i, sector, size, enabled, buttons, gbuttons) return cls( version=OnboardProfilesVersion, name=device.name, diff --git a/tests/logitech_receiver/test_device.py b/tests/logitech_receiver/test_device.py index eda6788443..54fe7d07ab 100644 --- a/tests/logitech_receiver/test_device.py +++ b/tests/logitech_receiver/test_device.py @@ -23,6 +23,8 @@ from logitech_receiver import common from logitech_receiver import device from logitech_receiver import hidpp20 +from logitech_receiver.common import BatteryLevelApproximation +from logitech_receiver.common import BatteryStatus from . import fake_hidpp @@ -325,14 +327,14 @@ def test_device_settings(device_info, responses, protocol, p, persister, setting @pytest.mark.parametrize( - "device_info, responses, protocol, battery, changed", + "device_info, responses, protocol, expected_battery, changed", [ (di_C318, fake_hidpp.r_empty, 1.0, None, {"active": True, "alert": 0, "reason": None}), ( di_C318, fake_hidpp.r_keyboard_1, 1.0, - common.Battery(50, None, 0, None), + common.Battery(BatteryLevelApproximation.GOOD.value, None, BatteryStatus.DISCHARGING, None), {"active": True, "alert": 0, "reason": None}, ), ( @@ -344,12 +346,12 @@ def test_device_settings(device_info, responses, protocol, p, persister, setting ), ], ) -def test_device_battery(device_info, responses, protocol, battery, changed, mocker): +def test_device_battery(device_info, responses, protocol, expected_battery, changed, mocker): test_device = FakeDevice(responses, None, None, online=True, device_info=device_info) test_device._name = "TestDevice" test_device._protocol = protocol spy_changed = mocker.spy(test_device, "changed") - assert test_device.battery() == battery + assert test_device.battery() == expected_battery test_device.read_battery() spy_changed.assert_called_with(**changed) diff --git a/tests/logitech_receiver/test_hidpp20_complex.py b/tests/logitech_receiver/test_hidpp20_complex.py index c0f7046736..389fc52401 100644 --- a/tests/logitech_receiver/test_hidpp20_complex.py +++ b/tests/logitech_receiver/test_hidpp20_complex.py @@ -23,6 +23,7 @@ from logitech_receiver import hidpp20_constants from logitech_receiver import special_keys from logitech_receiver.hidpp20 import KeyFlag +from logitech_receiver.hidpp20 import MappingFlag from logitech_receiver.hidpp20_constants import GestureId from . import fake_hidpp @@ -789,7 +790,7 @@ def test_LED_RGB_EffectsInfo(feature, cls, responses, readable, count, count_0): @pytest.mark.parametrize( - "hex, behavior, sector, address, typ, val, modifiers, data, byt", + "hex, expected_behavior, sector, address, typ, val, modifiers, data, byt", [ ("05010203", 0x0, 0x501, 0x0203, None, None, None, None, None), ("15020304", 0x1, 0x502, 0x0304, None, None, None, None, None), @@ -801,10 +802,10 @@ def test_LED_RGB_EffectsInfo(feature, cls, responses, readable, count, count_0): ("709090A0", 0x7, None, None, None, None, None, None, b"\x70\x90\x90\xa0"), ], ) -def test_button_bytes(hex, behavior, sector, address, typ, val, modifiers, data, byt): +def test_button_bytes(hex, expected_behavior, sector, address, typ, val, modifiers, data, byt): button = hidpp20.Button.from_bytes(bytes.fromhex(hex)) - assert getattr(button, "behavior", None) == behavior + assert getattr(button, "behavior", None) == expected_behavior assert getattr(button, "sector", None) == sector assert getattr(button, "address", None) == address assert getattr(button, "type", None) == typ @@ -881,7 +882,7 @@ def test_button_bytes(hex, behavior, sector, address, typ, val, modifiers, data, (hex3, "", 2, 1, 16, 0, [0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF], "FFFFFFFF", "FFFFFFFFFFFFFFFFFFFFFF"), ], ) -def test_OnboardProfile_bytes(hex, name, sector, enabled, buttons, gbuttons, resolutions, button, lighting): +def test_onboard_profile_bytes(hex, name, sector, enabled, buttons, gbuttons, resolutions, button, lighting): profile = hidpp20.OnboardProfile.from_bytes(sector, enabled, buttons, gbuttons, bytes.fromhex(hex)) assert profile.name == name @@ -902,7 +903,7 @@ def test_OnboardProfile_bytes(hex, name, sector, enabled, buttons, gbuttons, res (fake_hidpp.responses_profiles_rom_2, "ONB", 1, 2, 2, 1, 254), ], ) -def test_OnboardProfiles_device(responses, name, count, buttons, gbuttons, sectors, size): +def test_onboard_profiles_device(responses, name, count, buttons, gbuttons, sectors, size): device = fake_hidpp.Device( name, True, 4.5, responses=responses, feature=hidpp20_constants.SupportedFeature.ONBOARD_PROFILES, offset=0x9 ) @@ -919,4 +920,5 @@ def test_OnboardProfiles_device(responses, name, count, buttons, gbuttons, secto assert profiles.size == size assert len(profiles.profiles) == count - assert yaml.safe_load(yaml.dump(profiles)).to_bytes().hex() == profiles.to_bytes().hex() + yml_dump = yaml.dump(profiles) + assert yaml.safe_load(yml_dump).to_bytes().hex() == profiles.to_bytes().hex() diff --git a/tests/logitech_receiver/test_hidpp20_simple.py b/tests/logitech_receiver/test_hidpp20_simple.py index 80cb70c47f..031b7fc3d4 100644 --- a/tests/logitech_receiver/test_hidpp20_simple.py +++ b/tests/logitech_receiver/test_hidpp20_simple.py @@ -108,7 +108,7 @@ def test_get_battery_voltage(): assert feature == SupportedFeature.BATTERY_VOLTAGE assert battery.level == 90 - assert battery.status == common.BatteryStatus.RECHARGING + assert common.BatteryStatus.RECHARGING in battery.status assert battery.voltage == 0x1000 @@ -390,7 +390,7 @@ def test_decipher_battery_voltage(): assert feature == SupportedFeature.BATTERY_VOLTAGE assert battery.level == 90 - assert battery.status == common.BatteryStatus.RECHARGING + assert common.BatteryStatus.RECHARGING in battery.status assert battery.voltage == 0x1000 @@ -438,3 +438,14 @@ def test_feature_flag_names(code, expected_flags): flags = common.flag_names(hidpp20_constants.FeatureFlag, code) assert list(flags) == expected_flags + + +@pytest.mark.parametrize( + "code, expected_name", + [ + (0x00, "Unknown Location"), + (0x03, "Left Side"), + ], +) +def test_led_zone_locations(code, expected_name): + assert hidpp20.LEDZoneLocations[code] == expected_name From ad9e65b3cb0e10ef714f71177902239ab463c084 Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Sat, 16 Nov 2024 15:27:53 +0100 Subject: [PATCH 25/28] Prepare refactoring of NotificationFlag Ensure behavior stays the same. Related #2273 --- lib/logitech_receiver/hidpp10_constants.py | 12 ++++++++++++ lib/solaar/ui/window.py | 6 ++---- tests/logitech_receiver/test_hidpp10.py | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/lib/logitech_receiver/hidpp10_constants.py b/lib/logitech_receiver/hidpp10_constants.py index 6541014a6c..92b788638f 100644 --- a/lib/logitech_receiver/hidpp10_constants.py +++ b/lib/logitech_receiver/hidpp10_constants.py @@ -14,6 +14,8 @@ ## 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 __future__ import annotations + from enum import IntEnum from .common import NamedInts @@ -89,6 +91,16 @@ class PowerSwitchLocation(IntEnum): ) +def flags_to_str(flag_bits: int | None, fallback: str) -> str: + flag_names = [] + if flag_bits is not None: + if flag_bits == 0: + flag_names = (fallback,) + else: + flag_names = list(NOTIFICATION_FLAG.flag_names(flag_bits)) + return f"\n{' ':15}".join(flag_names) + + class ErrorCode(IntEnum): INVALID_SUB_ID_COMMAND = 0x01 INVALID_ADDRESS = 0x02 diff --git a/lib/solaar/ui/window.py b/lib/solaar/ui/window.py index b76a717de5..bc911a5a19 100644 --- a/lib/solaar/ui/window.py +++ b/lib/solaar/ui/window.py @@ -551,10 +551,8 @@ def _details_items(device, read_all=False): flag_bits = device.notification_flags if flag_bits is not None: - flag_names = ( - (f"({_('none')})",) if flag_bits == 0 else hidpp10_constants.NOTIFICATION_FLAG.flag_names(flag_bits) - ) - yield _("Notifications"), f"\n{' ':15}".join(flag_names) + flag_names = hidpp10_constants.flags_to_names(flag_bits, fallback=f"({_('none')})") + yield _("Notifications"), flag_names def _set_details(text): _details._text.set_markup(text) diff --git a/tests/logitech_receiver/test_hidpp10.py b/tests/logitech_receiver/test_hidpp10.py index 707d521ec6..6280ae278d 100644 --- a/tests/logitech_receiver/test_hidpp10.py +++ b/tests/logitech_receiver/test_hidpp10.py @@ -274,6 +274,25 @@ def test_set_notification_flags_bad(mocker): assert result is None +@pytest.mark.parametrize( + "flag_bits, expected_names", + [ + (None, ""), + (0x0, "none"), + (0x009020, "multi touch\n unknown:008020"), + (0x080000, "mouse extra buttons"), + ( + 0x080000 + 0x000400, + ("link quality\n mouse extra buttons"), + ), + ], +) +def test_notification_flag_str(flag_bits, expected_names): + flag_names = hidpp10_constants.flags_to_str(flag_bits, fallback="none") + + assert flag_names == expected_names + + def test_get_device_features(): result = _hidpp10.get_device_features(device_standard) From dcf546055df981fd537da83d3d23e81c33b2e63b Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Sat, 16 Nov 2024 14:46:45 +0100 Subject: [PATCH 26/28] Remove NamedInts: Convert NotificationFlag to flag Related #2273 --- lib/logitech_receiver/device.py | 15 ++-- lib/logitech_receiver/hidpp10.py | 5 +- lib/logitech_receiver/hidpp10_constants.py | 92 ++++++++++++++-------- lib/logitech_receiver/receiver.py | 8 +- lib/solaar/listener.py | 2 +- lib/solaar/ui/window.py | 2 +- tests/logitech_receiver/test_hidpp10.py | 4 +- 7 files changed, 81 insertions(+), 47 deletions(-) diff --git a/lib/logitech_receiver/device.py b/lib/logitech_receiver/device.py index 4cd5edc16e..eb06e04dc6 100644 --- a/lib/logitech_receiver/device.py +++ b/lib/logitech_receiver/device.py @@ -38,6 +38,7 @@ from . import settings_templates from .common import Alert from .common import Battery +from .hidpp10_constants import NotificationFlag from .hidpp20_constants import SupportedFeature if typing.TYPE_CHECKING: @@ -467,10 +468,8 @@ def enable_connection_notifications(self, enable=True): if enable: set_flag_bits = ( - hidpp10_constants.NOTIFICATION_FLAG.battery_status - | hidpp10_constants.NOTIFICATION_FLAG.ui - | hidpp10_constants.NOTIFICATION_FLAG.configuration_complete - ) + NotificationFlag.BATTERY_STATUS | NotificationFlag.UI | NotificationFlag.CONFIGURATION_COMPLETE + ).value else: set_flag_bits = 0 ok = _hidpp10.set_notification_flags(self, set_flag_bits) @@ -479,8 +478,12 @@ def enable_connection_notifications(self, enable=True): flag_bits = _hidpp10.get_notification_flags(self) if logger.isEnabledFor(logging.INFO): - flag_names = None if flag_bits is None else tuple(hidpp10_constants.NOTIFICATION_FLAG.flag_names(flag_bits)) - logger.info("%s: device notifications %s %s", self, "enabled" if enable else "disabled", flag_names) + if flag_bits is None: + flag_names = None + else: + flag_names = hidpp10_constants.NotificationFlag.flag_names(flag_bits) + is_enabled = "enabled" if enable else "disabled" + logger.info(f"{self}: device notifications {is_enabled} {flag_names}") return flag_bits if ok else None def add_notification_handler(self, id: str, fn): diff --git a/lib/logitech_receiver/hidpp10.py b/lib/logitech_receiver/hidpp10.py index 6d4ee95702..29399c8caf 100644 --- a/lib/logitech_receiver/hidpp10.py +++ b/lib/logitech_receiver/hidpp10.py @@ -26,6 +26,7 @@ from .common import BatteryLevelApproximation from .common import BatteryStatus from .common import FirmwareKind +from .hidpp10_constants import NotificationFlag from .hidpp10_constants import Registers logger = logging.getLogger(__name__) @@ -190,7 +191,7 @@ def set_3leds(self, device: Device, battery_level=None, charging=None, warning=N def get_notification_flags(self, device: Device): return self._get_register(device, Registers.NOTIFICATIONS) - def set_notification_flags(self, device: Device, *flag_bits): + def set_notification_flags(self, device: Device, *flag_bits: NotificationFlag): assert device is not None # Avoid a call if the device is not online, @@ -200,7 +201,7 @@ def set_notification_flags(self, device: Device, *flag_bits): if device.protocol and device.protocol >= 2.0: return - flag_bits = sum(int(b) for b in flag_bits) + flag_bits = sum(int(b.value) for b in flag_bits) assert flag_bits & 0x00FFFFFF == flag_bits result = write_register(device, Registers.NOTIFICATIONS, common.int2bytes(flag_bits, 3)) return result is not None diff --git a/lib/logitech_receiver/hidpp10_constants.py b/lib/logitech_receiver/hidpp10_constants.py index 92b788638f..d6306cc901 100644 --- a/lib/logitech_receiver/hidpp10_constants.py +++ b/lib/logitech_receiver/hidpp10_constants.py @@ -16,7 +16,9 @@ ## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from __future__ import annotations +from enum import Flag from enum import IntEnum +from typing import List from .common import NamedInts @@ -58,37 +60,61 @@ class PowerSwitchLocation(IntEnum): BOTTOM_EDGE = 0x0C -# Some flags are used both by devices and receivers. The Logitech documentation -# mentions that the first and last (third) byte are used for devices while the -# second is used for the receiver. In practise, the second byte is also used for -# some device-specific notifications (keyboard illumination level). Do not -# simply set all notification bits if the software does not support it. For -# example, enabling keyboard_sleep_raw makes the Sleep key a no-operation unless -# the software is updated to handle that event. -# Observations: -# - wireless and software present were seen on receivers, reserved_r1b4 as well -# - the rest work only on devices as far as we can tell right now -# In the future would be useful to have separate enums for receiver and device notification flags, -# but right now we don't know enough. -# additional flags taken from https://drive.google.com/file/d/0BxbRzx7vEV7eNDBheWY0UHM5dEU/view?usp=sharing -NOTIFICATION_FLAG = NamedInts( - numpad_numerical_keys=0x800000, - f_lock_status=0x400000, - roller_H=0x200000, - battery_status=0x100000, # send battery charge notifications (0x07 or 0x0D) - mouse_extra_buttons=0x080000, - roller_V=0x040000, - power_keys=0x020000, # system control keys such as Sleep - keyboard_multimedia_raw=0x010000, # consumer controls such as Mute and Calculator - multi_touch=0x001000, # notify on multi-touch changes - software_present=0x000800, # software is controlling part of device behaviour - link_quality=0x000400, # notify on link quality changes - ui=0x000200, # notify on UI changes - wireless=0x000100, # notify when the device wireless goes on/off-line - configuration_complete=0x000004, - voip_telephony=0x000002, - threed_gesture=0x000001, -) +class NotificationFlag(Flag): + """Some flags are used both by devices and receivers. + + The Logitech documentation mentions that the first and last (third) + byte are used for devices while the second is used for the receiver. + In practise, the second byte is also used for some device-specific + notifications (keyboard illumination level). Do not simply set all + notification bits if the software does not support it. For example, + enabling keyboard_sleep_raw makes the Sleep key a no-operation + unless the software is updated to handle that event. + + Observations: + - wireless and software present seen on receivers, + reserved_r1b4 as well + - the rest work only on devices as far as we can tell right now + In the future would be useful to have separate enums for receiver + and device notification flags, but right now we don't know enough. + Additional flags taken from https://drive.google.com/file/d/0BxbRzx7vEV7eNDBheWY0UHM5dEU/view?usp=sharing + """ + + @classmethod + def flag_names(cls, flag_bits: int) -> List[str]: + """Extract the names of the flags from the integer.""" + indexed = {item.value: item.name for item in cls} + + flag_names = [] + unknown_bits = flag_bits + for k in indexed: + # Ensure that the key (flag value) is a power of 2 (a single bit flag) + assert bin(k).count("1") == 1 + if k & flag_bits == k: + unknown_bits &= ~k + flag_names.append(indexed[k].replace("_", " ").lower()) + + # Yield any remaining unknown bits + if unknown_bits != 0: + flag_names.append(f"unknown:{unknown_bits:06X}") + return flag_names + + NUMPAD_NUMERICAL_KEYS = 0x800000 + F_LOCK_STATUS = 0x400000 + ROLLER_H = 0x200000 + BATTERY_STATUS = 0x100000 # send battery charge notifications (0x07 or 0x0D) + MOUSE_EXTRA_BUTTONS = 0x080000 + ROLLER_V = 0x040000 + POWER_KEYS = 0x020000 # system control keys such as Sleep + KEYBOARD_MULTIMEDIA_RAW = 0x010000 # consumer controls such as Mute and Calculator + MULTI_TOUCH = 0x001000 # notify on multi-touch changes + SOFTWARE_PRESENT = 0x000800 # software is controlling part of device behaviour + LINK_QUALITY = 0x000400 # notify on link quality changes + UI = 0x000200 # notify on UI changes + WIRELESS = 0x000100 # notify when the device wireless goes on/off-line + CONFIGURATION_COMPLETE = 0x000004 + VOIP_TELEPHONY = 0x000002 + THREED_GESTURE = 0x000001 def flags_to_str(flag_bits: int | None, fallback: str) -> str: @@ -97,8 +123,8 @@ def flags_to_str(flag_bits: int | None, fallback: str) -> str: if flag_bits == 0: flag_names = (fallback,) else: - flag_names = list(NOTIFICATION_FLAG.flag_names(flag_bits)) - return f"\n{' ':15}".join(flag_names) + flag_names = NotificationFlag.flag_names(flag_bits) + return f"\n{' ':15}".join(sorted(flag_names)) class ErrorCode(IntEnum): diff --git a/lib/logitech_receiver/receiver.py b/lib/logitech_receiver/receiver.py index 29f2abb2bc..a7ef1ede3f 100644 --- a/lib/logitech_receiver/receiver.py +++ b/lib/logitech_receiver/receiver.py @@ -36,6 +36,7 @@ from .common import Alert from .common import Notification from .device import Device +from .hidpp10_constants import NotificationFlag from .hidpp10_constants import Registers if typing.TYPE_CHECKING: @@ -172,7 +173,7 @@ def enable_connection_notifications(self, enable=True): return False if enable: - set_flag_bits = hidpp10_constants.NOTIFICATION_FLAG.wireless | hidpp10_constants.NOTIFICATION_FLAG.software_present + set_flag_bits = NotificationFlag.WIRELESS | NotificationFlag.SOFTWARE_PRESENT else: set_flag_bits = 0 ok = _hidpp10.set_notification_flags(self, set_flag_bits) @@ -181,7 +182,10 @@ def enable_connection_notifications(self, enable=True): return None flag_bits = _hidpp10.get_notification_flags(self) - flag_names = None if flag_bits is None else tuple(hidpp10_constants.NOTIFICATION_FLAG.flag_names(flag_bits)) + if flag_bits is None: + flag_names = None + else: + flag_names = hidpp10_constants.NotificationFlag.flag_names(flag_bits) if logger.isEnabledFor(logging.INFO): logger.info("%s: receiver notifications %s => %s", self, "enabled" if enable else "disabled", flag_names) return flag_bits diff --git a/lib/solaar/listener.py b/lib/solaar/listener.py index 06602931ce..5d49e30c11 100644 --- a/lib/solaar/listener.py +++ b/lib/solaar/listener.py @@ -81,7 +81,7 @@ def has_started(self): logger.info("%s: notifications listener has started (%s)", self.receiver, self.receiver.handle) nfs = self.receiver.enable_connection_notifications() if logger.isEnabledFor(logging.WARNING): - if not self.receiver.isDevice and not ((nfs if nfs else 0) & hidpp10_constants.NOTIFICATION_FLAG.wireless): + if not self.receiver.isDevice and not ((nfs if nfs else 0) & hidpp10_constants.NotificationFlag.WIRELESS.value): logger.warning( "Receiver on %s might not support connection notifications, GUI might not show its devices", self.receiver.path, diff --git a/lib/solaar/ui/window.py b/lib/solaar/ui/window.py index bc911a5a19..594a529a6d 100644 --- a/lib/solaar/ui/window.py +++ b/lib/solaar/ui/window.py @@ -551,7 +551,7 @@ def _details_items(device, read_all=False): flag_bits = device.notification_flags if flag_bits is not None: - flag_names = hidpp10_constants.flags_to_names(flag_bits, fallback=f"({_('none')})") + flag_names = hidpp10_constants.flags_to_str(flag_bits, fallback=f"({_('none')})") yield _("Notifications"), flag_names def _set_details(text): diff --git a/tests/logitech_receiver/test_hidpp10.py b/tests/logitech_receiver/test_hidpp10.py index 6280ae278d..1c39b0c057 100644 --- a/tests/logitech_receiver/test_hidpp10.py +++ b/tests/logitech_receiver/test_hidpp10.py @@ -255,7 +255,7 @@ def test_set_notification_flags(mocker): spy_request = mocker.spy(device, "request") result = _hidpp10.set_notification_flags( - device, hidpp10_constants.NOTIFICATION_FLAG.battery_status, hidpp10_constants.NOTIFICATION_FLAG.wireless + device, hidpp10_constants.NotificationFlag.BATTERY_STATUS, hidpp10_constants.NotificationFlag.WIRELESS ) spy_request.assert_called_once_with(0x8000 | Registers.NOTIFICATIONS, b"\x10\x01\x00") @@ -267,7 +267,7 @@ def test_set_notification_flags_bad(mocker): spy_request = mocker.spy(device, "request") result = _hidpp10.set_notification_flags( - device, hidpp10_constants.NOTIFICATION_FLAG.battery_status, hidpp10_constants.NOTIFICATION_FLAG.wireless + device, hidpp10_constants.NotificationFlag.BATTERY_STATUS, hidpp10_constants.NotificationFlag.WIRELESS ) assert spy_request.call_count == 0 From b42da963f414c1aca262f048b124ae6c8065936a Mon Sep 17 00:00:00 2001 From: MattHag <16444067+MattHag@users.noreply.github.com> Date: Sat, 16 Nov 2024 16:02:07 +0100 Subject: [PATCH 27/28] Remove NamedInts: Convert DeviceFeature to flag Related #2273 --- lib/logitech_receiver/hidpp10_constants.py | 43 ++++++++++++---------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/lib/logitech_receiver/hidpp10_constants.py b/lib/logitech_receiver/hidpp10_constants.py index d6306cc901..b34d685965 100644 --- a/lib/logitech_receiver/hidpp10_constants.py +++ b/lib/logitech_receiver/hidpp10_constants.py @@ -205,22 +205,27 @@ class Registers(IntEnum): bolt_device_name=0x60, # 0x6N01, by connected device, ) -# Flags taken from https://drive.google.com/file/d/0BxbRzx7vEV7eNDBheWY0UHM5dEU/view?usp=sharing -DEVICE_FEATURES = NamedInts( - reserved1=0x010000, - special_buttons=0x020000, - enhanced_key_usage=0x040000, - fast_fw_rev=0x080000, - reserved2=0x100000, - reserved3=0x200000, - scroll_accel=0x400000, - buttons_control_resolution=0x800000, - inhibit_lock_key_sound=0x000001, - reserved4=0x000002, - mx_air_3d_engine=0x000004, - host_control_leds=0x000008, - reserved5=0x000010, - reserved6=0x000020, - reserved7=0x000040, - reserved8=0x000080, -) + +class DeviceFeature(Flag): + """Features for devices. + + Flags taken from + https://drive.google.com/file/d/0BxbRzx7vEV7eNDBheWY0UHM5dEU/view?usp=sharing + """ + + RESERVED1 = 0x010000 + SPECIAL_BUTTONS = 0x020000 + ENHANCED_KEY_USAGE = 0x040000 + FAST_FW_REV = 0x080000 + RESERVED2 = 0x100000 + RESERVED3 = 0x200000 + SCROLL_ACCEL = 0x400000 + BUTTONS_CONTROL_RESOLUTION = 0x800000 + INHIBIT_LOCK_KEY_SOUND = 0x000001 + RESERVED4 = 0x000002 + MX_AIR_3D_ENGINE = 0x000004 + HOST_CONTROL_LEDS = 0x000008 + RESERVED5 = 0x000010 + RESERVED6 = 0x000020 + RESERVED7 = 0x000040 + RESERVED8 = 0x000080 From 5333e0dcc3d96fde837ea71c75463439c45da7d6 Mon Sep 17 00:00:00 2001 From: some_developer Date: Wed, 20 Nov 2024 01:31:52 +0100 Subject: [PATCH 28/28] Refactor InfoSubRegisters: Use IntEnum in favour of NamedInts --- lib/logitech_receiver/hidpp10_constants.py | 19 +++++++++---------- lib/logitech_receiver/receiver.py | 14 +++++++------- lib/solaar/listener.py | 2 +- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/lib/logitech_receiver/hidpp10_constants.py b/lib/logitech_receiver/hidpp10_constants.py index b34d685965..c27ba239db 100644 --- a/lib/logitech_receiver/hidpp10_constants.py +++ b/lib/logitech_receiver/hidpp10_constants.py @@ -194,16 +194,15 @@ class Registers(IntEnum): # Subregisters for receiver_info register -INFO_SUBREGISTERS = NamedInts( - serial_number=0x01, # not found on many receivers - fw_version=0x02, - receiver_information=0x03, - pairing_information=0x20, # 0x2N, by connected device - extended_pairing_information=0x30, # 0x3N, by connected device - device_name=0x40, # 0x4N, by connected device - bolt_pairing_information=0x50, # 0x5N, by connected device - bolt_device_name=0x60, # 0x6N01, by connected device, -) +class InfoSubRegisters(IntEnum): + SERIAL_NUMBER = 0x01 # not found on many receivers + FW_VERSION = 0x02 + RECEIVER_INFORMATION = 0x03 + PAIRING_INFORMATION = 0x20 # 0x2N, by connected device + EXTENDED_PAIRING_INFORMATION = 0x30 # 0x3N, by connected device + DEVICE_NAME = 0x40 # 0x4N, by connected device + BOLT_PAIRING_INFORMATION = 0x50 # 0x5N, by connected device + BOLT_DEVICE_NAME = 0x60 # 0x6N01, by connected device class DeviceFeature(Flag): diff --git a/lib/logitech_receiver/receiver.py b/lib/logitech_receiver/receiver.py index a7ef1ede3f..0617fdd596 100644 --- a/lib/logitech_receiver/receiver.py +++ b/lib/logitech_receiver/receiver.py @@ -36,6 +36,7 @@ from .common import Alert from .common import Notification from .device import Device +from .hidpp10_constants import InfoSubRegisters from .hidpp10_constants import NotificationFlag from .hidpp10_constants import Registers @@ -45,7 +46,6 @@ logger = logging.getLogger(__name__) _hidpp10 = hidpp10.Hidpp10() -_IR = hidpp10_constants.INFO_SUBREGISTERS class LowLevelInterface(Protocol): @@ -125,7 +125,7 @@ def __init__( def initialize(self, product_info: dict): # read the receiver information subregister, so we can find out max_devices - serial_reply = self.read_register(Registers.RECEIVER_INFO, _IR.receiver_information) + serial_reply = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.RECEIVER_INFORMATION) if serial_reply: self.serial = serial_reply[1:5].hex().upper() self.max_devices = serial_reply[6] @@ -191,7 +191,7 @@ def enable_connection_notifications(self, enable=True): return flag_bits def device_codename(self, n): - codename = self.read_register(Registers.RECEIVER_INFO, _IR.device_name + n - 1) + codename = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.DEVICE_NAME + n - 1) if codename: codename = codename[2 : 2 + ord(codename[1:2])] return codename.decode("ascii") @@ -216,7 +216,7 @@ def device_pairing_information(self, n: int) -> dict: polling_rate = "" serial = None power_switch = "(unknown)" - pair_info = self.read_register(Registers.RECEIVER_INFO, _IR.pairing_information + n - 1) + pair_info = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.PAIRING_INFORMATION + n - 1) if pair_info: # a receiver that uses Unifying-style pairing registers wpid = pair_info[3:5].hex().upper() kind = hidpp10_constants.DEVICE_KIND[pair_info[7] & 0x0F] @@ -231,7 +231,7 @@ def device_pairing_information(self, n: int) -> dict: raise exceptions.NoSuchDevice(number=n, receiver=self, error="read pairing information - non-unifying") else: raise exceptions.NoSuchDevice(number=n, receiver=self, error="read pairing information") - pair_info = self.read_register(Registers.RECEIVER_INFO, _IR.extended_pairing_information + n - 1) + pair_info = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.EXTENDED_PAIRING_INFORMATION + n - 1) if pair_info: power_switch = hidpp10_constants.PowerSwitchLocation(pair_info[9] & 0x0F) serial = pair_info[1:5].hex().upper() @@ -414,13 +414,13 @@ def initialize(self, product_info: dict): self.max_devices = product_info.get("max_devices", 1) def device_codename(self, n): - codename = self.read_register(Registers.RECEIVER_INFO, _IR.bolt_device_name + n, 0x01) + codename = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.BOLT_DEVICE_NAME + n, 0x01) if codename: codename = codename[3 : 3 + min(14, ord(codename[2:3]))] return codename.decode("ascii") def device_pairing_information(self, n: int) -> dict: - pair_info = self.read_register(Registers.RECEIVER_INFO, _IR.bolt_pairing_information + n) + pair_info = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.BOLT_PAIRING_INFORMATION + n) if pair_info: wpid = (pair_info[3:4] + pair_info[2:3]).hex().upper() kind = hidpp10_constants.DEVICE_KIND[pair_info[1] & 0x0F] diff --git a/lib/solaar/listener.py b/lib/solaar/listener.py index 5d49e30c11..4416f4b31c 100644 --- a/lib/solaar/listener.py +++ b/lib/solaar/listener.py @@ -196,7 +196,7 @@ def _notifications_handler(self, n): if ( self.receiver.read_register( hidpp10_constants.Registers.RECEIVER_INFO, - hidpp10_constants.INFO_SUBREGISTERS.pairing_information + n.devnumber - 1, + hidpp10_constants.InfoSubRegisters.PAIRING_INFORMATION + n.devnumber - 1, ) is None ):