diff --git a/lib/logitech_receiver/diversion.py b/lib/logitech_receiver/diversion.py index 5966a24d..54b3c4c2 100644 --- a/lib/logitech_receiver/diversion.py +++ b/lib/logitech_receiver/diversion.py @@ -158,6 +158,20 @@ def signed(bytes): return int.from_bytes(bytes, 'big', signed=True) +def xy_direction(d): + x, y = _unpack('!2h', d[:4]) + if x > 0 and x >= abs(y): + return 'right' + elif x < 0 and abs(x) >= abs(y): + return 'left' + elif y > 0: + return 'down' + elif y < 0: + return 'up' + else: + return None + + TESTS = { 'crown_right': lambda f, r, d: f == _F.CROWN and r == 0 and d[1] < 128 and d[1], 'crown_left': lambda f, r, d: f == _F.CROWN and r == 0 and d[1] >= 128 and 256 - d[1], @@ -173,6 +187,10 @@ TESTS = { 'lowres_wheel_down': lambda f, r, d: f == _F.LOWRES_WHEEL and r == 0 and signed(d[0:1]) < 0 and signed(d[0:1]), 'hires_wheel_up': lambda f, r, d: f == _F.HIRES_WHEEL and r == 0 and signed(d[1:3]) > 0 and signed(d[1:3]), 'hires_wheel_down': lambda f, r, d: f == _F.HIRES_WHEEL and r == 0 and signed(d[1:3]) < 0 and signed(d[1:3]), + 'mouse-down': lambda f, r, d: f == _F.MOUSE_GESTURE and xy_direction(d) == 'down', + 'mouse-up': lambda f, r, d: f == _F.MOUSE_GESTURE and xy_direction(d) == 'up', + 'mouse-left': lambda f, r, d: f == _F.MOUSE_GESTURE and xy_direction(d) == 'left', + 'mouse-right': lambda f, r, d: f == _F.MOUSE_GESTURE and xy_direction(d) == 'right', 'False': lambda f, r, d: False, 'True': lambda f, r, d: True, } diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 0ad5a407..524808d2 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -164,6 +164,8 @@ FEATURE = _NamedInts( SIDETONE=0x8300, EQUALIZER=0x8310, HEADSET_OUT=0x8320, + # Fake features for Solaar internal use + MOUSE_GESTURE=0xFE00, ) FEATURE._fallback = lambda x: 'unknown:%04X' % x @@ -525,13 +527,12 @@ class ReprogrammableKeyV4(ReprogrammableKey): """ flags = flags if flags else {} # See flake8 B006 - if special_keys.MAPPING_FLAG.raw_XY_diverted in flags and flags[special_keys.MAPPING_FLAG.raw_XY_diverted]: - # We need diversion to report raw XY, so divert temporarily - # (since XY reporting is also temporary) - flags[special_keys.MAPPING_FLAG.diverted] = True - - if special_keys.MAPPING_FLAG.diverted in flags and not flags[special_keys.MAPPING_FLAG.diverted]: - flags[special_keys.MAPPING_FLAG.raw_XY_diverted] = False + # if special_keys.MAPPING_FLAG.raw_XY_diverted in flags and flags[special_keys.MAPPING_FLAG.raw_XY_diverted]: + # We need diversion to report raw XY, so divert temporarily + # (since XY reporting is also temporary) + # flags[special_keys.MAPPING_FLAG.diverted] = True + # if special_keys.MAPPING_FLAG.diverted in flags and not flags[special_keys.MAPPING_FLAG.diverted]: + # flags[special_keys.MAPPING_FLAG.raw_XY_diverted] = False # The capability required to set a given reporting flag. FLAG_TO_CAPABILITY = { diff --git a/lib/logitech_receiver/settings.py b/lib/logitech_receiver/settings.py index 13f0e530..93aa2007 100644 --- a/lib/logitech_receiver/settings.py +++ b/lib/logitech_receiver/settings.py @@ -25,10 +25,13 @@ from copy import copy as _copy from logging import DEBUG as _DEBUG from logging import getLogger +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 .common import unpack as _unpack _log = getLogger(__name__) del getLogger @@ -1032,6 +1035,119 @@ class MultipleRangeValidator: return w + b'\xFF' +# Turn diverted mouse movement events into a mouse gesture +# +# Uses the following FSM. +# At initialization, we go into `start` state and begin accumulating displacement. +# If terminated in this state, we report back no movement. +# If the mouse moves enough, we go into the `moved` state run the progress function. +# If terminated in this state, we report back how much movement. +class DivertedMouseMovement(object): + def __init__(self, device, dpi_name, key): + self.device = device + self.key = key + self.dx = 0. + self.dy = 0. + self.fsmState = 'idle' + self.dpiSetting = next(filter(lambda s: s.name == dpi_name, device.settings), None) + + @staticmethod + def notification_handler(device, n): + """Called on notification events from the mouse.""" + if n.sub_id < 0x40 and device.features[n.sub_id] == _hidpp20.FEATURE.REPROG_CONTROLS_V4: + state = device._divertedMMState + assert state + if n.address == 0x00: + cid1, cid2, cid3, cid4 = _unpack('!HHHH', n.data[:8]) + state.handle_keys_event({cid1, cid2, cid3, cid4}) + elif n.address == 0x10: + dx, dy = _unpack('!hh', n.data[:4]) + state.handle_move_event(dx, dy) + + def handle_move_event(self, dx, dy): + # This multiplier yields a more-or-less DPI-independent dx of about 5/cm + # The multiplier could be configurable to allow adjusting dx + dpi = self.dpiSetting.read() if self.dpiSetting else 1000 + dx = float(dx) / float(dpi) * 15. + self.dx += dx + dy = float(dy) / float(dpi) * 15. + self.dy += dy + if self.fsmState == 'pressed': + if abs(self.dx) >= 1. or abs(self.dy) >= 1.: + self.fsmState = 'moved' + + def handle_keys_event(self, cids): + if self.fsmState == 'idle': + if self.key in cids: + self.fsmState = 'pressed' + self.dx = 0. + self.dy = 0. + elif self.fsmState == 'pressed' or self.fsmState == 'moved': + if self.key not in cids: + # emit mouse gesture notification + from .base import _HIDPP_Notification as _HIDPP_Notification + from .common import pack as _pack + from .diversion import process_notification as _process_notification + payload = _pack('!hh', int(self.dx), int(self.dy)) + notification = _HIDPP_Notification(0, 0, 0, 0, payload) + _process_notification(self.device, self.device.status, notification, _hidpp20.FEATURE.MOUSE_GESTURE) + self.fsmState = 'idle' + self.dx = 0. + self.dy = 0. + + +MouseGestureKeys = [ + _special_keys.CONTROL.App_Switch_Gesture, + _special_keys.CONTROL.MultiPlatform_Gesture_Button, +] + + +class DivertedMouseMovementRW(object): + def __init__(self, dpi_name): + self.kind = FeatureRW.kind # pretend to be FeatureRW as required for HID++ 2.0 devices + self.dpi_name = dpi_name + self.key = None + + def read(self, device): # need to return bytes, not a boolean + return b'0x01' if '_divertedMMState' in device.__dict__ else b'0x00' + + def write(self, device, data_bytes): + def handler(device, n): + """Called on notification events from the mouse.""" + if n.sub_id < 0x40 and device.features[n.sub_id] == _hidpp20.FEATURE.REPROG_CONTROLS_V4: + state = device._divertedMMState + if n.address == 0x00: + cid1, cid2, cid3, cid4 = _unpack('!HHHH', n.data[:8]) + state.handle_keys_event({cid1, cid2, cid3, cid4}) + elif n.address == 0x10: + dx, dy = _unpack('!hh', n.data[:4]) + state.handle_move_event(dx, dy) + + if bool(data_bytes): # enable + # Enable HID++ events on moving the mouse while button held + for key_number in MouseGestureKeys: + key_index = device.keys.index(key_number) + self.key = device.keys[key_index] if key_index is not None else None + if self.key and 'raw XY' in self.key.flags: + self.key.set_rawXY_reporting(True) + # Store our variables in the device object + device._divertedMMState = DivertedMouseMovement(device, self.dpi_name, self.key.key) + device.add_notification_handler('diverted-mouse-movement-handler', handler) + return True + else: + _log.error('cannot enable diverted mouse movement on %s', device) + else: # disable + try: + device.remove_notification_handler('diverted-mouse-movement-handler') + del device._divertedMMState + if self.key: + self.key.set_rawXY_reporting(False) + self.key = None + except Exception: + _log.error('cannot disable diverted mouse movement on %s', device) + return True + + 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 9d42b0c0..90fa3498 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -44,6 +44,7 @@ from .settings import ChoicesValidator as _ChoicesV from .settings import FeatureRW as _FeatureRW from .settings import FeatureRWMap as _FeatureRWMap from .settings import LongSettings as _LongSettings +from .settings import MouseGestureKeys as _MouseGestureKeys from .settings import MultipleRangeValidator as _MultipleRangeV from .settings import RangeValidator as _RangeV from .settings import RegisterRW as _RegisterRW @@ -110,6 +111,8 @@ _GESTURE2_GESTURES = ('gesture2-gestures', _('Gestures'), _('Tweak the mouse/tou _GESTURE2_PARAMS = ('gesture2-params', _('Gesture params'), _('Change numerical parameters of a mouse/touchpad.')) _DPI_SLIDING = ('dpi-sliding', _('DPI Sliding Adjustment'), _('Adjust the DPI by sliding the mouse horizontally while holding the DPI button.')) +_MOUSE_GESTURES = ('mouse-gestures', _('Mouse Gestures'), + _('Send a gesture by sliding the mouse while holding the App Switch button.')) _DIVERT_CROWN = ('divert-crown', _('Divert crown events'), _('Make crown send CROWN HID++ notifications (which trigger Solaar rules but are otherwise ignored).')) _DIVERT_GKEYS = ('divert-gkeys', _('Divert G Keys'), @@ -536,6 +539,27 @@ def _feature_adjustable_dpi(): return _Setting(_DPI, rw, callback=_feature_adjustable_dpi_callback, device_kind=(_DK.mouse, _DK.trackball)) +def _feature_mouse_gesture_callback(device): + # need a gesture button that can send raw XY + for key in _MouseGestureKeys: + key_index = device.keys.index(key) + if key_index is not None and 'raw XY' in device.keys[key_index].flags: + return _BooleanV() + return False + + +def _feature_mouse_gesture(): + """Implements the ability to send mouse gestures + by sliding a mouse horizontally or vertically while holding the App Switch button.""" + from .settings import DivertedMouseMovementRW as _DivertedMouseMovementRW + return _Setting( + _MOUSE_GESTURES, + _DivertedMouseMovementRW(_DPI[0]), + callback=_feature_mouse_gesture_callback, + device_kind=(_DK.mouse, ) + ) + + # Implemented based on code in libratrag def _feature_report_rate_callback(device): if device.wpid == '408E': @@ -784,6 +808,7 @@ _SETTINGS_TABLE = [ _S(_THUMB_SCROLL_INVERT, _F.THUMB_WHEEL, _feature_thumb_invert), _S(_DPI, _F.ADJUSTABLE_DPI, _feature_adjustable_dpi, registerFn=_register_dpi), _S(_DPI_SLIDING, _F.REPROG_CONTROLS_V4, _feature_dpi_sliding), + _S(_MOUSE_GESTURES, _F.REPROG_CONTROLS_V4, _feature_mouse_gesture), _S(_POINTER_SPEED, _F.POINTER_SPEED, _feature_pointer_speed), _S(_BACKLIGHT, _F.BACKLIGHT2, _feature_backlight2), _S(_FN_SWAP, _F.FN_INVERSION, _feature_fn_swap, registerFn=_register_fn_swap),