From dad3aa24a72b09f1755716eccb44cf1386b39749 Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Fri, 17 Apr 2026 17:08:56 -0700 Subject: [PATCH] Add basic headset RGB support (HeadsetRGBHostMode + HeadsetRGBColor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/logitech_receiver/settings_templates.py | 119 ++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index ddab8b0a..ffeef7e5 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -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, ]