diff --git a/README.md b/README.md index c3444c0f..b2496433 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,52 @@ -# Solaar +# Solaar-Battery -Solaar is a Linux manager for many Logitech keyboards, mice, and other devices -that connect wirelessly to a Unifying, Bolt, Lightspeed or Nano receiver -as well as many Logitech devices that connect via a USB cable or Bluetooth. -Solaar is not a device driver and responds only to special messages from devices -that are otherwise ignored by the Linux input system. +**Solaar-Battery** is a customized, feature-rich fork of Solaar for managing Logitech wireless peripherals, specifically designed with advanced RGB battery monitoring and enhanced profile management. -More Information - +*(Like the original Solaar, it connects wirelessly to Unifying, Bolt, Lightspeed, or Nano receivers, as well as via USB cable or Bluetooth.)* + +Original Solaar Docs - Usage - Capabilities - -Rules - -Manual Installation - -Known Issues - +Rules [![codecov](https://codecov.io/gh/pwr-Solaar/Solaar/graph/badge.svg?token=D7YWFEWID6)](https://codecov.io/gh/pwr-Solaar/Solaar) [![License: GPL v2](https://img.shields.io/badge/License-GPL%20v2+-blue.svg)](../LICENSE.txt) +--- + +## ✨ What's New in Solaar-Battery? + +### 🔋 Dynamic Battery LED Configurator +Never guess your mouse's battery level again. Solaar-Battery intelligently hijacks the primary RGB lighting zone on compatible Logitech G-Series hardware (such as the G305, G Pro Wireless, G502 Lightspeed, etc.) to act as a real-time battery indicator. + +* **Intelligent Color Mapping:** The mouse RGB dynamically shifts colors based on your current battery percentage: + * 🟢 **100% - 70%:** Green + * 🟡 **69% - 45%:** Yellow + * 🟠 **44% - 20%:** Orange + * 🔴 **19% - 6%:** Red + * 🚨 **< 5%:** Blinking Red (Critical) +* **Hardware-Bypassing Brightness Control:** Includes a custom "Battery LED Brightness" slider directly in the GUI. Because Logitech's proprietary firmware aggressively caches static lighting commands, Solaar-Battery includes a custom "Apply" macro that programmatically restarts the LED state machine, allowing you to dim or brighten the battery indicator colors seamlessly. + +### 💾 Enhanced Profile Management +Solaar-Battery features a completely overhauled profile saving and deletion system, fixing native bugs present in the original Solaar codebase. + +* **Explicit Profile Saving:** Prevents the "save-on-keystroke" bug. Solaar-Battery strictly binds profile creation to an explicit "Save Profile" button (or by hitting the `Enter` key), keeping your configuration files clean and intentional. +* **1-Click Profile Deletion:** Adds a dedicated "Delete Profile" button to the main GUI. Solaar-Battery completely bypasses the GTK signal-looping bugs that normally prevent profiles from being cleanly removed from the `profiles.json` cache, allowing for instant, error-free profile management. + +### 🎨 Custom Branding & Aesthetics +* Rebranded as **Solaar-Battery** with custom high-resolution icons integrated directly into the system tray, application launcher, and internal GUI windows. + +### 🖱️ Hardware Compatibility +Solaar-Battery's dynamic RGB battery indicator is built to automatically deploy on any connected device that meets two criteria: +1. It is a wireless, battery-powered device. +2. It features a software-controllable RGB lighting zone (utilizing Logitech's `led_zone_1` HID++ protocol). + +*(Note: Devices lacking programmable RGB chips, such as the MX Master series or the G Pro X Superlight, will still function normally but will not display the Battery LED Configurator.)* + +--- + +## 📷 Screenshots +

  @@ -29,30 +59,28 @@ that are otherwise ignored by the Linux input system.

-Solaar supports: +--- + +## 💻 Standard Solaar Features +In addition to the custom features above, Solaar-Battery retains all original Solaar functionality. Solaar supports: - pairing/unpairing of devices with receivers - configuring device settings - custom button configuration - running rules in response to special messages from devices For more information see - the main Solaar documentation page. - + the main Solaar documentation page. -## Installation Packages +## 📦 Installation Packages -Up-to-date prebuilt packages are available for some Linux distros -(e.g., Fedora) in their standard repositories. -If a recent version of Solaar is not -available from the standard repositories for your distribution, you can try -one of these packages: +Up-to-date prebuilt packages are available for some Linux distros (e.g., Fedora) in their standard repositories. If a recent version of Solaar is not available from the standard repositories for your distribution, you can try one of these packages: - Arch solaar package in the [extra repository][arch] - Ubuntu/Kubuntu package in [Solaar stable ppa][ppa stable] - NixOS Flake package in [Svenum/Solaar-Flake][nix flake] -Solaar is available from some other repositories -but may be several versions behind the current version: +Solaar is available from some other repositories but may be several versions behind the current version: - a [Debian package][debian], courtesy of Stephen Kitt - a Ubuntu package is available from [universe repository][ubuntu universe repository] diff --git a/lib/logitech_receiver/device.py b/lib/logitech_receiver/device.py index 725a7259..c23a572d 100644 --- a/lib/logitech_receiver/device.py +++ b/lib/logitech_receiver/device.py @@ -533,6 +533,7 @@ class Device: with self._settings_lock: if not self._feature_settings_checked: self._feature_settings_checked = settings_templates.check_feature_settings(self, self._settings) + settings_templates._inject_battery_led(self, self._settings) return self._settings def battery(self): # None or level, next, status, voltage diff --git a/lib/logitech_receiver/diversion.py b/lib/logitech_receiver/diversion.py index e933f5ad..a7682501 100644 --- a/lib/logitech_receiver/diversion.py +++ b/lib/logitech_receiver/diversion.py @@ -104,11 +104,34 @@ if logger.isEnabledFor(logging.INFO): logger.info("GDK Keymap %sset up", "" if gkeymap else "not ") wayland = os.getenv("WAYLAND_DISPLAY") # is this Wayland? -if wayland: - logger.warning( - "rules cannot access modifier keys in Wayland, " - "accessing process only works on GNOME with Solaar Gnome extension installed" - ) + +def get_global_modifiers(): + # Always try to use evdev first if on Wayland, falling back to GDK + if wayland and evdev: + mask = 0 + try: + devices = [evdev.InputDevice(path) for path in evdev.list_devices()] + for dev in devices: + if evdev.ecodes.EV_KEY in dev.capabilities(): + keys = dev.active_keys() + if keys: + if evdev.ecodes.KEY_LEFTSHIFT in keys or evdev.ecodes.KEY_RIGHTSHIFT in keys: + mask |= int(Gdk.ModifierType.SHIFT_MASK) + if evdev.ecodes.KEY_LEFTCTRL in keys or evdev.ecodes.KEY_RIGHTCTRL in keys: + mask |= int(Gdk.ModifierType.CONTROL_MASK) + if evdev.ecodes.KEY_LEFTALT in keys or evdev.ecodes.KEY_RIGHTALT in keys: + mask |= int(Gdk.ModifierType.MOD1_MASK) + if evdev.ecodes.KEY_LEFTMETA in keys or evdev.ecodes.KEY_RIGHTMETA in keys: + mask |= int(Gdk.ModifierType.MOD4_MASK) + for dev in devices: + dev.close() + return mask + except Exception: + pass + + if gkeymap: + return gkeymap.get_modifier_state() + return 0 try: _x11 = None # X11 might be available @@ -141,6 +164,24 @@ mr_key_down = False thumb_wheel_displacement = 0 _dbus_interface = None +active_profile = "Default" + +def get_all_profiles(): + profiles = set(["Default"]) + def _find_profiles(node): + if hasattr(node, "components"): + for comp in node.components: + if isinstance(comp, Profile): + if comp.profile_name: + profiles.add(comp.profile_name) + elif isinstance(comp, Rule): + _find_profiles(comp) + elif hasattr(comp, "rule"): + _find_profiles(comp.rule) + from logitech_receiver.diversion import rules + if rules: + _find_profiles(rules) + return sorted(list(profiles)) class XkbDisplay(ctypes.Structure): @@ -246,7 +287,11 @@ if evdev: key_events.append(evcode) devicecap = { evdev.ecodes.EV_KEY: key_events, - evdev.ecodes.EV_REL: [evdev.ecodes.REL_WHEEL, evdev.ecodes.REL_HWHEEL], + evdev.ecodes.EV_REL: [ + evdev.ecodes.REL_WHEEL, evdev.ecodes.REL_HWHEEL, + getattr(evdev.ecodes, 'REL_WHEEL_HI_RES', 11), + getattr(evdev.ecodes, 'REL_HWHEEL_HI_RES', 12) + ], } else: # Just mock these since they won't be useful without evdev anyway @@ -372,6 +417,20 @@ def simulate_scroll(dx, dy): logger.warning("no way to simulate scrolling") +def simulate_smooth_scroll(dx, dy): + if setup_uinput(): + if dx: + simulate_uinput(evdev.ecodes.EV_REL, getattr(evdev.ecodes, 'REL_HWHEEL_HI_RES', 12), int(dx * 120)) + if int(dx): + simulate_uinput(evdev.ecodes.EV_REL, evdev.ecodes.REL_HWHEEL, int(dx)) + if dy: + simulate_uinput(evdev.ecodes.EV_REL, getattr(evdev.ecodes, 'REL_WHEEL_HI_RES', 11), int(dy * 120)) + if int(dy): + simulate_uinput(evdev.ecodes.EV_REL, evdev.ecodes.REL_WHEEL, int(dy)) + return True + logger.warning("no way to simulate smooth scrolling") + + def thumb_wheel_up(f, r, d, a): global thumb_wheel_displacement if f != SupportedFeature.THUMB_WHEEL or r != 0: @@ -786,8 +845,8 @@ class Modifiers(Condition): def evaluate(self, feature, notification: HIDPPNotification, device, last_result): if logger.isEnabledFor(logging.DEBUG): logger.debug("evaluate condition: %s", self) - if gkeymap: - current = gkeymap.get_modifier_state() # get the current keyboard modifier + if True: + current = get_global_modifiers() # get the current keyboard modifier return self.desired == (current & MODIFIER_MASK) else: logger.warning("no keymap so cannot determine modifier keys") @@ -1021,6 +1080,25 @@ class MouseGesture(Condition): return {"MouseGesture": [str(m) for m in self.movements]} +class Profile(Condition): + def __init__(self, profile_name, warn=True): + if not (isinstance(profile_name, str)): + if warn: + logger.warning("rule Profile argument not a string: %s", profile_name) + self.profile_name = "" + else: + self.profile_name = profile_name + + def __str__(self): + return f"Profile: {str(self.profile_name)}" + + def evaluate(self, feature, notification, device, last_result): + return self.profile_name == active_profile + + def data(self): + return {"Profile": self.profile_name} + + class Active(Condition): def __init__(self, devID, warn=True): if not (isinstance(devID, str)): @@ -1180,8 +1258,8 @@ class KeyPress(Action): self.mods(level, modifiers, _KEY_RELEASE) def evaluate(self, feature, notification: HIDPPNotification, device, last_result): - if gkeymap: - current = gkeymap.get_modifier_state() + if True: + current = get_global_modifiers() if logger.isEnabledFor(logging.INFO): logger.info( "KeyPress action: %s %s, group %s, modifiers %s", @@ -1225,12 +1303,10 @@ class MouseScroll(Action): def __str__(self): return "MouseScroll: " + " ".join([str(a) for a in self.amounts]) - def evaluate(self, feature, notification: HIDPPNotification, device, last_result): + def evaluate(self, feature, notification, device, last_result): amounts = self.amounts if isinstance(last_result, numbers.Number): amounts = [math.floor(last_result * a) for a in self.amounts] - if logger.isEnabledFor(logging.INFO): - logger.info("MouseScroll action: %s %s %s", self.amounts, last_result, amounts) dx, dy = amounts simulate_scroll(dx, dy) time.sleep(0.01) @@ -1240,6 +1316,32 @@ class MouseScroll(Action): return {"MouseScroll": self.amounts[:]} +class SmoothScroll(Action): + def __init__(self, amounts, warn=True): + if len(amounts) == 1 and isinstance(amounts[0], list): + amounts = amounts[0] + if not (len(amounts) == 2 and all([isinstance(a, numbers.Number) for a in amounts])): + if warn: + logger.warning("rule SmoothScroll argument not two numbers %s", amounts) + amounts = [0.0, 0.0] + self.amounts = [float(amounts[0]), float(amounts[1])] + + def __str__(self): + return "SmoothScroll: " + " ".join([str(a) for a in self.amounts]) + + def evaluate(self, feature, notification, device, last_result): + amounts = self.amounts + if isinstance(last_result, numbers.Number): + amounts = [last_result * a for a in self.amounts] + dx, dy = amounts + simulate_smooth_scroll(dx, dy) + time.sleep(0.01) + return None + + def data(self): + return {"SmoothScroll": self.amounts[:]} + + class MouseClick(Action): def __init__(self, args, warn=True): if len(args) == 1 and isinstance(args[0], list): @@ -1279,6 +1381,54 @@ class MouseClick(Action): return {"MouseClick": [self.button, self.count]} +class MouseFollowsKeyboard(Action): + def __init__(self, args, warn=True): + if not (isinstance(args, list) and len(args) >= 2): + if warn: + logger.warning("rule MouseFollowsKeyboard argument not list with minimum length 2: %s", args) + self.args = [] + else: + self.args = args + + def __str__(self): + return "MouseFollowsKeyboard: " + " ".join([str(a) for a in self.args]) + + def evaluate(self, feature, notification, device, last_result): + if len(self.args) < 2: + return None + target_name = self.args[0] + host_num = self.args[1] + + target_mouse = device.find(target_name) + if not target_mouse: + logger.warning("MouseFollowsKeyboard: device %s is not known", target_name) + return None + + mouse_setting = next((s for s in target_mouse.settings if s.name == "change-host"), None) + keyboard_setting = next((s for s in device.settings if s.name == "change-host"), None) + divert_setting = next((s for s in device.settings if s.name == "divert-keys"), None) + + if mouse_setting: + mouse_setting.write(host_num, save=False) + + if divert_setting: + divert_setting.write(False, save=False) + + if keyboard_setting: + keyboard_setting.write(host_num, save=False) + + try: + from solaar.ui import desktop_notifications + desktop_notifications.show(target_mouse, reason=f"Flow: Switched to Host {host_num}") + except Exception as e: + logger.warning("Failed to show Flow notification: %s", e) + + return None + + def data(self): + return {"MouseFollowsKeyboard": self.args[:]} + + class Set(Action): def __init__(self, args, warn=True): if not (isinstance(args, list) and len(args) > 2): @@ -1400,15 +1550,18 @@ COMPONENTS = { "Test": Test, "TestBytes": TestBytes, "MouseGesture": MouseGesture, + "Profile": Profile, "Active": Active, "Device": Device, "Host": Host, "KeyPress": KeyPress, "MouseScroll": MouseScroll, + "SmoothScroll": SmoothScroll, "MouseClick": MouseClick, "Set": Set, "Execute": Execute, "Later": Later, + "MouseFollowsKeyboard": MouseFollowsKeyboard, } diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index a3891b35..f3009a51 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -4735,3 +4735,174 @@ def check_feature_setting(device, setting_name: str) -> settings.Setting | None: return s elif setting: return setting + +from . import settings as _settings +from . import settings_validator as _settings_validator + +class BatteryLedRW: + kind = 2 + def __init__(self, *args, **kwargs): pass + def read(self, device): + mode = device.persister.get("battery_led_mode", False) if device.persister else False + return b"\x01" if mode else b"\x00" + def write(self, device, value): + is_enabled = bool(value and value not in (0, b"\x00", [0])) + if device.persister: device.persister["battery_led_mode"] = is_enabled + if is_enabled: + try: + _update_battery_led(device) + except Exception: + pass + return b"\x01" if is_enabled else b"\x00" + +class BatteryLedSetting(_settings.Setting): + name = "battery_led_mode" + label = "Battery LED Configurator" + description = "Match LED color to battery (Green:100-70%, Yellow:69-45%, Orange:44-20%, Red:19-6%, Blink:<5%)" + rw_class = BatteryLedRW + feature = 0x1000 + +class BatteryLedBrightnessRW: + kind = 4 + def __init__(self, *args, **kwargs): pass + def read(self, device): + val = device.persister.get("battery_led_brightness", 100) if device.persister else 100 + import logitech_receiver.common as common + return common.int2bytes(val, 1) + def write(self, device, value_bytes): + if not value_bytes: return b"\x00" + import logitech_receiver.common as common + val = common.bytes2int(value_bytes) + + with open("/tmp/battery_led_debug.log", "a") as dbg: + dbg.write(f"Brightness slider moved. value_bytes={value_bytes}, unpacked val={val}\n") + + if device.persister: device.persister["battery_led_brightness"] = val + try: + _update_battery_led(device) + except Exception: + pass + return value_bytes + +class BatteryLedBrightnessSetting(_settings.Setting): + name = "battery_led_brightness" + label = "Battery LED Brightness" + description = "Adjust the brightness of the Battery LED Configurator." + rw_class = BatteryLedBrightnessRW + feature = 0x1000 + + @classmethod + def build(cls, device, **kwargs): + rw = cls.rw_class() + validator = _settings_validator.RangeValidator(min_value=0, max_value=100) + return cls(device, rw, validator) + +def _inject_battery_led(device, settings): + try: + if device.persister: + b_setting = BatteryLedSetting.build(device) + if b_setting: + settings.append(b_setting) + br_setting = BatteryLedBrightnessSetting.build(device) + if br_setting: + settings.append(br_setting) + except Exception as e: + import logging + logging.getLogger(__name__).error("Failed to inject Battery LED setting: %s", e) + +def _update_battery_led(device): + with open("/tmp/battery_led_debug.log", "a") as dbg: + import traceback + dbg.write(f"\n--- _update_battery_led called for {device.name} ---\n") + dbg.write("".join(traceback.format_stack())) + if not getattr(device, "persister", None) or not device.persister.get("battery_led_mode", False): + dbg.write("Not enabled in persister, aborting.\n") + return + try: + b = device.battery() + dbg.write(f"Battery info: {b}\n") + if not b or getattr(b, "level", None) is None: + dbg.write("No battery level, aborting.\n") + return + level = b.level + + # Load custom JSON config + import os, json + config_path = os.path.expanduser("~/.config/solaar/battery_led.json") + + default_config = [ + {"min": 70, "max": 100, "color": "00ff00", "blink": False}, + {"min": 45, "max": 69, "color": "ffff00", "blink": False}, + {"min": 20, "max": 44, "color": "ff8000", "blink": False}, + {"min": 6, "max": 19, "color": "ff0000", "blink": False}, + {"min": 0, "max": 5, "color": "ff0000", "blink": True} + ] + + if not os.path.exists(config_path): + dbg.write("Creating default JSON config.\n") + os.makedirs(os.path.dirname(config_path), exist_ok=True) + with open(config_path, "w") as f: + json.dump(default_config, f, indent=4) + config = default_config + else: + try: + with open(config_path, "r") as f: + config = json.load(f) + except Exception as e: + dbg.write(f"JSON load error: {e}\n") + config = default_config + + color = "00ff00" + blink = False + for rule in config: + if rule["min"] <= level <= rule["max"]: + color = rule["color"] + blink = rule["blink"] + break + + dbg.write(f"Computed color={color}, blink={blink}\n") + + intensity = device.persister.get("battery_led_brightness", 100) if getattr(device, "persister", None) else 100 + + # Scale RGB color by intensity because Static effect doesn't support the intensity parameter + r = int(int(color[0:2], 16) * (intensity / 100.0)) + g = int(int(color[2:4], 16) * (intensity / 100.0)) + b_ch = int(int(color[4:6], 16) * (intensity / 100.0)) + scaled_color = f"{r:02x}{g:02x}{b_ch:02x}" + + for s in getattr(device, "settings", []): + if s.name == "led_control": + dbg.write("Setting led_control = True\n") + try: + data_bytes = s._validator.prepare_write(True, s.read()) + if data_bytes is not None: + res = s._rw.write(device, data_bytes) + if res: + s._value = s._validator.validate_read(res) + except Exception: + pass + + for s in getattr(device, "settings", []): + if s.name in ("led_zone_1", "rgb_control", "backlight"): + dbg.write(f"Found {s.name}. Writing new effect...\n") + try: + import logitech_receiver.hidpp20 as hidpp20 + new_val = hidpp20.LEDEffectSetting( + ID=0x0a if blink else 1, + color=int(scaled_color, 16), + intensity=intensity, + period=1000 if blink else 0, + ramp=3 + ) + data_bytes = s._validator.prepare_write(new_val, s.read()) + res = s._rw.write(device, data_bytes) + if res: + s._value = s._validator.validate_read(res) + dbg.write(f"Write result: {res}\n") + except Exception as ex: + import traceback + dbg.write(f"Exception during write: {traceback.format_exc()}\n") + break + except Exception as e: + import traceback + dbg.write(f"Global exception: {traceback.format_exc()}\n") diff --git a/lib/logitech_receiver/settings_validator.py b/lib/logitech_receiver/settings_validator.py index 5e6d2eff..4c0e67d0 100644 --- a/lib/logitech_receiver/settings_validator.py +++ b/lib/logitech_receiver/settings_validator.py @@ -557,7 +557,8 @@ class RangeValidator(Validator): def prepare_write(self, new_value, current_value=None): if new_value < self.min_value or new_value > self.max_value: raise ValueError(f"invalid choice {new_value!r}") - current_value = self.validate_read(current_value) if current_value is not None else None + if isinstance(current_value, bytes): + current_value = self.validate_read(current_value) to_write = self.write_prefix_bytes + common.int2bytes(new_value, self._byte_count, signed=self._signed) # current value is known and same as value to be written return None to signal not to write it return None if current_value is not None and current_value == new_value else to_write diff --git a/lib/solaar/dbus.py b/lib/solaar/dbus.py index 142b5904..703cb899 100644 --- a/lib/solaar/dbus.py +++ b/lib/solaar/dbus.py @@ -24,6 +24,7 @@ logger = logging.getLogger(__name__) try: import dbus + import dbus.service from dbus.mainloop.glib import DBusGMainLoop # integration into the main GLib loop @@ -85,3 +86,46 @@ def watch_bluez_connect(serial, callback=None): _bluetooth_callbacks[serial] = bus.add_signal_receiver( callback, "PropertiesChanged", path=path, dbus_interface=_BLUETOOTH_INTERFACE ) + +class BatteryBroadcaster(dbus.service.Object): + def __init__(self, bus_name, path): + super().__init__(bus_name, path) + self.levels = {} + self.charging = {} + + @dbus.service.signal('io.github.pwr_solaar.solaar.Battery', signature='sib') + def BatteryChanged(self, serial, level, is_charging): + pass + + @dbus.service.method('io.github.pwr_solaar.solaar.Battery', in_signature='s', out_signature='(ib)') + def GetBattery(self, serial): + return (self.levels.get(serial, -1), self.charging.get(serial, False)) + + @dbus.service.method('io.github.pwr_solaar.solaar.Battery', in_signature='', out_signature='a{s(ib)}') + def GetAllBatteries(self): + return {s: (self.levels[s], self.charging[s]) for s in self.levels} + + def update_battery(self, serial, level, is_charging): + self.levels[serial] = level + self.charging[serial] = is_charging + self.BatteryChanged(serial, level, is_charging) + +battery_broadcaster = None + +# D-Bus names +NAME = 'io.github.pwr_solaar.solaar_beta.BatteryService' +PATH = '/io/github/pwr_solaar/solaar_beta/Battery' + +def setup_battery_broadcaster(): + global battery_broadcaster + try: + session_bus = dbus.SessionBus() + bus_name = dbus.service.BusName(NAME, bus=session_bus) + battery_broadcaster = BatteryBroadcaster(bus_name, PATH) + logger.info("Session DBus battery broadcaster started on %s", PATH) + except Exception as e: + logger.warning("Failed to start Session DBus battery broadcaster: %s", e) + +def broadcast_battery(serial, level, is_charging): + if battery_broadcaster: + battery_broadcaster.update_battery(serial, level, is_charging) diff --git a/lib/solaar/gtk.py b/lib/solaar/gtk.py index e0680820..2c7b1321 100755 --- a/lib/solaar/gtk.py +++ b/lib/solaar/gtk.py @@ -193,10 +193,12 @@ def main(): try: listener.setup_scanner(ui.status_changed, ui.setting_changed, ui.common.error_dialog) - if args.restart_on_wake_up: - dbus.watch_suspend_resume(listener.start_all, listener.stop_all) - else: - dbus.watch_suspend_resume(lambda: listener.ping_all(True)) + if dbus: + dbus.setup_battery_broadcaster() + if args.restart_on_wake_up: + dbus.watch_suspend_resume(listener.start_all, listener.stop_all) + else: + dbus.watch_suspend_resume(lambda: listener.ping_all(True)) configuration.defer_saves = True # allow configuration saves to be deferred diff --git a/lib/solaar/listener.py b/lib/solaar/listener.py index f09a9f18..3e773f28 100644 --- a/lib/solaar/listener.py +++ b/lib/solaar/listener.py @@ -141,6 +141,14 @@ class SolaarListener(listener.EventsListener): self.status_changed_callback(device, alert, reason) + if device and getattr(device, 'battery_info', None) is not None: + if device.battery_info.level is not None: + try: + from solaar.dbus import broadcast_battery + broadcast_battery(device.serial, device.battery_info.level, device.battery_info.charging()) + except Exception as e: + logger.warning("Failed to broadcast battery DBus event: %s", e) + if not device: # the device was just unpaired, need to update the status of the receiver as well self.status_changed_callback(self.receiver) diff --git a/lib/solaar/ui/__init__.py b/lib/solaar/ui/__init__.py index d3cc8136..382c15d1 100644 --- a/lib/solaar/ui/__init__.py +++ b/lib/solaar/ui/__init__.py @@ -46,7 +46,7 @@ logger = logging.getLogger(__name__) assert Gtk.get_major_version() > 2, "Solaar requires Gtk 3 python bindings" -APP_ID = "io.github.pwr_solaar.solaar" +APP_ID = "io.github.pwr_solaar.solaar_beta" class GtkSignal(Enum): @@ -142,6 +142,12 @@ def _status_changed(device, alert, reason, refresh=False): def status_changed(device, alert=Alert.NONE, reason=None, refresh=False): + try: + from logitech_receiver import settings_templates + if hasattr(settings_templates, "_update_battery_led"): + settings_templates._update_battery_led(device) + except Exception: + pass GLib.idle_add(_status_changed, device, alert, reason, refresh) diff --git a/lib/solaar/ui/about/model.py b/lib/solaar/ui/about/model.py index 209d7778..04995a1d 100644 --- a/lib/solaar/ui/about/model.py +++ b/lib/solaar/ui/about/model.py @@ -33,14 +33,16 @@ class AboutModel: return __version__ def get_description(self) -> str: - return _("Manages Logitech receivers,\nkeyboards, mice, and tablets.") + return _("Logifeed: Advanced Logitech peripheral manager.\nForked from Solaar with added features.") def get_copyright(self) -> str: - return f"© 2012-{_get_current_year()} Daniel Pavel and contributors to the Solaar project" + return f"© 2012-{_get_current_year()} Solaar Contributors, modified by Sir Will & Antigravity" def get_authors(self) -> List[str]: return [ "Daniel Pavel http://github.com/pwr", + "Linuxknows https://github.com/Linuxknows/Logifeed" + "Antigravity AI (Logifeed Modifications)", ] def get_translators(self) -> List[str]: @@ -57,7 +59,7 @@ class AboutModel: "Ferdina Kusumah (Indonesia)", "John Erling Blad (Norwegian Bokmål, Norwegian Nynorsk)", "Oleksandr Afanasiev (Ukrainian)", - ] + ]/home/will/.gemini/antigravity/scratch/Solaar/lib/solaar/ui/about/ def get_credit_sections(self) -> List[Tuple[str, List[str]]]: return [ diff --git a/lib/solaar/ui/config_panel.py b/lib/solaar/ui/config_panel.py index ebc3209b..cb4bb918 100644 --- a/lib/solaar/ui/config_panel.py +++ b/lib/solaar/ui/config_panel.py @@ -65,13 +65,20 @@ def _read_async(setting, force_read, sbox, device_is_online, sensitive): def _write_async(setting, value, sbox, sensitive=True, key=None): def _do_write(_s, v, sb, key): + with open("/tmp/solaar_slider_crash.log", "a") as f: + f.write(f"_do_write for {setting.name} with value {v}\n") try: if key is None: v = setting.write(v) else: v = setting.write_key_value(key, v) v = {key: v} - except Exception: + except Exception as e: + import traceback + logging.getLogger(__name__).error(f"EXCEPTION in _do_write: {traceback.format_exc()}") + with open("/tmp/solaar_slider_crash.log", "a") as dbg: + dbg.write(f"Crash in {setting.name} with value {v}:\n") + dbg.write(traceback.format_exc()) v = None if sb: GLib.idle_add(_update_setting_item, sb, v, True, sensitive, priority=99) @@ -117,6 +124,24 @@ class Control: sbox.pack_start(label, False, False, 0) sbox.pack_end(change, False, False, 0) fill = sbox.setting.kind == settings.Kind.RANGE or sbox.setting.kind == settings.Kind.HETERO + + if sbox.setting.name == "battery_led_brightness": + def apply_clicked(*args): + device = getattr(sbox.setting, "_device", None) + if not device: return + for setting in device.settings: + if setting.name == "battery_led_mode": + import solaar.ui.config_panel as cp + cp._write_async(setting, False, None) + from gi.repository import GLib + GLib.timeout_add(100, lambda s=setting: cp._write_async(s, True, None)) + break + + apply_btn = Gtk.Button(label="Apply") + apply_btn.connect("clicked", apply_clicked) + apply_btn.set_halign(Gtk.Align.CENTER) + sbox.pack_end(apply_btn, False, False, 5) + sbox.pack_end(self, fill, fill, 0) sbox.pack_end(spinner, False, False, 0) sbox.pack_end(failed, False, False, 0) diff --git a/lib/solaar/ui/diversion_rules.py b/lib/solaar/ui/diversion_rules.py index 6e2556fc..2fcb4a85 100644 --- a/lib/solaar/ui/diversion_rules.py +++ b/lib/solaar/ui/diversion_rules.py @@ -1853,6 +1853,7 @@ COMPONENT_UI: dict[Any, RuleComponentUI] = { diversion.MouseProcess: rule_conditions.MouseProcessUI, diversion.Active: ActiveUI, diversion.Device: DeviceUI, + diversion.Profile: rule_conditions.ProfileUI, diversion.Host: HostUI, diversion.Feature: rule_conditions.FeatureUI, diversion.Report: rule_conditions.ReportUI, @@ -1865,8 +1866,10 @@ COMPONENT_UI: dict[Any, RuleComponentUI] = { diversion.MouseGesture: rule_conditions.MouseGestureUI, diversion.KeyPress: rule_actions.KeyPressUI, diversion.MouseScroll: rule_actions.MouseScrollUI, + diversion.SmoothScroll: rule_actions.SmoothScrollUI, diversion.MouseClick: rule_actions.MouseClickUI, diversion.Execute: rule_actions.ExecuteUI, + diversion.MouseFollowsKeyboard: rule_actions.MouseFollowsKeyboardUI, diversion.Set: SetUI, # type(None): RuleComponentUI, # placeholders for empty rule/And/Or } diff --git a/lib/solaar/ui/icons.py b/lib/solaar/ui/icons.py index e5799b1b..c92a9097 100644 --- a/lib/solaar/ui/icons.py +++ b/lib/solaar/ui/icons.py @@ -24,9 +24,9 @@ import solaar.gtk as gtk logger = logging.getLogger(__name__) LARGE_SIZE = Gtk.IconSize.DIALOG # was 64 -TRAY_INIT = "solaar-init" -TRAY_OKAY = "solaar" -TRAY_ATTENTION = "solaar-attention" +TRAY_INIT = "logifeed-icon" +TRAY_OKAY = "logifeed-icon" +TRAY_ATTENTION = "logifeed-icon" _default_theme = None _has_level_icons = False diff --git a/lib/solaar/ui/rule_actions.py b/lib/solaar/ui/rule_actions.py index 706479f9..c238c6ad 100644 --- a/lib/solaar/ui/rule_actions.py +++ b/lib/solaar/ui/rule_actions.py @@ -60,6 +60,8 @@ class KeyPressUI(ActionUI): self.add_btn = Gtk.Button(label=_("Add key"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True) self.add_btn.connect(GtkSignal.CLICKED.value, self._clicked_add) self.widgets[self.add_btn] = (1, 1, 1, 1) + self.listen_btn = Gtk.ToggleButton(label=_("Listen for Keystrokes"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True) + self.widgets[self.listen_btn] = (2, 1, 1, 1) self.action_clicked_radio = Gtk.RadioButton.new_with_label_from_widget(None, _("Click")) self.action_clicked_radio.connect(GtkSignal.TOGGLED.value, self._on_update, CLICK) self.widgets[self.action_clicked_radio] = (0, 3, 1, 1) @@ -73,11 +75,29 @@ class KeyPressUI(ActionUI): def _create_field(self): field_entry = CompletionEntry(self.KEY_NAMES, halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True) field_entry.connect(GtkSignal.CHANGED.value, self._on_update) + field_entry.connect("key-press-event", self._on_key_press_event) field_entry.set_size_request(250, -1) self.fields.append(field_entry) self.widgets[field_entry] = (len(self.fields) - 1, 1, 1, 1) return field_entry + def _on_key_press_event(self, widget, event): + if not hasattr(self, "listen_btn") or not self.listen_btn.get_active(): + return False + + from gi.repository import Gdk + keyval = event.keyval + key_name = Gdk.keyval_name(keyval) + if key_name: + if key_name.startswith("Control_"): key_name = "Control" + elif key_name.startswith("Shift_"): key_name = "Shift" + elif key_name.startswith("Alt_"): key_name = "Alt" + elif key_name.startswith("Super_"): key_name = "Super" + widget.set_text(key_name) + self._on_update() + return True + return False + def _create_del_btn(self): btn = Gtk.Button(label=_("Delete"), halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True) self.del_btns.append(btn) @@ -116,6 +136,7 @@ class KeyPressUI(ActionUI): self._create_del_btn() self.widgets[self.add_btn] = (n, 1, 1, 1) + self.widgets[self.listen_btn] = (n + 1, 1, 1, 1) super().show(component, editable) for i in range(n): field_entry = self.fields[i] @@ -198,6 +219,62 @@ class MouseScrollUI(ActionUI): return f"{x}, {y}" +class SmoothScrollUI(ActionUI): + CLASS = diversion.SmoothScroll + MIN_VALUE = -2000.0 + MAX_VALUE = 2000.0 + + def create_widgets(self): + self.widgets = {} + self.label = Gtk.Label( + label=_("Simulate a smooth mouse scroll.\nOn Wayland requires write access to /dev/uinput."), + halign=Gtk.Align.CENTER, + justify=Gtk.Justification.CENTER, + ) + self.widgets[self.label] = (0, 0, 4, 1) + self.label_x = Gtk.Label(label="x", halign=Gtk.Align.END, valign=Gtk.Align.END, hexpand=True) + self.label_y = Gtk.Label(label="y", halign=Gtk.Align.END, valign=Gtk.Align.END, hexpand=True) + self.field_x = Gtk.SpinButton.new_with_range(self.MIN_VALUE, self.MAX_VALUE, 0.1) + self.field_x.set_digits(2) + self.field_y = Gtk.SpinButton.new_with_range(self.MIN_VALUE, self.MAX_VALUE, 0.1) + self.field_y.set_digits(2) + for f in [self.field_x, self.field_y]: + f.set_halign(Gtk.Align.CENTER) + f.set_valign(Gtk.Align.START) + self.field_x.connect(GtkSignal.CHANGED.value, self._on_update) + self.field_y.connect(GtkSignal.CHANGED.value, self._on_update) + self.widgets[self.label_x] = (0, 1, 1, 1) + self.widgets[self.field_x] = (1, 1, 1, 1) + self.widgets[self.label_y] = (2, 1, 1, 1) + self.widgets[self.field_y] = (3, 1, 1, 1) + + @classmethod + def __parse(cls, v): + try: + return float(v) + except (TypeError, ValueError): + return 0.0 + + def show(self, component, editable=True): + super().show(component, editable) + with self.ignore_changes(): + self.field_x.set_value(self.__parse(component.amounts[0] if len(component.amounts) >= 1 else 0)) + self.field_y.set_value(self.__parse(component.amounts[1] if len(component.amounts) >= 2 else 0)) + + def collect_value(self): + return [float(self.field_x.get_value()), float(self.field_y.get_value())] + + @classmethod + def left_label(cls, component): + return _("Smooth scroll") + + @classmethod + def right_label(cls, component): + x = cls.__parse(component.amounts[0] if len(component.amounts) >= 1 else 0) + y = cls.__parse(component.amounts[1] if len(component.amounts) >= 2 else 0) + return f"{x}, {y}" + + class MouseClickUI(ActionUI): CLASS = diversion.MouseClick MIN_VALUE = 1 @@ -330,3 +407,50 @@ class ExecuteUI(ActionUI): @classmethod def right_label(cls, component): return " ".join([shlex_quote(a) for a in component.args]) + + +class MouseFollowsKeyboardUI(ActionUI): + CLASS = diversion.MouseFollowsKeyboard + + def create_widgets(self): + self.widgets = {} + self.label = Gtk.Label( + label=_("Mouse Follows Keyboard (Enhanced Easy-Switch)\nSwitches the target mouse to the specified host."), + halign=Gtk.Align.CENTER, + justify=Gtk.Justification.CENTER, + ) + self.widgets[self.label] = (0, 0, 4, 1) + self.label_m = Gtk.Label(label=_("Mouse Name"), halign=Gtk.Align.END, valign=Gtk.Align.CENTER, hexpand=True) + self.label_h = Gtk.Label(label=_("Host Channel (1, 2, 3)"), halign=Gtk.Align.END, valign=Gtk.Align.CENTER, hexpand=True) + self.field_m = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER) + self.field_h = Gtk.SpinButton.new_with_range(1, 3, 1) + self.field_m.connect(GtkSignal.CHANGED.value, self._on_update) + self.field_h.connect(GtkSignal.CHANGED.value, self._on_update) + self.widgets[self.label_m] = (0, 1, 1, 1) + self.widgets[self.field_m] = (1, 1, 1, 1) + self.widgets[self.label_h] = (2, 1, 1, 1) + self.widgets[self.field_h] = (3, 1, 1, 1) + + def show(self, component, editable=True): + super().show(component, editable) + with self.ignore_changes(): + self.field_m.set_text(component.args[0] if len(component.args) >= 1 else "") + try: + self.field_h.set_value(int(component.args[1]) + 1 if len(component.args) >= 2 else 1) + except (ValueError, TypeError): + self.field_h.set_value(1) + + def collect_value(self): + m = self.field_m.get_text() + h = int(self.field_h.get_value()) - 1 + return [m, h] + + @classmethod + def left_label(cls, component): + return _("Follow Keyboard") + + @classmethod + def right_label(cls, component): + host = int(component.args[1]) + 1 if len(component.args) >= 2 else 1 + name = component.args[0] if len(component.args) >= 1 else "Unknown" + return f"{name} -> Host {host}" diff --git a/lib/solaar/ui/rule_conditions.py b/lib/solaar/ui/rule_conditions.py index 1f88c58d..383546c0 100644 --- a/lib/solaar/ui/rule_conditions.py +++ b/lib/solaar/ui/rule_conditions.py @@ -246,7 +246,10 @@ class KeyUI(ConditionUI): self.key_field = CompletionEntry(self.KEY_NAMES, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True) self.key_field.set_size_request(600, 0) self.key_field.connect(GtkSignal.CHANGED.value, self._on_update) + self.key_field.connect("key-press-event", self._on_key_press_event) self.widgets[self.key_field] = (0, 1, 2, 1) + self.listen_btn = Gtk.ToggleButton(label=_("Listen"), halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER) + self.widgets[self.listen_btn] = (4, 1, 1, 1) self.action_pressed_radio = Gtk.RadioButton.new_with_label_from_widget(None, _("Key down")) self.action_pressed_radio.connect(GtkSignal.TOGGLED.value, self._on_update, Key.DOWN) self.widgets[self.action_pressed_radio] = (2, 1, 1, 1) @@ -272,6 +275,21 @@ class KeyUI(ConditionUI): icon = "dialog-warning" if not self.component.key or not self.component.action else "" self.key_field.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) + def _on_key_press_event(self, widget, event): + if not hasattr(self, "listen_btn") or not self.listen_btn.get_active(): + return False + + from gi.repository import Gdk + keyval = event.keyval + key_name = Gdk.keyval_name(keyval) + if key_name: + if key_name.startswith("XF86"): + key_name = key_name[4:] + widget.set_text(key_name) + self._on_update() + return True + return False + @classmethod def left_label(cls, component): return _("Key") @@ -298,7 +316,10 @@ class KeyIsDownUI(ConditionUI): self.key_field = CompletionEntry(self.KEY_NAMES, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True) self.key_field.set_size_request(600, 0) self.key_field.connect(GtkSignal.CHANGED.value, self._on_update) + self.key_field.connect("key-press-event", self._on_key_press_event) self.widgets[self.key_field] = (0, 1, 1, 1) + self.listen_btn = Gtk.ToggleButton(label=_("Listen"), halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER) + self.widgets[self.listen_btn] = (1, 1, 1, 1) def show(self, component, editable=True): super().show(component, editable) @@ -313,6 +334,21 @@ class KeyIsDownUI(ConditionUI): icon = "dialog-warning" if not self.component.key else "" self.key_field.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) + def _on_key_press_event(self, widget, event): + if not hasattr(self, "listen_btn") or not self.listen_btn.get_active(): + return False + + from gi.repository import Gdk + keyval = event.keyval + key_name = Gdk.keyval_name(keyval) + if key_name: + if key_name.startswith("XF86"): + key_name = key_name[4:] + widget.set_text(key_name) + self._on_update() + return True + return False + @classmethod def left_label(cls, component): return _("KeyIsDown") @@ -612,4 +648,33 @@ class MouseGestureUI(ConditionUI): if len(component.movements) == 0: return "No-op" else: - return " -> ".join(component.movements) + return " + ".join([_(m) for m in component.movements]) + + +class ProfileUI(ConditionUI): + CLASS = diversion.Profile + + def create_widgets(self): + self.widgets = {} + self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, justify=Gtk.Justification.CENTER) + self.label.set_text(_("Active profile matches.")) + self.widgets[self.label] = (0, 0, 5, 1) + self.field = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True) + self.field.connect(GtkSignal.CHANGED.value, self._on_update) + self.widgets[self.field] = (0, 1, 5, 1) + + def show(self, component, editable=True): + super().show(component, editable) + with self.ignore_changes(): + self.field.set_text(component.profile_name) + + def collect_value(self): + return self.field.get_text().strip() + + @classmethod + def left_label(cls, component): + return _("Profile") + + @classmethod + def right_label(cls, component): + return component.profile_name diff --git a/lib/solaar/ui/window.py b/lib/solaar/ui/window.py index f2d5ed5f..8f306e4d 100644 --- a/lib/solaar/ui/window.py +++ b/lib/solaar/ui/window.py @@ -333,6 +333,116 @@ def _create_window_layout(): bottom_buttons_box.add(diversion_button) bottom_buttons_box.set_child_secondary(diversion_button, True) + profile_label = Gtk.Label(label=_(" Profile: ")) + bottom_buttons_box.add(profile_label) + bottom_buttons_box.set_child_secondary(profile_label, True) + + profile_combo = Gtk.ComboBoxText.new_with_entry() + + def _get_custom_profiles(): + import json, os + path = os.path.expanduser("~/.config/solaar/profiles.json") + try: + with open(path, "r") as f: + return json.load(f) + except Exception: + return {"profiles": [], "active-profile": "Default"} + + def _save_custom_profiles(data): + import json, os + path = os.path.expanduser("~/.config/solaar/profiles.json") + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + json.dump(data, f) + except Exception: + pass + + _updating_combo = False + def update_profile_combo(*args): + nonlocal _updating_combo + _updating_combo = True + profile_combo.remove_all() + from logitech_receiver import diversion + rule_profiles = diversion.get_all_profiles() + custom_data = _get_custom_profiles() + custom_profiles = custom_data.get("profiles", []) + profiles = sorted(list(set(rule_profiles + custom_profiles))) + if "Default" not in profiles: + profiles.insert(0, "Default") + + for p in profiles: + profile_combo.append_text(p) + + active_profile = custom_data.get("active-profile", "Default") + diversion.active_profile = active_profile + + if active_profile in profiles: + profile_combo.get_child().set_text(active_profile) + profile_combo.set_active(profiles.index(active_profile)) + _updating_combo = False + + def on_profile_changed(combo): + nonlocal _updating_combo + if _updating_combo: return + from logitech_receiver import diversion + active = combo.get_active_text() + if active: + diversion.active_profile = active + custom_data = _get_custom_profiles() + custom_data["active-profile"] = active + _save_custom_profiles(custom_data) + + def save_new_profile(*args): + active = profile_combo.get_active_text() + if active and active != "Default": + from logitech_receiver import diversion + custom_data = _get_custom_profiles() + custom_profiles = custom_data.get("profiles", []) + if active not in custom_profiles and active not in diversion.get_all_profiles(): + custom_profiles.append(active) + custom_data["profiles"] = custom_profiles + _save_custom_profiles(custom_data) + update_profile_combo() + + profile_combo.get_child().connect("activate", save_new_profile) + + def on_delete_profile(*args): + from logitech_receiver import diversion + active = profile_combo.get_active_text() + with open("/tmp/solaar_profile_debug.log", "a") as f: + f.write(f"on_delete_profile called, active={active}\n") + if not active or active == "Default": + return + custom_data = _get_custom_profiles() + custom_profiles = custom_data.get("profiles", []) + with open("/tmp/solaar_profile_debug.log", "a") as f: + f.write(f"custom_profiles before={custom_profiles}\n") + if active in custom_profiles: + custom_profiles.remove(active) + custom_data["profiles"] = custom_profiles + custom_data["active-profile"] = "Default" + _save_custom_profiles(custom_data) + diversion.active_profile = "Default" + with open("/tmp/solaar_profile_debug.log", "a") as f: + f.write(f"custom_profiles after={custom_profiles}, saving...\n") + update_profile_combo() + + update_profile_combo() + profile_combo.connect("changed", on_profile_changed) + bottom_buttons_box.add(profile_combo) + bottom_buttons_box.set_child_secondary(profile_combo, True) + + save_profile_button = Gtk.Button(label=_("Save Profile")) + save_profile_button.connect("clicked", save_new_profile) + bottom_buttons_box.add(save_profile_button) + bottom_buttons_box.set_child_secondary(save_profile_button, True) + + delete_profile_button = Gtk.Button(label=_("Delete Profile")) + delete_profile_button.connect("clicked", on_delete_profile) + bottom_buttons_box.add(delete_profile_button) + bottom_buttons_box.set_child_secondary(delete_profile_button, True) + vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL, 8) vbox.set_border_width(8) vbox.pack_start(panel, True, True, 0) @@ -811,7 +921,7 @@ _window = None def init(show_window, hide_on_close): - Gtk.Window.set_default_icon_name(NAME.lower()) + Gtk.Window.set_default_icon_name("logifeed-icon-v3") global _model, _tree, _details, _info, _empty, _window _model = Gtk.TreeStore(*_COLUMN_TYPES) diff --git a/share/solaar/icons/solaar-battery-icon.png b/share/solaar/icons/solaar-battery-icon.png new file mode 100644 index 00000000..e2164f9b Binary files /dev/null and b/share/solaar/icons/solaar-battery-icon.png differ