diff --git a/lib/logitech_receiver/common.py b/lib/logitech_receiver/common.py index 1a70fa93..8e3124ec 100644 --- a/lib/logitech_receiver/common.py +++ b/lib/logitech_receiver/common.py @@ -115,6 +115,11 @@ class NamedInts(object): # assert len(values) == len(self._indexed), "(%d) %r\n=> (%d) %r" % (len(values), values, len(self._indexed), self._indexed) self._fallback = None + @classmethod + def list(cls, items, name_generator=lambda x: str(x)): + values = {name_generator(x): x for x in items} + return NamedInts(**values) + @classmethod def range(cls, from_value, to_value, name_generator=lambda x: str(x), step=1): values = {name_generator(x): x for x in range(from_value, to_value + 1, step)} diff --git a/lib/logitech_receiver/descriptors.py b/lib/logitech_receiver/descriptors.py index 4a65f159..98c44698 100644 --- a/lib/logitech_receiver/descriptors.py +++ b/lib/logitech_receiver/descriptors.py @@ -271,6 +271,8 @@ _D('Performance Mouse MX', codename='Performance MX', protocol=1.0, wpid='101A', ], ) +_D('Wireless Mouse MX Master', codename='MX Master', protocol=4.5, wpid='4041') + _D('G7 Cordless Laser Mouse', codename='G7', protocol=1.0, wpid='1002', registers=(_R.battery_status, ), ) diff --git a/lib/logitech_receiver/settings.py b/lib/logitech_receiver/settings.py index 84ecf665..6534f24d 100644 --- a/lib/logitech_receiver/settings.py +++ b/lib/logitech_receiver/settings.py @@ -317,7 +317,11 @@ class ChoicesValidator(object): kind = KIND.choice - def __init__(self, choices): + """Translates between NamedInts and a byte sequence. + :param choices: a list of NamedInts + :param bytes_count: the size of the derived byte sequence. If None, it + will be calculated from the choices.""" + def __init__(self, choices, bytes_count=None): assert choices is not None assert isinstance(choices, _NamedInts) assert len(choices) > 2 @@ -326,6 +330,9 @@ class ChoicesValidator(object): max_bits = max(x.bit_length() for x in choices) self._bytes_count = (max_bits // 8) + (1 if max_bits % 8 else 0) + if bytes_count: + assert self._bytes_count <= bytes_count + self._bytes_count = bytes_count assert self._bytes_count < 8 def validate_read(self, reply_bytes): diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index 3056c9a0..a2df4a29 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -23,6 +23,11 @@ from __future__ import absolute_import, division, print_function, unicode_litera from .i18n import _ from . import hidpp10 as _hidpp10 from . import hidpp20 as _hidpp20 +from .common import ( + bytes2int as _bytes2int, + NamedInts as _NamedInts, + unpack as _unpack, + ) from .settings import ( KIND as _KIND, Setting as _Setting, @@ -70,6 +75,30 @@ def feature_toggle(name, feature, rw = _FeatureRW(feature, read_function_id, write_function_id) return _Setting(name, rw, validator, label=label, description=description, device_kind=device_kind) +def feature_choices(name, feature, choices, + read_function_id, write_function_id, + bytes_count=None, + label=None, description=None, device_kind=None): + assert choices + validator = _ChoicesV(choices, bytes_count=bytes_count) + rw = _FeatureRW(feature, read_function_id, write_function_id) + return _Setting(name, rw, validator, kind=_KIND.choice, label=label, description=description, device_kind=device_kind) + +def feature_choices_dynamic(name, feature, choices_callback, + read_function_id, write_function_id, + bytes_count=None, + label=None, description=None, device_kind=None): + # Proxy that obtains choices dynamically from a device + def instantiate(device): + # Obtain choices for this feature + choices = choices_callback(device) + setting = feature_choices(name, feature, choices, + read_function_id, write_function_id, + bytes_count=bytes_count, + label=None, description=None, device_kind=None) + return setting(device) + return instantiate + # # common strings for settings # @@ -135,6 +164,41 @@ def _feature_smooth_scroll(): label=_SMOOTH_SCROLL[1], description=_SMOOTH_SCROLL[2], device_kind=_DK.mouse) +def _feature_adjustable_dpi_choices(device): + # [1] getSensorDpiList(sensorIdx) + reply = device.feature_request(_F.ADJUSTABLE_DPI, 0x10) + # Should not happen, but might happen when the user unplugs device while the + # query is being executed. TODO retry logic? + assert reply, 'Oops, DPI list cannot be retrieved!' + dpi_list = [] + step = None + for val in _unpack('!7H', reply[1:1+14]): + if val == 0: + break + if val >> 13 == 0b111: + assert step is None and len(dpi_list) == 1, \ + 'Invalid DPI list item: %r' % val + step = val & 0x1fff + else: + dpi_list.append(val) + if step: + assert len(dpi_list) == 2, 'Invalid DPI list range: %r' % dpi_list + dpi_list = range(dpi_list[0], dpi_list[1] + 1, step) + return _NamedInts.list(dpi_list) + +def _feature_adjustable_dpi(): + """Pointer Speed feature""" + # Assume sensorIdx 0 (there is only one sensor) + # [2] getSensorDpi(sensorIdx) -> sensorIdx, dpiMSB, dpiLSB + # [3] setSensorDpi(sensorIdx, dpi) + return feature_choices_dynamic(_DPI[0], _F.ADJUSTABLE_DPI, + _feature_adjustable_dpi_choices, + read_function_id=0x20, + write_function_id=0x30, + bytes_count=3, + label=_DPI[1], description=_DPI[2], + device_kind=_DK.mouse) + # # # @@ -165,7 +229,7 @@ FeatureSettings = _SETTINGS_LIST( new_fn_swap=_feature_new_fn_swap, smooth_scroll=_feature_smooth_scroll, side_scroll=None, - dpi=None, + dpi=_feature_adjustable_dpi, hand_detection=None, typing_illumination=None, ) @@ -182,9 +246,26 @@ def check_feature_settings(device, already_known): return if device.protocol and device.protocol < 2.0: return - if not any(s.name == _FN_SWAP[0] for s in already_known) and _F.FN_INVERSION in device.features: - fn_swap = FeatureSettings.fn_swap() - already_known.append(fn_swap(device)) - if not any(s.name == _SMOOTH_SCROLL[0] for s in already_known) and _F.HI_RES_SCROLLING in device.features: - smooth_scroll = FeatureSettings.smooth_scroll() - already_known.append(smooth_scroll(device)) + + def check_feature(name, featureId, field_name=None): + """ + :param name: user-visible setting name. + :param featureId: the numeric Feature ID for this setting. + :param field_name: override the FeatureSettings name if it is + different from the user-visible setting name. Useful if there + are multiple features for the same setting. + """ + if not featureId in device.features: + return + if any(s.name == name for s in already_known): + return + if not field_name: + # Convert user-visible settings name for FeatureSettings + field_name = name.replace('-', '_') + feature = getattr(FeatureSettings, field_name)() + already_known.append(feature(device)) + + check_feature(_SMOOTH_SCROLL[0], _F.HI_RES_SCROLLING) + check_feature(_FN_SWAP[0], _F.FN_INVERSION) + check_feature(_FN_SWAP[0], _F.NEW_FN_INVERSION, 'new_fn_swap') + check_feature(_DPI[0], _F.ADJUSTABLE_DPI) diff --git a/lib/solaar/cli/config.py b/lib/solaar/cli/config.py index d26522ec..c6bfed06 100644 --- a/lib/solaar/cli/config.py +++ b/lib/solaar/cli/config.py @@ -27,7 +27,8 @@ from logitech_receiver import settings as _settings def _print_setting(s, verbose=True): print ('#', s.label) if verbose: - print ('#', s.description.replace('\n', ' ')) + if s.description: + print ('#', s.description.replace('\n', ' ')) if s.kind == _settings.KIND.toggle: print ('# possible values: on/true/t/yes/y/1 or off/false/f/no/n/0') elif s.choices: @@ -116,5 +117,5 @@ def run(receivers, args, find_receiver, find_device): result = setting.write(value) if result is None: - raise Exception("failed to set '%s' = '%s' [%r]" % (setting.name, value, value)) + raise Exception("failed to set '%s' = '%s' [%r]" % (setting.name, str(value), value)) _print_setting(setting, False)