Repository: pwr-Solaar/Solaar

Issue:
rule editor only allows strings for Test conditions
Test conditions can be lists and strings, but the rule editor only allows strings.

An example list test is:
```
Test: [0, 1, 1, 1]
```
which checks whether the first (0) byte of the notification is 1.

See the rule documentation for more information, but the list can have either three or four members, each an integer.  The first two select several bytes (0-origin addressing), the third and fourth provide a range, and the third alone is a bit mask.


Discussion:
@viniciusbm Can you take a look at this?   Rule Test conditions are unlikely to have many users so if it is easier to handle this if the condition is split into Test and TestBytes then that would be possible.

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
@@ -553,22 +553,42 @@ def __init__(self, test):
             else:
                 _log.warn('rule Test string argument not name of a test: %s', test)
                 self.function = TESTS['False']
-        elif (
+        elif isinstance(test, list) and all(isinstance(t, int) for t in test):
+            _log.warn('Test rules consisting of numbers are deprecated, converting to a TestBytes condition')
+            self.__class__ = TestBytes
+            self.__init__(test)
+        else:
+            _log.warn('rule Test argument not valid %s', test)
+
+    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)
+
+    def data(self):
+        return {'Test': str(self.test)}
+
+
+class TestBytes(Condition):
+    def __init__(self, test):
+        self.test = test
+        if (
             isinstance(test, list) and 2 < len(test) <= 4 and all(isinstance(t, int) for t in test) and test[0] >= 0
             and test[0] <= 16 and test[1] >= 0 and test[1] <= 16 and test[0] < test[1]
         ):
             self.function = bit_test(*test) if len(test) == 3 else range_test(*test)
         else:
-            _log.warn('rule Test argument not valid %s', test)
+            _log.warn('rule TestBytes argument not valid %s', test)
 
     def __str__(self):
-        return 'Test: ' + str(self.test)
+        return 'TestBytes: ' + str(self.test)
 
     def evaluate(self, feature, notification, device, status, last_result):
         return self.function(feature, notification.address, notification.data)
 
     def data(self):
-        return {'Test': str(self.test)}
+        return {'TestBytes': self.test[:]}
 
 
 class MouseGesture(Condition):
@@ -860,6 +880,7 @@ def data(self):
     'Modifiers': Modifiers,
     'Key': Key,
     'Test': Test,
+    'TestBytes': TestBytes,
     'MouseGesture': MouseGesture,
     'KeyPress': KeyPress,
     'MouseScroll': MouseScroll,
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
@@ -18,7 +18,7 @@
 import string
 import threading
 
-from collections import defaultdict
+from collections import defaultdict, namedtuple
 from contextlib import contextmanager as contextlib_contextmanager
 from copy import copy
 from dataclasses import dataclass, field
@@ -512,11 +512,12 @@ def _menu_insert(self, m, it, below=False):
                     [
                         (_('Feature'), _DIV.Feature, FeatureUI.FEATURES_WITH_DIVERSION[0]),
                         (_('Process'), _DIV.Process, ''),
-                        (_('MouseProcess'), _DIV.MouseProcess, ''),
+                        (_('Mouse process'), _DIV.MouseProcess, ''),
                         (_('Report'), _DIV.Report, 0),
                         (_('Modifiers'), _DIV.Modifiers, []),
                         (_('Key'), _DIV.Key, ''),
                         (_('Test'), _DIV.Test, next(iter(_DIV.TESTS))),
+                        (_('Test bytes'), _DIV.TestBytes, [0, 1, 0]),
                         (_('Mouse Gesture'), _DIV.MouseGesture, ''),
                     ]
                 ],
@@ -1394,6 +1395,123 @@ def right_label(cls, component):
         return str(component.test)
 
 
+_TestBytesElement = namedtuple('TestBytesElement', ['id', 'label', 'min', 'max'])
+_TestBytesMode = namedtuple('TestBytesMode', ['label', 'elements', 'label_fn'])
+
+
+class TestBytesUI(ConditionUI):
+
+    CLASS = _DIV.TestBytes
+
+    _common_elements = [
+        _TestBytesElement('begin', _('begin (inclusive)'), 0, 16),
+        _TestBytesElement('end', _('end (exclusive)'), 0, 16)
+    ]
+
+    _global_min = -2**31
+    _global_max = 2**31 - 1
+
+    _modes = {
+        'range':
+        _TestBytesMode(
+            _('range'),
+            _common_elements + [
+                _TestBytesElement('minimum', _('minimum'), _global_min, _global_max),  # uint32
+                _TestBytesElement('maximum', _('maximum'), _global_min, _global_max),
+            ],
+            lambda e: _('bytes %(0)d to %(1)d, ranging from %(2)d to %(3)d' % {str(i): v
+                                                                               for i, v in enumerate(e)})
+        ),
+        'mask':
+        _TestBytesMode(
+            _('mask'), _common_elements + [_TestBytesElement('mask', _('mask'), _global_min, _global_max)],
+            lambda e: _('bytes %(0)d to %(1)d, mask %(2)d' % {str(i): v
+                                                              for i, v in enumerate(e)})
+        )
+    }
+
+    def create_widgets(self):
+        self.fields = {}
+        self.field_labels = {}
+        self.widgets = {}
+        col = 0
+        mode_col = 2
+        self.mode_field = Gtk.ComboBox.new_with_model(Gtk.ListStore(str, str))
+        mode_renderer = Gtk.CellRendererText()
+        self.mode_field.set_id_column(0)
+        self.mode_field.pack_start(mode_renderer, True)
+        self.mode_field.add_attribute(mode_renderer, 'text', 1)
+        self.widgets[self.mode_field] = (mode_col, 1, 1, 1)
+        mode_label = Gtk.Label(_('type'), margin_top=20)
+        self.widgets[mode_label] = (mode_col, 0, 1, 1)
+        for mode_id, mode in TestBytesUI._modes.items():
+            self.mode_field.get_model().append([mode_id, mode.label])
+            for element in mode.elements:
+                if element.id not in self.fields:
+                    field = Gtk.SpinButton.new_with_range(element.min, element.max, 1)
+                    field.set_value(0)
+                    field.set_size_request(150, 0)
+                    field.connect('value-changed', self._on_update)
+                    label = Gtk.Label(element.label, margin_top=20)
+                    self.fields[element.id] = field
+                    self.field_labels[element.id] = label
+                    self.widgets[label] = (col, 0, 1, 1)
+                    self.widgets[field] = (col, 1, 1, 1)
+                    col += 1 if col != mode_col - 1 else 2
+        self.mode_field.connect('changed', lambda cb: (self._on_update(), self._only_mode(cb.get_active_id())))
+        self.mode_field.set_active_id('range')
+
+    def show(self, component, editable):
+        super().show(component, editable)
+
+        with self.ignore_changes():
+            mode_id = {3: 'mask', 4: 'range'}.get(len(component.test), None)
+            self._only_mode(mode_id)
+            if not mode_id:
+                return
+            self.mode_field.set_active_id(mode_id)
+            if mode_id:
+                mode = TestBytesUI._modes[mode_id]
+                for i, element in enumerate(mode.elements):
+                    self.fields[element.id].set_value(component.test[i])
+
+    def collect_value(self):
+        mode_id = self.mode_field.get_active_id()
+        return [self.fields[element.id].get_value_as_int() for element in TestBytesUI._modes[mode_id].elements]
+
+    def _only_mode(self, mode_id):
+        if not mode_id:
+            return
+        keep = {element.id for element in TestBytesUI._modes[mode_id].elements}
+        for element_id, f in self.fields.items():
+            visible = element_id in keep
+            f.set_visible(visible)
+            self.field_labels[element_id].set_visible(visible)
+
+    def _on_update(self, *args):
+        super()._on_update(*args)
+        if not self.component:
+            return
+        begin, end, *etc = self.component.test
+        icon = 'dialog-warning' if end <= begin else ''
+        self.fields['end'].set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon)
+        if len(self.component.test) == 4:
+            *etc, minimum, maximum = self.component.test
+            icon = 'dialog-warning' if maximum < minimum else ''
+            self.fields['maximum'].set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon)
+
+    @classmethod
+    def left_label(cls, component):
+        return _('Test bytes')
+
+    @classmethod
+    def right_label(cls, component):
+        mode_id = {3: 'mask', 4: 'range'}.get(len(component.test), None)
+        if not mode_id:
+            return str(component.test)
+        return TestBytesUI._modes[mode_id].label_fn(component.test)
+
+
 class MouseGestureUI(ConditionUI):
 
     CLASS = _DIV.MouseGesture
@@ -2234,6 +2352,7 @@ def right_label(cls, component):
     _DIV.Modifiers: ModifiersUI,
     _DIV.Key: KeyUI,
     _DIV.Test: TestUI,
+    _DIV.TestBytes: TestBytesUI,
     _DIV.MouseGesture: MouseGestureUI,
     _DIV.KeyPress: KeyPressUI,
     _DIV.MouseScroll: MouseScrollUI,


Test patch: