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.
This commit is contained in:
Ken Sanislo 2026-03-11 21:37:37 -07:00
parent 3733713d68
commit 65df109e92
4 changed files with 73 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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