feat: Solaar-Battery custom features - dynamic battery RGB, profile management, rebranding

This commit is contained in:
Will 2026-07-31 15:04:22 -04:00
parent 5e4ae7261e
commit 2f97d9fa2d
17 changed files with 792 additions and 49 deletions

View File

@ -1,22 +1,52 @@
# <img src="https://pwr-solaar.github.io/Solaar/img/solaar.svg" width="60px"/> Solaar
# <img src="https://pwr-solaar.github.io/Solaar/img/solaar.svg" width="60px"/> 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.
<a href="https://pwr-solaar.github.io/Solaar/index">More Information</a> -
*(Like the original Solaar, it connects wirelessly to Unifying, Bolt, Lightspeed, or Nano receivers, as well as via USB cable or Bluetooth.)*
<a href="https://pwr-solaar.github.io/Solaar/index">Original Solaar Docs</a> -
<a href="https://pwr-solaar.github.io/Solaar/usage">Usage</a> -
<a href="https://pwr-solaar.github.io/Solaar/capabilities">Capabilities</a> -
<a href="https://pwr-solaar.github.io/Solaar/rules">Rules</a> -
<a href="https://pwr-solaar.github.io/Solaar/installation">Manual Installation</a> -
<a href="https://pwr-solaar.github.io/Solaar/issues">Known Issues</a>
<a href="https://pwr-solaar.github.io/Solaar/rules">Rules</a>
[![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
<p align="center">
<img src="https://pwr-solaar.github.io/Solaar/screenshots/Solaar-main-window-multiple.png" width="54%"/>
&#160;
@ -29,30 +59,28 @@ that are otherwise ignored by the Linux input system.
<img src="https://pwr-solaar.github.io/Solaar/screenshots/Solaar-rule-editor.png" width="48%"/>
</p>
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
<a href="https://pwr-solaar.github.io/Solaar/index">the main Solaar documentation page.</a> -
<a href="https://pwr-solaar.github.io/Solaar/index">the main Solaar documentation page.</a>
## 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]

View File

@ -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

View File

@ -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,
}

View File

@ -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")

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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 [

View File

@ -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)

View File

@ -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
}

View File

@ -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

View File

@ -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}"

View File

@ -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

View File

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB