Add basic headset RGB support (HeadsetRGBHostMode + HeadsetRGBColor)

First pass at controlling the G522's RGB LEDs via feature 0x0620
HEADSET_RGB_HOSTMODE. Exposes two settings:

1. headset-rgb-hostmode — boolean toggle for SetHostModeState
   (function 8). Turning this on claims LED control; off returns
   control to firmware effects.

2. headset-rgb-color — preset color chooser (Off, Red, Green, Blue,
   White, Yellow, Cyan, Magenta, Orange, Purple). On write it enables
   host mode, queries zone IDs via GetRGBZoneInfo (function 1), calls
   SetRgbZonesSingleValue (function 5) with the chosen RGB, then
   commits via FrameEnd (function 6). "Off" issues SetHostModeState(0)
   only.

Protocol reference: ~/ghub/LOGITECH_HIDPP2_PROTOCOL.md functions on
0x0620. We skip the SetSWControl on 0x0600 step because the G522
doesn't expose 0x0600. If the device rejects host-mode writes without
that prerequisite we'll add it back.

Falls back to zones 0x01/0x02 (typical left/right earcup) if
GetRGBZoneInfo returns an unexpected format.
This commit is contained in:
Ken Sanislo 2026-04-17 17:08:56 -07:00
parent ab02d6e0c2
commit dad3aa24a7
1 changed files with 119 additions and 0 deletions

View File

@ -1768,6 +1768,123 @@ class HeadsetOnboardEQ(settings.RangeFieldSetting):
return result
class HeadsetRGBHostMode(settings.Setting):
"""Toggle host control of headset RGB lighting.
When enabled, solaar drives the LEDs via HeadsetRGBColor. When disabled,
firmware-driven onboard/signature effects resume.
"""
name = "headset-rgb-hostmode"
label = _("Headset RGB Host Control")
description = _("Enable software control of headset RGB lighting.")
feature = _F.HEADSET_RGB_HOSTMODE
rw_options = {"read_fnid": 0x70, "write_fnid": 0x80}
validator_class = settings_validator.BooleanValidator
# Preset RGB colors for headset-rgb-color. Name → (R, G, B).
# "Off" disables host mode and returns control to firmware.
_HEADSET_RGB_COLORS = [
("Off", None),
("Red", (0xFF, 0x00, 0x00)),
("Green", (0x00, 0xFF, 0x00)),
("Blue", (0x00, 0x00, 0xFF)),
("White", (0xFF, 0xFF, 0xFF)),
("Yellow", (0xFF, 0xFF, 0x00)),
("Cyan", (0x00, 0xFF, 0xFF)),
("Magenta", (0xFF, 0x00, 0xFF)),
("Orange", (0xFF, 0x80, 0x00)),
("Purple", (0x80, 0x00, 0xFF)),
]
class HeadsetRGBColor(settings.Setting):
"""Set headset RGB zones to a preset color.
Drives HeadsetRGBHostmode (0x0620). On write, enables host mode, queries
available zones, sets them all to the chosen RGB color, and commits via
FrameEnd. "Off" disables host mode to return control to firmware.
"""
name = "headset-rgb-color"
label = _("Headset RGB Color")
description = _("Set headset LED color (zones set to a single preset).")
feature = _F.HEADSET_RGB_HOSTMODE
choices_universe = common.NamedInts(**{name: idx for idx, (name, _rgb) in enumerate(_HEADSET_RGB_COLORS)})
validator_class = settings_validator.ChoicesValidator
persist = True
# Write-only: we don't read back the current color (host mode just reports on/off).
rw_options = {"read_fnid": None, "write_fnid": None}
@classmethod
def build(cls, device):
rw = settings.FeatureRW(cls.feature)
validator = settings_validator.ChoicesValidator(choices=cls.choices_universe)
return cls(device, rw, validator)
def read(self, cached=True):
# Write-only; return persisted/cached value if we have one.
if cached and getattr(self, "_value", None) is not None:
return self._value
return None
def write(self, value, save=True):
if value is None:
return None
idx = int(value)
if not (0 <= idx < len(_HEADSET_RGB_COLORS)):
return None
_name, rgb = _HEADSET_RGB_COLORS[idx]
device = self._device
if not device.online:
return None
try:
if rgb is None:
# "Off" → disable host mode, return to firmware control.
device.feature_request(_F.HEADSET_RGB_HOSTMODE, 0x80, b"\x00")
else:
zone_ids = self._zone_ids(device)
if not zone_ids:
logger.warning("HeadsetRGBColor: no zones discovered; cannot set color")
return None
# Enable host mode first.
device.feature_request(_F.HEADSET_RGB_HOSTMODE, 0x80, b"\x01")
# SetRgbZonesSingleValue: [R, G, B, count, zone_ids...]
r, g, b = rgb
payload = bytes([r, g, b, len(zone_ids)]) + bytes(zone_ids)
device.feature_request(_F.HEADSET_RGB_HOSTMODE, 0x50, payload)
# FrameEnd: commit the frame.
device.feature_request(_F.HEADSET_RGB_HOSTMODE, 0x60, b"\x00\x00\x00\x00")
except Exception as e:
logger.warning("HeadsetRGBColor write failed: %s", e)
return None
self.update(value, save)
return value
@staticmethod
def _zone_ids(device):
"""Query GetRGBZoneInfo (function 1) and return list of zone IDs."""
cached = getattr(device, "_headset_rgb_zone_ids", None)
if cached is not None:
return cached
try:
resp = device.feature_request(_F.HEADSET_RGB_HOSTMODE, 0x10)
except Exception:
resp = None
if not resp or len(resp) < 1:
device._headset_rgb_zone_ids = []
return []
zone_count = resp[0]
# Response: [count, 3 reserved, reserved, zone_ids...]
zone_ids = list(resp[5 : 5 + zone_count]) if len(resp) >= 5 + zone_count else []
# Fallback to typical left/right earcup zone IDs if response format differs.
if not zone_ids:
zone_ids = [0x01, 0x02]
device._headset_rgb_zone_ids = zone_ids
return zone_ids
class BrightnessControl(settings.Setting):
name = "brightness_control"
label = _("Brightness Control")
@ -2258,6 +2375,8 @@ SETTINGS: list[settings.Setting] = [
HeadsetMixBalance,
HeadsetAutoSleep,
HeadsetOnboardEQ,
HeadsetRGBHostMode,
HeadsetRGBColor,
]