# -*- python-mode -*- ## 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 logging import DEBUG as _DEBUG from logging import INFO as _INFO from logging import WARN as _WARN from logging import getLogger from struct import pack as _pack from struct import unpack as _unpack from time import time as _time from . import hidpp10 as _hidpp10 from . import hidpp20 as _hidpp20 from . import special_keys as _special_keys from .common import NamedInt as _NamedInt from .common import NamedInts as _NamedInts from .common import bytes2int as _bytes2int from .common import int2bytes as _int2bytes from .i18n import _ from .settings import ActionSettingRW as _ActionSettingRW from .settings import BitFieldSetting as _BitFieldSetting from .settings import BitFieldValidator as _BitFieldV from .settings import BitFieldWithOffsetAndMaskSetting as _BitFieldOMSetting from .settings import BitFieldWithOffsetAndMaskValidator as _BitFieldOMV from .settings import ChoicesMapValidator as _ChoicesMapV from .settings import ChoicesValidator as _ChoicesV from .settings import FeatureRW as _FeatureRW from .settings import LongSettings as _LongSettings from .settings import MultipleRangeValidator as _MultipleRangeV from .settings import PackedRangeValidator as _PackedRangeV from .settings import RangeFieldSetting as _RangeFieldSetting from .settings import RangeValidator as _RangeV from .settings import RawXYProcessing as _RawXYProcessing from .settings import Setting as _Setting from .settings import Settings as _Settings from .special_keys import DISABLE as _DKEY _log = getLogger(__name__) del getLogger _DK = _hidpp10.DEVICE_KIND _R = _hidpp10.REGISTERS _F = _hidpp20.FEATURE _GG = _hidpp20.GESTURE _GP = _hidpp20.PARAM # 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. # _BitFieldOMSetting 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 settings.RegisterRW is created. # and if the setting has a feature then 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, _RangeV, and _ChoicesV # _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). # _RangeV 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). # _RangeV uses min_value and max_value from the setting class as minimum and maximum. # _ChoicesV 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 _ChoicesV 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. # _ChoicesMapV 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 _ChoicesV, # read_skip_byte_count is as for _ChoicesV, # write_prefix_bytes is as for _ChoicesV, # 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 _ChoicesV should have keys_universe and choices_universe class variable of # the potential keys and potential choices or None for no limitation. # _BitFieldV 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. # yapf: disable class FnSwapVirtual(_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.')) # yapf: enable class RegisterHandDetection(_Setting): name = 'hand-detection' label = _('Hand Detection') description = _('Turn on illumination when the hands hover over the keyboard.') register = _R.keyboard_hand_detection validator_options = {'true_value': b'\x00\x00\x00', 'false_value': b'\x00\x00\x30', 'mask': b'\x00\x00\xFF'} class RegisterSmoothScroll(_Setting): name = 'smooth-scroll' label = _('Scroll Wheel Smooth Scrolling') description = _('High-sensitivity mode for vertical scroll with the wheel.') register = _R.mouse_button_flags validator_options = {'true_value': 0x40, 'mask': 0x40} class RegisterSideScroll(_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 = _R.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(_Setting): name = 'dpi-old' label = _('Sensitivity (DPI - older mice)') description = _('Mouse movement sensitivity') register = _R.mouse_dpi choices_universe = _NamedInts.range(0x81, 0x8F, lambda x: str((x - 0x80) * 100)) validator_class = _ChoicesV validator_options = {'choices': choices_universe} class RegisterFnSwap(FnSwapVirtual): register = _R.keyboard_fn_swap validator_options = {'true_value': b'\x00\x01', 'mask': b'\x00\x01'} class FnSwap(FnSwapVirtual): feature = _F.FN_INVERSION class NewFnSwap(FnSwapVirtual): feature = _F.NEW_FN_INVERSION # ignore the capabilities part of the feature - all devices should be able to swap Fn state # just use the current host (first byte = 0xFF) part of the feature to read and set the Fn state class K375sFnSwap(FnSwapVirtual): feature = _F.K375S_FN_INVERSION rw_options = {'prefix': b'\xFF'} validator_options = {'true_value': b'\x01', 'false_value': b'\x00', 'read_skip_byte_count': 1} class Backlight(_Setting): name = 'backlight-qualitative' label = _('Backlight') description = _('Set illumination time for keyboard.') feature = _F.BACKLIGHT choices_universe = _NamedInts(Off=0, Varying=2, VeryShort=5, Short=10, Medium=20, Long=60, VeryLong=180) validator_class = _ChoicesV validator_options = {'choices': choices_universe} class Backlight2(_Setting): name = 'backlight' label = _('Backlight') description = _('Turn illumination on or off on keyboard.') feature = _F.BACKLIGHT2 class Backlight3(_Setting): name = 'backlight-timed' label = _('Backlight') description = _('Set illumination time for keyboard.') feature = _F.BACKLIGHT3 rw_options = {'read_fnid': 0x10, 'write_fnid': 0x20, 'suffix': 0x09} validator_class = _RangeV min_value = 0 max_value = 1000 validator_options = {'byte_count': 2} class HiResScroll(_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(_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(_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(_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(_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(_Setting): name = 'pointer_speed' label = _('Sensitivity (Pointer Speed)') description = _('Speed multiplier for mouse (256 is normal multiplier).') feature = _F.POINTER_SPEED validator_class = _RangeV min_value = 0x002e max_value = 0x01ff validator_options = {'byte_count': 2} class ThumbMode(_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(_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'} class OnboardProfiles(_Setting): name = 'onboard_profiles' label = _('Onboard Profiles') description = _('Enable onboard profiles, which often control report rate and keyboard lighting') feature = _F.ONBOARD_PROFILES rw_options = {'read_fnid': 0x20, 'write_fnid': 0x10} choices_universe = _NamedInts(Enable=1, Disable=2) validator_class = _ChoicesV validator_options = {'choices': choices_universe} class ReportRate(_Setting): name = 'report_rate' label = _('Polling Rate (ms)') description = ( _('Frequency of device polling, in milliseconds') + '\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 = _NamedInts.range(1, 8) class _rw_class(_FeatureRW): # no longer needed - set Onboard Profiles to disable def write(self, device, data_bytes): # Host mode is required for report rate to be adjustable if _hidpp20.get_onboard_mode(device) != _hidpp20.ONBOARD_MODES.MODE_HOST: _hidpp20.set_onboard_mode(device, _hidpp20.ONBOARD_MODES.MODE_HOST) return super().write(device, data_bytes) class validator_class(_ChoicesV): @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 = _bytes2int(reply[0:1]) for i in range(0, 8): if (rate_flags >> i) & 0x01: rate_list.append(i + 1) return cls(choices=_NamedInts.list(rate_list), byte_count=1) if rate_list else None class DivertCrown(_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(_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(_Setting): name = 'divert-gkeys' label = _('Divert G Keys') description = ( _('Make G keys send GKEY HID++ notifications (which trigger Solaar rules but are otherwise ignored).') + '\n' + _('May also make M keys and MR key send HID++ notifications') ) feature = _F.GKEY validator_options = {'true_value': 0x01, 'false_value': 0x00, 'mask': 0xff} class rw_class(_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(_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 = _NamedInts(**{_('Freespinning'): 1, _('Ratcheted'): 2}) validator_class = _ChoicesV validator_options = {'choices': choices_universe} class SmartShift(_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(_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 _bytes2int(value[0:1]) == 1: # Mode = Freespin, map to minimum return _int2bytes(self.MIN_VALUE, count=1) else: # Mode = smart shift, map to the value, capped at maximum threshold = min(_bytes2int(value[1:2]), self.MAX_VALUE) return _int2bytes(threshold, count=1) def write(self, device, data_bytes): threshold = _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 = _int2bytes(mode, count=1) + _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 = _RangeV class SmartShiftEnhanced(SmartShift): feature = _F.SMART_SHIFT_ENHANCED rw_options = {'read_fnid': 0x10, 'write_fnid': 0x20} # 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): 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 = _FeatureRW.kind def read(self, device, key): key_index = device.keys.index(key) key_struct = device.keys[key_index] return b'\x00\x00' + _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[_bytes2int(data_bytes)]) return True class validator_class(_ChoicesMapV): @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(_RawXYProcessing): def activate_action(self): self.dpiSetting = next(filter(lambda s: s.name == 'dpi', 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 = 'idle' self.dx = 0. self.movingDpiIdx = None def setNewDpi(self, newDpiIdx): newDpi = self.dpiChoices[newDpiIdx] self.dpiSetting.write(newDpi) from solaar.ui import status_changed as _status_changed _status_changed(self.device, refresh=True) # update main window def displayNewDpi(self, newDpiIdx): from solaar.ui import notify as _notify # import here to avoid circular import when running `solaar show`, if _notify.available: reason = 'DPI %d [min %d, max %d]' % (self.dpiChoices[newDpiIdx], self.dpiChoices[0], self.dpiChoices[-1]) # if there is a progress percentage then the reason isn't shown # asPercentage = int(float(newDpiIdx) / float(len(self.dpiChoices) - 1) * 100.) # _notify.show(self.device, reason=reason, progress=asPercentage) _notify.show(self.device, reason=reason) def press_action(self, key): # start tracking self.starting = True if self.fsmState == 'idle': self.fsmState = 'pressed' self.dx = 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 == '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 == 'moved': # Set DPI according to displacement self.setNewDpi(self.movingDpiIdx) self.fsmState = '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. # yields a more-or-less DPI-independent dx of about 5/cm if self.fsmState == 'pressed': if abs(self.dx) >= 1.: self.fsmState = 'moved' self.movingDpiIdx = self.dpiChoices.index(currDpi) elif self.fsmState == '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(_RawXYProcessing): def activate_action(self): self.dpiSetting = next(filter(lambda s: s.name == 'dpi', self.device.settings), None) self.fsmState = 'idle' self.initialize_data() def initialize_data(self): self.dx = 0. self.dy = 0. self.lastEv = None self.data = [] def press_action(self, key): self.starting = True if self.fsmState == 'idle': self.fsmState = 'pressed' self.initialize_data() self.data = [key.key] def release_action(self): if self.fsmState == 'pressed': # emit mouse gesture notification from .base import _HIDPP_Notification as _HIDPP_Notification from .diversion import process_notification as _process_notification self.push_mouse_event() if _log.isEnabledFor(_INFO): _log.info('mouse gesture notification %s', self.data) payload = _pack('!' + (len(self.data) * 'h'), *self.data) notification = _HIDPP_Notification(0, 0, 0, 0, payload) _process_notification(self.device, self.device.status, notification, _hidpp20.FEATURE.MOUSE_GESTURE) self.fsmState = 'idle' def move_action(self, dx, dy): if self.fsmState == '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.: self.push_mouse_event() dpi = self.dpiSetting.read() if self.dpiSetting else 1000 dx = float(dx) / float(dpi) * 15. # This multiplier yields a more-or-less DPI-independent dx of about 5/cm self.dx += dx dy = float(dy) / float(dpi) * 15. # 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 _log.isEnabledFor(_DEBUG): _log.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. self.dy = 0. if _log.isEnabledFor(_DEBUG): _log.debug('mouse gesture move event %d %d %s', x, y, self.data) class DivertKeys(_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 = _NamedInts(**{_('Regular'): 0, _('Diverted'): 1, _('Mouse Gestures'): 2, _('Sliding DPI'): 3}) choices_gesture = _NamedInts(**{_('Regular'): 0, _('Diverted'): 1, _('Mouse Gestures'): 2}) choices_divert = _NamedInts(**{_('Regular'): 0, _('Diverted'): 1}) class rw_class: def __init__(self, feature): self.feature = feature self.kind = _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 '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(_bytes2int(data_bytes) != 0) # not regular return True class validator_class(_ChoicesMapV): 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 k in device.keys: if 'divertable' in k.flags and 'virtual' not in k.flags: if 'raw XY' in k.flags: choices[k.key] = setting_class.choices_gesture if gestures is None: gestures = MouseGesturesXY(device, name='MouseGestures') if _F.ADJUSTABLE_DPI in device.features: choices[k.key] = setting_class.choices_universe if sliding is None: sliding = DpiSlidingXY(device, name='DpiSlding') else: choices[k.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 class AdjustableDpi(_Setting): """Pointer Speed feature""" # Assume sensorIdx 0 (there is only one sensor) # [2] getSensorDpi(sensorIdx) -> sensorIdx, dpiMSB, dpiLSB # [3] setSensorDpi(sensorIdx, dpi) name = 'dpi' label = _('Sensitivity (DPI)') description = _('Mouse movement sensitivity') feature = _F.ADJUSTABLE_DPI rw_options = {'read_fnid': 0x20, 'write_fnid': 0x30} choices_universe = _NamedInts.range(200, 4000, str, 50) class validator_class(_ChoicesV): @classmethod def build(cls, setting_class, device): # [1] getSensorDpiList(sensorIdx) reply = device.feature_request(_F.ADJUSTABLE_DPI, 0x10) assert reply, 'Oops, DPI list cannot be retrieved!' dpi_list = [] step = None for val in _unpack('!7H', reply[1:1 + 14]): if val == 0: break if val >> 13 == 0b111: assert step is None and len(dpi_list) == 1, \ 'Invalid DPI list item: %r' % val step = val & 0x1fff else: dpi_list.append(val) if step: assert len(dpi_list) == 2, 'Invalid DPI list range: %r' % dpi_list dpi_list = range(dpi_list[0], dpi_list[1] + 1, step) return cls(choices=_NamedInts.list(dpi_list), byte_count=3) if dpi_list else None def validate_read(self, reply_bytes): # special validator to use default DPI if needed reply_value = _bytes2int(reply_bytes[1:3]) if reply_value == 0: # use default value instead reply_value = _bytes2int(reply_bytes[3:5]) valid_value = self.choices[reply_value] assert valid_value is not None, '%s: failed to validate read value %02X' % (self.__class__.__name__, reply_value) return valid_value class SpeedChange(_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 = _NamedInt(0, _('Off')) feature = _F.POINTER_SPEED rw_options = {'name': 'speed change'} class rw_class(_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) else: _log.error('cannot save sensitivity setting on %s', self.device) from solaar.ui import status_changed as _status_changed _status_changed(self.device, refresh=True) # update main window if self.device.persister: self.device.persister['_speed-change'] = currentSpeed class validator_class(_ChoicesV): @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 'divertable' in key.flags: keys = [setting_class.choices_extra, key.key] return cls(choices=_NamedInts.list(keys), byte_count=2) class DisableKeyboardKeys(_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 _DKEY} choices_universe = _DKEY class validator_class(_BitFieldV): @classmethod def build(cls, setting_class, device): mask = device.feature_request(_F.KEYBOARD_DISABLE_KEYS, 0x00)[0] options = [_DKEY[1 << i] for i in range(8) if mask & (1 << i)] return cls(options) if options else None class Multiplatform(_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 = _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(_ChoicesV): @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 str(version >> 8) + '.' + str(version & 0xFF) else: return str(version >> 8) return '' if low == 0 and high == 0 else ' ' + _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 = _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 = _unpack('!BBHHH', descriptor[:8]) descriptors.append((platform, os_flags, low, high)) choices = _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(_Setting): name = 'multiplatform' label = _('Set OS') description = _('Change keys to match OS.') choices_universe = _NamedInts() choices_universe[0x00] = 'iOS, MacOS' choices_universe[0x01] = 'Android, Windows' feature = _F.DUALPLATFORM rw_options = {'read_fnid': 0x00, 'write_fnid': 0x20} validator_class = _ChoicesV validator_options = {'choices': choices_universe} class ChangeHost(_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 = _NamedInts(**{'Host ' + str(i + 1): i for i in range(3)}) class validator_class(_ChoicesV): @classmethod def build(cls, setting_class, device): infos = device.feature_request(_F.CHANGE_HOST) assert infos, 'Oops, host count cannot be retrieved!' numHosts, currentHost = _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] == '': import socket # find name of current host and use it hostNames[currentHost] = (True, socket.gethostname().partition('.')[0]) choices = _NamedInts() for host in range(0, numHosts): paired, hostName = hostNames.get(host, (True, '')) choices[host] = 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 = { _GG['Tap1Finger']: (_('Single tap'), _('Performs a left click.')), _GG['Tap2Finger']: (_('Single tap with two fingers'), _('Performs a right click.')), _GG['Tap3Finger']: (_('Single tap with three fingers'), None), _GG['Click1Finger']: (None, None), _GG['Click2Finger']: (None, None), _GG['Click3Finger']: (None, None), _GG['DoubleTap1Finger']: (_('Double tap'), _('Performs a double click.')), _GG['DoubleTap2Finger']: (_('Double tap with two fingers'), None), _GG['DoubleTap3Finger']: (_('Double tap with three fingers'), None), _GG['Track1Finger']: (None, None), _GG['TrackingAcceleration']: (None, None), _GG['TapDrag1Finger']: (_('Tap and drag'), _('Drags items by dragging the finger after double tapping.')), _GG['TapDrag2Finger']: (_('Tap and drag with two fingers'), _('Drags items by dragging the fingers after double tapping.')), _GG['Drag3Finger']: (_('Tap and drag with three fingers'), None), _GG['TapGestures']: (None, None), _GG['FnClickGestureSuppression']: (_('Suppress tap and edge gestures'), _('Disables tap and edge gestures (equivalent to pressing Fn+LeftClick).')), _GG['Scroll1Finger']: (_('Scroll with one finger'), _('Scrolls.')), _GG['Scroll2Finger']: (_('Scroll with two fingers'), _('Scrolls.')), _GG['Scroll2FingerHoriz']: (_('Scroll horizontally with two fingers'), _('Scrolls horizontally.')), _GG['Scroll2FingerVert']: (_('Scroll vertically with two fingers'), _('Scrolls vertically.')), _GG['Scroll2FingerStateless']: (_('Scroll with two fingers'), _('Scrolls.')), _GG['NaturalScrolling']: (_('Natural scrolling'), _('Inverts the scrolling direction.')), _GG['Thumbwheel']: (_('Thumbwheel'), _('Enables the thumbwheel.')), _GG['VScrollInertia']: (None, None), _GG['VScrollBallistics']: (None, None), _GG['Swipe2FingerHoriz']: (None, None), _GG['Swipe3FingerHoriz']: (None, None), _GG['Swipe4FingerHoriz']: (None, None), _GG['Swipe3FingerVert']: (None, None), _GG['Swipe4FingerVert']: (None, None), _GG['LeftEdgeSwipe1Finger']: (None, None), _GG['RightEdgeSwipe1Finger']: (None, None), _GG['BottomEdgeSwipe1Finger']: (None, None), _GG['TopEdgeSwipe1Finger']: (_('Swipe from the top edge'), None), _GG['LeftEdgeSwipe1Finger2']: (_('Swipe from the left edge'), None), _GG['RightEdgeSwipe1Finger2']: (_('Swipe from the right edge'), None), _GG['BottomEdgeSwipe1Finger2']: (_('Swipe from the bottom edge'), None), _GG['TopEdgeSwipe1Finger2']: (_('Swipe from the top edge'), None), _GG['LeftEdgeSwipe2Finger']: (_('Swipe two fingers from the left edge'), None), _GG['RightEdgeSwipe2Finger']: (_('Swipe two fingers from the right edge'), None), _GG['BottomEdgeSwipe2Finger']: (_('Swipe two fingers from the bottom edge'), None), _GG['TopEdgeSwipe2Finger']: (_('Swipe two fingers from the top edge'), None), _GG['Zoom2Finger']: (_('Zoom with two fingers.'), _('Pinch to zoom out; spread to zoom in.')), _GG['Zoom2FingerPinch']: (_('Pinch to zoom out.'), _('Pinch to zoom out.')), _GG['Zoom2FingerSpread']: (_('Spread to zoom in.'), _('Spread to zoom in.')), _GG['Zoom3Finger']: (_('Zoom with three fingers.'), None), _GG['Zoom2FingerStateless']: (_('Zoom with two fingers'), _('Pinch to zoom out; spread to zoom in.')), _GG['TwoFingersPresent']: (None, None), _GG['Rotate2Finger']: (None, None), _GG['Finger1']: (None, None), _GG['Finger2']: (None, None), _GG['Finger3']: (None, None), _GG['Finger4']: (None, None), _GG['Finger5']: (None, None), _GG['Finger6']: (None, None), _GG['Finger7']: (None, None), _GG['Finger8']: (None, None), _GG['Finger9']: (None, None), _GG['Finger10']: (None, None), _GG['DeviceSpecificRawData']: (None, None), } _GESTURE2_PARAMS_LABELS = { _GP['ExtraCapabilities']: (None, None), # not supported _GP['PixelZone']: (_('Pixel zone'), None), # TO DO: replace None with a short description _GP['RatioZone']: (_('Ratio zone'), None), # TO DO: replace None with a short description _GP['ScaleFactor']: (_('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(_BitFieldOMSetting): 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.GESTURE _labels = _GESTURE2_GESTURES_LABELS class validator_class(_BitFieldOMV): @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(_BitFieldOMSetting): 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.GESTURE _labels = _GESTURE2_GESTURES_LABELS class validator_class(_BitFieldOMV): @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(_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.PARAM 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(_MultipleRangeV): @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(_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 = _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(_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(_BitFieldV): @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(_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(_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): 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 = _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(_ChoicesMapV): @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 _log.isEnabledFor(_WARN): _log.warn('%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 = _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 _log.isEnabledFor(_WARN): _log.warn('unusual persistent remappable action mapping %x: use Default', reply_value) reply_value = _special_keys.KEYS_Default return reply_value class Sidetone(_Setting): name = 'sidetone' label = _('Sidetone') description = _('Set sidetone level.') feature = _F.SIDETONE validator_class = _RangeV min_value = 0 max_value = 100 class Equalizer(_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(_PackedRangeV): @classmethod def build(cls, setting_class, device): data = device.feature_request(_F.EQUALIZER, 0x00) if not data: return None count, dbRange, _x, dbMin, dbMax = _unpack('!BBBBB', data[:5]) if dbMin == 0: dbMin = -dbRange if dbMax == 0: dbMax = dbRange map = _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(_Setting): name = 'adc_power_management' label = _('Power Management') description = _('Power off in minutes (0 for never).') feature = _F.ADC_MEASUREMENT rw_options = {'read_fnid': 0x10, 'write_fnid': 0x20} validator_class = _RangeV min_value = 0x00 max_value = 0xff validator_options = {'byte_count': 1} SETTINGS = [ RegisterHandDetection, # simple RegisterSmoothScroll, # simple RegisterSideScroll, # simple RegisterDpi, RegisterFnSwap, # working HiResScroll, # simple LowresMode, # simple HiresSmoothInvert, # working HiresSmoothResolution, # working HiresMode, # simple ScrollRatchet, # simple SmartShift, # working SmartShiftEnhanced, # simple ThumbInvert, # working ThumbMode, # working OnboardProfiles, ReportRate, # working PointerSpeed, # simple AdjustableDpi, # working SpeedChange, # Backlight, # not working - disabled temporarily Backlight2, # working Backlight3, FnSwap, # simple NewFnSwap, # simple K375sFnSwap, # working ReprogrammableKeys, # working PersistentRemappableAction, DivertKeys, # working DisableKeyboardKeys, # working CrownSmooth, # working DivertCrown, # working DivertGkeys, # working MKeyLEDs, # working MRKeyLED, # working Multiplatform, # working DualPlatform, # simple ChangeHost, # working Gesture2Gestures, # working Gesture2Divert, Gesture2Params, # working Sidetone, Equalizer, ADCPower, ] # # # def check_feature(device, sclass): if sclass.feature not in device.features: return try: detected = sclass.build(device) if _log.isEnabledFor(_DEBUG): _log.debug('check_feature %s [%s] detected %s', sclass.name, sclass.feature, detected) return detected except Exception as e: from traceback import format_exc _log.error('check_feature %s [%s] error %s\n%s', sclass.name, sclass.feature, e, format_exc()) return False # differentiate from an error-free determination that the setting is not supported # Returns True if device was queried to find features, False otherwise def check_feature_settings(device, already_known): """Auto-detect device settings by the HID++ 2.0 features they have.""" 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 [] newAbsent = [] for sclass in SETTINGS: if sclass.feature: known_present = device.persister and sclass.name in device.persister if not any(s.name == sclass.name for s in already_known) and (known_present or sclass.name not in absent): setting = check_feature(device, sclass) if setting: already_known.append(setting) if sclass.name in newAbsent: newAbsent.remove(sclass.name) elif setting is None: if sclass.name not in newAbsent and sclass.name not in absent and sclass.name not in device.persister: newAbsent.append(sclass.name) if device.persister and newAbsent: absent.extend(newAbsent) device.persister['_absent'] = absent return True def check_feature_setting(device, setting_name): for sclass in SETTINGS: if sclass.feature and sclass.name == setting_name and device.features: setting = check_feature(device, sclass) if setting: return setting