diff --git a/lib/logitech_receiver/device.py b/lib/logitech_receiver/device.py index 111704b8..82e4ae12 100644 --- a/lib/logitech_receiver/device.py +++ b/lib/logitech_receiver/device.py @@ -140,7 +140,7 @@ class Device: self._modelId = None # model id (contains identifiers for the transports of the device) self._tid_map = None # map from transports to product identifiers self._persister = None # persister holds settings - self._led_effects = self._firmware = self._keys = self._remap_keys = self._gestures = None + self._led_effects = self._firmware = self._keys = self._remap_keys = self._gestures = self._force_buttons = None self._profiles = self._backlight = self._settings = None self.registers = [] self.notification_flags = None @@ -346,6 +346,12 @@ class Device: self._profiles = _hidpp20.get_profiles(self) return self._profiles + def force_buttons(self): + if self._force_buttons is None: + if self.online and self.protocol >= 2.0: + self._force_buttons = _hidpp20.get_force_buttons(self) or () + return self._force_buttons + def set_configuration(self, configuration_, no_reply=False): if self.online and self.protocol >= 2.0: _hidpp20.config_change(self, configuration_, no_reply=no_reply) diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 1db43090..ec0e2527 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -21,6 +21,7 @@ import socket import struct import threading +from collections import UserDict from enum import Flag from enum import IntEnum from typing import Any @@ -1713,6 +1714,12 @@ class Hidpp20: if SupportedFeature.BACKLIGHT2 in device.features: return Backlight(device) + def get_force_buttons(self, device: Device): + if getattr(device, "_force_buttons", None) is not None: + return device._force_buttons + if SupportedFeature.FORCE_SENSING_BUTTON in device.features: + return ForceSensingButtonArray(device) + def get_profiles(self, device: Device): if getattr(device, "_profiles", None) is not None: return device._profiles @@ -2021,3 +2028,77 @@ def estimate_battery_level_percentage(value_millivolt: int) -> int | None: percent = p_low + (p_high - p_low) * (value_millivolt - v_low) / (v_high - v_low) return round(percent) return 0 + + +class ForceSensingButton: + """A button that has a force value at which to trigger the button""" + + @classmethod + def create(cls, device, number: int): + buttondata = device.feature_request(SupportedFeature.FORCE_SENSING_BUTTON, 0x10, number) + buttoncurrent = device.feature_request(SupportedFeature.FORCE_SENSING_BUTTON, 0x20, number) + if buttondata is not None and buttoncurrent is not None: + changeable, default, max_value, min_value = struct.unpack("!HHHH", buttondata[:8]) + changeable = changeable & 0x01 + current = struct.unpack("!H", buttoncurrent[:2])[0] + return cls(device, number, changeable, default, max_value, min_value, current) + + def __init__(self, device, number: int, changeable: bool, default: int, max_value: int, min_value: int, current: int): + self._device = device + self.number = number + self.changeable = changeable + self.default = default + self.min_value = min_value + self.max_value = max_value + self._current = current + + def get_current(self) -> int: + return self._current + + def set_current(self, current: int) -> None: + if not self.changeable: + logger.warning(f"FORCE_SENSING_BUTTON on device {self._device} does not allow changing force.") + if self.min_value <= current <= self.max_value: + ret = self._device.feature_request( + SupportedFeature.FORCE_SENSING_BUTTON, 0x30, struct.pack("!BH", self.number, current) + ) + if ret is None and logger.isEnabledFor(logging.DEBUG): + logger.debug(f"FORCE_SENSING_BUTTON setButtonConfig on device {self._device} didn't respond.") + + def acceptable_current(self, value: int) -> bool: + return self.min_value <= value <= self.max_value + + +class ForceSensingButtonArray(UserDict): + """A map of buttons supporting force sensing""" + + def __new__(cls, device: Device): + assert device is not None + count = device.feature_request(SupportedFeature.FORCE_SENSING_BUTTON, 0x00) + if count: + instance = super().__new__(cls) + instance._count = ord(count[:1]) + return instance + + def __init__(self, device: Device): + super().__init__(self) + self.device = device + for index in range(0, self._count): + self[index] = None + + def __getitem__(self, index: int): + item = super().__getitem__(index) + if item is None: + self.query_key(index) + return super().__getitem__(index) + + def query_key(self, index): + if index not in self: + raise IndexError(index) + button = ForceSensingButton.create(self.device, index) + if button: + self[index] = button + return button + + def acceptable(self, index: int, value: int) -> bool: + return self[index].acceptable(value) diff --git a/lib/logitech_receiver/hidpp20_constants.py b/lib/logitech_receiver/hidpp20_constants.py index bb345218..45d0e7e8 100644 --- a/lib/logitech_receiver/hidpp20_constants.py +++ b/lib/logitech_receiver/hidpp20_constants.py @@ -65,6 +65,7 @@ class SupportedFeature(IntEnum): BACKLIGHT2 = 0x1982 BACKLIGHT3 = 0x1983 ILLUMINATION = 0x1990 + FORCE_SENSING_BUTTON = 0x19C0 PRESENTER_CONTROL = 0x1A00 SENSOR_3D = 0x1A01 REPROG_CONTROLS = 0x1B00 diff --git a/lib/logitech_receiver/settings.py b/lib/logitech_receiver/settings.py index 9fe57cc2..25b3fb01 100644 --- a/lib/logitech_receiver/settings.py +++ b/lib/logitech_receiver/settings.py @@ -35,6 +35,7 @@ SENSITIVITY_IGNORE = "ignore" class Kind(IntEnum): + NONE = 0 TOGGLE = 0x01 CHOICE = 0x02 RANGE = 0x04 @@ -43,6 +44,7 @@ class Kind(IntEnum): PACKED_RANGE = 0x20 MULTIPLE_RANGE = 0x40 HETERO = 0x80 + MAP_RANGE = 0x102 class Setting: diff --git a/lib/logitech_receiver/settings_new.py b/lib/logitech_receiver/settings_new.py new file mode 100644 index 00000000..46c0b57c --- /dev/null +++ b/lib/logitech_receiver/settings_new.py @@ -0,0 +1,169 @@ +## Copyright (C) 2025 Solaar contributors +## +## This program is free software; you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation; either version 2 of the License, or +## (at your option) any later version. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License along +## with this program; if not, write to the Free Software Foundation, Inc., +## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +## A new way of supporting settings, using a feature-specifi device class to store, read, and write relevant information +## The setting uses the device class to interact with the device feature. +## The setting uses a persist class to keep track of the setting. + +## Interface: + +import logging + +from .settings import Kind + +logger = logging.getLogger(__name__) + + +class Setting: + name = None # Solaar internal name for the setting + label = None # Solaar user name for the setting (translatable) + description = None # Solaar extra desciption for the setting (translatable) + feature = None # Logitech feature that the setting uses + min_version = 0 # Minimum version of the feature needed + setup = None # method name on Device class to get the device object + get = None # method name on the device object to get the setting value + set = None # method name on the device object to set the setting value + acceptable = None # method name on the device object to check for acceptable values + choices_universe = None # All possible acceptable keys, for settings with keys + kind = Kind.NONE # What GUI interface to use + persist = True # Whether to remember the setting + _device = None # The device that this setting is for + _device_object = None # The object that interacts with the feature for the device + _value = None # Stored value as maintained by Solaar, used for persistence + + @classmethod + def check_properties(cl, cls): + assert cls.name and cls.label and cls.description, "New settings require a name, label, and description" + assert cls.feature, "New settings require a feature" + assert cls.setup, "New settings require a setup device method" + assert cls.get and cls.set and cls.acceptable, "New settings require get, set, and acceptable methods" + + @classmethod + def build(cls, device): + """Create the setting.""" + pass + + def _pre_read(self, cached): + """Get information from and save information to the persister""" + # Get the persister map if available and not done already + if self.persist and self._value is None and getattr(self._device, "persister", None): + self._value = self._device.persister.get(self.name) + # If this is new save its current value for the next time + if cached and self._value is not None: + if getattr(self._device, "persister", None) and self.name not in self._device.persister: + self._device.persister[self.name] = self._value if self.persist else None + + def read(self, cached=True): + """Get all the data for the setting. If cached is True the data in the _value can be used.""" + pass + + def write(self, value, save=True): + """Write the value to the device. If saved is True also save in the persister""" + pass + + def apply(self): + """Write saved data to the device, using persisted data if available""" + if logger.isEnabledFor(logging.DEBUG): + logger.debug("%s: apply (%s)", self.name, self._device) + value = None + try: + value = self.read(self.persist) # Don't use persisted value if setting doesn't persist + if self.persist and value is not None: # If setting doesn't persist no need to write value just read + self.write(value, save=False) + except Exception as e: + if logger.isEnabledFor(logging.WARNING): + logger.warning("%s: error applying %s so ignore it (%s): %s", self.name, value, self._device, repr(e)) + + def val_to_string(self, value): + return str(value) + + +## key mapping from symbols to values???? + + +class Settings(Setting): + """A setting descriptor for multiple keys. + Supported by a class that provides the interface to the device, see ForceSensingButtonArray in hidpp20.py + Picks out a field from the mapped device feature objects.""" + + # setup creates a dictionary with entries for all the keys + # get, set, and acceptable are methods of dict value objects, not of the device object itself + + @classmethod + def build(cls, device): + cls.check_properties(cls) + _device_object = getattr(device, cls.setup)() + if _device_object: + setting = cls() + setting._device = device + setting._device_object = _device_object + setting._value = {} + return setting + + def read(self, cached=True): + self._pre_read(cached) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("%s: settings read %r from %s", self.name, self._value, self._device) + for key in self._device_object: + self.read_key(key, cached) + return self._value + + def read_key(self, key, cached=True): + """Get the data for the key. If cached is True the data in the _device_object can be used.""" + self._pre_read(cached) + if key not in self._device_object: + logger.error("%s: settings illegal read key %r for %s", self.name, key, self._device) + return None + if logger.isEnabledFor(logging.DEBUG): + logger.debug("%s: settings key %r read %r from %s", self.name, key, self._value, self._device) + if cached and key in self._value and self._value[key] is not None: + return self._value[key] + if cached: + data = self._device_object[key] + self._value[key] = getattr(data, self.get)() + return self._value[key] + if self._device.online: + data = self._device_object.query_key(key) + self._value[key] = getattr(data, self.get)() + return self._value[key] + + def write(self, value, save=True): + if logger.isEnabledFor(logging.DEBUG): + logger.debug("%s: settings read %r from %s", self.name, self._value, self._device) + for key, val in value.items(): + self.write_key_value(key, val, save) + + def write_key_value(self, key, value, save=True): + """Write the data for the key. If saved is True also save in the persister""" + if key not in self._device_object: + logger.error("%s: settings illegal write key %r for %s", self.name, key, self._device) + return None + if logger.isEnabledFor(logging.DEBUG): + logger.debug("%s: settings write key %r value %r to %s", self.name, key, value, self._device) + if self._device.online: + if self._device_object[key] is None: + self.read_key(key) + if self._device_object[key] is None: + logger.error("%s: settings illegal write key %r for %s", self.name, key, self._device) + return None + if not getattr(self._device_object[key], self.acceptable)(value): + logger.error("%s: settings illegal write key %r value %r for %s", self.name, key, value, self._device) + return None + self._value[key] = value + if self._device.persister and self.persist and save: + self._device.persister[self.name][key] = value + getattr(self._device_object[key], self.set)(value) + return value diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index 9f1616e0..d529eccd 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -36,6 +36,7 @@ from . import exceptions from . import hidpp20 from . import hidpp20_constants from . import settings +from . import settings_new from . import settings_validator from . import special_keys from .hidpp10_constants import Registers @@ -1779,6 +1780,20 @@ class PerKeyLighting(settings.Settings): return result +# Allow changes to force sensing buttons +class ForceSensing(settings_new.Settings): + name = "force-sensing" + label = _("Force Sensing Buttons") + description = _("Change the force required to activate button.") + feature = _F.FORCE_SENSING_BUTTON + setup = "force_buttons" + get = "get_current" + set = "set_current" + acceptable = "acceptable_current" + choices_universe = list(range(0, 256)) + kind = settings.Kind.MAP_RANGE + + SETTINGS: list[settings.Setting] = [ RegisterHandDetection, # simple RegisterSmoothScroll, # simple @@ -1824,6 +1839,7 @@ SETTINGS: list[settings.Setting] = [ PersistentRemappableAction, DivertKeys, # working DisableKeyboardKeys, # working + ForceSensing, CrownSmooth, # working DivertCrown, # working DivertGkeys, # working diff --git a/lib/solaar/cli/config.py b/lib/solaar/cli/config.py index c1b71f39..c5d1f1dd 100644 --- a/lib/solaar/cli/config.py +++ b/lib/solaar/cli/config.py @@ -278,6 +278,8 @@ def set(dev, setting: SettingsProtocol, args, save): key = args.value_key all_keys = getattr(setting, "choices_universe", None) ikey = all_keys[int(key) if key.isdigit() else key] if isinstance(all_keys, NamedInts) else to_int(key) + print("S", args.extra2, key, type(all_keys), ikey) + print("SS", args) if args.extra2 is None or to_int(args.extra2) is None: raise Exception(f"{setting.name}: setting needs an integer value, not {args.extra2}") if not setting._value: # ensure that there are values to look through @@ -295,7 +297,19 @@ def set(dev, setting: SettingsProtocol, args, save): result = setting.write_key_value(int(k), item, save=save) value = item + elif setting.kind == settings.Kind.MAP_RANGE: + if args.extra_subkey is None: + _print_setting_keyed(setting, args.value_key) + return None, None, None + key = int(args.value_key) + value = int(args.extra_subkey) + if key not in setting._device_object: + raise Exception(f"{setting.name}: key '{key}' not in setting") + message = f"Setting {setting.name} of {dev.name} key {key} to {value}" + result = setting.write_key_value(key, value, save=save) + else: + print("KIND", setting.kind) raise Exception("NotImplemented") return result, message, value diff --git a/lib/solaar/cli/show.py b/lib/solaar/cli/show.py index a0a9e5f6..c5d2fbb0 100644 --- a/lib/solaar/cli/show.py +++ b/lib/solaar/cli/show.py @@ -260,7 +260,8 @@ def _print_device(dev, num=None): v = setting.val_to_string(setting._device.persister.get(setting.name)) print(f" {setting.label} (saved): {v}") try: - v = setting.val_to_string(setting.read(False)) + v = setting.read(False) + v = setting.val_to_string(v) except exceptions.FeatureCallError as e: v = "HID++ error " + str(e) except AssertionError as e: