From 65df109e9202faa413d5ea074abf29635e936254 Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Wed, 11 Mar 2026 21:37:37 -0700 Subject: [PATCH] Add RGB lighting persistence via SW control claim for G515/G502 When Solaar claims LED control, properly disable firmware power management via ProfileManagement or OnboardProfiles and claim the LED pipeline with SetSWControl(mode=3, flags=5). This prevents the device from reverting colors to its onboard profile after a few seconds. Adds cleanup handler to restore firmware control on device close, and auto-claims SW control when writing per-key lighting colors. Adds PROFILE_MANAGEMENT (0x8101) feature constant. --- lib/logitech_receiver/notifications.py | 4 ++ lib/logitech_receiver/settings_templates.py | 65 +++++++++++++++++++ tests/logitech_receiver/fake_hidpp.py | 3 + .../test_setting_templates.py | 2 +- 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/lib/logitech_receiver/notifications.py b/lib/logitech_receiver/notifications.py index 693e3afd..a8657168 100644 --- a/lib/logitech_receiver/notifications.py +++ b/lib/logitech_receiver/notifications.py @@ -422,6 +422,10 @@ def _process_feature_notification(device: Device, notification: HIDPPNotificatio brightness = struct.unpack("!H", device.feature_request(SupportedFeature.BRIGHTNESS_CONTROL, 0x10)[:2])[0] device.setting_callback(device, settings_templates.BrightnessControl, [brightness]) + elif feature == SupportedFeature.RGB_EFFECTS: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("%s: RGB_EFFECTS notification addr=%02x: %s", device, notification.address, notification) + diversion.process_notification(device, notification, feature) return True diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index ddab8b0a..f04b04c8 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -1875,6 +1875,57 @@ class RGBControl(settings.Setting): validator_class = settings_validator.ChoicesValidator validator_options = {"choices": choices_universe, "write_prefix_bytes": b"\x01", "read_skip_byte_count": 1} + def write(self, value, save=True): + assert hasattr(self, "_value") + assert hasattr(self, "_device") + assert value is not None + device = self._device + if not device.online: + return None + if self._value != value: + self.update(value, save) + claiming = int(value) == 1 # Solaar + if claiming: + self._claim_sw_control(device) + else: + self._release_sw_control(device) + return value + + def _claim_sw_control(self, device): + # Disable firmware power management via profile management or onboard profiles + if device.features and _F.PROFILE_MANAGEMENT in device.features: + device.feature_request(_F.PROFILE_MANAGEMENT, 0x60, b"\x05") + elif device.features and _F.ONBOARD_PROFILES in device.features: + device.feature_request(_F.ONBOARD_PROFILES, 0x10, b"\x02") + # Claim LED pipeline: SetSWControl(mode=3, flags=5) + device.feature_request(_F.RGB_EFFECTS, 0x50, b"\x01\x03\x05") + # Register cleanup for graceful release on device close + if _rgb_cleanup not in device.cleanups: + device.cleanups.append(_rgb_cleanup) + + def _release_sw_control(self, device): + # Release LED pipeline: SetSWControl(mode=0, flags=0) + device.feature_request(_F.RGB_EFFECTS, 0x50, b"\x01\x00\x00") + # Restore firmware power management + if device.features and _F.PROFILE_MANAGEMENT in device.features: + device.feature_request(_F.PROFILE_MANAGEMENT, 0x60, b"\x03") + elif device.features and _F.ONBOARD_PROFILES in device.features: + device.feature_request(_F.ONBOARD_PROFILES, 0x10, b"\x01") + if _rgb_cleanup in device.cleanups: + device.cleanups.remove(_rgb_cleanup) + + +def _rgb_cleanup(device): + """Cleanup handler called when device is closed — restores firmware control.""" + try: + device.feature_request(_F.RGB_EFFECTS, 0x50, b"\x01\x00\x00") + if device.features and _F.PROFILE_MANAGEMENT in device.features: + device.feature_request(_F.PROFILE_MANAGEMENT, 0x60, b"\x03") + elif device.features and _F.ONBOARD_PROFILES in device.features: + device.feature_request(_F.ONBOARD_PROFILES, 0x10, b"\x01") + except Exception: + pass # Device may already be offline + class RGBEffectSetting(LEDZoneSetting): name = "rgb_zone_" # the trailing underscore signals that this setting creates other settings @@ -1895,6 +1946,18 @@ class PerKeyLighting(settings.Settings): keys_universe = special_keys.KEYCODES choices_universe = special_keys.COLORSPLUS + def _ensure_sw_control(self): + """Ensure SW control is claimed before writing per-key colors.""" + if getattr(self, "_has_rgb_effects", None) is None: + self._has_rgb_effects = bool(self._device.features and _F.RGB_EFFECTS in self._device.features) + if not self._has_rgb_effects: + return # No autonomous effect engine, no claim needed + for s in self._device.settings: + if s.name == "rgb_control": + if s._value != 1: # Not already claimed by Solaar + s.write(1) # Triggers full claim sequence in RGBControl + return + def read(self, cached=True): self._pre_read(cached) if cached and self._value is not None: @@ -1907,6 +1970,7 @@ class PerKeyLighting(settings.Settings): def write(self, map, save=True): if self._device.online: + self._ensure_sw_control() self.update(map, save) table = {} for key, value in map.items(): @@ -1939,6 +2003,7 @@ class PerKeyLighting(settings.Settings): return map def write_key_value(self, key, value, save=True): + self._ensure_sw_control() if value != special_keys.COLORSPLUS["No change"]: # this signals no change result = super().write_key_value(int(key), value, save) if self._device.online: diff --git a/tests/logitech_receiver/fake_hidpp.py b/tests/logitech_receiver/fake_hidpp.py index 7758f2f5..91fcabb8 100644 --- a/tests/logitech_receiver/fake_hidpp.py +++ b/tests/logitech_receiver/fake_hidpp.py @@ -387,6 +387,8 @@ class Device: wpid: Optional[str] = "0000" setting_callback: Any = None centurion: bool = False + path = None + cleanups = None sliding = profiles = _backlight = _keys = _remap_keys = _led_effects = _gestures = None _gestures_lock = threading.Lock() number = "d1" @@ -409,6 +411,7 @@ class Device: self.persister = configuration._DeviceEntry() self.features = hidpp20.FeaturesArray(self) self.settings = [] + self.cleanups = [] self.receiver = [] if self.feature is not None: self.features = hidpp20.FeaturesArray(self) diff --git a/tests/logitech_receiver/test_setting_templates.py b/tests/logitech_receiver/test_setting_templates.py index dbfb9b05..ccb0d5bd 100644 --- a/tests/logitech_receiver/test_setting_templates.py +++ b/tests/logitech_receiver/test_setting_templates.py @@ -293,7 +293,7 @@ simple_tests = [ Setup( FeatureTest(settings_templates.RGBControl, 0, 1), fake_hidpp.Response("0000", 0x0450), - fake_hidpp.Response("010100", 0x0450, "0101"), + fake_hidpp.Response("010305", 0x0450, "010305"), ), Setup( FeatureTest(