diff --git a/lib/logitech_receiver/rgb_power.py b/lib/logitech_receiver/rgb_power.py index f332191e..96ae6e2f 100644 --- a/lib/logitech_receiver/rgb_power.py +++ b/lib/logitech_receiver/rgb_power.py @@ -324,8 +324,14 @@ def cleanup(device): shutdown animation during the active→off transition. If the cap is disabled, the firmware powers down LEDs silently. Matches LGHUB exit. See solaar_shutdown_effect_trigger_spec.md. + + rgb_control is the gate: when LED Control is off, skip every wire write + here. We never claimed, so there's nothing to release; firing the shutdown + animation would visibly contradict the user's "leave my lighting alone". """ stop(device) + if any(s.name == "rgb_control" and not s._value for s in getattr(device, "settings", []) or []): + return try: device.feature_request(SupportedFeature.RGB_EFFECTS, 0x50, SW_RELEASE) if device.features and SupportedFeature.PROFILE_MANAGEMENT in device.features: diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index 4d24d71a..312c8e48 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -2053,8 +2053,13 @@ class RGBControl(settings.Setting): logger.warning("%s: post-claim per-key repaint failed: %s", device, e) def _release_sw_control(self, device): - # Stop software power management + # If we never claimed in this session, don't touch the device at all. + # The presence of an RGBPowerManager is the canonical "we claimed" signal + # — _claim_sw_control creates it via rgb_power.start, and stop() pops it. + had_claim = rgb_power.get_manager(device) is not None rgb_power.stop(device) + if not had_claim: + return # Release LED pipeline: SetSWControl(mode=0, flags=0) device.feature_request(_F.RGB_EFFECTS, 0x50, rgb_power.SW_RELEASE) # Restore firmware power management @@ -2456,6 +2461,10 @@ class RGBEffectSetting(LEDZoneSetting): # Persist undimmed value first (single source of truth). if self._value != value: self.update(value, save) + # rgb_control gate: skip wire when the user has LED Control off. + rgb_ctrl = next((s for s in device.settings if s.name == "rgb_control"), None) + if rgb_ctrl is not None and not rgb_ctrl._value: + return value wire_value = self._translate_for_wire(value) if wire_value is None: # SLEEPING — _wake() will re-push at full brightness. return value @@ -2537,19 +2546,22 @@ class PerKeyLighting(settings.Settings): def update_key_value(self, key, value, save=True): super().update_key_value(key, self._wrap_color(value), save) - def _ensure_sw_control(self): - """Ensure SW control is claimed before writing per-key colors.""" + def _sw_control_held(self): + """Return True if it's safe to push LED bytes to the wire. + + rgb_control is the gate: when the user has it off, LED writes must be + silent no-ops at the wire. Never auto-flip it on from here — doing so + rewrites the persister and turns the user-facing toggle into a lie.""" 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 + return True # No autonomous effect engine, no gate needed for s in self._device.settings: if s.name == "rgb_control": - # _value may be bool (current) or int (legacy persister value + # _value may be bool (current) or int 3/0 (legacy persister value # before BooleanValidator migration); both coerce cleanly. - if not s._value: # Not already claimed by Solaar - s.write(True) # Triggers full claim sequence in RGBControl - return + return bool(s._value) + return True # rgb_control not on this device → no gate to enforce # BUSY-retry backoff (ms). _BUSY_BACKOFF_MS = (30, 60, 90) @@ -2719,9 +2731,10 @@ class PerKeyLighting(settings.Settings): def write(self, map, save=True): if self._device.online: - self._ensure_sw_control() # Persist undimmed (single source of truth). self.update(map, save) + if not self._sw_control_held(): + return map # gate is off — keep state in memory, skip the wire # Per-key is a sub-mode of Static — when zone is animating, the # firmware engine owns the visible layer. if not rgb_power.zone_effect_is_static(self._device): @@ -2752,13 +2765,14 @@ class PerKeyLighting(settings.Settings): return map def write_key_value(self, key, value, save=True): - self._ensure_sw_control() no_change = special_keys.COLORSPLUS["No change"] zone_id = int(key) if value != no_change: self.update_key_value(zone_id, value, save) if not self._device.online: return value + if not self._sw_control_held(): + return value # gate is off — state stored, no wire push # Per-key is a sub-mode of Static — defer to firmware animation. if not rgb_power.zone_effect_is_static(self._device): return value @@ -2788,6 +2802,8 @@ class PerKeyLighting(settings.Settings): self.update_key_value(zone_id, no_change, save) if not self._device.online: return no_change + if not self._sw_control_held(): + return no_change # gate is off — state stored, no wire push if not rgb_power.zone_effect_is_static(self._device): return no_change zone_base = self._zone_base_color() diff --git a/lib/solaar/ui/config_panel.py b/lib/solaar/ui/config_panel.py index ea82314a..a3d37482 100644 --- a/lib/solaar/ui/config_panel.py +++ b/lib/solaar/ui/config_panel.py @@ -865,6 +865,17 @@ def _set_row_sensitive(device, name, can_function): sbox._control.set_sensitive(user_allowed is True and can_function) +def _gate_blocks(device, name): + """Single source of truth for "is this setting's row gated off?". Used by + `_apply_rgb_gates` to grey rows and by `_update_setting_item` so async-read + completions can't undo the grey-out when their callbacks land later.""" + if name in _SW_CONTROL_DEPENDENT_NAMES or any(name.startswith(p) for p in _SW_CONTROL_DEPENDENT_PREFIXES): + return _sw_control_blocked(device) + if name == "per-key-lighting": + return _sw_control_blocked(device) or _zone_effect_blocks_perkey(device) + return False + + def _apply_rgb_gates(device): """Grey out RGB settings whose prerequisites aren't met. Visual-only: leaves persister _sensitive flags (user lock-icon opt-ins) intact. @@ -874,12 +885,14 @@ def _apply_rgb_gates(device): - per-key-lighting needs LED Control = Solaar AND every zone effect on Static (0x01), because non-Static zone animations mask per-key writes. """ - sw_blocked = _sw_control_blocked(device) for s in getattr(device, "settings", []) or []: - if s.name in _SW_CONTROL_DEPENDENT_NAMES or any(s.name.startswith(p) for p in _SW_CONTROL_DEPENDENT_PREFIXES): - _set_row_sensitive(device, s.name, not sw_blocked) - perkey_blocked = sw_blocked or _zone_effect_blocks_perkey(device) - _set_row_sensitive(device, "per-key-lighting", not perkey_blocked) + name = s.name + if ( + name in _SW_CONTROL_DEPENDENT_NAMES + or any(name.startswith(p) for p in _SW_CONTROL_DEPENDENT_PREFIXES) + or name == "per-key-lighting" + ): + _set_row_sensitive(device, name, not _gate_blocks(device, name)) def _change_click(button, sbox): @@ -1015,8 +1028,10 @@ def _create_sbox(s, _device): def _update_setting_item(sbox, value, is_online=True, sensitive=True, null_okay=False): sbox._spinner.stop() sensitive = sbox._change_icon._allowed if sensitive is None else sensitive + name = sbox.setting.name + can_function = not _gate_blocks(sbox.setting._device, name) if value is None and not null_okay: - sbox._control.set_sensitive(sensitive is True) + sbox._control.set_sensitive(sensitive is True and can_function) _change_icon(sensitive, sbox._change_icon) sbox._failed.set_visible(is_online) return @@ -1026,10 +1041,9 @@ def _update_setting_item(sbox, value, is_online=True, sensitive=True, null_okay= sbox._control.set_value(value) except TypeError as e: logger.warning("%s: error setting control value (%s): %s", sbox.setting.name, sbox.setting._device, repr(e)) - sbox._control.set_sensitive(sensitive is True) + sbox._control.set_sensitive(sensitive is True and can_function) _change_icon(sensitive, sbox._change_icon) # rgb_control and rgb_zone_* state gate per-key sensitivity. - name = sbox.setting.name if name == "rgb_control" or name.startswith("rgb_zone_"): _apply_rgb_gates(sbox.setting._device) diff --git a/tests/logitech_receiver/test_rgb_power.py b/tests/logitech_receiver/test_rgb_power.py index 52b0a9c6..cafe6d59 100644 --- a/tests/logitech_receiver/test_rgb_power.py +++ b/tests/logitech_receiver/test_rgb_power.py @@ -636,7 +636,6 @@ def test_perkey_write_skipped_when_zone_is_animation(monkeypatch): s._device = device s._value = {} s._has_rgb_effects = True - s._ensure_sw_control = MagicMock() s._send_with_retry = MagicMock(return_value=True) s._fill_unset_zones_with_base_color = MagicMock(return_value=True) monkeypatch.setattr(rgb_power, "translate_for_device", lambda d, c: c) @@ -663,7 +662,6 @@ def test_perkey_write_key_value_skipped_when_zone_is_animation(monkeypatch): s._device = device s._value = {} s._has_rgb_effects = True - s._ensure_sw_control = MagicMock() s._send_with_retry = MagicMock(return_value=True) s._send_zone_color = MagicMock(return_value=True) s._fill_unset_zones_with_base_color = MagicMock(return_value=True)