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._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)

View File

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

View File

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

View File

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

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

View File

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

View File

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