## Copyright (C) 2012-2013 Daniel Pavel ## ## 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. from __future__ import annotations import enum import logging import socket import struct import traceback from time import time from typing import Callable from typing import Protocol from solaar.i18n import _ from . import base from . import common from . import descriptors from . import desktop_notifications from . import diversion from . import exceptions from . import headset_rgb from . import hidpp20 from . import hidpp20_constants from . import logivoice from . import rgb_effects_probe from . import settings from . import settings_new from . import settings_validator from . import special_keys from .hidpp10_constants import Registers from .hidpp20 import KeyFlag from .hidpp20 import MappingFlag from .hidpp20_constants import GestureId from .hidpp20_constants import ParamId logger = logging.getLogger(__name__) _hidpp20 = hidpp20.Hidpp20() _F = hidpp20_constants.SupportedFeature class State(enum.Enum): IDLE = "idle" PRESSED = "pressed" MOVED = "moved" # Setting classes are used to control the settings that the Solaar GUI shows and manipulates. # Each setting class has to several class variables: # name, which is used as a key when storing information about the setting, # setting classes can have the same name, as long as devices only have one setting with the same name; # label, which is shown in the Solaar main window; # description, which is shown when the mouse hovers over the setting in the main window; # either register or feature, the register or feature that the setting uses; # rw_class, the class of the reader/writer (if it is not the standard one), # rw_options, a dictionary of options for the reader/writer. # validator_class, the class of the validator (default settings.BooleanValidator) # validator_options, a dictionary of options for the validator # persist (inherited True), which is whether to store the value and apply it when setting up the device. # # The different setting classes imported from settings.py are for different numbers and kinds of arguments. # Setting is for settings with a single value (boolean, number in a range, and symbolic choice). # Settings is for settings that are maps from keys to values # and permit reading or writing the entire map or just one key/value pair. # The BitFieldSetting class is for settings that have multiple boolean values packed into a bit field. # BitFieldWithOffsetAndMaskSetting is similar. # The RangeFieldSetting class is for settings that have multiple ranges packed into a byte string. # LongSettings is for settings that have an even more complex structure. # # When settings are created a reader/writer and a validator are created. # If the setting class has a value for rw_class then an instance of that class is created. # Otherwise if the setting has a register then an instance of RegisterRW is created. # and if the setting has a feature then an instance of FeatureRW is created. # The instance is created with the register or feature as the first argument and rw_options as keyword arguments. # RegisterRW doesn't use any options. # FeatureRW options include # read_fnid - the feature function (times 16) to read the value (default 0x00), # write_fnid - the feature function (times 16) to write the value (default 0x10), # prefix - a prefix to add to the data being written and the read request (default b''), used for features # that provide and set multiple settings (e.g., to read and write function key inversion for current host) # no_reply - whether to wait for a reply (default false) (USE WITH EXTREME CAUTION). # # There are three simple validator classes - BooleanV, RangeValidator, and ChoicesValidator # BooleanV is for boolean values and is the default. It takes # true_value is the raw value for true (default 0x01), this can be an integer or a byte string, # false_value is the raw value for false (default 0x00), this can be an integer or a byte string, # mask is used to keep only some bits from a sequence of bits, this can be an integer or a byte string, # read_skip_byte_count is the number of bytes to ignore at the beginning of the read value (default 0), # write_prefix_bytes is a byte string to write before the value (default empty). # RangeValidator is for an integer in a range. It takes # byte_count is number of bytes that the value is stored in (defaults to size of max_value). # read_skip_byte_count is as for BooleanV # write_prefix_bytes is as for BooleanV # RangeValidator uses min_value and max_value from the setting class as minimum and maximum. # ChoicesValidator is for symbolic choices. It takes one positional and three keyword arguments: # choices is a list of named integers that are the valid choices, # byte_count is the number of bytes for the integer (default size of largest choice integer), # read_skip_byte_count is as for BooleanV, # write_prefix_bytes is as for BooleanV. # Settings that use ChoicesValidator should have a choices_universe class variable of the potential choices, # or None for no limitation and optionally a choices_extra class variable with an extra choice. # The choices_extra is so that there is no need to specially extend a large existing NamedInts. # ChoicesMapValidator validator is for map settings that map onto symbolic choices. It takes # choices_map is a map from keys to possible values # byte_count is as for ChoicesValidator, # read_skip_byte_count is as for ChoicesValidator, # write_prefix_bytes is as for ChoicesValidator, # key_byte_count is the number of bytes for the key integer (default size of largest key), # extra_default is an extra raw value that is used as a default value (default None). # Settings that use ChoicesValidator should have keys_universe and choices_universe class variable of # the potential keys and potential choices or None for no limitation. # BitFieldValidator validator is for bit field settings. It takes one positional and one keyword argument # the positional argument is the number of bits in the bit field # byte_count is the size of the bit field (default size of the bit field) # # A few settings work very differently. They divert a key, which is then used to start and stop some special action. # These settings have reader/writer classes that perform special processing instead of sending commands to the device. class FnSwapVirtual(settings.Setting): # virtual setting to hold fn swap strings name = "fn-swap" label = _("Swap Fx function") description = ( _( "When set, the F1..F12 keys will activate their special function,\n" "and you must hold the FN key to activate their standard function." ) + "\n\n" + _( "When unset, the F1..F12 keys will activate their standard function,\n" "and you must hold the FN key to activate their special function." ) ) class RegisterHandDetection(settings.Setting): name = "hand-detection" label = _("Hand Detection") description = _("Turn on illumination when the hands hover over the keyboard.") register = Registers.KEYBOARD_HAND_DETECTION validator_options = {"true_value": b"\x00\x00\x00", "false_value": b"\x00\x00\x30", "mask": b"\x00\x00\xff"} class RegisterSmoothScroll(settings.Setting): name = "smooth-scroll" label = _("Scroll Wheel Smooth Scrolling") description = _("High-sensitivity mode for vertical scroll with the wheel.") register = Registers.MOUSE_BUTTON_FLAGS validator_options = {"true_value": 0x40, "mask": 0x40} class RegisterSideScroll(settings.Setting): name = "side-scroll" label = _("Side Scrolling") description = _( "When disabled, pushing the wheel sideways sends custom button events\n" "instead of the standard side-scrolling events." ) register = Registers.MOUSE_BUTTON_FLAGS validator_options = {"true_value": 0x02, "mask": 0x02} # different devices have different sets of permissible dpis, so this should be subclassed class RegisterDpi(settings.Setting): name = "dpi-old" label = _("Sensitivity (DPI - older mice)") description = _("Mouse movement sensitivity") register = Registers.MOUSE_DPI choices_universe = common.NamedInts.range(0x81, 0x8F, lambda x: str((x - 0x80) * 100)) validator_class = settings_validator.ChoicesValidator validator_options = {"choices": choices_universe} class RegisterFnSwap(FnSwapVirtual): register = Registers.KEYBOARD_FN_SWAP validator_options = {"true_value": b"\x00\x01", "mask": b"\x00\x01"} class _PerformanceMXDpi(RegisterDpi): choices_universe = common.NamedInts.range(0x81, 0x8F, lambda x: str((x - 0x80) * 100)) validator_options = {"choices": choices_universe} # set up register settings for devices - this is done here to break up an import loop descriptors.get_wpid("0060").settings = [RegisterFnSwap] descriptors.get_wpid("2008").settings = [RegisterFnSwap] descriptors.get_wpid("2010").settings = [RegisterFnSwap, RegisterHandDetection] descriptors.get_wpid("2011").settings = [RegisterFnSwap] descriptors.get_usbid(0xC318).settings = [RegisterFnSwap] descriptors.get_wpid("C714").settings = [RegisterFnSwap] descriptors.get_wpid("100B").settings = [RegisterSmoothScroll, RegisterSideScroll] descriptors.get_wpid("100F").settings = [RegisterSmoothScroll, RegisterSideScroll] descriptors.get_wpid("1013").settings = [RegisterSmoothScroll, RegisterSideScroll] descriptors.get_wpid("1014").settings = [RegisterSmoothScroll, RegisterSideScroll] descriptors.get_wpid("1017").settings = [RegisterSmoothScroll, RegisterSideScroll] descriptors.get_wpid("1023").settings = [RegisterSmoothScroll, RegisterSideScroll] # somehow messed up ? descriptors.get_wpid("4004").settings = [_PerformanceMXDpi, RegisterSmoothScroll, RegisterSideScroll] descriptors.get_wpid("101A").settings = [_PerformanceMXDpi, RegisterSmoothScroll, RegisterSideScroll] descriptors.get_wpid("101B").settings = [RegisterSmoothScroll, RegisterSideScroll] descriptors.get_wpid("101D").settings = [RegisterSmoothScroll, RegisterSideScroll] descriptors.get_wpid("101F").settings = [RegisterSideScroll] descriptors.get_usbid(0xC06B).settings = [RegisterSmoothScroll, RegisterSideScroll] descriptors.get_wpid("1025").settings = [RegisterSideScroll] descriptors.get_wpid("102A").settings = [RegisterSmoothScroll, RegisterSideScroll] descriptors.get_usbid(0xC048).settings = [_PerformanceMXDpi, RegisterSmoothScroll, RegisterSideScroll] descriptors.get_usbid(0xC066).settings = [_PerformanceMXDpi, RegisterSmoothScroll, RegisterSideScroll] # ignore the capabilities part of the feature - all devices should be able to swap Fn state # can't just use the first byte = 0xFF (for current host) because of a bug in the firmware of the MX Keys S class K375sFnSwap(FnSwapVirtual): feature = _F.K375S_FN_INVERSION validator_options = {"true_value": b"\x01", "false_value": b"\x00", "read_skip_byte_count": 1} class rw_class(settings.FeatureRW): def find_current_host(self, device): if not self.prefix: response = device.feature_request(_F.HOSTS_INFO, 0x00) self.prefix = response[3:4] if response else b"\xff" def read(self, device, data_bytes=b""): self.find_current_host(device) return super().read(device, data_bytes) def write(self, device, data_bytes): self.find_current_host(device) return super().write(device, data_bytes) class FnSwap(FnSwapVirtual): feature = _F.FN_INVERSION class NewFnSwap(FnSwapVirtual): feature = _F.NEW_FN_INVERSION class Backlight(settings.Setting): name = "backlight-qualitative" label = _("Backlight Timed") description = _("Set illumination time for keyboard.") feature = _F.BACKLIGHT choices_universe = common.NamedInts(Off=0, Varying=2, VeryShort=5, Short=10, Medium=20, Long=60, VeryLong=180) validator_class = settings_validator.ChoicesValidator validator_options = {"choices": choices_universe} # MX Keys S requires some extra values, as in 11 02 0c1a 000dff000b000b003c00000000000000 # on/off options (from current) effect (FF-no change) level (from current) durations[6] (from current) class Backlight2(settings.Setting): name = "backlight" label = _("Backlight") description = _("Illumination level on keyboard. Changes made are only applied in Manual mode.") feature = _F.BACKLIGHT2 choices_universe = common.NamedInts(Disabled=0xFF, Enabled=0x00, Automatic=0x01, Manual=0x02) min_version = 0 class rw_class: def __init__(self, feature): self.feature = feature self.kind = settings.FeatureRW.kind def read(self, device): backlight = device.backlight if not backlight.enabled: return b"\xff" else: return common.int2bytes(backlight.mode, 1) def write(self, device, data_bytes): backlight = device.backlight backlight.enabled = data_bytes[0] != 0xFF if data_bytes[0] != 0xFF: backlight.mode = data_bytes[0] backlight.write() return True class validator_class(settings_validator.ChoicesValidator): @classmethod def build(cls, setting_class, device): backlight = device.backlight choices = common.NamedInts() choices[0xFF] = _("Disabled") if backlight.auto_supported: choices[0x1] = _("Automatic") if backlight.perm_supported: choices[0x3] = _("Manual") if not (backlight.auto_supported or backlight.temp_supported or backlight.perm_supported): choices[0x0] = _("Enabled") return cls(choices=choices, byte_count=1) class Backlight2Level(settings.Setting): name = "backlight_level" label = _("Backlight Level") description = _("Illumination level on keyboard when in Manual mode.") feature = _F.BACKLIGHT2 min_version = 3 class rw_class: def __init__(self, feature): self.feature = feature self.kind = settings.FeatureRW.kind def read(self, device): backlight = device.backlight return common.int2bytes(backlight.level, 1) def write(self, device, data_bytes): if device.backlight.level != common.bytes2int(data_bytes): device.backlight.level = common.bytes2int(data_bytes) device.backlight.write() return True class validator_class(settings_validator.RangeValidator): @classmethod def build(cls, setting_class, device): reply = device.feature_request(_F.BACKLIGHT2, 0x20) assert reply, "Oops, backlight range cannot be retrieved!" if reply[0] > 1: return cls(min_value=0, max_value=reply[0] - 1, byte_count=1) class Backlight2Duration(settings.Setting): feature = _F.BACKLIGHT2 min_version = 3 validator_class = settings_validator.RangeValidator min_value = 1 max_value = 600 # 10 minutes - actual maximum is 2 hours validator_options = {"byte_count": 2} class rw_class: def __init__(self, feature, field): self.feature = feature self.kind = settings.FeatureRW.kind self.field = field def read(self, device): backlight = device.backlight return common.int2bytes(getattr(backlight, self.field) * 5, 2) # use seconds instead of 5-second units def write(self, device, data_bytes): backlight = device.backlight new_duration = (int.from_bytes(data_bytes, byteorder="big") + 4) // 5 # use ceiling in 5-second units if new_duration != getattr(backlight, self.field): setattr(backlight, self.field, new_duration) backlight.write() return True class Backlight2DurationHandsOut(Backlight2Duration): name = "backlight_duration_hands_out" label = _("Backlight Delay Hands Out") description = _("Delay in seconds until backlight fades out with hands away from keyboard.") feature = _F.BACKLIGHT2 validator_class = settings_validator.RangeValidator rw_options = {"field": "dho"} class Backlight2DurationHandsIn(Backlight2Duration): name = "backlight_duration_hands_in" label = _("Backlight Delay Hands In") description = _("Delay in seconds until backlight fades out with hands near keyboard.") feature = _F.BACKLIGHT2 validator_class = settings_validator.RangeValidator rw_options = {"field": "dhi"} class Backlight2DurationPowered(Backlight2Duration): name = "backlight_duration_powered" label = _("Backlight Delay Powered") description = _("Delay in seconds until backlight fades out with external power.") feature = _F.BACKLIGHT2 validator_class = settings_validator.RangeValidator rw_options = {"field": "dpow"} class Backlight3(settings.Setting): name = "backlight-timed" label = _("Backlight (Seconds)") description = _("Set illumination time for keyboard.") feature = _F.BACKLIGHT3 rw_options = {"read_fnid": 0x10, "write_fnid": 0x20, "suffix": b"\x09"} validator_class = settings_validator.RangeValidator min_value = 0 max_value = 1000 validator_options = {"byte_count": 2} class HiResScroll(settings.Setting): name = "hi-res-scroll" label = _("Scroll Wheel High Resolution") description = ( _("High-sensitivity mode for vertical scroll with the wheel.") + "\n" + _("Set to ignore if scrolling is abnormally fast or slow") ) feature = _F.HI_RES_SCROLLING class LowresMode(settings.Setting): name = "lowres-scroll-mode" label = _("Scroll Wheel Diversion") description = _( "Make scroll wheel send LOWRES_WHEEL HID++ notifications (which trigger Solaar rules but are otherwise ignored)." ) feature = _F.LOWRES_WHEEL class HiresSmoothInvert(settings.Setting): name = "hires-smooth-invert" label = _("Scroll Wheel Direction") description = _("Invert direction for vertical scroll with wheel.") feature = _F.HIRES_WHEEL rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} validator_options = {"true_value": 0x04, "mask": 0x04} class HiresSmoothResolution(settings.Setting): name = "hires-smooth-resolution" label = _("Scroll Wheel Resolution") description = ( _("High-sensitivity mode for vertical scroll with the wheel.") + "\n" + _("Set to ignore if scrolling is abnormally fast or slow") ) feature = _F.HIRES_WHEEL rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} validator_options = {"true_value": 0x02, "mask": 0x02} class HiresMode(settings.Setting): name = "hires-scroll-mode" label = _("Scroll Wheel Diversion") description = _( "Make scroll wheel send HIRES_WHEEL HID++ notifications (which trigger Solaar rules but are otherwise ignored)." ) feature = _F.HIRES_WHEEL rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} validator_options = {"true_value": 0x01, "mask": 0x01} class PointerSpeed(settings.Setting): name = "pointer_speed" label = _("Sensitivity (Pointer Speed)") description = _("Speed multiplier for mouse (256 is normal multiplier).") feature = _F.POINTER_SPEED validator_class = settings_validator.RangeValidator min_value = 0x002E max_value = 0x01FF validator_options = {"byte_count": 2} class ThumbMode(settings.Setting): name = "thumb-scroll-mode" label = _("Thumb Wheel Diversion") description = _( "Make thumb wheel send THUMB_WHEEL HID++ notifications (which trigger Solaar rules but are otherwise ignored)." ) feature = _F.THUMB_WHEEL rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} validator_options = {"true_value": b"\x01\x00", "false_value": b"\x00\x00", "mask": b"\x01\x00"} class ThumbInvert(settings.Setting): name = "thumb-scroll-invert" label = _("Thumb Wheel Direction") description = _("Invert thumb wheel scroll direction.") feature = _F.THUMB_WHEEL rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} validator_options = {"true_value": b"\x00\x01", "false_value": b"\x00\x00", "mask": b"\x00\x01"} # change UI to show result of onboard profile change def profile_change(device, profile_sector): if device.setting_callback: device.setting_callback(device, OnboardProfiles, [profile_sector]) for profile in device.profiles.profiles.values() if device.profiles else []: if profile.sector == profile_sector: resolution_index = profile.resolution_default_index device.setting_callback(device, AdjustableDpi, [profile.resolutions[resolution_index]]) device.setting_callback(device, ReportRate, [profile.report_rate]) break class OnboardProfiles(settings.Setting): name = "onboard_profiles" label = _("Onboard Profiles") description = _("Enable an onboard profile, which controls report rate, sensitivity, and button actions") feature = _F.ONBOARD_PROFILES choices_universe = common.NamedInts(Disabled=0) for i in range(1, 16): choices_universe[i] = f"Profile {i}" choices_universe[i + 0x100] = f"Read-Only Profile {i}" validator_class = settings_validator.ChoicesValidator class rw_class: def __init__(self, feature): self.feature = feature self.kind = settings.FeatureRW.kind def read(self, device): enabled = device.feature_request(_F.ONBOARD_PROFILES, 0x20)[0] if enabled == 0x01: active = device.feature_request(_F.ONBOARD_PROFILES, 0x40) return active[:2] else: return b"\x00\x00" def write(self, device, data_bytes): if data_bytes == b"\x00\x00": result = device.feature_request(_F.ONBOARD_PROFILES, 0x10, b"\x02") else: device.feature_request(_F.ONBOARD_PROFILES, 0x10, b"\x01") result = device.feature_request(_F.ONBOARD_PROFILES, 0x30, data_bytes) profile_change(device, common.bytes2int(data_bytes)) return result class validator_class(settings_validator.ChoicesValidator): @classmethod def build(cls, setting_class, device): headers = hidpp20.OnboardProfiles.get_profile_headers(device) profiles_list = [setting_class.choices_universe[0]] if headers: for sector, enabled in headers: if enabled and setting_class.choices_universe[sector]: profiles_list.append(setting_class.choices_universe[sector]) return cls(choices=common.NamedInts.list(profiles_list), byte_count=2) if len(profiles_list) > 1 else None class ReportRate(settings.Setting): name = "report_rate" label = _("Report Rate") description = ( _("Frequency of device movement reports") + "\n" + _("May need Onboard Profiles set to Disable to be effective.") ) feature = _F.REPORT_RATE rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} choices_universe = common.NamedInts() choices_universe[1] = "1ms" choices_universe[2] = "2ms" choices_universe[3] = "3ms" choices_universe[4] = "4ms" choices_universe[5] = "5ms" choices_universe[6] = "6ms" choices_universe[7] = "7ms" choices_universe[8] = "8ms" class validator_class(settings_validator.ChoicesValidator): @classmethod def build(cls, setting_class, device): # if device.wpid == '408E': # return None # host mode borks the function keys on the G915 TKL keyboard reply = device.feature_request(_F.REPORT_RATE, 0x00) assert reply, "Oops, report rate choices cannot be retrieved!" rate_list = [] rate_flags = common.bytes2int(reply[0:1]) for i in range(0, 8): if (rate_flags >> i) & 0x01: rate_list.append(setting_class.choices_universe[i + 1]) return cls(choices=common.NamedInts.list(rate_list), byte_count=1) if rate_list else None class ExtendedReportRate(settings.Setting): name = "report_rate_extended" label = _("Report Rate") description = ( _("Frequency of device movement reports") + "\n" + _("May need Onboard Profiles set to Disable to be effective.") ) feature = _F.EXTENDED_ADJUSTABLE_REPORT_RATE rw_options = {"read_fnid": 0x20, "write_fnid": 0x30} choices_universe = common.NamedInts() choices_universe[0] = "8ms" choices_universe[1] = "4ms" choices_universe[2] = "2ms" choices_universe[3] = "1ms" choices_universe[4] = "500us" choices_universe[5] = "250us" choices_universe[6] = "125us" class validator_class(settings_validator.ChoicesValidator): @classmethod def build(cls, setting_class, device): reply = device.feature_request(_F.EXTENDED_ADJUSTABLE_REPORT_RATE, 0x10) assert reply, "Oops, report rate choices cannot be retrieved!" rate_list = [] rate_flags = common.bytes2int(reply[0:2]) for i in range(0, 7): if rate_flags & (0x01 << i): rate_list.append(setting_class.choices_universe[i]) return cls(choices=common.NamedInts.list(rate_list), byte_count=1) if rate_list else None class DivertCrown(settings.Setting): name = "divert-crown" label = _("Divert crown events") description = _("Make crown send CROWN HID++ notifications (which trigger Solaar rules but are otherwise ignored).") feature = _F.CROWN rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} validator_options = {"true_value": 0x02, "false_value": 0x01, "mask": 0xFF} class CrownSmooth(settings.Setting): name = "crown-smooth" label = _("Crown smooth scroll") description = _("Set crown smooth scroll") feature = _F.CROWN rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} validator_options = {"true_value": 0x01, "false_value": 0x02, "read_skip_byte_count": 1, "write_prefix_bytes": b"\x00"} class DivertGkeys(settings.Setting): name = "divert-gkeys" label = _("Divert G and M Keys") description = _("Make G and M keys send HID++ notifications (which trigger Solaar rules but are otherwise ignored).") feature = _F.GKEY validator_options = {"true_value": 0x01, "false_value": 0x00, "mask": 0xFF} class rw_class(settings.FeatureRW): def __init__(self, feature): super().__init__(feature, write_fnid=0x20) def read(self, device): # no way to read, so just assume not diverted return b"\x00" class ScrollRatchet(settings.Setting): name = "scroll-ratchet" label = _("Scroll Wheel Ratcheted") description = _("Switch the mouse wheel between speed-controlled ratcheting and always freespin.") feature = _F.SMART_SHIFT choices_universe = common.NamedInts(**{_("Freespinning"): 1, _("Ratcheted"): 2}) validator_class = settings_validator.ChoicesValidator validator_options = {"choices": choices_universe} class SmartShift(settings.Setting): name = "smart-shift" label = _("Scroll Wheel Ratchet Speed") description = _( "Use the mouse wheel speed to switch between ratcheted and freespinning.\n" "The mouse wheel is always ratcheted at 50." ) feature = _F.SMART_SHIFT rw_options = {"read_fnid": 0x00, "write_fnid": 0x10} class rw_class(settings.FeatureRW): MIN_VALUE = 1 MAX_VALUE = 50 def __init__(self, feature, read_fnid, write_fnid): super().__init__(feature, read_fnid, write_fnid) def read(self, device): value = super().read(device) if common.bytes2int(value[0:1]) == 1: # Mode = Freespin, map to minimum return common.int2bytes(self.MIN_VALUE, count=1) else: # Mode = smart shift, map to the value, capped at maximum threshold = min(common.bytes2int(value[1:2]), self.MAX_VALUE) return common.int2bytes(threshold, count=1) def write(self, device, data_bytes): threshold = common.bytes2int(data_bytes) # Freespin at minimum mode = 0 # 1 if threshold <= self.MIN_VALUE else 2 # Ratchet at maximum if threshold >= self.MAX_VALUE: threshold = 255 data = common.int2bytes(mode, count=1) + common.int2bytes(max(0, threshold), count=1) return super().write(device, data) min_value = rw_class.MIN_VALUE max_value = rw_class.MAX_VALUE validator_class = settings_validator.RangeValidator class SmartShiftEnhanced(SmartShift): feature = _F.SMART_SHIFT_ENHANCED rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} class ScrollRatchetEnhanced(ScrollRatchet): feature = _F.SMART_SHIFT_ENHANCED rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} class ScrollRatchetTorque(settings.Setting): name = "scroll-ratchet-torque" label = _("Scroll Wheel Ratchet Torque") description = _("Change the torque needed to overcome the ratchet.") feature = _F.SMART_SHIFT_ENHANCED min_value = 1 max_value = 100 rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} class rw_class(settings.FeatureRW): def write(self, device, data_bytes): ratchetSetting = next(filter(lambda s: s.name == "scroll-ratchet", device.settings), None) if ratchetSetting: # for MX Master 4, the ratchet setting needs to be written for changes to take effect ratchet_value = ratchetSetting.read(True) data_bytes = ratchet_value.to_bytes(1, "big") + data_bytes[1:] result = super().write(device, data_bytes) return result class validator_class(settings_validator.RangeValidator): @classmethod def build(cls, setting_class, device): reply = device.feature_request(_F.SMART_SHIFT_ENHANCED, 0x00) if reply[0] & 0x01: # device supports tunable torque return cls( min_value=setting_class.min_value, max_value=setting_class.max_value, byte_count=1, write_prefix_bytes=b"\x00\x00", # don't change mode or disengage, but see above read_skip_byte_count=2, ) # the keys for the choice map are Logitech controls (from special_keys) # each choice value is a NamedInt with the string from a task (to be shown to the user) # and the integer being the control number for that task (to be written to the device) # Solaar only remaps keys (controlled by key gmask and group), not other key reprogramming class ReprogrammableKeys(settings.Settings): name = "reprogrammable-keys" label = _("Key/Button Actions") description = ( _("Change the action for the key or button.") + " " + _("Overridden by diversion.") + "\n" + _("Changing important actions (such as for the left mouse button) can result in an unusable system.") ) feature = _F.REPROG_CONTROLS_V4 keys_universe = special_keys.CONTROL choices_universe = special_keys.CONTROL class rw_class: def __init__(self, feature): self.feature = feature self.kind = settings.FeatureRW.kind def read(self, device, key): key_index = device.keys.index(key) key_struct = device.keys[key_index] return b"\x00\x00" + common.int2bytes(int(key_struct.mapped_to), 2) def write(self, device, key, data_bytes): key_index = device.keys.index(key) key_struct = device.keys[key_index] key_struct.remap(special_keys.CONTROL[common.bytes2int(data_bytes)]) return True class validator_class(settings_validator.ChoicesMapValidator): @classmethod def build(cls, setting_class, device): choices = {} if device.keys: for k in device.keys: tgts = k.remappable_to if len(tgts) > 1: choices[k.key] = tgts return cls(choices, key_byte_count=2, byte_count=2, extra_default=0) if choices else None class DpiSlidingXY(settings.RawXYProcessing): def __init__( self, *args, show_notification: Callable[[str, str], bool], **kwargs, ): super().__init__(*args, **kwargs) self.fsmState = None self._show_notification = show_notification def activate_action(self): self.dpiSetting = next(filter(lambda s: s.name == "dpi" or s.name == "dpi_extended", self.device.settings), None) self.dpiChoices = list(self.dpiSetting.choices) self.otherDpiIdx = self.device.persister.get("_dpi-sliding", -1) if self.device.persister else -1 if not isinstance(self.otherDpiIdx, int) or self.otherDpiIdx < 0 or self.otherDpiIdx >= len(self.dpiChoices): self.otherDpiIdx = self.dpiChoices.index(self.dpiSetting.read()) self.fsmState = State.IDLE self.dx = 0.0 self.movingDpiIdx = None def setNewDpi(self, newDpiIdx): newDpi = self.dpiChoices[newDpiIdx] self.dpiSetting.write(newDpi) if self.device.setting_callback: self.device.setting_callback(self.device, type(self.dpiSetting), [newDpi]) def displayNewDpi(self, newDpiIdx): selected_dpi = self.dpiChoices[newDpiIdx] min_dpi = self.dpiChoices[0] max_dpi = self.dpiChoices[-1] reason = f"DPI {selected_dpi} [min {min_dpi}, max {max_dpi}]" self._show_notification(self.device, reason) def press_action(self, key): # start tracking self.starting = True if self.fsmState == State.IDLE: self.fsmState = State.PRESSED self.dx = 0.0 # While in 'moved' state, the index into 'dpiChoices' of the currently selected DPI setting self.movingDpiIdx = None def release_action(self): # adjust DPI and stop tracking if self.fsmState == State.PRESSED: # Swap with other DPI thisIdx = self.dpiChoices.index(self.dpiSetting.read()) newDpiIdx, self.otherDpiIdx = self.otherDpiIdx, thisIdx if self.device.persister: self.device.persister["_dpi-sliding"] = self.otherDpiIdx self.setNewDpi(newDpiIdx) self.displayNewDpi(newDpiIdx) elif self.fsmState == State.MOVED: # Set DPI according to displacement self.setNewDpi(self.movingDpiIdx) self.fsmState = State.IDLE def move_action(self, dx, dy): if self.device.features.get_feature_version(_F.REPROG_CONTROLS_V4) >= 5 and self.starting: self.starting = False # hack to ignore strange first movement report from MX Master 3S return currDpi = self.dpiSetting.read() self.dx += float(dx) / float(currDpi) * 15.0 # yields a more-or-less DPI-independent dx of about 5/cm if self.fsmState == State.PRESSED: if abs(self.dx) >= 1.0: self.fsmState = State.MOVED self.movingDpiIdx = self.dpiChoices.index(currDpi) elif self.fsmState == State.MOVED: currIdx = self.dpiChoices.index(self.dpiSetting.read()) newMovingDpiIdx = min(max(currIdx + int(self.dx), 0), len(self.dpiChoices) - 1) if newMovingDpiIdx != self.movingDpiIdx: self.movingDpiIdx = newMovingDpiIdx self.displayNewDpi(newMovingDpiIdx) class MouseGesturesXY(settings.RawXYProcessing): def activate_action(self): self.dpiSetting = next(filter(lambda s: s.name == "dpi" or s.name == "dpi_extended", self.device.settings), None) self.fsmState = State.IDLE self.initialize_data() def initialize_data(self): self.dx = 0.0 self.dy = 0.0 self.lastEv = None self.data = [] def press_action(self, key): self.starting = True if self.fsmState == State.IDLE: self.fsmState = State.PRESSED self.initialize_data() self.data = [key.key] def release_action(self): if self.fsmState == State.PRESSED: # emit mouse gesture notification self.push_mouse_event() if logger.isEnabledFor(logging.INFO): logger.info("mouse gesture notification %s", self.data) payload = struct.pack("!" + (len(self.data) * "h"), *self.data) notification = base.HIDPPNotification(0, 0, 0, 0, payload) diversion.process_notification(self.device, notification, _F.MOUSE_GESTURE) self.fsmState = State.IDLE def move_action(self, dx, dy): if self.fsmState == State.PRESSED: now = time() * 1000 # time_ns() / 1e6 if self.device.features.get_feature_version(_F.REPROG_CONTROLS_V4) >= 5 and self.starting: self.starting = False # hack to ignore strange first movement report from MX Master 3S return if self.lastEv is not None and now - self.lastEv > 200.0: self.push_mouse_event() dpi = self.dpiSetting.read() if self.dpiSetting else 1000 dx = float(dx) / float(dpi) * 15.0 # This multiplier yields a more-or-less DPI-independent dx of about 5/cm self.dx += dx dy = float(dy) / float(dpi) * 15.0 # This multiplier yields a more-or-less DPI-independent dx of about 5/cm self.dy += dy self.lastEv = now def key_action(self, key): self.push_mouse_event() self.data.append(1) self.data.append(key) self.lastEv = time() * 1000 # time_ns() / 1e6 if logger.isEnabledFor(logging.DEBUG): logger.debug("mouse gesture key event %d %s", key, self.data) def push_mouse_event(self): x = int(self.dx) y = int(self.dy) if x == 0 and y == 0: return self.data.append(0) self.data.append(x) self.data.append(y) self.dx = 0.0 self.dy = 0.0 if logger.isEnabledFor(logging.DEBUG): logger.debug("mouse gesture move event %d %d %s", x, y, self.data) class DivertKeys(settings.Settings): name = "divert-keys" label = _("Key/Button Diversion") description = _("Make the key or button send HID++ notifications (Diverted) or initiate Mouse Gestures or Sliding DPI") feature = _F.REPROG_CONTROLS_V4 keys_universe = special_keys.CONTROL choices_universe = common.NamedInts(**{_("Regular"): 0, _("Diverted"): 1, _("Mouse Gestures"): 2, _("Sliding DPI"): 3}) choices_gesture = common.NamedInts(**{_("Regular"): 0, _("Diverted"): 1, _("Mouse Gestures"): 2}) choices_divert = common.NamedInts(**{_("Regular"): 0, _("Diverted"): 1}) class rw_class: def __init__(self, feature): self.feature = feature self.kind = settings.FeatureRW.kind def read(self, device, key): key_index = device.keys.index(key) key_struct = device.keys[key_index] return b"\x00\x00\x01" if MappingFlag.DIVERTED in key_struct.mapping_flags else b"\x00\x00\x00" def write(self, device, key, data_bytes): key_index = device.keys.index(key) key_struct = device.keys[key_index] key_struct.set_diverted(common.bytes2int(data_bytes) != 0) # not regular return True class validator_class(settings_validator.ChoicesMapValidator): def __init__(self, choices, key_byte_count=2, byte_count=1, mask=0x01): super().__init__(choices, key_byte_count, byte_count, mask) def prepare_write(self, key, new_value): if self.gestures and new_value != 2: # mouse gestures self.gestures.stop(key) if self.sliding and new_value != 3: # sliding DPI self.sliding.stop(key) if self.gestures and new_value == 2: # mouse gestures self.gestures.start(key) if self.sliding and new_value == 3: # sliding DPI self.sliding.start(key) return super().prepare_write(key, new_value) @classmethod def build(cls, setting_class, device): sliding = gestures = None choices = {} if device.keys: for key in device.keys: if KeyFlag.DIVERTABLE in key.flags and KeyFlag.VIRTUAL not in key.flags: if KeyFlag.RAW_XY in key.flags: choices[key.key] = setting_class.choices_gesture if gestures is None: gestures = MouseGesturesXY(device, name="MouseGestures") if _F.ADJUSTABLE_DPI in device.features: choices[key.key] = setting_class.choices_universe if sliding is None: sliding = DpiSlidingXY( device, name="DpiSliding", show_notification=desktop_notifications.show ) else: choices[key.key] = setting_class.choices_divert if not choices: return None validator = cls(choices, key_byte_count=2, byte_count=1, mask=0x01) validator.sliding = sliding validator.gestures = gestures return validator def produce_dpi_list(feature, function, ignore, device, direction): dpi_bytes = b"" for i in range(0, 0x100): # there will be only a very few iterations performed reply = device.feature_request(feature, function, 0x00, direction, i) assert reply, "Oops, DPI list cannot be retrieved!" dpi_bytes += reply[ignore:] if dpi_bytes[-2:] == b"\x00\x00": break dpi_list = [] i = 0 while i < len(dpi_bytes): val = common.bytes2int(dpi_bytes[i : i + 2]) if val == 0: break if val >> 13 == 0b111: step = val & 0x1FFF last = common.bytes2int(dpi_bytes[i + 2 : i + 4]) assert len(dpi_list) > 0 and last > dpi_list[-1], f"Invalid DPI list item: {val!r}" dpi_list += range(dpi_list[-1] + step, last + 1, step) i += 4 else: dpi_list.append(val) i += 2 return dpi_list class AdjustableDpi(settings.Setting): name = "dpi" label = _("Sensitivity (DPI)") description = _("Mouse movement sensitivity") + "\n" + _("May need Onboard Profiles set to Disable to be effective.") feature = _F.ADJUSTABLE_DPI rw_options = {"read_fnid": 0x20, "write_fnid": 0x30} choices_universe = common.NamedInts.range(100, 4000, str, 50) class validator_class(settings_validator.ChoicesValidator): @classmethod def build(cls, setting_class, device): dpilist = produce_dpi_list(setting_class.feature, 0x10, 1, device, 0) setting = ( cls(choices=common.NamedInts.list(dpilist), byte_count=2, write_prefix_bytes=b"\x00") if dpilist else None ) setting.dpilist = dpilist return setting def validate_read(self, reply_bytes): # special validator to use default DPI if needed reply_value = common.bytes2int(reply_bytes[1:3]) if reply_value == 0: # use default value instead reply_value = common.bytes2int(reply_bytes[3:5]) valid_value = self.choices[reply_value] assert valid_value is not None, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}" return valid_value class ExtendedAdjustableDpi(settings.Setting): # the extended version allows for two dimensions, longer dpi descriptions, but still assume only one sensor name = "dpi_extended" label = _("Sensitivity (DPI)") description = _("Mouse movement sensitivity") + "\n" + _("May need Onboard Profiles set to Disable to be effective.") feature = _F.EXTENDED_ADJUSTABLE_DPI rw_options = {"read_fnid": 0x50, "write_fnid": 0x60} keys_universe = common.NamedInts(X=0, Y=1, LOD=2) choices_universe = common.NamedInts.range(100, 4000, str, 50) choices_universe[1] = "LOW" choices_universe[2] = "MEDIUM" choices_universe[3] = "HIGH" keys = common.NamedInts(X=0, Y=1, LOD=2) def write_key_value(self, key, value, save=True): # Force a read to populate the full X/Y/LOD dictionary if it's missing (fixes CLI) if not isinstance(self._value, dict): self.read() if isinstance(self._value, dict): self._value[key] = value else: self._value = {key: value} result = self.write(self._value, save) return result[key] if isinstance(result, dict) else result class validator_class(settings_validator.ChoicesMapValidator): @classmethod def build(cls, setting_class, device): reply = device.feature_request(setting_class.feature, 0x10, 0x00) y = bool(reply[2] & 0x01) lod = bool(reply[2] & 0x02) choices_map = {} dpilist_x = produce_dpi_list(setting_class.feature, 0x20, 3, device, 0) choices_map[setting_class.keys["X"]] = common.NamedInts.list(dpilist_x) if y: dpilist_y = produce_dpi_list(setting_class.feature, 0x20, 3, device, 1) choices_map[setting_class.keys["Y"]] = common.NamedInts.list(dpilist_y) if lod: choices_map[setting_class.keys["LOD"]] = common.NamedInts(LOW=0, MEDIUM=1, HIGH=2) validator = cls(choices_map=choices_map, byte_count=2, write_prefix_bytes=b"\x00") validator.y = y validator.lod = lod validator.keys = setting_class.keys return validator def validate_read(self, reply_bytes): # special validator to read entire setting dpi_x = common.bytes2int(reply_bytes[3:5]) if reply_bytes[1:3] == 0 else common.bytes2int(reply_bytes[1:3]) assert dpi_x in self.choices[0], f"{self.__class__.__name__}: failed to validate dpi_x value {dpi_x:04X}" value = {self.keys["X"]: dpi_x} if self.y: dpi_y = common.bytes2int(reply_bytes[7:9]) if reply_bytes[5:7] == 0 else common.bytes2int(reply_bytes[5:7]) assert dpi_y in self.choices[1], f"{self.__class__.__name__}: failed to validate dpi_y value {dpi_y:04X}" value[self.keys["Y"]] = dpi_y if self.lod: lod = reply_bytes[9] assert lod in self.choices[2], f"{self.__class__.__name__}: failed to validate lod value {lod:02X}" value[self.keys["LOD"]] = lod return value def prepare_write(self, new_value, current_value=None): # special preparer to write entire setting data_bytes = self._write_prefix_bytes if new_value[self.keys["X"]] not in self.choices[self.keys["X"]]: raise ValueError(f"invalid value {new_value!r}") data_bytes += common.int2bytes(new_value[0], 2) if self.y: if new_value[self.keys["Y"]] not in self.choices[self.keys["Y"]]: raise ValueError(f"invalid value {new_value!r}") data_bytes += common.int2bytes(new_value[self.keys["Y"]], 2) else: data_bytes += b"\x00\x00" if self.lod: if new_value[self.keys["LOD"]] not in self.choices[self.keys["LOD"]]: raise ValueError(f"invalid value {new_value!r}") data_bytes += common.int2bytes(new_value[self.keys["LOD"]], 1) else: data_bytes += b"\x00" return data_bytes class SpeedChange(settings.Setting): """Implements the ability to switch Sensitivity by clicking on the DPI_Change button.""" name = "speed-change" label = _("Sensitivity Switching") description = _( "Switch the current sensitivity and the remembered sensitivity when the key or button is pressed.\n" "If there is no remembered sensitivity, just remember the current sensitivity" ) choices_universe = special_keys.CONTROL choices_extra = common.NamedInt(0, _("Off")) feature = _F.POINTER_SPEED rw_options = {"name": "speed change"} class rw_class(settings.ActionSettingRW): def press_action(self): # switch sensitivity currentSpeed = self.device.persister.get("pointer_speed", None) if self.device.persister else None newSpeed = self.device.persister.get("_speed-change", None) if self.device.persister else None speed_setting = next(filter(lambda s: s.name == "pointer_speed", self.device.settings), None) if newSpeed is not None: if speed_setting: speed_setting.write(newSpeed) if self.device.setting_callback: self.device.setting_callback(self.device, type(speed_setting), [newSpeed]) else: logger.error("cannot save sensitivity setting on %s", self.device) if self.device.persister: self.device.persister["_speed-change"] = currentSpeed class validator_class(settings_validator.ChoicesValidator): @classmethod def build(cls, setting_class, device): key_index = device.keys.index(special_keys.CONTROL.DPI_Change) key = device.keys[key_index] if key_index is not None else None if key is not None and KeyFlag.DIVERTABLE in key.flags: keys = [setting_class.choices_extra, key.key] return cls(choices=common.NamedInts.list(keys), byte_count=2) class DisableKeyboardKeys(settings.BitFieldSetting): name = "disable-keyboard-keys" label = _("Disable keys") description = _("Disable specific keyboard keys.") feature = _F.KEYBOARD_DISABLE_KEYS rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} _labels = {k: (None, _("Disables the %s key.") % k) for k in special_keys.DISABLE} choices_universe = special_keys.DISABLE class validator_class(settings_validator.BitFieldValidator): @classmethod def build(cls, setting_class, device): mask = device.feature_request(_F.KEYBOARD_DISABLE_KEYS, 0x00)[0] options = [special_keys.DISABLE[1 << i] for i in range(8) if mask & (1 << i)] return cls(options) if options else None class Multiplatform(settings.Setting): name = "multiplatform" label = _("Set OS") description = _("Change keys to match OS.") feature = _F.MULTIPLATFORM rw_options = {"read_fnid": 0x00, "write_fnid": 0x30} choices_universe = common.NamedInts(**{"OS " + str(i + 1): i for i in range(8)}) # multiplatform OS bits OSS = [ ("Linux", 0x0400), ("MacOS", 0x2000), ("Windows", 0x0100), ("iOS", 0x4000), ("Android", 0x1000), ("WebOS", 0x8000), ("Chrome", 0x0800), ("WinEmb", 0x0200), ("Tizen", 0x0001), ] # the problem here is how to construct the right values for the rules Set GUI, # as, for example, the integer value for 'Windows' can be different on different devices class validator_class(settings_validator.ChoicesValidator): @classmethod def build(cls, setting_class, device): def _str_os_versions(low, high): def _str_os_version(version): if version == 0: return "" elif version & 0xFF: return f"{str(version >> 8)}.{str(version & 0xFF)}" else: return str(version >> 8) return "" if low == 0 and high == 0 else f" {_str_os_version(low)}-{_str_os_version(high)}" infos = device.feature_request(_F.MULTIPLATFORM) assert infos, "Oops, multiplatform count cannot be retrieved!" flags, _ignore, num_descriptors = struct.unpack("!BBB", infos[:3]) if not (flags & 0x02): # can't set platform so don't create setting return [] descriptors = [] for index in range(0, num_descriptors): descriptor = device.feature_request(_F.MULTIPLATFORM, 0x10, index) platform, _ignore, os_flags, low, high = struct.unpack("!BBHHH", descriptor[:8]) descriptors.append((platform, os_flags, low, high)) choices = common.NamedInts() for os_name, os_bit in setting_class.OSS: for platform, os_flags, low, high in descriptors: os = os_name + _str_os_versions(low, high) if os_bit & os_flags and platform not in choices and os not in choices: choices[platform] = os return cls(choices=choices, read_skip_byte_count=6, write_prefix_bytes=b"\xff") if choices else None class DualPlatform(settings.Setting): name = "multiplatform" label = _("Set OS") description = _("Change keys to match OS.") choices_universe = common.NamedInts() choices_universe[0x00] = "iOS, MacOS" choices_universe[0x01] = "Android, Windows" feature = _F.DUALPLATFORM rw_options = {"read_fnid": 0x00, "write_fnid": 0x20} validator_class = settings_validator.ChoicesValidator validator_options = {"choices": choices_universe} class ChangeHost(settings.Setting): name = "change-host" label = _("Change Host") description = _("Switch connection to a different host") persist = False # persisting this setting is harmful feature = _F.CHANGE_HOST rw_options = {"read_fnid": 0x00, "write_fnid": 0x10, "no_reply": True} choices_universe = common.NamedInts(**{"Host " + str(i + 1): i for i in range(3)}) class validator_class(settings_validator.ChoicesValidator): @classmethod def build(cls, setting_class, device): infos = device.feature_request(_F.CHANGE_HOST) assert infos, "Oops, host count cannot be retrieved!" numHosts, currentHost = struct.unpack("!BB", infos[:2]) hostNames = _hidpp20.get_host_names(device) hostNames = hostNames if hostNames is not None else {} if currentHost not in hostNames or hostNames[currentHost][1] == "": hostNames[currentHost] = (True, socket.gethostname().partition(".")[0]) choices = common.NamedInts() for host in range(0, numHosts): paired, hostName = hostNames.get(host, (True, "")) choices[host] = f"{str(host + 1)}:{hostName}" if hostName else str(host + 1) return cls(choices=choices, read_skip_byte_count=1) if choices and len(choices) > 1 else None _GESTURE2_GESTURES_LABELS = { GestureId.TAP_1_FINGER: (_("Single tap"), _("Performs a left click.")), GestureId.TAP_2_FINGER: (_("Single tap with two fingers"), _("Performs a right click.")), GestureId.TAP_3_FINGER: (_("Single tap with three fingers"), None), GestureId.CLICK_1_FINGER: (None, None), GestureId.CLICK_2_FINGER: (None, None), GestureId.CLICK_3_FINGER: (None, None), GestureId.DOUBLE_TAP_1_FINGER: (_("Double tap"), _("Performs a double click.")), GestureId.DOUBLE_TAP_2_FINGER: (_("Double tap with two fingers"), None), GestureId.DOUBLE_TAP_3_FINGER: (_("Double tap with three fingers"), None), GestureId.TRACK_1_FINGER: (None, None), GestureId.TRACKING_ACCELERATION: (None, None), GestureId.TAP_DRAG_1_FINGER: (_("Tap and drag"), _("Drags items by dragging the finger after double tapping.")), GestureId.TAP_DRAG_2_FINGER: ( _("Tap and drag with two fingers"), _("Drags items by dragging the fingers after double tapping."), ), GestureId.DRAG_3_FINGER: (_("Tap and drag with three fingers"), None), GestureId.TAP_GESTURES: (None, None), GestureId.FN_CLICK_GESTURE_SUPPRESSION: ( _("Suppress tap and edge gestures"), _("Disables tap and edge gestures (equivalent to pressing Fn+LeftClick)."), ), GestureId.SCROLL_1_FINGER: (_("Scroll with one finger"), _("Scrolls.")), GestureId.SCROLL_2_FINGER: (_("Scroll with two fingers"), _("Scrolls.")), GestureId.SCROLL_2_FINGER_HORIZONTAL: (_("Scroll horizontally with two fingers"), _("Scrolls horizontally.")), GestureId.SCROLL_2_FINGER_VERTICAL: (_("Scroll vertically with two fingers"), _("Scrolls vertically.")), GestureId.SCROLL_2_FINGER_STATELESS: (_("Scroll with two fingers"), _("Scrolls.")), GestureId.NATURAL_SCROLLING: (_("Natural scrolling"), _("Inverts the scrolling direction.")), GestureId.THUMBWHEEL: (_("Thumbwheel"), _("Enables the thumbwheel.")), GestureId.V_SCROLL_INTERTIA: (None, None), GestureId.V_SCROLL_BALLISTICS: (None, None), GestureId.SWIPE_2_FINGER_HORIZONTAL: (None, None), GestureId.SWIPE_3_FINGER_HORIZONTAL: (None, None), GestureId.SWIPE_4_FINGER_HORIZONTAL: (None, None), GestureId.SWIPE_3_FINGER_VERTICAL: (None, None), GestureId.SWIPE_4_FINGER_VERTICAL: (None, None), GestureId.LEFT_EDGE_SWIPE_1_FINGER: (None, None), GestureId.RIGHT_EDGE_SWIPE_1_FINGER: (None, None), GestureId.BOTTOM_EDGE_SWIPE_1_FINGER: (None, None), GestureId.TOP_EDGE_SWIPE_1_FINGER: (_("Swipe from the top edge"), None), GestureId.LEFT_EDGE_SWIPE_1_FINGER_2: (_("Swipe from the left edge"), None), GestureId.RIGHT_EDGE_SWIPE_1_FINGER_2: (_("Swipe from the right edge"), None), GestureId.BOTTOM_EDGE_SWIPE_1_FINGER_2: (_("Swipe from the bottom edge"), None), GestureId.TOP_EDGE_SWIPE_1_FINGER_2: (_("Swipe from the top edge"), None), GestureId.LEFT_EDGE_SWIPE_2_FINGER: (_("Swipe two fingers from the left edge"), None), GestureId.RIGHT_EDGE_SWIPE_2_FINGER: (_("Swipe two fingers from the right edge"), None), GestureId.BOTTOM_EDGE_SWIPE_2_FINGER: (_("Swipe two fingers from the bottom edge"), None), GestureId.TOP_EDGE_SWIPE_2_FINGER: (_("Swipe two fingers from the top edge"), None), GestureId.ZOOM_2_FINGER: (_("Zoom with two fingers."), _("Pinch to zoom out; spread to zoom in.")), GestureId.ZOOM_2_FINGER_PINCH: (_("Pinch to zoom out."), _("Pinch to zoom out.")), GestureId.ZOOM_2_FINGER_SPREAD: (_("Spread to zoom in."), _("Spread to zoom in.")), GestureId.ZOOM_3_FINGER: (_("Zoom with three fingers."), None), GestureId.ZOOM_2_FINGER_STATELESS: (_("Zoom with two fingers"), _("Pinch to zoom out; spread to zoom in.")), GestureId.TWO_FINGERS_PRESENT: (None, None), GestureId.ROTATE_2_FINGER: (None, None), GestureId.FINGER_1: (None, None), GestureId.FINGER_2: (None, None), GestureId.FINGER_3: (None, None), GestureId.FINGER_4: (None, None), GestureId.FINGER_5: (None, None), GestureId.FINGER_6: (None, None), GestureId.FINGER_7: (None, None), GestureId.FINGER_8: (None, None), GestureId.FINGER_9: (None, None), GestureId.FINGER_10: (None, None), GestureId.DEVICE_SPECIFIC_RAW_DATA: (None, None), } _GESTURE2_PARAMS_LABELS = { ParamId.EXTRA_CAPABILITIES: (None, None), # not supported ParamId.PIXEL_ZONE: (_("Pixel zone"), None), # TO DO: replace None with a short description ParamId.RATIO_ZONE: (_("Ratio zone"), None), # TO DO: replace None with a short description ParamId.SCALE_FACTOR: (_("Scale factor"), _("Sets the cursor speed.")), } _GESTURE2_PARAMS_LABELS_SUB = { "left": (_("Left"), _("Left-most coordinate.")), "bottom": (_("Bottom"), _("Bottom coordinate.")), "width": (_("Width"), _("Width.")), "height": (_("Height"), _("Height.")), "scale": (_("Scale"), _("Cursor speed.")), } class Gesture2Gestures(settings.BitFieldWithOffsetAndMaskSetting): name = "gesture2-gestures" label = _("Gestures") description = _("Tweak the mouse/touchpad behaviour.") feature = _F.GESTURE_2 rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} validator_options = {"om_method": hidpp20.Gesture.enable_offset_mask} choices_universe = hidpp20_constants.GestureId _labels = _GESTURE2_GESTURES_LABELS class validator_class(settings_validator.BitFieldWithOffsetAndMaskValidator): @classmethod def build(cls, setting_class, device, om_method=None): options = [g for g in device.gestures.gestures.values() if g.can_be_enabled or g.default_enabled] return cls(options, om_method=om_method) if options else None class Gesture2Divert(settings.BitFieldWithOffsetAndMaskSetting): name = "gesture2-divert" label = _("Gestures Diversion") description = _("Divert mouse/touchpad gestures.") feature = _F.GESTURE_2 rw_options = {"read_fnid": 0x30, "write_fnid": 0x40} validator_options = {"om_method": hidpp20.Gesture.diversion_offset_mask} choices_universe = hidpp20_constants.GestureId _labels = _GESTURE2_GESTURES_LABELS class validator_class(settings_validator.BitFieldWithOffsetAndMaskValidator): @classmethod def build(cls, setting_class, device, om_method=None): options = [g for g in device.gestures.gestures.values() if g.can_be_diverted] return cls(options, om_method=om_method) if options else None class Gesture2Params(settings.LongSettings): name = "gesture2-params" label = _("Gesture params") description = _("Change numerical parameters of a mouse/touchpad.") feature = _F.GESTURE_2 rw_options = {"read_fnid": 0x70, "write_fnid": 0x80} choices_universe = hidpp20_constants.ParamId sub_items_universe = hidpp20.SUB_PARAM # item (NamedInt) -> list/tuple of objects that have the following attributes # .id (sub-item text), .length (in bytes), .minimum and .maximum _labels = _GESTURE2_PARAMS_LABELS _labels_sub = _GESTURE2_PARAMS_LABELS_SUB class validator_class(settings_validator.MultipleRangeValidator): @classmethod def build(cls, setting_class, device): params = _hidpp20.get_gestures(device).params.values() items = [i for i in params if i.sub_params] if not items: return None sub_items = {i: i.sub_params for i in items} return cls(items, sub_items) class MKeyLEDs(settings.BitFieldSetting): name = "m-key-leds" label = _("M-Key LEDs") description = ( _("Control the M-Key LEDs.") + "\n" + _("May need Onboard Profiles set to Disable to be effective.") + "\n" + _("May need G Keys diverted to be effective.") ) feature = _F.MKEYS choices_universe = common.NamedInts() for i in range(8): choices_universe[1 << i] = "M" + str(i + 1) _labels = {k: (None, _("Lights up the %s key.") % k) for k in choices_universe} class rw_class(settings.FeatureRW): def __init__(self, feature): super().__init__(feature, write_fnid=0x10) def read(self, device): # no way to read, so just assume off return b"\x00" class validator_class(settings_validator.BitFieldValidator): @classmethod def build(cls, setting_class, device): number = device.feature_request(setting_class.feature, 0x00)[0] options = [setting_class.choices_universe[1 << i] for i in range(number)] return cls(options) if options else None class MRKeyLED(settings.Setting): name = "mr-key-led" label = _("MR-Key LED") description = ( _("Control the MR-Key LED.") + "\n" + _("May need Onboard Profiles set to Disable to be effective.") + "\n" + _("May need G Keys diverted to be effective.") ) feature = _F.MR class rw_class(settings.FeatureRW): def __init__(self, feature): super().__init__(feature, write_fnid=0x00) def read(self, device): # no way to read, so just assume off return b"\x00" ## Only implemented for devices that can produce Key and Consumer Codes (e.g., Craft) ## and devices that can produce Key, Mouse, and Horizontal Scroll (e.g., M720) ## Only interested in current host, so use 0xFF for it class PersistentRemappableAction(settings.Settings): name = "persistent-remappable-keys" label = _("Persistent Key/Button Mapping") description = ( _("Permanently change the mapping for the key or button.") + "\n" + _("Changing important keys or buttons (such as for the left mouse button) can result in an unusable system.") ) persist = False # This setting is persistent in the device so no need to persist it here feature = _F.PERSISTENT_REMAPPABLE_ACTION keys_universe = special_keys.CONTROL choices_universe = special_keys.KEYS class rw_class: def __init__(self, feature): self.feature = feature self.kind = settings.FeatureRW.kind def read(self, device, key): ks = device.remap_keys[device.remap_keys.index(key)] return b"\x00\x00" + ks.data_bytes def write(self, device, key, data_bytes): ks = device.remap_keys[device.remap_keys.index(key)] v = ks.remap(data_bytes) return v class validator_class(settings_validator.ChoicesMapValidator): @classmethod def build(cls, setting_class, device): remap_keys = device.remap_keys if not remap_keys: return None capabilities = device.remap_keys.capabilities if capabilities & 0x0041 == 0x0041: # Key and Consumer Codes keys = special_keys.KEYS_KEYS_CONSUMER elif capabilities & 0x0023 == 0x0023: # Key, Mouse, and HScroll Codes keys = special_keys.KEYS_KEYS_MOUSE_HSCROLL else: if logger.isEnabledFor(logging.WARNING): logger.warning("%s: unimplemented Persistent Remappable capability %s", device.name, hex(capabilities)) return None choices = {} for k in remap_keys: if k is not None: key = special_keys.CONTROL[k.key] choices[key] = keys # TO RECOVER FROM BAD VALUES use special_keys.KEYS return cls(choices, key_byte_count=2, byte_count=4) if choices else None def validate_read(self, reply_bytes, key): start = self._key_byte_count + self._read_skip_byte_count end = start + self._byte_count reply_value = common.bytes2int(reply_bytes[start:end]) & self.mask # Craft keyboard has a value that isn't valid so fudge these values if reply_value not in self.choices[key]: if logger.isEnabledFor(logging.WARNING): logger.warning("unusual persistent remappable action mapping %x: use Default", reply_value) reply_value = special_keys.KEYS_Default return reply_value class Sidetone(settings.Setting): name = "sidetone" label = _("Sidetone") description = _("Set sidetone level.") feature = _F.SIDETONE validator_class = settings_validator.RangeValidator min_value = 0 max_value = 100 class Equalizer(settings.RangeFieldSetting): name = "equalizer" label = _("Equalizer") description = _("Set equalizer levels.") feature = _F.EQUALIZER rw_options = {"read_fnid": 0x20, "write_fnid": 0x30, "read_prefix": b"\x00"} keys_universe = [] class validator_class(settings_validator.PackedRangeValidator): @classmethod def build(cls, setting_class, device): data = device.feature_request(_F.EQUALIZER, 0x00) if not data: return None count, dbRange, _x, dbMin, dbMax = struct.unpack("!BBBBB", data[:5]) if dbMin == 0: dbMin = -dbRange if dbMax == 0: dbMax = dbRange map = common.NamedInts() for g in range((count + 6) // 7): freqs = device.feature_request(_F.EQUALIZER, 0x10, g * 7) for b in range(7): if g * 7 + b >= count: break map[g * 7 + b] = str(int.from_bytes(freqs[2 * b + 1 : 2 * b + 3], "big")) + _("Hz") return cls(map, min_value=dbMin, max_value=dbMax, count=count, write_prefix_bytes=b"\x02") class ADCPower(settings.Setting): name = "adc_power_management" label = _("Power Management") description = _("Power off in minutes (0 for never).") feature = _F.ADC_MEASUREMENT min_version = 2 # documentation for version 1 does not mention this capability rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} validator_class = settings_validator.RangeValidator min_value = 0x00 max_value = 0xFF validator_options = {"byte_count": 1} class HeadsetEcoMode(settings.Setting): name = "headset-eco-mode" label = _("Eco Mode") description = _("Battery saver mode.") feature = _F.HEADSET_BATTERY_SAVER validator_class = settings_validator.BooleanValidator @classmethod def build(cls, device): # G522 firmware rejects no-op writes with device-specific NACK 0x0B. # BooleanValidator.prepare_write already skips writes that match the # current value when needs_current_value=True; default-mask (0xFF) # BooleanValidators get needs_current_value=False, so flip it here. rw = settings.FeatureRW(cls.feature) validator = settings_validator.BooleanValidator() validator.needs_current_value = True return cls(device, rw, validator) class HeadsetDoNotDisturb(settings.Setting): name = "headset-do-not-disturb" label = _("Do Not Disturb") description = _("Suppress notification sounds.") feature = _F.HEADSET_DO_NOT_DISTURB validator_class = settings_validator.BooleanValidator class HeadsetMicMute(settings.Setting): name = "headset-mic-mute" label = _("Mic Mute") description = _("Mute the microphone.") feature = _F.HEADSET_MIC_MUTE validator_class = settings_validator.BooleanValidator # HEADSET_MIC_MUTE (0x0601) doesn't follow the typical fn 0 GetState / # fn 1 SetState pattern that BooleanValidator defaults to. Function # layout (confirmed via G HUB pcap on G522): # fn 0 — physical-mute-switch state-change events from the device # fn 1 — state-change events emitted as the device's echo of a # host-driven SetState; also serves as the host-callable # GetState read # fn 2 — host-callable SetState (single byte: 0=unmuted, 1=muted) # The standard fn 0/1 write path returns 0x0A UNSUPPORTED. State-change # events from both fn 0 and fn 1 are handled by _process_feature_notification # so the toggle reflects physical mute presses too. rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} class HeadsetMicSNR(settings.Setting): name = "headset-mic-snr" label = _("Mic SNR") description = _("Microphone signal-to-noise ratio enhancement.") feature = _F.HEADSET_MIC_SNR validator_class = settings_validator.BooleanValidator class HeadsetAINR(settings.Setting): name = "headset-ai-nr" label = _("AI Noise Reduction") description = _("Enable AI noise reduction.") feature = _F.HEADSET_AI_NOISE_REDUCTION validator_class = settings_validator.BooleanValidator class HeadsetAINRLevel(settings.Setting): name = "headset-ai-nr-level" label = _("AI Noise Reduction Level") description = _("AI noise reduction intensity.") feature = _F.HEADSET_AI_NOISE_REDUCTION rw_options = {"read_fnid": 0x20, "write_fnid": 0x30} validator_class = settings_validator.ChoicesValidator choices_universe = common.NamedInts(Off=0, Low=1, Medium=2, High=3) class HeadsetSidetone(settings.Setting): name = "headset-sidetone" label = _("Headset Sidetone") description = _("Sidetone level (0 = off, 100 = max).") feature = _F.HEADSET_AUDIO_SIDETONE rw_options = {"read_fnid": 0x00, "write_fnid": 0x10} validator_class = settings_validator.RangeValidator min_value = 0 max_value = 100 @classmethod def build(cls, device): # Version <= 1: GetSidetone returns [mic_count, mic_id, level]; SetSidetone takes [mic_id, level] # Version > 1: GetSidetone returns [mic_count, mic_id, reserved, level]; SetSidetone takes [mic_id, 0xFF, level] version = device.features.get_feature_version(cls.feature) or 0 if version > 1: skip, prefix = 3, b"\x01\xff" else: skip, prefix = 2, b"\x01" rw = settings.FeatureRW(cls.feature, **cls.rw_options) validator = cls.validator_class.build(cls, device, read_skip_byte_count=skip, write_prefix_bytes=prefix) if validator: return cls(device, rw, validator) class HeadsetMicGain(settings.Setting): name = "headset-mic-gain" label = _("Mic Gain") description = _("Microphone gain level.") feature = _F.HEADSET_MIC_GAIN rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} validator_class = settings_validator.RangeValidator # Fallback range covers int8; build() overrides with device-reported bounds # from GetInfo (fn 0) so SetMicGain doesn't get device-specific # out-of-range NACK (error 0x0B) on devices that use a small signed range # (e.g. G522 reports a narrow window like -12..+12). min_value = -128 max_value = 127 validator_options = {"byte_count": 1, "signed": True} @classmethod def build(cls, device): # GetInfo (function 0) returns [min_gain (int8), max_gain (int8)]. # Query once at build time so the slider range reflects the device's # actual supported range rather than a generic int8 window. try: info = device.feature_request(cls.feature, 0x00) except Exception as e: logger.info("HeadsetMicGain: GetInfo raised %s, using fallback int8 range", e) info = None if info and len(info) >= 2: min_gain = struct.unpack("b", bytes([info[0]]))[0] max_gain = struct.unpack("b", bytes([info[1]]))[0] if max_gain <= min_gain: # sanity — fall back to class defaults logger.info( "HeadsetMicGain: GetInfo returned nonsense range [%d, %d] (hex=%s), using fallback int8 range", min_gain, max_gain, info.hex(), ) min_gain, max_gain = cls.min_value, cls.max_value elif logger.isEnabledFor(logging.DEBUG): logger.debug( "HeadsetMicGain: device reports gain range [%d, %d]", min_gain, max_gain, ) else: logger.info( "HeadsetMicGain: GetInfo returned %s, using fallback int8 range", info.hex() if info else info, ) min_gain, max_gain = cls.min_value, cls.max_value rw = settings.FeatureRW(cls.feature, **cls.rw_options) validator = settings_validator.RangeValidator(min_value=min_gain, max_value=max_gain, byte_count=1, signed=True) return cls(device, rw, validator) class HeadsetMixBalance(settings.Setting): name = "headset-mix-balance" label = _("Audio Mix Balance") description = _("Balance between game and chat audio.") feature = _F.HEADSET_MIX validator_class = settings_validator.RangeValidator min_value = 0 max_value = 255 validator_options = {"byte_count": 1} class _AutoSleepRangeValidator(settings_validator.RangeValidator): """Single-slot read-modify-write validator for HID++ 0x0108 AutoSleep. 0x0108 is not a single timer: V3 has two uint8 bytes, V4+ has three. Each byte is an independent timer slot. Solaar exposes only the user-facing slot today and preserves the others via RMW; writing zero into the other slots causes the firmware to reject the request. Wire byte layout per feature version: V<3: [timer] V3: [reserved, timer] — preserve byte[0] V4+: [timer_a, timer_b, timer_c] — preserve byte[1], byte[2] """ def __init__(self, byte_count, **kwargs): super().__init__(byte_count=byte_count, **kwargs) # V3 sources the user-controllable timer from byte[1] per LGHUB. self._slot = 1 if byte_count == 2 else 0 def validate_read(self, reply_bytes): if len(reply_bytes) <= self._slot: raise AssertionError( f"{self.__class__.__name__}: read returned {len(reply_bytes)} bytes, expected ≥ {self._slot + 1}" ) return reply_bytes[self._slot] def prepare_write(self, new_value, current_value=None): if new_value < self.min_value or new_value > self.max_value: raise ValueError(f"invalid choice {new_value!r}") if current_value is None: payload = bytearray(self._byte_count) else: payload = bytearray(current_value[: self._byte_count]) if len(payload) < self._byte_count: payload.extend(b"\x00" * (self._byte_count - len(payload))) if payload[self._slot] == new_value: return None payload[self._slot] = new_value return bytes(payload) class HeadsetAutoSleep(settings.Setting): name = "headset-auto-sleep" label = _("Auto Sleep Timeout") description = _("Idle time in minutes before the headset enters sleep mode (0 = disabled).") feature = _F.CENTURION_AUTO_SLEEP rw_options = {"read_fnid": 0x00, "write_fnid": 0x10} validator_class = _AutoSleepRangeValidator min_value = 0 max_value = 255 # uint8 slot validator_options = {"byte_count": 1} @classmethod def build(cls, device): version = device.features.get_feature_version(cls.feature) or 0 if version >= 4: byte_count = 3 elif version >= 3: byte_count = 2 else: byte_count = 1 rw = settings.FeatureRW(cls.feature, **cls.rw_options) validator = _AutoSleepRangeValidator(min_value=0, max_value=cls.max_value, byte_count=byte_count) return cls(device, rw, validator) class HeadsetOnboardEQ(settings.RangeFieldSetting): name = "headset-onboard-eq" label = _("Headset Equalizer") description = _("Set equalizer levels.") feature = _F.HEADSET_ONBOARD_EQ rw_options = {"read_fnid": 0x10, "write_fnid": 0x20, "read_prefix": b"\x00"} keys_universe = [] class validator_class(settings_validator.PackedRangeValidator): kind = settings.Kind.GRAPHIC_EQ @classmethod def build(cls, setting_class, device): info = hidpp20.get_onboard_eq_info(device) if not info: logger.info("HeadsetOnboardEQ.build: getEQInfo failed, no panel will be built") return None _has_hw_eq, num_bands = info bands = hidpp20.get_onboard_eq_params(device, slot=0x00) if not bands: logger.info("HeadsetOnboardEQ.build: getEQParameters returned no bands, no panel will be built") return None if len(bands) != num_bands: logger.info( "HeadsetOnboardEQ.build: band count mismatch — EQInfo=%d getEQParameters=%d; skipping", num_bands, len(bands), ) return None keys = common.NamedInts() for i, (freq, _gain, _q) in enumerate(bands): keys[i] = str(freq) + _("Hz") v = cls(keys, min_value=-12, max_value=12, count=num_bands, byte_count=1) v._band_freqs = [freq for freq, _g, _q in bands] v._band_qs = [q for _f, _g, q in bands] logger.info("HeadsetOnboardEQ.build: panel built with %d band(s)", num_bands) return v def validate_read(self, reply_bytes): if reply_bytes is None or len(reply_bytes) < 2: return {} band_count = reply_bytes[1] result = {} offset = 2 for i in range(band_count): if offset + 4 > len(reply_bytes): break freq = struct.unpack(">H", reply_bytes[offset : offset + 2])[0] gain = struct.unpack("b", bytes([reply_bytes[offset + 2]]))[0] q = reply_bytes[offset + 3] result[i] = gain # Update stored freq/Q arrays if they exist if hasattr(self, "_band_freqs") and i < len(self._band_freqs): self._band_freqs[i] = freq if hasattr(self, "_band_qs") and i < len(self._band_qs): self._band_qs[i] = q offset += 4 return result def prepare_write(self, new_values): if not hasattr(self, "_band_freqs") or not hasattr(self, "_band_qs"): return None bands = [] for i in range(self.count): freq = self._band_freqs[i] if i < len(self._band_freqs) else 1000 q = self._band_qs[i] if i < len(self._band_qs) else 10 gain = new_values.get(i, 0) bands.append((freq, gain, q)) self._pending_bands = bands # stash for persist step return hidpp20._build_set_eq_payload(0x00, bands) def write(self, map, save=True): result = super().write(map, save) # Also persist to device flash (slot 0x80) so EQ survives power cycle if result is not None and hasattr(self._validator, "_pending_bands"): bands = self._validator._pending_bands del self._validator._pending_bands try: self._device.feature_request(_F.HEADSET_ONBOARD_EQ, 0x20, hidpp20._build_set_eq_payload(0x80, bands)) except Exception: logger.warning("HeadsetOnboardEQ: failed to persist EQ to slot 0x80") return result class HeadsetAdvancedEQ(settings.RangeFieldSetting): """Read-only display of the headset's active AdvancedParaEQ (0x020D) bands. Writes are intentionally disabled for now. The V2 wire format is known (see advanced_para_eq.py) but we want a round-trip test on real hardware before enabling user-facing writes that could misconfigure the DSP. V0/V1: 3-byte band stride [freq_hi, freq_lo, gain_i8], gain is whole dB. V2: 5-byte band stride [filter_type, freq_hi, freq_lo, gain_hi, gain_lo], filter_type 0x00=HP 0x78=peaking, freq is raw Hz, gain is signed int16 × step_db (step_db from getEQInfos). """ name = "headset-advanced-eq" label = _("Headset Advanced EQ (read-only)") description = _("Display the headset's active parametric EQ. Writes are disabled pending verification.") feature = _F.HEADSET_ADVANCED_PARA_EQ rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} keys_universe = [] class rw_class(settings.FeatureRW): """getCustomEQ takes [direction, slot]; the slot is the *active* EQ preset, which the device may have switched while we weren't looking (G HUB on another machine, an onboard button, etc.). Re-query it on every read instead of caching slot 0 at build time. Direction is hardcoded to 0 (playback) — mic-side EQ isn't exposed yet.""" def read(self, device, data_bytes=b""): active_slot = hidpp20.get_advanced_eq_active_slot(device, direction=0) self.read_prefix = bytes([0, active_slot if active_slot is not None else 0]) return super().read(device, data_bytes) class validator_class(settings_validator.PackedRangeValidator): kind = settings.Kind.GRAPHIC_EQ @classmethod def build(cls, setting_class, device): info = hidpp20.get_advanced_eq_info(device) if not info: logger.info("HeadsetAdvancedEQ.build: getEQInfos failed, no panel will be built") return None device._advanced_eq_info = info version = info["version"] gain_min = info["gain_min_db"] gain_max = info["gain_max_db"] step_db = info["step_db"] active_slot = hidpp20.get_advanced_eq_active_slot(device, direction=0) or 0 bands = hidpp20.get_advanced_eq_params(device, direction=0, slot=active_slot) if not bands: logger.info("HeadsetAdvancedEQ.build: getCustomEQ returned no bands, no panel will be built") return None band_count = len(bands) expected = info.get("band_count") if expected is not None and expected != band_count: logger.info( "HeadsetAdvancedEQ.build: V%d band count mismatch — EQInfos=%d getCustomEQ=%d; trusting getCustomEQ", version, expected, band_count, ) keys = common.NamedInts() for i, (filter_type, freq_hz, _gain_db) in enumerate(bands): if filter_type == hidpp20.FILTER_TYPE_HP: keys[i] = "HP " + str(freq_hz) + _("Hz") else: keys[i] = str(freq_hz) + _("Hz") v = cls( keys, min_value=int(round(gain_min)), max_value=int(round(gain_max)), count=band_count, byte_count=1, ) v._version = version v._step_db = step_db v._gain_min = gain_min v._gain_max = gain_max v._gain_steps = info.get("gain_steps", 241) v._band_types = [band[0] for band in bands] v._band_freqs = [band[1] for band in bands] v._active_slot = active_slot logger.info( "HeadsetAdvancedEQ.build: panel built V%d with %d band(s), slot=%d, range=[%d,%d], step_db=%.4f", version, band_count, active_slot, gain_min, gain_max, step_db, ) # One-shot per-slot probe — logs band data for each slot the # firmware actually honors and caches the working-slot list on # `device._advanced_eq_working_slots`. Cheap if HeadsetActiveEQPreset # already populated the cache (it usually has at this point); # otherwise this is the first-time probe. if version >= 2: try: hidpp20.probe_advanced_eq_slots(device, direction=0, info=info) except Exception as e: logger.info("HeadsetAdvancedEQ.build: preset corpus probe failed: %s", e) return v def validate_read(self, reply_bytes): if reply_bytes is None: return {} version = getattr(self, "_version", 0) if version >= 2: info = { "gain_min_db": getattr(self, "_gain_min", -6), "gain_max_db": getattr(self, "_gain_max", 6), "gain_steps": getattr(self, "_gain_steps", 241), "step_db": getattr(self, "_step_db", 0.05), } bands = hidpp20.parse_v2_bands(reply_bytes, info) if bands is None: return {} result = {} for i, (filter_type, freq_hz, gain_db) in enumerate(bands): if i >= self.count: break result[i] = int(round(gain_db)) if hasattr(self, "_band_types") and i < len(self._band_types): self._band_types[i] = filter_type if hasattr(self, "_band_freqs") and i < len(self._band_freqs): self._band_freqs[i] = freq_hz return result # V0/V1: 3-byte stride. result = {} offset = 0 i = 0 while offset + 3 <= len(reply_bytes) and i < self.count: freq = struct.unpack(">H", reply_bytes[offset : offset + 2])[0] if freq == 0: break gain = struct.unpack("b", bytes([reply_bytes[offset + 2]]))[0] result[i] = gain if hasattr(self, "_band_freqs") and i < len(self._band_freqs): self._band_freqs[i] = freq offset += 3 i += 1 return result def prepare_write(self, new_values): # Read-only mode: never actually build a write payload. return None def write(self, map, save=True): # Read-only for now — log attempted writes but don't transmit anything. logger.info("HeadsetAdvancedEQ: write ignored (read-only mode); requested=%s", map) return None class HeadsetActiveEQPreset(settings.Setting): """Choose which AdvancedParaEQ slot drives live audio. Activation works for any slot — read-only factory presets and user-custom slots alike. The "(factory)" tag in the slot label distinguishes the read-only ones; that distinction matters for band-editing (not supported yet), not for activation today. """ name = "headset-eq-active-preset" label = _("EQ Preset") description = _("Switch the active EQ preset. Factory presets are read-only.") feature = _F.HEADSET_ADVANCED_PARA_EQ rw_options = {"read_fnid": 0x30, "write_fnid": 0x40, "prefix": b"\x00"} validator_class = settings_validator.ChoicesValidator @classmethod def build(cls, device): info = getattr(device, "_advanced_eq_info", None) or hidpp20.get_advanced_eq_info(device) if not info: return None ro_count = info.get("onboard_ro_preset_count", 0) or 0 # Probe each advertised slot — getEQInfos may report capacity that # the firmware doesn't actually back (G522 advertises 16 slots but # only honors slot 0). Only include slots that responded with band # data; the result is cached on device._advanced_eq_working_slots # so HeadsetAdvancedEQ.build can reuse it without re-probing. working = hidpp20.probe_advanced_eq_slots(device, direction=0, info=info) if len(working) <= 1: # One option (or zero) is meaningless as a selector — there's # nothing for the user to choose between. The active EQ is # whatever slot 0 has, no preset switching is available. return None choices = common.NamedInts() for slot, slot_name, _bands in working: if not slot_name: slot_name = _("Slot") + " " + str(slot) if slot < ro_count: slot_name = slot_name + " " + _("(factory)") choices[slot] = slot_name rw = settings.FeatureRW(cls.feature, **cls.rw_options) validator = settings_validator.ChoicesValidator(choices=choices) return cls(device, rw, validator) def write(self, value, save=True): result = super().write(value, save) if result is not None: # After setActiveEQ, repopulate the AdvancedParaEQ band-display # cache so the panel reflects the newly-active slot. Force a # fresh read so _value is a real dict — leaving it as None # would let a UI band-click hit `_value[item]` on None and # crash (config_panel.py:589 'NoneType' is not subscriptable). # The visible widget redraw still waits for a manual refresh / # panel reopen — auto-redraw would need UI-side plumbing. eq_panel = _headset_setting_by_name(self._device, HeadsetAdvancedEQ.name) if eq_panel is not None: try: eq_panel._value = None eq_panel.read(cached=False) except Exception as e: logger.info("HeadsetActiveEQPreset: failed to refresh EQ panel: %s", e) return result _NO_CHANGE_COLOR = int(special_keys.COLORSPLUS["No change"]) def _headset_setting_by_name(device, name): for s in getattr(device, "settings", None) or []: if getattr(s, "name", None) == name: return s return None def _headset_primary_color(device, default=0xFFFFFF): """Resolve the currently-saved Primary color, or `default` if absent.""" s = _headset_setting_by_name(device, HeadsetLEDsPrimary.name) if s is None: return default value = getattr(s, "_value", None) color = getattr(value, "color", None) if value is not None else None return int(color) if color is not None else default def _headset_per_zone_overrides(device): """Return `{zone_id: color_int}` for zones with explicit (non-'No change') colors set via the Per-zone Lighting setting, or `None` if the setting isn't built/present.""" s = _headset_setting_by_name(device, HeadsetPerZoneLighting.name) if s is None: return None value = getattr(s, "_value", None) if not isinstance(value, dict): return None overrides = {} for zone, color in value.items(): try: color_int = int(color) except (TypeError, ValueError): continue if color_int != _NO_CHANGE_COLOR: overrides[int(zone)] = color_int return overrides class _HeadsetStaticEffectOption: """Minimal stand-in for `hidpp20.LEDEffectInfo`. `HeteroValidator` only inspects `.ID` and `.index` on its `options` list; we don't need the full device-query machinery here because the headset wire protocol is handled by `headset_rgb.write_zone_map`. """ ID = 0x01 # matches hidpp20.LEDEffects[0x01] = Static index = 0x01 class HeadsetLEDControl(settings.Setting): """Switch headset LED control between device and Solaar. Mirrors the `LEDControl` / `RGBControl` pattern used for keyboards and mice. When set to Solaar, the `LEDs Primary` and `Per-zone Lighting` settings drive the LEDs; when set to Device, firmware-driven onboard and signature effects resume. """ name = "headset_led_control" label = _("LED Control") description = _("Switch control of LED zones between device and Solaar") feature = _F.HEADSET_RGB_HOSTMODE rw_options = {"read_fnid": 0x70, "write_fnid": 0x80} choices_universe = common.NamedInts(Device=0, Solaar=1) validator_class = settings_validator.ChoicesValidator validator_options = {"choices": choices_universe} @classmethod def build(cls, device): # One-shot read-only probe of 0x0621 / 0x0622 — logs the data the RE # pass needs to pin down RGB onboard/signature effect structures. # Skip cleanly if neither feature is exposed. try: rgb_effects_probe.probe(device) except Exception as e: logger.info("RGB effects probe raised %r", e) return super().build(device) def write(self, value, save=True): # After switching to Solaar control, the firmware drops whatever # colors we'd programmed — so reassert the saved Primary + per-zone # overrides immediately. Otherwise the LEDs stay on whatever # device-driven effect was last shown until the user edits a color. result = super().write(value, save) if result is not None and int(value) == 1 and self._device.online: primary = _headset_primary_color(self._device) zones = headset_rgb.discover_zones(self._device) if zones: zone_map = {int(z): primary for z in zones} zone_map.update(_headset_per_zone_overrides(self._device) or {}) headset_rgb.write_zone_map(self._device, zone_map) return result class HeadsetLEDsPrimary(settings.Setting): """Primary headset LED color, rendered as a GTK color picker. Mirrors the `LEDZoneSetting` / `RGBEffectSetting` shape: a `HeteroValidator` with a single "Static" effect whose only visible field is the color. Write applies the chosen color across all zones discovered at build time, then re-applies any per-zone overrides on top so they aren't clobbered. Read support is deliberately disabled — the feature exposes no "get current color" function, so we rely on the persister. """ name = "headset_leds_primary" label = _("LEDs") + " " + _("Primary") description = _( "Set the primary color applied to every headset LED zone.\n" "LED Control needs to be set to Solaar to be effective." ) feature = _F.HEADSET_RGB_HOSTMODE persist = True rw_options = {"read_fnid": None, "write_fnid": None} # HeteroKeyControl renders exactly these fields; ID is hidden # (`label=None`) but kept so setup_visibles can key off it. color_field = {"name": hidpp20.LEDParam.color, "kind": settings.Kind.COLOR, "label": _("Color")} possible_fields = [ { "name": "ID", "kind": settings.Kind.CHOICE, "label": None, "choices": [common.NamedInt(0x01, _("Static"))], }, color_field, ] # HeteroKeyControl.setup_visibles looks up fields_map[effect_id][1] to # decide which fields to show — we only expose the color. fields_map = {0x01: [common.NamedInt(0x01, _("Static")), {hidpp20.LEDParam.color: 0}]} @classmethod def build(cls, device): zones = headset_rgb.discover_zones(device) if not zones: return None rw = settings.FeatureRW(cls.feature) validator = settings_validator.HeteroValidator( data_class=hidpp20.LEDEffectSetting, options=[_HeadsetStaticEffectOption()], readable=False, ) return cls(device, rw, validator) def read(self, cached=True): # Feature 0x0620 doesn't expose a "current primary color" read — # pull from the persister via _pre_read, fall back to white so # the picker opens on a sane starting color. self._pre_read(cached) if self._value is not None: return self._value self._value = hidpp20.LEDEffectSetting(ID=common.NamedInt(0x01, _("Static")), color=0xFFFFFF) return self._value def write(self, value, save=True): color = getattr(value, "color", None) if color is None: return None device = self._device if not device.online: return None zones = headset_rgb.discover_zones(device) if not zones: return None primary = int(color) zone_map = {int(z): primary for z in zones} # Re-apply any non-"No change" per-zone overrides on top of the # fresh Primary baseline so the user's explicit zone choices stick # when they change the bulk color. overrides = _headset_per_zone_overrides(device) or {} zone_map.update(overrides) if headset_rgb.write_zone_map(device, zone_map): self.update(value, save) return value return None class HeadsetPerZoneLighting(settings.Settings): """Per-zone LED color overrides. Mirrors `PerKeyLighting` — keys are firmware zone IDs, values are 24-bit RGB ints with the `-1` sentinel meaning "inherit the current `LEDs Primary` color." Surfaces in the UI via the per-key painter. """ name = "headset_per_zone_lighting" label = _("Per-zone Lighting") description = _( "Override individual zone colors. 'No change' inherits the LEDs Primary color.\n" "LED Control needs to be set to Solaar to be effective." ) feature = _F.HEADSET_RGB_HOSTMODE persist = True editor_class = "solaar.ui.perkey.control:PerKeyControl" class rw_class(settings.FeatureRWMap): pass class validator_class(settings_validator.MapRangeValidator): _COLOR_RANGE = settings_validator.Range(min=0, max=0xFFFFFF, byte_count=3) @classmethod def build(cls, setting_class, device): zones = headset_rgb.discover_zones(device) if not zones: return None choices_map = {common.NamedInt(int(z), _("Zone") + " " + str(int(z))): cls._COLOR_RANGE for z in zones} return cls(choices_map) if choices_map else None def read(self, cached=True): self._pre_read(cached) if cached and self._value is not None: return self._value # Device doesn't expose current per-zone state; default every # zone to "No change" so the primary color shows through. reply_map = {int(key): _NO_CHANGE_COLOR for key in self._validator.choices} self._value = reply_map return reply_map def _resolve_zone_map(self, map_, primary): """Substitute 'No change' entries with the primary color.""" resolved = {} for key, value in map_.items(): try: v = int(value) except (TypeError, ValueError): continue resolved[int(key)] = primary if v == _NO_CHANGE_COLOR else v return resolved def write(self, map_, save=True): device = self._device if not device.online: return None self.update(map_, save) primary = _headset_primary_color(device) zone_map = self._resolve_zone_map(map_, primary) if not zone_map: return None if headset_rgb.write_zone_map(device, zone_map): return map_ return None def write_key_value(self, key, value, save=True): result = super().write_key_value(int(key), value, save) device = self._device if not device.online: return result try: v = int(value) except (TypeError, ValueError): return result effective = _headset_primary_color(device) if v == _NO_CHANGE_COLOR else v headset_rgb.write_zone_map(device, {int(key): int(effective)}) return result # ---------------------------------------------------------------------------- # LogiVoice (0x0900 + 0x0901..0x0907) — read-only presentation pass. # # Per module we auto-generate two settings: # 1. A flat State toggle — reads GetState (fn 1), renders as a boolean. # Top-level so users see a direct on/off indicator at a glance. # 2. A collapsible "Parameters" panel — one MULTIPLE_RANGE-kind setting # that reads GetParameters (fn 3) once and distributes the bytes to # per-field sliders. The existing MultipleRangeControl widget is # collapsible by default, so the field-level clutter stays folded. # # Writes are disabled — the Parameters struct carries fields whose wire # encodings are still ambiguous (see logivoice.py) and a SetParameters # write must bundle all fields at once. A write pass can be added once # each field's encoding is confirmed live. # ---------------------------------------------------------------------------- class _LogiVoiceStateSetting(settings.Setting): """Per-module State toggle. Reads GetState (fn 1) and writes SetState (fn 0). State wire format is unambiguous (one byte: 0 = off, 1 = on), so this is the one piece of the LogiVoice surface we enable for writes. The per-module Parameters struct stays read-only until each field's encoding is confirmed. """ rw_options = {"read_fnid": logivoice.FN_GET_STATE, "write_fnid": logivoice.FN_SET_STATE} validator_class = settings_validator.BooleanValidator @classmethod def build(cls, device): # Corpus probe runs here (once per module) so -dd users get a full # snapshot of state + raw Parameters + raw Info for future decoding. try: logivoice.probe_module(device, cls.feature) except Exception as e: logger.info("LogiVoice probe_module(%s) raised %s", cls.feature, e) return super().build(device) class _LogiVoiceModuleItem: """Top-level MULTIPLE_RANGE item representing one LogiVoice module. One `item` per setting — the module itself. `__int__` returns the feature id so the Setting's reply dict is keyed predictably. """ def __init__(self, feature: hidpp20_constants.SupportedFeature): self._feature = feature self.id = logivoice.MODULE_SLUGS.get(feature, f"0x{int(feature):04X}") self.index = 0 def __int__(self): return int(self._feature) def __str__(self): return logivoice.MODULE_NAMES.get(self._feature, f"0x{int(self._feature):04X}") class _LogiVoiceFieldSubItem: """MULTIPLE_RANGE sub-item wrapping one decoded Parameters field. MultipleRangeControl reads minimum/maximum/length/widget/str(). We pick SpinButton for wide ranges (0..65535) where a 64k-step slider is useless, and Scale for small ranges (e.g. signed int8 thresholds). """ def __init__(self, field: logivoice.Field): self._field = field self.id = field.name self.minimum = field.min_value self.maximum = field.max_value self.length = field.byte_count self.widget = "SpinButton" if (field.max_value - field.min_value) > 512 else "Scale" def __int__(self): return hash(self.id) & 0xFFFFFF def __str__(self): return self._field.label + (" (raw)" if self._field.opaque else "") class _LogiVoiceParametersValidator(settings_validator.MultipleRangeValidator): """Reads the whole GetParameters struct once and distributes bytes to fields. MULTIPLE_RANGE's default read loop fires prepare_read_item once per top- level item; we have exactly one item (the module), so this issues a single GetParameters call. validate_read_item parses the shared reply into a {field_name: value} dict. Writes are blocked. """ def __init__(self, feature: hidpp20_constants.SupportedFeature): fields = logivoice.PARAMETERS_FIELDS.get(feature, []) self._fields = list(fields) item = _LogiVoiceModuleItem(feature) sub_items = {item: [_LogiVoiceFieldSubItem(f) for f in fields]} super().__init__(items=[item], sub_items=sub_items) def prepare_read_item(self, item): return b"" # GetParameters takes no wire arguments def validate_read(self, reply_bytes): # Setting.read() calls validate_read with the raw GetParameters reply. # MultipleRangeValidator only defines validate_read_item, so wrap that # call — we have a single item (the module) so one call suffices. item = self.items[0] return {int(item): self.validate_read_item(reply_bytes, item)} def validate_read_item(self, reply_bytes, item): parsed = {} # Key by str(sub_item) so MultipleRangeControl.set_value can look up # values via v[str(sub_item)] — the UI uses the label as the dict key. for sub in self.sub_items[item]: f = sub._field end = f.offset + f.byte_count if end > len(reply_bytes): continue chunk = reply_bytes[f.offset : end] if f.byte_count == 1: v = struct.unpack("b" if f.signed else "B", chunk)[0] elif f.byte_count == 2: v = struct.unpack(">h" if f.signed else ">H", chunk)[0] else: v = int.from_bytes(chunk, "big", signed=f.signed) parsed[str(sub)] = v return parsed def prepare_write_item(self, item, value): return None def prepare_write(self, value): return None class _LogiVoiceParametersSetting(settings.Setting): """Collapsible read-only display of one module's GetParameters struct.""" rw_options = {"read_fnid": logivoice.FN_GET_PARAMETERS} persist = False kind = settings.Kind.MULTIPLE_RANGE @classmethod def build(cls, device): if not logivoice.PARAMETERS_FIELDS.get(cls.feature): return None rw = settings.FeatureRW(cls.feature, **cls.rw_options) validator = _LogiVoiceParametersValidator(cls.feature) return cls(device, rw, validator) def write(self, map, save=True): return None def _logivoice_make_state_class(feature: hidpp20_constants.SupportedFeature): slug = logivoice.MODULE_SLUGS.get(feature) if not slug: return None module_name = logivoice.MODULE_NAMES.get(feature, f"0x{int(feature):04X}") attrs = { "name": f"logivoice-{slug}-state", "label": f"LogiVoice {module_name}", "description": f"Enable the headset {module_name} processing block.", "feature": feature, } return type(f"LogiVoice_{slug}_State", (_LogiVoiceStateSetting,), attrs) def _logivoice_make_parameters_class(feature: hidpp20_constants.SupportedFeature): slug = logivoice.MODULE_SLUGS.get(feature) if not slug or not logivoice.PARAMETERS_FIELDS.get(feature): return None module_name = logivoice.MODULE_NAMES.get(feature, f"0x{int(feature):04X}") attrs = { "name": f"logivoice-{slug}-parameters", "label": f"LogiVoice {module_name}: Parameters (read-only)", "description": ( f"Decoded {module_name} GetParameters fields. " "Opaque raw values shown where the wire encoding isn't confirmed yet." ), "feature": feature, } return type(f"LogiVoice_{slug}_Parameters", (_LogiVoiceParametersSetting,), attrs) _LOGIVOICE_SETTINGS: list[type] = [] for _feature in logivoice.PARAMETERS_FIELDS: _state_cls = _logivoice_make_state_class(_feature) if _state_cls is not None: _LOGIVOICE_SETTINGS.append(_state_cls) _params_cls = _logivoice_make_parameters_class(_feature) if _params_cls is not None: _LOGIVOICE_SETTINGS.append(_params_cls) class BrightnessControl(settings.Setting): name = "brightness_control" label = _("Brightness Control") description = _("Control overall brightness") feature = _F.BRIGHTNESS_CONTROL rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} validator_class = settings_validator.RangeValidator def __init__(self, device, rw, validator): super().__init__(device, rw, validator) rw.on_off = validator.on_off rw.min_nonzero_value = validator.min_value validator.min_value = 0 if validator.on_off else validator.min_value class rw_class(settings.FeatureRW): def read(self, device, data_bytes=b""): if self.on_off: reply = device.feature_request(self.feature, 0x30) if not reply[0] & 0x01: return b"\x00\x00" return super().read(device, data_bytes) def write(self, device, data_bytes): if self.on_off: off = int.from_bytes(data_bytes, byteorder="big") < self.min_nonzero_value reply = device.feature_request(self.feature, 0x40, b"\x00" if off else b"\x01", no_reply=False) if off: return reply return super().write(device, data_bytes) class validator_class(settings_validator.RangeValidator): @classmethod def build(cls, setting_class, device): reply = device.feature_request(_F.BRIGHTNESS_CONTROL) assert reply, "Oops, brightness range cannot be retrieved!" if reply: max_value = int.from_bytes(reply[0:2], byteorder="big") min_value = int.from_bytes(reply[4:6], byteorder="big") on_off = bool(reply[3] & 0x04) # separate on/off control validator = cls(min_value=min_value, max_value=max_value, byte_count=2) validator.on_off = on_off return validator class LEDControl(settings.Setting): name = "led_control" label = _("LED Control") description = _("Switch control of LED zones between device and Solaar") feature = _F.COLOR_LED_EFFECTS rw_options = {"read_fnid": 0x70, "write_fnid": 0x80} choices_universe = common.NamedInts(Device=0, Solaar=1) validator_class = settings_validator.ChoicesValidator validator_options = {"choices": choices_universe} colors = special_keys.COLORS _LEDP = hidpp20.LEDParam # an LED Zone has an index, a set of possible LED effects, and an LED effect setting class LEDZoneSetting(settings.Setting): name = "led_zone_" # the trailing underscore signals that this setting creates other settings label = _("LED Zone Effects") description = _("Set effect for LED Zone") + "\n" + _("LED Control needs to be set to Solaar to be effective.") feature = _F.COLOR_LED_EFFECTS color_field = {"name": _LEDP.color, "kind": settings.Kind.COLOR, "label": _("Color")} speed_field = {"name": _LEDP.speed, "kind": settings.Kind.RANGE, "label": _("Speed"), "min": 0, "max": 255} period_field = {"name": _LEDP.period, "kind": settings.Kind.RANGE, "label": _("Period"), "min": 100, "max": 5000} intensity_field = {"name": _LEDP.intensity, "kind": settings.Kind.RANGE, "label": _("Intensity"), "min": 0, "max": 100} ramp_field = {"name": _LEDP.ramp, "kind": settings.Kind.CHOICE, "label": _("Ramp"), "choices": hidpp20.LedRampChoice} possible_fields = [color_field, speed_field, period_field, intensity_field, ramp_field] @classmethod def setup(cls, device, read_fnid, write_fnid, suffix): infos = device.led_effects settings_ = [] for zone in infos.zones: prefix = common.int2bytes(zone.index, 1) rw = settings.FeatureRW(cls.feature, read_fnid, write_fnid, prefix=prefix, suffix=suffix) validator = settings_validator.HeteroValidator( data_class=hidpp20.LEDEffectSetting, options=zone.effects, readable=infos.readable and read_fnid is not None ) setting = cls(device, rw, validator) setting.name = cls.name + str(int(zone.location)) setting.label = _("LEDs") + " " + str(hidpp20.LEDZoneLocations[zone.location]) choices = [hidpp20.LEDEffects[e.ID][0] for e in zone.effects if e.ID in hidpp20.LEDEffects] ID_field = {"name": "ID", "kind": settings.Kind.CHOICE, "label": None, "choices": choices} setting.possible_fields = [ID_field] + cls.possible_fields setting.fields_map = hidpp20.LEDEffects settings_.append(setting) return settings_ @classmethod def build(cls, device): return cls.setup(device, 0xE0, 0x30, b"") class RGBControl(settings.Setting): name = "rgb_control" label = _("LED Control") description = _("Switch control of LED zones between device and Solaar") feature = _F.RGB_EFFECTS rw_options = {"read_fnid": 0x50, "write_fnid": 0x50} choices_universe = common.NamedInts(Device=0, Solaar=1) validator_class = settings_validator.ChoicesValidator validator_options = {"choices": choices_universe, "write_prefix_bytes": b"\x01", "read_skip_byte_count": 1} class RGBEffectSetting(LEDZoneSetting): name = "rgb_zone_" # the trailing underscore signals that this setting creates other settings label = _("LED Zone Effects") description = _("Set effect for LED Zone") + "\n" + _("LED Control needs to be set to Solaar to be effective.") feature = _F.RGB_EFFECTS @classmethod def build(cls, device): return cls.setup(device, None, 0x10, b"\x01") class PerKeyLighting(settings.Settings): name = "per-key-lighting" label = _("Per-key Lighting") description = _("Control per-key lighting.") feature = _F.PER_KEY_LIGHTING_V2 keys_universe = special_keys.KEYCODES editor_class = "solaar.ui.perkey.control:PerKeyControl" def read(self, cached=True): self._pre_read(cached) if cached and self._value is not None: return self._value reply_map = {} for key in self._validator.choices: reply_map[int(key)] = special_keys.COLORSPLUS["No change"] # this signals no change self._value = reply_map return reply_map def write(self, map, save=True): if self._device.online: self.update(map, save) table = {} for key, value in map.items(): if value in table: table[value].append(key) # keys will be in order from small to large else: table[value] = [key] if len(table) == 1: # use range update for value, keys in table.items(): # only one, of course if value != special_keys.COLORSPLUS["No change"]: # this signals no change, so don't update at all data_bytes = keys[0].to_bytes(1, "big") + keys[-1].to_bytes(1, "big") + value.to_bytes(3, "big") self._device.feature_request(self.feature, 0x50, data_bytes) # range update command to update all keys self._device.feature_request(self.feature, 0x70, 0x00) # signal device to make the changes else: data_bytes = b"" for value, keys in table.items(): # only one, of course if value != special_keys.COLORSPLUS["No change"]: # this signals no change, so ignore it while len(keys) > 3: # use an optimized update command that can update up to 13 keys data = value.to_bytes(3, "big") + b"".join([key.to_bytes(1, "big") for key in keys[0:13]]) self._device.feature_request(self.feature, 0x60, data) # single-value multiple-keys update keys = keys[13:] for key in keys: data_bytes += key.to_bytes(1, "big") + value.to_bytes(3, "big") if len(data_bytes) >= 16: # up to four values are packed into a regular update self._device.feature_request(self.feature, 0x10, data_bytes) data_bytes = b"" if len(data_bytes) > 0: # update any remaining keys self._device.feature_request(self.feature, 0x10, data_bytes) self._device.feature_request(self.feature, 0x70, 0x00) # signal device to make the changes return map def write_key_value(self, key, value, save=True): if value != special_keys.COLORSPLUS["No change"]: # this signals no change result = super().write_key_value(int(key), value, save) if self._device.online: self._device.feature_request(self.feature, 0x70, 0x00) # signal device to make the change return result else: return True class rw_class(settings.FeatureRWMap): pass class validator_class(settings_validator.MapRangeValidator): _COLOR_RANGE = settings_validator.Range(min=0, max=0xFFFFFF, byte_count=3) @classmethod def build(cls, setting_class, device): choices_map = {} key_bitmap = device.feature_request(setting_class.feature, 0x00, 0x00, 0x00)[2:] key_bitmap += device.feature_request(setting_class.feature, 0x00, 0x00, 0x01)[2:] key_bitmap += device.feature_request(setting_class.feature, 0x00, 0x00, 0x02)[2:] for i in range(1, 255): if (key_bitmap[i // 8] >> i % 8) & 0x01: key = ( setting_class.keys_universe[i] if i in setting_class.keys_universe else common.NamedInt(i, f"KEY {str(i)}") ) choices_map[key] = cls._COLOR_RANGE return cls(choices_map) if choices_map else None # 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_key" choices_universe = list(range(0, 256)) kind = settings.Kind.MAP_RANGE @classmethod def build(cls, device): cls.check_properties(cls) device_object = getattr(device, cls.setup)() if device_object: setting = cls(device, device_object) if setting and len(device_object) == 1: ## If there is only one force button a simpler interface can be used setting.label = _("Force Sensing Button") setting.acceptable = "acceptable_current" setting.min_value = device_object[0].min_value setting.max_value = device_object[0].max_value setting.kind = settings.Kind.RANGE return setting # Analog button tuning settings (actuation point, rapid trigger, haptics) # # Bytes 1 (actuation), 2 (rapid trigger), 3 (haptics) of the 0x1B0C config struct # pack a logical value in bits 7..2 (i.e. wire = logical << 2). Byte 2 bit 0 is a # firmware-managed sensitivityFlag that must be preserved across writes; all other # low-order bits are reserved and must be zero. Sending a wire byte that doesn't # match this layout produces INVALID_ARGUMENT — see issue #3202. class _AnalogButtonActuationRW(settings.FeatureRW): """RW for analog button actuation point per button.""" def __init__(self, feature, button_index): super().__init__(feature, read_fnid=0x20, write_fnid=0x10) self.button_index = button_index def read(self, device, data_bytes=b""): res = device.feature_request(self.feature, 0x20, self.button_index) if not res: return b"\x05" # default mid-point (logical) return bytes([res[1] >> 2]) def write(self, device, data_bytes): current = device.feature_request(self.feature, 0x20, self.button_index) if not current: return None wire_act = (data_bytes[0] & 0x3F) << 2 return device.feature_request(self.feature, 0x10, self.button_index, wire_act, current[2], current[3]) class _AnalogButtonRapidTriggerRW(settings.FeatureRW): """RW for analog button rapid trigger sensitivity per button.""" def __init__(self, feature, button_index): super().__init__(feature, read_fnid=0x20, write_fnid=0x10) self.button_index = button_index def read(self, device, data_bytes=b""): res = device.feature_request(self.feature, 0x20, self.button_index) if not res: return b"\x03" # default mid-point (logical) return bytes([res[2] >> 2]) def write(self, device, data_bytes): current = device.feature_request(self.feature, 0x20, self.button_index) if not current: return None # Preserve the firmware-managed sensitivityFlag (byte 2 bit 0). wire_rt = ((data_bytes[0] & 0x3F) << 2) | (current[2] & 0x01) return device.feature_request(self.feature, 0x10, self.button_index, current[1], wire_rt, current[3]) class _AnalogButtonHapticsRW(settings.FeatureRW): """RW for analog button click haptics per button.""" def __init__(self, feature, button_index): super().__init__(feature, read_fnid=0x20, write_fnid=0x10) self.button_index = button_index def read(self, device, data_bytes=b""): res = device.feature_request(self.feature, 0x20, self.button_index) if not res: return b"\x03" # default mid-point (logical) return bytes([res[3] >> 2]) def write(self, device, data_bytes): current = device.feature_request(self.feature, 0x20, self.button_index) if not current: return None wire_haptics = (data_bytes[0] & 0x3F) << 2 return device.feature_request(self.feature, 0x10, self.button_index, current[1], current[2], wire_haptics) class _AnalogButtonSetting(settings.Setting): """Setting subclass that migrates legacy raw-byte persisted values. Solaar 1.1.19 stored the wire byte (logical value × 4) under these names. After the encoding fix, the same key holds the logical value. If a stored value is above the new max but a divide-by-4 lands inside the valid range, treat it as legacy raw and migrate in place — this prevents apply() from raising INVALID_ARGUMENT/ValueError on first run after upgrade. """ def _pre_read(self, cached, key=None): super()._pre_read(cached, key) if self._value is None or not isinstance(self._value, int): return validator = self._validator if self._value > validator.max_value and (self._value & 0x03) == 0: migrated = self._value >> 2 if validator.min_value <= migrated <= validator.max_value: if logger.isEnabledFor(logging.INFO): logger.info( "%s: migrating legacy raw value %d to logical %d on %s", self.name, self._value, migrated, self._device, ) self._value = migrated if getattr(self._device, "persister", None) is not None: self._device.persister[self.name] = self._value class AnalogButtonTuning(settings.Setting): """Analog button tuning: actuation point, rapid trigger, and haptics configuration.""" name = "analog-button-tuning" label = _("Analog Button Tuning") description = _("Configure analog button settings including actuation point, rapid trigger, and haptics.") feature = _F.ANALOG_BUTTONS @classmethod def build(cls, device): if cls.feature not in device.features: return None # Capabilities: [flags, button_count, max_act<<2, max_rt<<2, max_haptics<<2, ...] caps = device.feature_request(cls.feature, 0x00) if not caps or len(caps) < 5: return None button_count = min(caps[1], 2) # firmware reports 3; only L/R are user-accessible max_actuation = (caps[2] >> 2) if caps[2] > 0 else 10 max_rt_level = (caps[3] >> 2) if caps[3] > 0 else 5 max_haptics = (caps[4] >> 2) if caps[4] > 0 else 5 if button_count == 0: return None button_names = [_("Left Button"), _("Right Button")] all_settings = [] for i in range(button_count): btn_name = button_names[i] if i < len(button_names) else f"Button {i}" rw_act = _AnalogButtonActuationRW(cls.feature, i) val_act = settings_validator.RangeValidator(min_value=1, max_value=max_actuation) s_act = _AnalogButtonSetting(device, rw_act, val_act) s_act.name = f"analog-button-tuning_actuation-{i}" s_act.label = f"{btn_name} Actuation Point" s_act.description = _("Actuation point depth (1=shallow, %d=deep).") % max_actuation all_settings.append(s_act) rw_rt = _AnalogButtonRapidTriggerRW(cls.feature, i) val_rt = settings_validator.RangeValidator(min_value=1, max_value=max_rt_level) s_rt = _AnalogButtonSetting(device, rw_rt, val_rt) s_rt.name = f"analog-button-tuning_rapid-trigger-{i}" s_rt.label = f"{btn_name} Rapid Trigger" s_rt.description = _("Rapid trigger sensitivity (1..%d).") % max_rt_level all_settings.append(s_rt) rw_haptics = _AnalogButtonHapticsRW(cls.feature, i) val_haptics = settings_validator.RangeValidator(min_value=0, max_value=max_haptics) s_haptics = _AnalogButtonSetting(device, rw_haptics, val_haptics) s_haptics.name = f"analog-button-tuning_haptics-{i}" s_haptics.label = f"{btn_name} Click Haptics" s_haptics.description = _("Click haptic feedback intensity (0=off, %d=max).") % max_haptics all_settings.append(s_haptics) return all_settings if all_settings else None class HapticLevel(settings.Setting): name = "haptic-level" label = _("Haptic Feedback Level") description = _("Change power of haptic feedback. (Zero to turn off.)") feature = _F.HAPTIC choices_universe = common.NamedInts(Off=0, Low=25, Medium=50, High=75, Maximum=100) min_value = 0 max_value = 100 class rw_class(settings.FeatureRW): def __init__(self, feature): super().__init__(feature, read_fnid=0x10, write_fnid=0x20) def read(self, device, data_bytes=b""): result = device.feature_request(self.feature, 0x10) if result[0] & 0x01 == 0: # disabled, return 0 return b"\x00" else: # enabled, return second byte return result[1:2] def write(self, device, data_bytes): if data_bytes == b"\x00": write_bytes = b"\x00\x32" # disable, at 50 percent else: write_bytes = b"\x01" + data_bytes reply = device.feature_request(self.feature, 0x20, write_bytes) return reply @classmethod def build(cls, device): response = device.feature_request(cls.feature, 0x10) if response: rw = cls.rw_class(cls.feature) levels = response[2] & 0x01 if levels: # device only has four levels validator = settings_validator.ChoicesValidator(choices=cls.choices_universe) else: # device has all levels validator = settings_validator.RangeValidator(min_value=cls.min_value, max_value=cls.max_value) return cls(device, rw, validator) # This setting is not displayed in the UI # Use `solaar config haptic-play
` to play a haptic form class PlayHapticWaveForm(settings.Setting): name = "haptic-play" label = _("Play Haptic Waveform") description = _("Tell device to play a haptic waveform.") feature = _F.HAPTIC choices_universe = hidpp20_constants.HapticWaveForms rw_options = {"read_fnid": None, "write_fnid": 0x40} # nothing to read persist = False # persisting this setting is useless display = False # don't display in UI, interact using `solaar config ...` class validator_class(settings_validator.ChoicesValidator): @classmethod def build(cls, setting_class, device): response = device.feature_request(_F.HAPTIC, 0x00) if response: waves = common.NamedInts() waveforms = int.from_bytes(response[4:8]) for waveform in hidpp20_constants.HapticWaveForms: if (1 << int(waveform)) & waveforms: waves[int(waveform)] = str(waveform) return cls(choices=waves, byte_count=1) SETTINGS: list[settings.Setting] = [ RegisterHandDetection, # simple RegisterSmoothScroll, # simple RegisterSideScroll, # simple RegisterDpi, RegisterFnSwap, # working HiResScroll, # simple LowresMode, # simple HiresSmoothInvert, # working HiresSmoothResolution, # working HiresMode, # simple ScrollRatchet, # simple ScrollRatchetTorque, SmartShift, # working ScrollRatchetEnhanced, SmartShiftEnhanced, # simple ThumbInvert, # working ThumbMode, # working OnboardProfiles, ReportRate, # working ExtendedReportRate, PointerSpeed, # simple AdjustableDpi, # working ExtendedAdjustableDpi, SpeedChange, # Backlight, # not working - disabled temporarily Backlight2, # working Backlight2Level, Backlight2DurationHandsOut, Backlight2DurationHandsIn, Backlight2DurationPowered, Backlight3, LEDControl, LEDZoneSetting, RGBControl, RGBEffectSetting, BrightnessControl, PerKeyLighting, FnSwap, # simple NewFnSwap, # simple K375sFnSwap, # working ReprogrammableKeys, # working PersistentRemappableAction, DivertKeys, # working DisableKeyboardKeys, # working ForceSensing, CrownSmooth, # working DivertCrown, # working DivertGkeys, # working MKeyLEDs, # working MRKeyLED, # working Multiplatform, # working DualPlatform, # simple ChangeHost, # working Gesture2Gestures, # working Gesture2Divert, Gesture2Params, # working AnalogButtonTuning, HapticLevel, PlayHapticWaveForm, Sidetone, Equalizer, ADCPower, HeadsetEcoMode, HeadsetDoNotDisturb, HeadsetMicMute, HeadsetMicSNR, HeadsetAINR, HeadsetAINRLevel, HeadsetSidetone, HeadsetMicGain, HeadsetMixBalance, HeadsetAutoSleep, HeadsetOnboardEQ, HeadsetActiveEQPreset, HeadsetAdvancedEQ, HeadsetLEDControl, HeadsetLEDsPrimary, HeadsetPerZoneLighting, *_LOGIVOICE_SETTINGS, ] class SettingsProtocol(Protocol): @property def name(self): ... @property def label(self): ... @property def description(self): ... @property def feature(self): ... @property def register(self): ... @property def kind(self): ... @property def min_version(self): ... @property def persist(self): ... @property def rw_options(self): ... @property def validator_class(self): ... @property def validator_options(self): ... @classmethod def build(cls, device): ... def val_to_string(self, value): ... @property def choices(self): ... @property def range(self): ... def _pre_read(self, cached, key=None): ... def read(self, cached=True): ... def _pre_write(self, save=True): ... def update(self, value, save=True): ... def write(self, value, save=True): ... def acceptable(self, args, current): ... def compare(self, args, current): ... def apply(self): ... def __str__(self): ... def check_feature(device, settings_class: SettingsProtocol) -> None | bool | SettingsProtocol: if settings_class.feature not in device.features: return if settings_class.min_version > device.features.get_feature_version(settings_class.feature): logger.info( "check_feature %s [%s]: min_version=%d > device feature version=%d; skipping", settings_class.name, settings_class.feature, settings_class.min_version, device.features.get_feature_version(settings_class.feature) or 0, ) return if device.features.get_hidden(settings_class.feature): flags = device.features.flags.get(settings_class.feature, 0) logger.info( "check_feature %s [%s]: feature has INTERNAL flag set (flags=0x%02X); skipping", settings_class.name, settings_class.feature, flags, ) return try: detected = settings_class.build(device) if logger.isEnabledFor(logging.DEBUG): logger.debug("check_feature %s [%s] detected", settings_class.name, settings_class.feature) return detected except Exception as e: logger.error( "check_feature %s [%s] error %s\n%s", settings_class.name, settings_class.feature, e, traceback.format_exc() ) raise e # differentiate from an error-free determination that the setting is not supported def check_feature_settings(device, already_known) -> bool: """Auto-detect device settings by the HID++ 2.0 features they have. Returns ------- bool True, if device was fully queried to find features, False otherwise. """ if not device.features or not device.online: return False if device.protocol and device.protocol < 2.0: return False absent = device.persister.get("_absent", []) if device.persister else [] new_absent = [] for sclass in SETTINGS: if sclass.feature: known_present = device.persister and sclass.name in device.persister already = any(s.name == sclass.name for s in already_known) if already: continue if not known_present and sclass.name in absent: # Silent-skip cache from an earlier run's failed build(). If the # feature is actually present on this device now, the cache is # stale (e.g. from a prior build that returned None for a # feature that currently works) — drop it and retry the probe. if sclass.feature in device.features: logger.info( "check_feature_settings: retrying %s — cached in _absent but feature %s is present now", sclass.name, sclass.feature, ) absent.remove(sclass.name) if device.persister: device.persister["_absent"] = absent else: continue try: setting = check_feature(device, sclass) except Exception as err: # on an internal HID++ error, assume offline and stop further checking if isinstance(err, exceptions.FeatureCallError) and err.error == hidpp20_constants.ErrorCode.LOGITECH_ERROR: logger.warning(f"HID++ internal error checking feature {sclass.name}: make device not present") device.online = False device.present = False return False else: logger.warning(f"ignore feature {sclass.name} because of error {err}") if isinstance(setting, list): for s in setting: already_known.append(s) if sclass.name in new_absent: new_absent.remove(sclass.name) elif setting: already_known.append(setting) if sclass.name in new_absent: new_absent.remove(sclass.name) elif setting is None: if sclass.name not in new_absent and sclass.name not in absent and sclass.name not in device.persister: new_absent.append(sclass.name) if device.persister and new_absent: absent.extend(new_absent) device.persister["_absent"] = absent return True def check_feature_setting(device, setting_name: str) -> settings.Setting | None: for sclass in SETTINGS: if ( sclass.feature and device.features and (sclass.name == setting_name or sclass.name.endswith("_") and setting_name.startswith(sclass.name)) ): try: setting = check_feature(device, sclass) except Exception: return None if isinstance(setting, list): for s in setting: if s.name == setting_name: return s elif setting: return setting