diff --git a/lib/logitech_receiver/diversion.py b/lib/logitech_receiver/diversion.py index a92be66d..4b863dfb 100644 --- a/lib/logitech_receiver/diversion.py +++ b/lib/logitech_receiver/diversion.py @@ -776,7 +776,7 @@ class MouseGesture(Condition): movements = [movements] for x in movements: if x not in self.MOVEMENTS and x not in _CONTROL: - _log.warn('rule Key argument not name of a Logitech key: %s', x) + _log.warn('rule Mouse Gesture argument not direction or name of a Logitech key: %s', x) self.movements = movements def __str__(self): @@ -785,26 +785,28 @@ class MouseGesture(Condition): def evaluate(self, feature, notification, device, status, last_result): if feature == _F.MOUSE_GESTURE: d = notification.data - count = _unpack('!h', d[:2])[0] - data = _unpack('!' + ((int(len(d) / 2) - 1) * 'h'), d[2:]) - if count != len(self.movements): - return False - x = 0 - z = 0 - while x < len(data): - if data[x] == 0: - direction = xy_direction(data[x + 1], data[x + 2]) - if self.movements[z] != direction: + data = _unpack('!' + (int(len(d) / 2) * 'h'), d) + data_offset = 0 + for m in self.movements: + if data_offset == 0: + data_offset += 1 + if m not in self.MOVEMENTS: # matching against initiating key + if m != str(_CONTROL[data[0]]): + return False + else: + continue + if data_offset >= len(data): + return False + if data[data_offset] == 0: + direction = xy_direction(data[data_offset + 1], data[data_offset + 2]) + if m != direction: return False - x += 3 - elif data[x] == 1: - if data[x + 1] not in _CONTROL: + data_offset += 3 + elif data[data_offset] == 1: + if m != str(_CONTROL[data[data_offset + 1]]): return False - if self.movements[z] != str(_CONTROL[data[x + 1]]): - return False - x += 2 - z += 1 - return True + data_offset += 2 + return data_offset == len(data) return False def data(self): diff --git a/lib/logitech_receiver/settings.py b/lib/logitech_receiver/settings.py index 43421e36..4d02f878 100644 --- a/lib/logitech_receiver/settings.py +++ b/lib/logitech_receiver/settings.py @@ -1410,6 +1410,84 @@ class ActionSettingRW: return True +class RawXYProcessing: + """Special class for processing RawXY action messages initiated by pressing a key with rawXY diversion capability""" + def __init__(self, device, name=''): + self.device = device + self.name = name + self.keys = [] # the keys that can initiate processing + self.initiating_key = None # the key that did initiate processing + self.active = False + self.feature_offset = device.features[_hidpp20.FEATURE.REPROG_CONTROLS_V4] + assert self.feature_offset is not False + + def handler(self, device, n): # Called on notification events from the device + if n.sub_id < 0x40 and device.features.get_feature(n.sub_id) == _hidpp20.FEATURE.REPROG_CONTROLS_V4: + if n.address == 0x00: + cids = _unpack('!HHHH', n.data[:8]) + ## generalize to list of keys + if not self.initiating_key: # no initiating key pressed + for k in self.keys: + if int(k.key) in cids: # initiating key that was pressed + self.initiating_key = k + if self.initiating_key: + self.press_action(self.initiating_key) + else: + if int(self.initiating_key.key) not in cids: # initiating key released + self.initiating_key = None + self.release_action() + else: + for key in cids: + if key and key != self.initiating_key.key: + self.key_action(key) + elif n.address == 0x10: + if self.initiating_key: + dx, dy = _unpack('!hh', n.data[:4]) + self.move_action(dx, dy) + + def start(self, key): + device_key = next((k for k in self.device.keys if k.key == key), None) + self.keys.append(device_key) + if not self.active: + self.active = True + self.activate_action() + self.device.add_notification_handler(self.name, self.handler) + device_key.set_rawXY_reporting(True) + + def stop(self, key): # only stop if this is the active key + if self.active: + processing_key = next((k for k in self.keys if k.key == key), None) + if processing_key: + processing_key.set_rawXY_reporting(False) + self.keys.remove(processing_key) + if not self.keys: + try: + self.device.remove_notification_handler(self.name) + except Exception: + if _log.isEnabledFor(_WARNING): + _log.warn('cannot disable %s on %s', self.name, self.device) + self.deactivate_action() + self.active = False + + def activate_action(self): # action to take when processing is activated + pass + + def deactivate_action(self): # action to take when processing is deactivated + pass + + def press_action(self, key): # action to take when an initiating key is pressed + pass + + def release_action(self): # action to take when key is released + pass + + def move_action(self, dx, dy): # action to take when mouse is moved while key is down + pass + + def key_action(self, key): # acction to take when some other diverted key is pressed + pass + + def apply_all_settings(device): persister = getattr(device, 'persister', None) sensitives = persister.get('_sensitive', {}) if persister else {} diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index 8dbafa24..9c0bfab6 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -45,6 +45,7 @@ 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 @@ -483,13 +484,140 @@ class ReprogrammableKeys(_Settings): 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 + 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): + 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): + 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.lastEv is not None and now - self.lastEv > 50.: + 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 (which trigger Solaar rules but are otherwise ignored).') + 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}) + 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): @@ -504,20 +632,49 @@ class DivertKeys(_Settings): def write(self, device, key, data_bytes): key_index = device.keys.index(key) key_struct = device.keys[key_index] - key_struct.set_diverted(data_bytes == b'\x01') + 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: - choices[k.key] = setting_class.choices_universe + 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 not in device.features: + choices[k.key] = setting_class.choices_gesture + else: + 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 - return cls(choices, key_byte_count=2, byte_count=1, mask=0x01) + validator = cls(choices, key_byte_count=2, byte_count=1, mask=0x01) + validator.sliding = sliding + validator.gestures = gestures + return validator class AdjustableDpi(_Setting): @@ -810,11 +967,11 @@ class MouseGesture(_Setting): def build(cls, setting_class, device): if device.keys: # ensure that the device has a keys array keys = [] - for key in cls.MouseGestureKeys: - key_index = device.keys.index(key) - dkey = device.keys[key_index] if key_index is not None else None - if dkey is not None and 'raw XY' in dkey.flags and 'divertable' in dkey.flags: - keys.append(dkey.key) + # for key in cls.MouseGestureKeys: + # key_index = device.keys.index(key) + # dkey = device.keys[key_index] if key_index is not None else None + # if dkey is not None and 'raw XY' in dkey.flags and 'divertable' in dkey.flags: + # keys.append(dkey.key) if not keys: # none of the keys designed for this, so look for any key with correct flags for key in device.keys: if 'raw XY' in key.flags and 'divertable' in key.flags and 'virtual' not in key.flags: @@ -1218,9 +1375,9 @@ SETTINGS = [ ReportRate, # working PointerSpeed, # simple AdjustableDpi, # working - DpiSliding, # working + # DpiSliding, # working SpeedChange, - MouseGesture, # working + # MouseGesture, # working # Backlight, # not working - disabled temporarily Backlight2, # working Backlight3,