settings: add new settings type for structure-backed setting

This commit is contained in:
Peter F. Patel-Schneider 2025-10-23 07:26:00 -04:00
parent ec5b406909
commit f739331dc2
8 changed files with 292 additions and 2 deletions

View File

@ -140,7 +140,7 @@ class Device:
self._modelId = None # model id (contains identifiers for the transports of the 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._tid_map = None # map from transports to product identifiers
self._persister = None # persister holds settings 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._profiles = self._backlight = self._settings = None
self.registers = [] self.registers = []
self.notification_flags = None self.notification_flags = None
@ -346,6 +346,12 @@ class Device:
self._profiles = _hidpp20.get_profiles(self) self._profiles = _hidpp20.get_profiles(self)
return self._profiles 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): def set_configuration(self, configuration_, no_reply=False):
if self.online and self.protocol >= 2.0: if self.online and self.protocol >= 2.0:
_hidpp20.config_change(self, configuration_, no_reply=no_reply) _hidpp20.config_change(self, configuration_, no_reply=no_reply)

View File

@ -21,6 +21,7 @@ import socket
import struct import struct
import threading import threading
from collections import UserDict
from enum import Flag from enum import Flag
from enum import IntEnum from enum import IntEnum
from typing import Any from typing import Any
@ -1713,6 +1714,12 @@ class Hidpp20:
if SupportedFeature.BACKLIGHT2 in device.features: if SupportedFeature.BACKLIGHT2 in device.features:
return Backlight(device) 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): def get_profiles(self, device: Device):
if getattr(device, "_profiles", None) is not None: if getattr(device, "_profiles", None) is not None:
return device._profiles 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) percent = p_low + (p_high - p_low) * (value_millivolt - v_low) / (v_high - v_low)
return round(percent) return round(percent)
return 0 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)

View File

@ -65,6 +65,7 @@ class SupportedFeature(IntEnum):
BACKLIGHT2 = 0x1982 BACKLIGHT2 = 0x1982
BACKLIGHT3 = 0x1983 BACKLIGHT3 = 0x1983
ILLUMINATION = 0x1990 ILLUMINATION = 0x1990
FORCE_SENSING_BUTTON = 0x19C0
PRESENTER_CONTROL = 0x1A00 PRESENTER_CONTROL = 0x1A00
SENSOR_3D = 0x1A01 SENSOR_3D = 0x1A01
REPROG_CONTROLS = 0x1B00 REPROG_CONTROLS = 0x1B00

View File

@ -35,6 +35,7 @@ SENSITIVITY_IGNORE = "ignore"
class Kind(IntEnum): class Kind(IntEnum):
NONE = 0
TOGGLE = 0x01 TOGGLE = 0x01
CHOICE = 0x02 CHOICE = 0x02
RANGE = 0x04 RANGE = 0x04
@ -43,6 +44,7 @@ class Kind(IntEnum):
PACKED_RANGE = 0x20 PACKED_RANGE = 0x20
MULTIPLE_RANGE = 0x40 MULTIPLE_RANGE = 0x40
HETERO = 0x80 HETERO = 0x80
MAP_RANGE = 0x102
class Setting: class Setting:

View File

@ -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

View File

@ -36,6 +36,7 @@ from . import exceptions
from . import hidpp20 from . import hidpp20
from . import hidpp20_constants from . import hidpp20_constants
from . import settings from . import settings
from . import settings_new
from . import settings_validator from . import settings_validator
from . import special_keys from . import special_keys
from .hidpp10_constants import Registers from .hidpp10_constants import Registers
@ -1779,6 +1780,20 @@ class PerKeyLighting(settings.Settings):
return result 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] = [ SETTINGS: list[settings.Setting] = [
RegisterHandDetection, # simple RegisterHandDetection, # simple
RegisterSmoothScroll, # simple RegisterSmoothScroll, # simple
@ -1824,6 +1839,7 @@ SETTINGS: list[settings.Setting] = [
PersistentRemappableAction, PersistentRemappableAction,
DivertKeys, # working DivertKeys, # working
DisableKeyboardKeys, # working DisableKeyboardKeys, # working
ForceSensing,
CrownSmooth, # working CrownSmooth, # working
DivertCrown, # working DivertCrown, # working
DivertGkeys, # working DivertGkeys, # working

View File

@ -278,6 +278,8 @@ def set(dev, setting: SettingsProtocol, args, save):
key = args.value_key key = args.value_key
all_keys = getattr(setting, "choices_universe", None) 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) 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: 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}") 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 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) result = setting.write_key_value(int(k), item, save=save)
value = item 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: else:
print("KIND", setting.kind)
raise Exception("NotImplemented") raise Exception("NotImplemented")
return result, message, value return result, message, value

View File

@ -260,7 +260,8 @@ def _print_device(dev, num=None):
v = setting.val_to_string(setting._device.persister.get(setting.name)) v = setting.val_to_string(setting._device.persister.get(setting.name))
print(f" {setting.label} (saved): {v}") print(f" {setting.label} (saved): {v}")
try: try:
v = setting.val_to_string(setting.read(False)) v = setting.read(False)
v = setting.val_to_string(v)
except exceptions.FeatureCallError as e: except exceptions.FeatureCallError as e:
v = "HID++ error " + str(e) v = "HID++ error " + str(e)
except AssertionError as e: except AssertionError as e: