Repository: pwr-Solaar/Solaar

Issue:
Rules for MX Master 3S Thumb Wheel
Hello,
First, thanks you a lot for your software! It works perfectly for managing logitech devices on linux and is a godsend.
There is one function that I can't seems to achieve : I have an MX Master 3 and I would like to switch virtual desktop (on gnome) by using the thumb wheel.
So far, I have try to add custom rule but it seems that the thumb wheel inputs aren't recognized.
I have, of course, enabled the "Thumb Wheel Diversion" tickbox.
I have tried to make the thumb wheel send a notify first to test it, but it hasn't worked so far.
Does someone have more experience than me in this field?
Here is a picture of my rules so far : https://ibb.co/d5nds3M
Thanks you a lot!


Discussion:
notify-send with no arguments does not create a notification.   You need to add an argument.

You can run Solaar as `solaar -ddd` to show what is going on when you move the thumb wheel.
Thanks for your answer, I have tried to replace notify-send by "notify-send "la date d’aujourd’hui :" "`date`"" but it doens't change anything, probably because the thumb wheel isn't detected.

If I launch solaar -ddd and that I scroll the thumb wheel, I have a lot of messages like that : 
```
DEBUG [ReceiverListener:hidraw9] logitech_receiver.base: (12) => r[11 01 0F00 00000000000000000000000000000000]
2022-12-04 16:00:07,375,375    DEBUG [ReceiverListener:hidraw9] logitech_receiver.notifications: <Device(1,4082,MX Master 3 Wireless Mouse,78007208)>: notification for feature THUMB WHEEL, report 0, data 00000000000000000000000000000000
```
Where the data field change for each entry.
Thanks you a lot!
I just see a lot of error message if I try to add a rule with solaar -ddd

```
Traceback (most recent call last):
  File "/usr/lib/python3.10/site-packages/logitech_receiver/diversion.py", line 394, in evaluate
    result = component.evaluate(feature, notification, device, status, result)
  File "/usr/lib/python3.10/site-packages/logitech_receiver/diversion.py", line 394, in evaluate
    result = component.evaluate(feature, notification, device, status, result)
  File "/usr/lib/python3.10/site-packages/logitech_receiver/diversion.py", line 394, in evaluate
    result = component.evaluate(feature, notification, device, status, result)
  File "/usr/lib/python3.10/site-packages/logitech_receiver/diversion.py", line 1092, in evaluate
    subprocess.Popen(self.args)
  File "/usr/lib/python3.10/subprocess.py", line 971, in __init__
    self._execute_child(args, executable, preexec_fn, close_fds,
  File "/usr/lib/python3.10/subprocess.py", line 1847, in _execute_child
    raise child_exception_type(errno_num, err_msg, err_filename)
```

Do you think it's related? I use archlinux
These messages show that Solaar is processing thumb wheel notifications.   The first 16 bits of  the data is the amount the wheel moved as a signed integer.  
The exception appears to be related.  There is some problem running the command in the Execute action.   The line before the traceback should show what is being executed as a list containing the command name and the arguments.  I expect that the problem is that you are trying to execute the command "notify-send", i.e., notify-send enclosed in double quotes.

 Solaar executes commands directly, not via a shell, so the way to print the date is to explicitly call a shell with arguments.  If all you want to do is to check whether your rule is getting to some point just use notify-send with a single argument.
Ok, I understand, I have tried to not use the execute feature and test if the input was working differently, and you are right it works.
So, the last thing to do is to set it to switch virtual desktop. So far, I have tried to set the thumb wheel to the "Super+Page Up/Down" key combinations.

Everything seems to work except that it's way too sensitive, do you know a way to make it less sensitive?
My settings are here : https://ibb.co/2Z4xRbw

Thanks you a lot!

EDIT : I have found the "Rapport" condition, but if I put it to more than 0 it doesn't work

It is possible but not easy to slow down the movement.   

You would have to write a program that kept track of the last time it was run and only switched workspaces after a certain amount of time had elapsed.  Then you would execute this program.


As far as the "Rapport"  (Report) condition goes, this is part of the thumb-wheel test - test - thumb wheel movements are notifications from the THUMB_WHEEL feature with report 0.  So checking the report number separately isn't useful.
Shit, I really wanted this feature but it will become too much of a hassle.
Thanks you a lot for your help anyway.
A better solution might be to use mouse gestures.  You would press the mouse gesture button (hidden in under where your thumb normally goes) and move the mouse to the left or right and then release the mouse gesture button.

A rule would look something like:
Mouse Gesture   Mouse Left
Key Press   Super_L + Page_Down

You would need to set up the mouse gesture button to initiate mouse gestures.  In older versions of Solaar there is a special setting for this.  In newer versions of Solaar you use Key/Button Diversion.
Thanks you a lot, but unfortunately, I'm only interested in thumb wheel scrolling for switching desktop.
I understand that it's a complicated task though, I was already unable to achieve it with logiops.
Thanks for your help!

Resolving patch:
diff --git a/lib/logitech_receiver/diversion.py b/lib/logitech_receiver/diversion.py
--- a/lib/logitech_receiver/diversion.py
+++ b/lib/logitech_receiver/diversion.py
@@ -338,23 +338,49 @@ def simulate_scroll(dx, dy):
     _log.warn('no way to simulate scrolling')
 
 
+def thumb_wheel_up(f, r, d, a):
+    global thumb_wheel_displacement
+    if f != _F.THUMB_WHEEL or r != 0:
+        return False
+    if a is None:
+        return signed(d[0:2]) < 0 and signed(d[0:2])
+    elif thumb_wheel_displacement <= -a:
+        thumb_wheel_displacement += a
+        return 1
+    else:
+        return False
+
+
+def thumb_wheel_down(f, r, d, a):
+    global thumb_wheel_displacement
+    if f != _F.THUMB_WHEEL or r != 0:
+        return False
+    if a is None:
+        return signed(d[0:2]) > 0 and signed(d[0:2])
+    elif thumb_wheel_displacement >= a:
+        thumb_wheel_displacement -= a
+        return 1
+    else:
+        return False
+
+
 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],
-    'crown_right_ratchet': lambda f, r, d: f == _F.CROWN and r == 0 and d[2] < 128 and d[2],
-    'crown_left_ratchet': lambda f, r, d: f == _F.CROWN and r == 0 and d[2] >= 128 and 256 - d[2],
-    'crown_tap': lambda f, r, d: f == _F.CROWN and r == 0 and d[5] == 0x01 and d[5],
-    'crown_start_press': lambda f, r, d: f == _F.CROWN and r == 0 and d[6] == 0x01 and d[6],
-    'crown_end_press': lambda f, r, d: f == _F.CROWN and r == 0 and d[6] == 0x05 and d[6],
-    'crown_pressed': lambda f, r, d: f == _F.CROWN and r == 0 and d[6] >= 0x01 and d[6] <= 0x04 and d[6],
-    'thumb_wheel_up': lambda f, r, d: f == _F.THUMB_WHEEL and r == 0 and signed(d[0:2]) < 0 and signed(d[0:2]),
-    'thumb_wheel_down': lambda f, r, d: f == _F.THUMB_WHEEL and r == 0 and signed(d[0:2]) > 0 and signed(d[0:2]),
-    'lowres_wheel_up': lambda f, r, d: f == _F.LOWRES_WHEEL and r == 0 and signed(d[0:1]) > 0 and signed(d[0:1]),
-    '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]),
-    'False': lambda f, r, d: False,
-    'True': lambda f, r, d: True,
+    'crown_right': [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[1] < 128 and d[1], False],
+    'crown_left': [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[1] >= 128 and 256 - d[1], False],
+    'crown_right_ratchet': [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[2] < 128 and d[2], False],
+    'crown_left_ratchet': [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[2] >= 128 and 256 - d[2], False],
+    'crown_tap': [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[5] == 0x01 and d[5], False],
+    'crown_start_press': [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[6] == 0x01 and d[6], False],
+    'crown_end_press': [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[6] == 0x05 and d[6], False],
+    'crown_pressed': [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[6] >= 0x01 and d[6] <= 0x04 and d[6], False],
+    'thumb_wheel_up': [thumb_wheel_up, True],
+    'thumb_wheel_down': [thumb_wheel_down, True],
+    'lowres_wheel_up': [lambda f, r, d, a: f == _F.LOWRES_WHEEL and r == 0 and signed(d[0:1]) > 0 and signed(d[0:1]), False],
+    'lowres_wheel_down': [lambda f, r, d, a: f == _F.LOWRES_WHEEL and r == 0 and signed(d[0:1]) < 0 and signed(d[0:1]), False],
+    'hires_wheel_up': [lambda f, r, d, a: f == _F.HIRES_WHEEL and r == 0 and signed(d[1:3]) > 0 and signed(d[1:3]), False],
+    'hires_wheel_down': [lambda f, r, d, a: f == _F.HIRES_WHEEL and r == 0 and signed(d[1:3]) < 0 and signed(d[1:3]), False],
+    'False': [lambda f, r, d, a: False, False],
+    'True': [lambda f, r, d, a: True, False],
 }
 
 MOUSE_GESTURE_TESTS = {
@@ -749,24 +775,30 @@ def range_test_helper(f, r, d):
 class Test(Condition):
 
     def __init__(self, test, warn=True):
-        self.test = test
+        self.test = ''
+        self.parameter = None
         if isinstance(test, str):
-            if test in MOUSE_GESTURE_TESTS:
+            test = [test]
+        if isinstance(test, list) and all(isinstance(t, int) for t in test):
+            if warn:
+                _log.warn('Test rules consisting of numbers are deprecated, converting to a TestBytes condition')
+            self.__class__ = TestBytes
+            self.__init__(test, warn=warn)
+        elif isinstance(test, list):
+            if test[0] in MOUSE_GESTURE_TESTS:
                 if warn:
                     _log.warn('mouse movement test %s deprecated, converting to a MouseGesture', test)
                 self.__class__ = MouseGesture
-                self.__init__(MOUSE_GESTURE_TESTS[test], warn=warn)
-            elif test in TESTS:
-                self.function = TESTS[test]
+                self.__init__(MOUSE_GESTURE_TESTS[0][test], warn=warn)
+            elif test[0] in TESTS:
+                self.test = test[0]
+                self.function = TESTS[test[0]][0]
+                self.parameter = test[1] if len(test) > 1 else None
             else:
                 if warn:
-                    _log.warn('rule Test string argument not name of a test: %s', test)
-                self.function = TESTS['False']
-        elif isinstance(test, list) and all(isinstance(t, int) for t in test):
-            if warn:
-                _log.warn('Test rules consisting of numbers are deprecated, converting to a TestBytes condition')
-            self.__class__ = TestBytes
-            self.__init__(test, warn=warn)
+                    _log.warn('rule Test name not name of a test: %s', test)
+                self.test = 'False'
+                self.function = TESTS['False'][0]
         else:
             if warn:
                 _log.warn('rule Test argument not valid %s', test)
@@ -775,10 +807,10 @@ def __str__(self):
         return 'Test: ' + str(self.test)
 
     def evaluate(self, feature, notification, device, status, last_result):
-        return self.function(feature, notification.address, notification.data)
+        return self.function(feature, notification.address, notification.data, self.parameter)
 
     def data(self):
-        return {'Test': str(self.test)}
+        return {'Test': ([self.test, self.parameter] if self.parameter is not None else [self.test])}
 
 
 class TestBytes(Condition):
@@ -1204,11 +1236,12 @@ def data(self):
 g_keys_down = [0, 0, 0, 0]
 m_keys_down = 0
 mr_key_down = False
+thumb_wheel_displacement = 0
 
 
 # process a notification
 def process_notification(device, status, notification, feature):
-    global keys_down, g_keys_down, m_keys_down, mr_key_down, key_down, key_up
+    global keys_down, g_keys_down, m_keys_down, mr_key_down, key_down, key_up, thumb_wheel_displacement
     key_down, key_up = None, None
     # need to keep track of keys that are down to find a new key down
     if feature == _F.REPROG_CONTROLS_V4 and notification.address == 0x00:
@@ -1249,6 +1282,12 @@ def process_notification(device, status, notification, feature):
         if mr_key_down and not new_mr_key_down:
             key_up = _CONTROL['MR']
         mr_key_down = new_mr_key_down
+    # keep track of thumb wheel movment
+    elif feature == _F.THUMB_WHEEL and notification.address == 0x00:
+        if notification.data[4] <= 0x01:  # when wheel starts, zero out last movement
+            thumb_wheel_displacement = 0
+        thumb_wheel_displacement += signed(notification.data[0:2])
+
     GLib.idle_add(rules.evaluate, feature, notification, device, status, True)
 
 
diff --git a/lib/solaar/ui/diversion_rules.py b/lib/solaar/ui/diversion_rules.py
--- a/lib/solaar/ui/diversion_rules.py
+++ b/lib/solaar/ui/diversion_rules.py
@@ -1428,36 +1428,53 @@ def create_widgets(self):
         self.widgets = {}
         self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
         self.label.set_text(_('Test condition on notification triggering rule processing.'))
-        self.widgets[self.label] = (0, 0, 1, 1)
-        self.field = Gtk.ComboBoxText.new_with_entry()
-        self.field.append('', '')
+        self.widgets[self.label] = (0, 0, 4, 1)
+        lbl = Gtk.Label(_('Test'), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=False, vexpand=False)
+        self.widgets[lbl] = (0, 1, 1, 1)
+        lbl = Gtk.Label(_('Parameter'), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=False, vexpand=False)
+        self.widgets[lbl] = (2, 1, 1, 1)
+
+        self.test = Gtk.ComboBoxText.new_with_entry()
+        self.test.append('', '')
         for t in _DIV.TESTS:
-            self.field.append(t, t)
-        self.field.set_valign(Gtk.Align.CENTER)
-        #        self.field.set_vexpand(True)
-        self.field.set_size_request(600, 0)
-        CompletionEntry.add_completion_to_entry(self.field.get_child(), _DIV.TESTS)
-        self.field.connect('changed', self._on_update)
-        self.widgets[self.field] = (0, 1, 1, 1)
+            self.test.append(t, t)
+        self.test.set_halign(Gtk.Align.END)
+        self.test.set_valign(Gtk.Align.CENTER)
+        self.test.set_hexpand(False)
+        self.test.set_size_request(300, 0)
+        CompletionEntry.add_completion_to_entry(self.test.get_child(), _DIV.TESTS)
+        self.test.connect('changed', self._on_update)
+        self.widgets[self.test] = (1, 1, 1, 1)
+
+        self.parameter = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True)
+        self.parameter.set_size_request(150, 0)
+        self.parameter.connect('changed', self._on_update)
+        self.widgets[self.parameter] = (3, 1, 1, 1)
 
     def show(self, component, editable):
         super().show(component, editable)
         with self.ignore_changes():
-            self.field.set_active_id(component.test or '')
+            self.test.set_active_id(component.test)
+            self.parameter.set_text(str(component.parameter) if component.parameter is not None else '')
             if component.test not in _DIV.TESTS:
-                self.field.get_child().set_text(component.test)
+                self.test.get_child().set_text(component.test)
                 self._change_status_icon()
 
     def collect_value(self):
-        return (self.field.get_active_text() or '').strip()
+        try:
+            param = int(self.parameter.get_text()) if self.parameter.get_text() else None
+        except Exception:
+            param = self.parameter.get_text()
+        test = (self.test.get_active_text() or '').strip()
+        return [test, param] if param is not None else [test]
 
     def _on_update(self, *args):
         super()._on_update(*args)
         self._change_status_icon()
 
     def _change_status_icon(self):
-        icon = 'dialog-warning' if self.component.test not in _DIV.TESTS else ''
-        self.field.get_child().set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon)
+        icon = 'dialog-warning' if (self.test.get_active_text() or '').strip() not in _DIV.TESTS else ''
+        self.test.get_child().set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon)
 
     @classmethod
     def left_label(cls, component):
@@ -1465,7 +1482,7 @@ def left_label(cls, component):
 
     @classmethod
     def right_label(cls, component):
-        return str(component.test)
+        return component.test + (' ' + repr(component.parameter) if component.parameter is not None else '')
 
 
 _TestBytesElement = namedtuple('TestBytesElement', ['id', 'label', 'min', 'max'])


Test patch: