feat: Solaar-Battery custom features - dynamic battery RGB, profile management, rebranding
This commit is contained in:
parent
5e4ae7261e
commit
2f97d9fa2d
70
README.md
70
README.md
|
|
@ -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>
|
||||
|
||||
[](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
|
||||
|
||||
<p align="center">
|
||||
<img src="https://pwr-solaar.github.io/Solaar/screenshots/Solaar-main-window-multiple.png" width="54%"/>
|
||||
 
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
Loading…
Reference in New Issue