diff --git a/lib/logitech_receiver/device_quirks.py b/lib/logitech_receiver/device_quirks.py index e2c09009..6fac2c52 100644 --- a/lib/logitech_receiver/device_quirks.py +++ b/lib/logitech_receiver/device_quirks.py @@ -69,6 +69,38 @@ HEADSET_SIGNATURE_EFFECTS_ALLOWED: dict[str, dict[int, set[str]]] = { } +# Keyboards whose firmware breaks the F-row / media keys when the RGB +# SW-control claim switches them to host mode (ONBOARD_PROFILES fn 0x10 mode +# 0x02) — Solaar #1100. A USB capture of G HUB's bring-up shows it drives the +# LEDs from onboard mode via the RGB SetSWControl claim alone, never making +# that switch. For these models the claim keeps onboard mode, which also avoids +# the host-mode transition that made the firmware re-assert the M/MR indicator +# over a per-key cell (the G915 TKL F4 blackout). +RGB_CLAIM_KEEPS_ONBOARD_MODE: set[str] = { + "B35F408EC343", # G915 TKL LIGHTSPEED — verified: host mode disables F-keys/media keys +} + +# Keyboards where switching onboard profiles drops the software per-key/zone +# paint (the firmware loads the switched-to profile's own onboard lighting). +# For these, re-assert the claim + repaint on a profile-change notification so +# an accidental profile switch doesn't strand the user's software scheme. +RGB_REPAINT_ON_PROFILE_CHANGE: set[str] = { + "B35F408EC343", # G915 TKL LIGHTSPEED +} + + +def rgb_claim_keeps_onboard_mode(device) -> bool: + """True when the RGB SW-control claim must NOT switch this model to host + mode (it would disable the F-row / media keys).""" + return (getattr(device, "modelId", None) or "") in RGB_CLAIM_KEEPS_ONBOARD_MODE + + +def rgb_repaint_on_profile_change(device) -> bool: + """True when a profile-change notification should re-apply the claimed + software RGB state on this model (the switch drops the per-key paint).""" + return (getattr(device, "modelId", None) or "") in RGB_REPAINT_ON_PROFILE_CHANGE + + def rgb_effects_nvconfig_allowed_fields(device, cap_id: int) -> set[str] | None: """Color fields to expose for an 0x8071 NvConfig boot effect. diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index 3bbc4896..8cc6957d 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -542,6 +542,18 @@ def profile_change(device, profile_sector): device.setting_callback(device, AdjustableDpi, [profile.resolutions[resolution_index]]) device.setting_callback(device, ReportRate, [profile.report_rate]) break + # A profile switch reloads that profile's onboard lighting, dropping the + # software per-key/zone paint. On models prone to this, re-assert the claim + # and repaint (only when Solaar already holds the LED claim) so an + # accidental profile switch doesn't strand the user's scheme. + if device_quirks.rgb_repaint_on_profile_change(device): + for s in device.settings or []: + if s.name == "rgb_control" and getattr(s, "_value", None): + try: + s._claim_sw_control(device) + except Exception as e: + logger.warning("%s: repaint after profile change failed: %s", device, e) + break class OnboardProfiles(settings.Setting): @@ -3330,11 +3342,16 @@ class RGBControl(settings.Setting): 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") + # Disable firmware power management via profile management or onboard + # profiles. Skipped on models where the onboard->host mode switch + # disables the F-row / media keys (device_quirks, Solaar #1100): they + # drive the LEDs from onboard mode via the SetSWControl claim alone, + # exactly as G HUB does. + if not device_quirks.rgb_claim_keeps_onboard_mode(device): + 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, rgb_power.SW_ACTIVE) # Reset per-key one-shot flags so the first write after this claim @@ -3349,15 +3366,15 @@ class RGBControl(settings.Setting): # Register cleanup for graceful release on device close if rgb_power.cleanup not in device.cleanups: device.cleanups.append(rgb_power.cleanup) - # Repaint LEDs with Solaar's saved state. Without this the firmware's - # last-active onboard profile keeps showing until the user changes - # something — the takeover would look like it did nothing. + # Repaint saved zone effects and the per-key buffer after the claim. + # (No settle-repaint timer needed: keeping onboard mode on the affected + # models avoids the host-mode transition that caused the F4 blackout.) self._repaint_after_claim(device) def _repaint_after_claim(self, device): - """Push saved zone effects and (if opted in) per-key buffer to the - device after a fresh SW claim. Best-effort: individual failures get - logged but don't abort the rest of the repaint.""" + """Push saved zone effects and the per-key buffer to the device after + a fresh SW claim. Best-effort: individual failures get logged but + don't abort the rest of the repaint.""" for s in device.settings: if s.name.startswith("rgb_zone_") and s._value is not None: try: diff --git a/tests/logitech_receiver/test_rgb_power.py b/tests/logitech_receiver/test_rgb_power.py index 667a8595..d614f598 100644 --- a/tests/logitech_receiver/test_rgb_power.py +++ b/tests/logitech_receiver/test_rgb_power.py @@ -763,3 +763,30 @@ def test_perkey_read_heals_poisoned_persisted_map(monkeypatch): assert -1 not in result assert result[7] == 0x00FF00 + + +# --- post-claim repaint ------------------------------------------------------ + + +def test_repaint_after_claim_pushes_zones_and_perkey(monkeypatch): + """The post-claim repaint pushes saved zone effects and the per-key + buffer. (The F4/MR blackout is handled structurally by keeping affected + models in onboard mode — device_quirks.rgb_claim_keeps_onboard_mode — not + by any timed repaint.)""" + from unittest.mock import MagicMock + + from logitech_receiver import settings_templates + + ctl = settings_templates.RGBControl.__new__(settings_templates.RGBControl) + zone = MagicMock() + zone.name = "rgb_zone_1" + zone._value = object() + device = MagicMock() + device.settings = [zone] + perkey = MagicMock() + perkey._value = {1: 0xFF0000} + monkeypatch.setattr(rgb_power, "perkey_has_paint", lambda d: (perkey, True)) + + ctl._repaint_after_claim(device) + zone.write.assert_called_once() + perkey.write.assert_called_once()