Compare commits

...

6 Commits

Author SHA1 Message Date
MattHag 8ef86b4f30
Merge d77838282c into bdb0e9589b 2025-10-12 20:51:11 +01:00
Matthaiks bdb0e9589b Update Polish translation 2025-10-10 19:12:19 -04:00
Peter F. Patel-Schneider 0335dd003c release 1.1.15rc2 2025-10-10 09:19:31 -04:00
Peter F. Patel-Schneider 8bea0121cc release 1.1.15rc1 2025-10-10 09:19:31 -04:00
MattHag d77838282c rule storage: Use composition for diversion dialog
Introduce a higher level storage provider interface to decouple storage
implementation from diversion dialog and use composition to pass an
implementation.

This simplifies testing of the diversion dialog and enables clean unit
testing without I/O.

Related #2675
2025-01-02 01:47:06 +01:00
MattHag 062d866951 rule storage: Apply AbstractRepository pattern
Introduce a AbstractRepository as interface to the rules database and
implement a YML storage that implements it.

Additionally, a FakeStorage implements that interface to test diversion
related code, without I/O calls and side effects. It keeps the rules
in-memory for the test duration.

Related #2675
2025-01-02 01:47:06 +01:00
8 changed files with 742 additions and 540 deletions

View File

@ -1,3 +1,75 @@
# 1.1.15rc1
* Center labels and remove buggy entry resizing logic
* Add shape keys from Key POP Icon
* Device and Action rule conditions match on codename and name
* Fix listing hidpp10 devices - bytes vs string concatenation (#2856)
* Add present flag, unset when internal error occurs, set when notification appears
* Pause setting up features when error occurs; use ADC message to signal connection and disconnection
* Fix listing of hidpp10 peripherals
* Complete DEVICE_FEATURES to DeviceFeature transition for hidpp10 devices
* Fix NOTIFICATION_FLAG to NotificationFlag transition leftovers
* Fix github workflow stopping all matrix jobs when one of them fails
* Fix ubuntu github CI
* Update index.md
* Python documentation appears to be broken so don't set it up
* Improve documentation on onboard profiles
* Use correct LOD values for extended adjustable dpi
* Better support RGB Effects - not readable
* Fix crash when asking for help about config
* Fix error when updating ChoiceControlBig box
* Add uninstallation docs
* Handle unknown power switch locations again
* Correctly handle selection of [empty] in rule editor
* Handle `HIDError` in `hidapi.hidapi_impl._match()` (#2804)
* Give ghost devices a path
* Guard against typeerror when setting the value of a control box
* Recover from errors in ping
* Replace spaces by underscores when looking up features
* Rewrote string concatenation/format with f strings
* Fix logo not showing in about dialog box
* Make typing-extensions dependency mandatory
* Properly ignore unsupported locale
* hidapi: skip unsupported devices and handle exception on open
* Ignore macOS junk files and pipenv config
* Fix ui desktop notifications test
* hidpp20: Remove dependency to NamedInts
* Estimate accurate battery level for some rechargable devices (#2745)
* Upgrade desktop notifications tests to take notifications availability into account
* Update tests to run on Python 3.13
* Remove outdated logger enabled checks
* Introduce GTK signal types
* Introduce error types
* Remove alias for SupportedFeature
* Refactor process_device_notification
* Refactor process_receiver_notification
* Refactor receiver event handling
* Introduce custom logger
* Refactor notifications
* Rename variable to full name notification
* Test notifications
* Test extraction of serial and max. devices
* Refactor extraction of serial and max. devices
* macOS: Fix int.from_bytes, int.to_bytes for show.py
* macOS: Remove udev rule warning
* macOS: Add support for Bluetooth devices
* Add back and forward mouseclick actions
* Speedup lookup of known receivers
* Refactor device filtering
* Reorder private functions and variable definitions
* Turn filter_products_of_interest into a public function
* Improve tests of known receivers
* Refactor: Remove NamedInts and move enums where used
* Add docstrings and type hints
* Enforce rules on RuleComponentUI subclasses
* Simplify settings UI class
* Remove diversion alias
* Refactor: Convert Kind to IntEnum
* Split up huge settings module
* Remove Python 2 specific path handling
* Delete logging temp file on exit
* Update Swedish translation
# 1.1.14
* Handle fake feature enums in show

View File

@ -2,6 +2,7 @@
## Version 1.1.15
* Device and Action rule conditions match on device codename and name
* Solaar supports configuration of Bluetooth devices on macOS.
## Version 1.1.13

View File

@ -0,0 +1,64 @@
solaar show
rules cannot access modifier keys in Wayland, accessing process only works on GNOME with Solaar Gnome extension installed
solaar version 1.1.14-2
Unifying Receiver
Device path : /dev/hidraw1
USB id : 046d:C52B
Serial : EC219AC2
C Pending : ff
0 : 12.11.B0032
1 : 04.16
3 : AA.AA
Has 2 paired device(s) out of a maximum of 6.
Notifications: wireless (0x000100)
Device activity counters: 1=195, 2=74
1: Wireless Mouse M175
Device path : /dev/hidraw2
WPID : 4008
Codename : M175
Kind : mouse
Protocol : HID++ 2.0
Report Rate : 8ms
Serial number: 16E46E8C
Model ID: 000000000000
Unit ID: 00000000
0: RQM 40.00.B0016
The power switch is located on the base.
Supports 21 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: DEVICE FW VERSION {0003} V0
Firmware: 0 RQM 40.00.B0016 4008
Unit ID: 00000000 Model ID: 000000000000 Transport IDs: {}
3: DEVICE NAME {0005} V0
Name: Wireless Mouse M185
Kind: mouse
4: BATTERY STATUS {1000} V0
Battery: 70%, 0, next level 5%.
5: unknown:1830 {1830} V0 internal, hidden
6: unknown:1850 {1850} V0 internal, hidden
7: unknown:1860 {1860} V0 internal, hidden
8: unknown:1890 {1890} V0 internal, hidden
9: unknown:18A0 {18A0} V0 internal, hidden
10: unknown:18C0 {18C0} V0 internal, hidden
11: WIRELESS DEVICE STATUS {1D4B} V0
12: unknown:1DF3 {1DF3} V0 internal, hidden
13: REPROG CONTROLS {1B00} V0
14: REMAINING PAIRING {1DF0} V0 hidden
Remaining Pairings: 117
15: unknown:1E00 {1E00} V0 hidden
16: unknown:1E80 {1E80} V0 internal, hidden
17: unknown:1E90 {1E90} V0 internal, hidden
18: unknown:1F03 {1F03} V0 internal, hidden
19: VERTICAL SCROLLING {2100} V0
Roller type: standard
Ratchet per turn: 24
Scroll lines: 0
20: MOUSE POINTER {2200} V0
DPI: 1000
Acceleration: low
Override OS ballistics
No vertical tuning, standard mice
Battery: 70%, 0, next level 5%.

View File

@ -29,16 +29,18 @@ import sys
import time
import typing
from pathlib import Path
from typing import Any
from typing import Dict
from typing import Tuple
import gi
import psutil
import yaml
from keysyms import keysymdef
from . import rule_storage
# There is no evdev on macOS or Windows. Diversion will not work without
# it but other Solaar functionality is available.
if platform.system() in ("Darwin", "Windows"):
@ -58,6 +60,14 @@ if typing.TYPE_CHECKING:
logger = logging.getLogger(__name__)
if os.environ.get("XDG_CONFIG_HOME"):
xdg_config_home = Path(os.environ.get("XDG_CONFIG_HOME"))
else:
xdg_config_home = Path("~/.config").expanduser()
RULES_CONFIG = xdg_config_home / "solaar" / "rules.yaml"
#
# See docs/rules.md for documentation
#
@ -146,6 +156,17 @@ thumb_wheel_displacement = 0
_dbus_interface = None
class AbstractRepository(typing.Protocol):
def save(self, rules: Dict[str, str]) -> None:
...
def load(self) -> list:
...
storage: AbstractRepository = rule_storage.YmlRuleStorage(RULES_CONFIG)
class XkbDisplay(ctypes.Structure):
"""opaque struct"""
@ -1553,83 +1574,64 @@ def process_notification(device, notification: HIDPPNotification, feature) -> No
GLib.idle_add(evaluate_rules, feature, notification, device)
_XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser(os.path.join("~", ".config"))
_file_path = os.path.join(_XDG_CONFIG_HOME, "solaar", "rules.yaml")
class Persister:
@staticmethod
def save_config_rule_file() -> None:
"""Saves user configured rules."""
rules = built_in_rules
# This is a trick to show str/float/int lists in-line (inspired by https://stackoverflow.com/a/14001707)
class inline_list(list):
pass
def convert(elem):
if isinstance(elem, list):
if len(elem) == 1 and isinstance(elem[0], (int, str, float)):
# All diversion classes that expect a list of scalars also support a single scalar without a list
return elem[0]
if all(isinstance(c, (int, str, float)) for c in elem):
return inline_list([convert(c) for c in elem])
return [convert(c) for c in elem]
if isinstance(elem, dict):
return {k: convert(v) for k, v in elem.items()}
if isinstance(elem, NamedInt):
return int(elem)
return elem
def _save_config_rule_file(file_name: str = _file_path):
# This is a trick to show str/float/int lists in-line (inspired by https://stackoverflow.com/a/14001707)
class inline_list(list):
pass
global rules
def blockseq_rep(dumper, data):
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=True)
# Save only user-defined rules
rules_to_save = sum((r.data()["Rule"] for r in rules.components if r.source == str(RULES_CONFIG)), [])
if logger.isEnabledFor(logging.INFO):
logger.info(f"saving {len(rules_to_save)} rule(s) to {str(RULES_CONFIG)}")
dump_data = [r["Rule"] for r in rules_to_save]
try:
data = convert(dump_data)
storage.save(data)
except Exception:
logger.error("failed to save to rules config")
yaml.add_representer(inline_list, blockseq_rep)
@staticmethod
def load_rule_config() -> Rule:
"""Loads user configured rules."""
global rules
def convert(elem):
if isinstance(elem, list):
if len(elem) == 1 and isinstance(elem[0], (int, str, float)):
# All diversion classes that expect a list of scalars also support a single scalar without a list
return elem[0]
if all(isinstance(c, (int, str, float)) for c in elem):
return inline_list([convert(c) for c in elem])
return [convert(c) for c in elem]
if isinstance(elem, dict):
return {k: convert(v) for k, v in elem.items()}
if isinstance(elem, NamedInt):
return int(elem)
return elem
# YAML format settings
dump_settings = {
"encoding": "utf-8",
"explicit_start": True,
"explicit_end": True,
"default_flow_style": False,
# 'version': (1, 3), # it would be printed for every rule
}
# Save only user-defined rules
rules_to_save = sum((r.data()["Rule"] for r in rules.components if r.source == file_name), [])
if logger.isEnabledFor(logging.INFO):
logger.info("saving %d rule(s) to %s", len(rules_to_save), file_name)
try:
with open(file_name, "w") as f:
if rules_to_save:
f.write("%YAML 1.3\n") # Write version manually
dump_data = [r["Rule"] for r in rules_to_save]
yaml.dump_all(convert(dump_data), f, **dump_settings)
except Exception as e:
logger.error("failed to save to %s\n%s", file_name, e)
return False
return True
def load_config_rule_file():
"""Loads user configured rules."""
global rules
if os.path.isfile(_file_path):
rules = _load_rule_config(_file_path)
def _load_rule_config(file_path: str) -> Rule:
loaded_rules = []
try:
with open(file_path) as config_file:
loaded_rules = []
for loaded_rule in yaml.safe_load_all(config_file):
rule = Rule(loaded_rule, source=file_path)
loaded_rules = []
try:
plain_rules = storage.load()
for loaded_rule in plain_rules:
rule = Rule(loaded_rule, source=str(RULES_CONFIG))
if logger.isEnabledFor(logging.DEBUG):
logger.debug("load rule: %s", rule)
logger.debug(f"load rule: {rule}")
loaded_rules.append(rule)
if logger.isEnabledFor(logging.INFO):
logger.info("loaded %d rules from %s", len(loaded_rules), config_file.name)
except Exception as e:
logger.error("failed to load from %s\n%s", file_path, e)
return Rule([Rule(loaded_rules, source=file_path), built_in_rules])
logger.info(
f"loaded {len(loaded_rules)} rules from config file",
)
except Exception as e:
logger.error(f"failed to load from {RULES_CONFIG}\n{e}")
user_rules = Rule(loaded_rules, source=str(RULES_CONFIG))
rules = Rule([user_rules, built_in_rules])
return rules
load_config_rule_file()
Persister.load_rule_config()

View File

@ -0,0 +1,47 @@
from pathlib import Path
from typing import Dict
import yaml
class YmlRuleStorage:
def __init__(self, path: Path):
self._config_path = path
def save(self, rules: Dict[str, str]) -> None:
# This is a trick to show str/float/int lists in-line (inspired by https://stackoverflow.com/a/14001707)
class inline_list(list):
pass
def blockseq_rep(dumper, data):
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=True)
yaml.add_representer(inline_list, blockseq_rep)
format_settings = {
"encoding": "utf-8",
"explicit_start": True,
"explicit_end": True,
"default_flow_style": False,
}
with open(self._config_path, "w") as f:
f.write("%YAML 1.3\n") # Write version manually
yaml.dump_all(rules, f, **format_settings)
def load(self) -> list:
with open(self._config_path) as config_file:
plain_rules = list(yaml.safe_load_all(config_file))
return plain_rules
class FakeRuleStorage:
def __init__(self, rules=None):
if rules is None:
self._rules = {}
else:
self._rules = rules
def save(self, rules: dict) -> None:
self._rules = rules
def load(self) -> dict:
return self._rules

View File

@ -31,6 +31,7 @@ from typing import Any
from typing import Callable
from typing import Dict
from typing import Optional
from typing import Protocol
from gi.repository import Gdk
from gi.repository import GObject
@ -359,7 +360,7 @@ class ActionMenu:
else:
idx = parent_c.components.index(c)
if isinstance(new_c, diversion.Rule) and wrapped.level == 1:
new_c.source = diversion._file_path # new rules will be saved to the YAML file
new_c.source = str(diversion.RULES_CONFIG) # new rules will be saved to the YAML file
idx += int(below)
parent_c.components.insert(idx, new_c)
self._populate_model_func(m, parent_it, new_c, level=wrapped.level, pos=idx)
@ -562,8 +563,14 @@ class ActionMenu:
return menu_copy
class RulePersister(Protocol):
def load_rule_config(self) -> _DIV.Rule: ...
def save_config_rule_file(self) -> None: ...
class DiversionDialog:
def __init__(self, action_menu):
def __init__(self, action_menu, rule_persister: RulePersister):
window = Gtk.Window()
window.set_title(_("Solaar Rule Editor"))
window.connect(GtkSignal.DELETE_EVENT.value, self._closing)
@ -580,6 +587,7 @@ class DiversionDialog:
populate_model_func=_populate_model,
on_update=self.on_update,
)
self._ruler_persister = rule_persister
self.dirty = False # if dirty, there are pending changes to be saved
@ -638,16 +646,20 @@ class DiversionDialog:
self.dirty = False
for c in self.selected_rule_edit_panel.get_children():
self.selected_rule_edit_panel.remove(c)
self._ruler_persister.load_rule_config()
diversion.load_config_rule_file()
self.model = self._create_model()
self.view.set_model(self.model)
self.view.expand_all()
def _save_yaml_file(self):
if diversion._save_config_rule_file():
try:
self._ruler_persister.save_config_rule_file()
self.dirty = False
self.save_btn.set_sensitive(False)
self.discard_btn.set_sensitive(False)
except Exception:
pass
def _create_top_panel(self):
sw = Gtk.ScrolledWindow()
@ -1879,6 +1891,6 @@ def show_window(model: Gtk.TreeStore):
global _dev_model
_dev_model = model
if _diversion_dialog is None:
_diversion_dialog = DiversionDialog(ActionMenu)
_diversion_dialog = DiversionDialog(action_menu=ActionMenu, rule_persister=diversion.Persister())
update_devices()
_diversion_dialog.window.present()

934
po/pl.po

File diff suppressed because it is too large Load Diff

View File

@ -48,7 +48,7 @@ def test_load_rule_config(rule_config):
]
with mock.patch("builtins.open", new=mock_open(read_data=rule_config)):
loaded_rules = diversion._load_rule_config(file_path=mock.Mock())
loaded_rules = diversion.Persister.load_rule_config()
assert len(loaded_rules.components) == 2 # predefined and user configured rules
user_configured_rules = loaded_rules.components[0]