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
[](https://codecov.io/gh/pwr-Solaar/Solaar)
[](../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