From 3b76be9abb7238566d376f60235ff54d8a26464c Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Thu, 16 Jul 2026 21:08:21 -0700 Subject: [PATCH] Leave lighting alone on connect when LED control is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect-time apply pushed persisted zone effects and the LED-control release write even when the user had LED control switched off — with the G560 (0x8070) accepting zone writes without a SW-control claim, Solaar repainted lighting it didn't own on every startup, stomping the device's onboard effect or any other controller (e.g. OpenRGB). Off now doubles as "leave the lighting alone": LEDControl.apply skips the wire entirely unless the persisted value is an explicit claim, and zone-effect applies follow their control gate (led_control for 0x8070, rgb_control for 0x8071 — the same gate PerKeyLighting already enforces). When the gate is on, the claim itself repaints the saved zone effects, matching the RGBControl claim flow. --- lib/logitech_receiver/settings_templates.py | 27 +++++++++++ .../test_setting_templates.py | 46 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index 0139001c..6efa002c 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -3246,6 +3246,18 @@ class LEDControl(settings.Setting): logger.warning("%s: post-claim repaint of %s failed: %s", self._device, s.name, e) return result + def apply(self): + # Off = leave the lighting alone on connect (don't send the release); + # another app may own it. Only an explicit On claim is applied. + try: + value = self.read(self.persist) + except Exception: + value = None + if value: + super().apply() + elif logger.isEnabledFor(logging.DEBUG): + logger.debug("%s: LED control off — leaving lighting untouched on %s", self.name, self._device) + colors = special_keys.COLORS _LEDP = hidpp20.LEDParam @@ -3257,6 +3269,20 @@ class LEDZoneSetting(settings.Setting): label = _("LED Zone Effects") description = _("Set effect for LED Zone") + "\n" + _("LED Control needs to be enabled.") feature = _F.COLOR_LED_EFFECTS + gate_setting_name = LEDControl.name + + def apply(self): + # Skip when the control gate is off (SETTINGS order runs it first, so + # its _value is set) — leave the lighting to whatever owns it. + for s in self._device.settings: + if s.name == self.gate_setting_name: + if not s._value: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("%s: %s off — not applying on %s", self.name, s.name, self._device) + return + break + super().apply() + color_field = {"name": _LEDP.color, "kind": settings.Kind.COLOR, "label": _("Color")} speed_field = {"name": _LEDP.speed, "kind": settings.Kind.RANGE, "label": _("Speed"), "min": 0, "max": 255} period_field = { @@ -3809,6 +3835,7 @@ class RGBEffectSetting(LEDZoneSetting): label = _("LED Zone Effects") description = _("Set effect for LED Zone") + "\n" + _("LED Control needs to be enabled.") feature = _F.RGB_EFFECTS + gate_setting_name = RGBControl.name # 0x8071 firmware-fixes ramp/form bytes; drop those widgets here. possible_fields = [ LEDZoneSetting.color_field, diff --git a/tests/logitech_receiver/test_setting_templates.py b/tests/logitech_receiver/test_setting_templates.py index 02a855fe..32305306 100644 --- a/tests/logitech_receiver/test_setting_templates.py +++ b/tests/logitech_receiver/test_setting_templates.py @@ -1072,3 +1072,49 @@ def test_HeadsetOnboardEffect_absent_animated_fields_seed_defaults(): assert effect.intensity == 100 assert effect.period == 5000 + + +def _led_lighting_device(): + responses = [ + fake_hidpp.Response("0100000001", 0x0400), + fake_hidpp.Response("00000102", 0x0410, "00FF00"), + fake_hidpp.Response("0000000300040005", 0x0420, "000000"), + fake_hidpp.Response("0001000B00080009", 0x0420, "000100"), + fake_hidpp.Response("01", 0x0480, "01"), + fake_hidpp.Response("000000000000000020500000", 0x0430, "000000000000000020500000"), + ] + device = fake_hidpp.Device(responses=responses, feature=hidpp20_constants.SupportedFeature.COLOR_LED_EFFECTS, offset=4) + control = settings_templates.check_feature(device, settings_templates.LEDControl) + zones = settings_templates.check_feature(device, settings_templates.LEDZoneSetting) + device.settings = [control] + zones + return device + + +def test_lighting_apply_led_control_off_leaves_lighting_alone(mocker): + """With led_control persisted off, connect-time apply must not send any + lighting traffic — the device or another app (e.g. OpenRGB) owns it.""" + device = _led_lighting_device() + device.persister["led_control"] = False + device.persister[device.settings[1].name] = hidpp20.LEDEffectSetting(ID=3, period=0x20, intensity=0x50) + spy = mocker.spy(device, "request") + + for s in device.settings: + s.apply() + + assert spy.call_count == 0 + + +def test_lighting_apply_led_control_on_claims_and_repaints(mocker): + """With led_control persisted on, apply claims SW control and pushes the + saved zone effect.""" + device = _led_lighting_device() + device.persister["led_control"] = True + device.persister[device.settings[1].name] = hidpp20.LEDEffectSetting(ID=3, period=0x20, intensity=0x50) + spy = mocker.spy(device, "request") + + for s in device.settings: + s.apply() + + request_ids = [c.args[0] for c in spy.call_args_list] + assert 0x0480 in request_ids # SetSWControl claim + assert 0x0430 in request_ids # zone effect write