From 936991e0b43d0c701e0cd16797a36ed20dfff5f5 Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Fri, 15 May 2026 17:31:23 -0700 Subject: [PATCH] HeadsetOnboardEffect: add 0x0621 onboard RGB effect control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RGB headsets (e.g. G522) expose HEADSET_RGB_ONBOARD_EFFECTS (0x0621) — a firmware-played RGB effect on the headset's primary lighting cluster, chosen from six effect types: Fixed, Color Cycle, Color Wave, Breathing, Dual Color, Custom. Add an effect-switching HETERO setting modeled on the keyboard zone effects: a six-way effect picker plus the per-effect parameter widgets (colours, intensity, period, saturation, direction), with fields_map showing only the fields the selected effect uses. The rw_class reads getRGBClusterEffect and writes setRGBClusterEffect; the data class encodes each effect's distinct parameter layout. build() reads getRGBClusterInfo to learn the cluster's supported-effect set and offers only those in the picker; an unparseable reply falls back to offering all six (the firmware rejects any it doesn't support). intensity is a 0-100 percent; saturation is a raw 0-255 byte, matching the keyboard RGB effects. Like the signature/boot effects this runs autonomously on the firmware and is not gated on host LED control. Cluster 0 only — no multi-cluster headset has been seen and the getInfo multi-cluster descriptor stride is unconfirmed. --- lib/logitech_receiver/settings_templates.py | 172 ++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index 5b0c8137..042f6e60 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -2725,6 +2725,177 @@ class HeadsetSignaturePassiveEffect(_HeadsetSignatureEffectSetting): effect_id = 2 +class _HeadsetOnboardEffect: + """A 0x0621 onboard RGB effect: an effect ID plus the parameters that + effect uses. Synthetic form [effectId_u16_BE, 7 param bytes]; to_bytes / + from_bytes encode the per-effect parameter layout (the rw_class adds the + leading clusterIndex). intensity is a 0-100 percent; saturation is a raw + 0-255 byte (same as the keyboard RGB effects).""" + + def __init__(self, ID=0, color1=0, color2=0, intensity=0, saturation=0, period=0, speed=0, direction=0): + self.ID = int(ID) + self.intensity = max(0, min(100, int(intensity))) + self.saturation = max(0, min(255, int(saturation))) + self.period = max(0, min(0xFFFF, int(period))) + self.speed = max(0, min(0xFF, int(speed))) + self.direction = max(0, min(3, int(direction))) + for k, v in (("color1", color1), ("color2", color2)): + setattr(self, k, common.ColorInt(int(v) & 0xFFFFFF)) + + @classmethod + def from_bytes(cls, data, options=None): + if data is None or len(data) < 9: + return cls() + eid = (data[0] << 8) | data[1] + p = data[2:9] + kw = {"ID": eid} + if eid == 0: # Fixed + kw["color1"] = (p[0] << 16) | (p[1] << 8) | p[2] + elif eid in (1, 2): # ColorCycle / ColorWave + kw["intensity"] = p[0] + kw["period"] = (p[1] << 8) | p[2] + kw["saturation"] = p[3] + if eid == 2: + kw["direction"] = p[4] + elif eid == 3: # Breathing + kw["color1"] = (p[0] << 16) | (p[1] << 8) | p[2] + kw["period"] = (p[4] << 8) | p[5] + elif eid == 4: # DualColor + kw["color1"] = (p[0] << 16) | (p[1] << 8) | p[2] + kw["color2"] = (p[3] << 16) | (p[4] << 8) | p[5] + kw["speed"] = p[6] + return cls(**kw) + + def to_bytes(self, options=None): + eid = self.ID + p = bytearray(7) + c1, c2 = int(self.color1), int(self.color2) + if eid == 0: # Fixed: R, G, B + p[0], p[1], p[2] = (c1 >> 16) & 0xFF, (c1 >> 8) & 0xFF, c1 & 0xFF + elif eid in (1, 2): # ColorCycle / ColorWave + p[0] = self.intensity + p[1], p[2] = (self.period >> 8) & 0xFF, self.period & 0xFF + p[3] = self.saturation + if eid == 2: + p[4] = self.direction + elif eid == 3: # Breathing: R, G, B, CE[6]=0, period u16 BE + p[0], p[1], p[2] = (c1 >> 16) & 0xFF, (c1 >> 8) & 0xFF, c1 & 0xFF + p[4], p[5] = (self.period >> 8) & 0xFF, self.period & 0xFF + elif eid == 4: # DualColor: R1,G1,B1, R2,G2,B2, speed + p[0], p[1], p[2] = (c1 >> 16) & 0xFF, (c1 >> 8) & 0xFF, c1 & 0xFF + p[3], p[4], p[5] = (c2 >> 16) & 0xFF, (c2 >> 8) & 0xFF, c2 & 0xFF + p[6] = self.speed + # eid 5 Custom: no inline parameters + return bytes([(eid >> 8) & 0xFF, eid & 0xFF]) + bytes(p) + + def __eq__(self, other): + return isinstance(other, self.__class__) and self.to_bytes() == other.to_bytes() + + def __str__(self): + return yaml.dump(self, width=float("inf")).rstrip("\n") + + @classmethod + def from_yaml(cls, loader, node): + return cls(**loader.construct_mapping(node)) + + @classmethod + def to_yaml(cls, dumper, data): + return dumper.represent_mapping("!HeadsetOnboardEffect", data.__dict__, flow_style=True) + + +yaml.SafeLoader.add_constructor("!HeadsetOnboardEffect", _HeadsetOnboardEffect.from_yaml) +yaml.add_representer(_HeadsetOnboardEffect, _HeadsetOnboardEffect.to_yaml) + + +class HeadsetOnboardEffect(settings.Setting): + """The firmware RGB effect a headset runs autonomously on its primary + lighting cluster (HEADSET_RGB_ONBOARD_EFFECTS, 0x0621). Build reads the + cluster's supported-effect set so the picker offers only those. Like the + signature/boot effects this is firmware-driven and not gated on host LED + control. Multi-cluster devices (none seen yet) drive only cluster 0.""" + + name = "headset-onboard-effect" + label = _("Onboard Effect") + description = _("Firmware RGB effect the headset plays on its own.") + feature = _F.HEADSET_RGB_ONBOARD_EFFECTS + + _CLUSTER = 0 + _ALL_EFFECTS = ( + ("Fixed", 0), + ("Color Cycle", 1), + ("Color Wave", 2), + ("Breathing", 3), + ("Dual Color", 4), + ("Custom", 5), + ) + _EFFECT_FIELDS = { + 0: ("color1",), + 1: ("intensity", "period", "saturation"), + 2: ("intensity", "period", "saturation", "direction"), + 3: ("color1", "period"), + 4: ("color1", "color2", "speed"), + 5: (), + } + _DIRECTIONS = common.NamedInts(**{"Horizontal": 0, "Vertical": 1, "Reverse Horizontal": 2, "Reverse Vertical": 3}) + + class rw_class: + kind = settings.FeatureRW.kind + + def __init__(self, feature, cluster): + self.feature = feature + self._cluster = cluster + + def read(self, device): + reply = device.feature_request(self.feature, 0x20, bytes([self._cluster])) # getRGBClusterEffect + if reply is None or len(reply) < 10: + return None + return reply[1:10] # strip clusterIndex -> [effectId_u16, 7 param bytes] + + def write(self, device, data_bytes): + # data_bytes is [effectId_u16, 7 param bytes]; prepend clusterIndex + reply = device.feature_request(self.feature, 0x30, bytes([self._cluster]) + bytes(data_bytes)) + return data_bytes if reply is not None else None + + @classmethod + def build(cls, device): + try: + info = device.feature_request(cls.feature, 0x10, bytes([cls._CLUSTER])) # getRGBClusterInfo + except exceptions.FeatureCallError: + return None + if info is None or len(info) < 1: + return None + # [count, count x {effectId_u16_BE, caps_u16_BE}] — take effectId of each entry + supported = [] + for i in range(info[0]): + off = 1 + i * 4 + if off + 2 > len(info): + break + eid = (info[off] << 8) | info[off + 1] + if 0 <= eid <= 5 and eid not in supported: + supported.append(eid) + if not supported: + # Unparseable reply — offer all six; the firmware rejects any it + # doesn't support. See the 0x0621 fallback note in protocol RE. + supported = [0, 1, 2, 3, 4, 5] + rw = cls.rw_class(cls.feature, cls._CLUSTER) + validator = settings_validator.HeteroValidator(data_class=_HeadsetOnboardEffect, options=None) + setting = cls(device, rw, validator) + id_choices = common.NamedInts(**{name: eid for name, eid in cls._ALL_EFFECTS if eid in supported}) + id_field = {"name": "ID", "kind": settings.Kind.CHOICE, "label": None, "choices": id_choices} + setting.possible_fields = [ + id_field, + {"name": "color1", "kind": settings.Kind.COLOR, "label": _("Primary")}, + {"name": "color2", "kind": settings.Kind.COLOR, "label": _("Secondary")}, + {"name": "intensity", "kind": settings.Kind.RANGE, "label": _("Intensity"), "min": 0, "max": 100}, + {"name": "saturation", "kind": settings.Kind.RANGE, "label": _("Saturation"), "min": 0, "max": 255}, + {"name": "period", "kind": settings.Kind.RANGE, "label": _("Period"), "min": 0, "max": 0xFFFF}, + {"name": "speed", "kind": settings.Kind.RANGE, "label": _("Speed"), "min": 0, "max": 0xFF}, + {"name": "direction", "kind": settings.Kind.CHOICE, "label": _("Direction"), "choices": cls._DIRECTIONS}, + ] + setting.fields_map = {eid: (id_choices[eid], {field: 1 for field in cls._EFFECT_FIELDS[eid]}) for eid in supported} + return setting + + # ---------------------------------------------------------------------------- # LogiVoice (0x0900 + 0x0901..0x0907) — read-only presentation pass. # @@ -4297,6 +4468,7 @@ SETTINGS: list[settings.Setting] = [ HeadsetSignatureStartupEffect, HeadsetSignatureShutdownEffect, HeadsetSignaturePassiveEffect, + HeadsetOnboardEffect, *_LOGIVOICE_SETTINGS, ]