settings: add new settings type for structure-backed setting
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
169
lib/logitech_receiver/settings_new.py
Normal file
169
lib/logitech_receiver/settings_new.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user