From 73adad2a522fab0d90935f3aed8a4bb47a5e0033 Mon Sep 17 00:00:00 2001 From: grechmarlon <55017324+grechmarlon@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:27:23 +0200 Subject: [PATCH] =?UTF-8?q?G915=20TKL:=20device-gated=20RGB-claim=20quirk?= =?UTF-8?q?=20=E2=80=94=20keep=20onboard=20mode=20on=20claim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A usbmon capture of G HUB's bring-up (receiver passed to a Win11 VM) showed the root cause of the dead F-row/media keys AND the F4 blackout: the RGB SW-control claim switched the keyboard to host mode (ONBOARD_PROFILES 0x10 0x02). On the G915 TKL host mode disables the F-row/media keys (#1100), and the host-mode transition made the firmware re-assert the M/MR indicator (overlaid on F1-F4 on this keyless model) over the per-key colours. G HUB drives the LEDs from onboard mode via the RGB SetSWControl claim alone, never switching. Gated by modelId in device_quirks.py so no other device changes: - rgb_claim_keeps_onboard_mode(): _claim_sw_control skips the onboard-> host switch for listed models -> F-keys/media keys work and the power-on F4 blackout is gone, with no timer and no G-key divert. - rgb_repaint_on_profile_change(): re-assert + repaint on a reported profile change so an accidental switch doesn't strand the scheme. Documented residual (cannot be fixed without breaking the keys): F1-F4 are the M1/M2/M3/MR indicator zones on the TKL. The firmware re-asserts the active-profile indicator on them on profile switch and after idle, so those keys can show the indicator instead of the user colour. Claiming that subsystem (G-key divert) would stop it but disables the F-row/media keys, so onboard mode + working keys is the chosen trade-off. Co-Authored-By: Claude Fable 5 --- lib/logitech_receiver/device_quirks.py | 32 +++++++++++++++++ lib/logitech_receiver/settings_templates.py | 39 +++++++++++++++------ tests/logitech_receiver/test_rgb_power.py | 27 ++++++++++++++ 3 files changed, 87 insertions(+), 11 deletions(-) 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()