Compare commits
6 Commits
812b78a494
...
8ef86b4f30
Author | SHA1 | Date |
---|---|---|
|
8ef86b4f30 | |
|
bdb0e9589b | |
|
0335dd003c | |
|
8bea0121cc | |
|
d77838282c | |
|
062d866951 |
72
CHANGELOG.md
72
CHANGELOG.md
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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%.
|
|
@ -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()
|
||||
|
|
|
@ -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
|
|
@ -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()
|
||||
|
|
|
@ -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]
|
||||
|
|
Loading…
Reference in New Issue