G915 TKL: device-gated RGB-claim quirk — keep onboard mode on claim

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 <noreply@anthropic.com>
This commit is contained in:
grechmarlon 2026-06-15 11:27:23 +02:00
parent 19865f6b54
commit 73adad2a52
3 changed files with 87 additions and 11 deletions

View File

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

View File

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

View File

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