From 56bce3fc1bb5af08a2680c0ec8656ec5184661dd Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Mon, 27 Apr 2026 20:12:41 -0700 Subject: [PATCH] G522 mic-mute fix + cluster-info decode + 0x0623 probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from G HUB pcap analysis (~/ghub/g522/*.pcapng): 1. Mic-mute on G522 actually works — we had the wrong fnids. The firmware uses fn 0x10 (function 1) for state-change events / reads and fn 0x20 (function 2) for SetState; the standard fn 0x10 SetState path returns 0x0A NOT_SUPPORTED. Replace the previous "suppress entirely on G522" quirk with a per-PID rw_options override that uses the right fnids. Other headsets keep the standard pattern. 2. setRGBClusterEffect (0x0621 fn 0x30) takes effect_id 0x0000 paired with RGB bytes to mean "Static color" — *not* "Off / Disabled" as our cluster-info parse had guessed. Add a structured decoder for getRGBClusterInfo (fn 0x10) that emits a human-readable summary alongside the raw hex, with effect_id 0x0000 labelled "Static". Records turn out to be 4 bytes each (effect_id LE u16, slot_idx LE u16) — most other HID++ fields are BE but this one is LE per the captured factory-default bytes. 3. Sub-feature 0x0623 is present on G522 but unmapped (it appeared as `unknown:0623` in the feature-table dump). Add a placeholder SupportedFeature entry and probe the first 8 functions read-side so the next bring-up captures whatever responds. --- lib/logitech_receiver/hidpp20_constants.py | 4 + lib/logitech_receiver/rgb_effects_probe.py | 88 ++++++++++++++++++++- lib/logitech_receiver/settings_templates.py | 20 +++-- 3 files changed, 101 insertions(+), 11 deletions(-) diff --git a/lib/logitech_receiver/hidpp20_constants.py b/lib/logitech_receiver/hidpp20_constants.py index be0082c1..064c175c 100644 --- a/lib/logitech_receiver/hidpp20_constants.py +++ b/lib/logitech_receiver/hidpp20_constants.py @@ -214,6 +214,10 @@ class SupportedFeature(IntEnum): HEADSET_RGB_HOSTMODE = 0x0620 HEADSET_RGB_ONBOARD_EFFECTS = 0x0621 HEADSET_RGB_SIGNATURE_EFFECTS = 0x0622 + # 0x0623 is present on G522 sub-device but its function set is unmapped; + # add probe coverage in rgb_effects_probe so the next bring-up captures + # whatever read functions respond. + HEADSET_RGB_0623 = 0x0623 HEADSET_DO_NOT_DISTURB = 0x0631 CENTURION_ONBOARD_PROFILES = 0x0634 HEADSET_RGB_STREAMING = 0x0635 diff --git a/lib/logitech_receiver/rgb_effects_probe.py b/lib/logitech_receiver/rgb_effects_probe.py index a30ba932..ec832fa3 100644 --- a/lib/logitech_receiver/rgb_effects_probe.py +++ b/lib/logitech_receiver/rgb_effects_probe.py @@ -1,10 +1,20 @@ -"""Read-only corpus probe for HEADSET_RGB_ONBOARD_EFFECTS (0x0621) and -HEADSET_RGB_SIGNATURE_EFFECTS (0x0622). +"""Read-only corpus probe for the headset RGB feature triplet: + + - HEADSET_RGB_ONBOARD_EFFECTS (0x0621) + - HEADSET_RGB_SIGNATURE_EFFECTS (0x0622) + - HEADSET_RGB_0623 (0x0623, function set unmapped) Logs raw response bytes and lengths at INFO so field testers without ``-dd`` can still capture the data. All calls are strictly read-side — -no setters are invoked. If either feature isn't present the probe +no setters are invoked. If a feature isn't present the probe short-circuits cleanly. + +Pcap analysis of G HUB's color-set traffic confirmed that on 0x0621, +``setRGBClusterEffect`` (fn 0x30) takes a 10-byte payload +``[cluster, effect_id_BE_u16, R, G, B, ...]`` where ``effect_id=0x0000`` +means "Static (with RGB)" — this is also the slot-0 entry in the +fn 0x10 ``getRGBClusterInfo`` reply, which we decode structurally so +the test corpus shows effect-id semantics in plaintext. """ from __future__ import annotations @@ -94,6 +104,53 @@ def _call(device, feature: SupportedFeature, fn: int, *params): return resp +# Names for known effect_ids on the headset RGB cluster. Confirmed via +# pcap analysis of G HUB color-set traffic: setRGBClusterEffect with +# effect_id=0x0000 + RGB writes a static color, so 0x0000 is "Static" +# rather than the "Off / Disabled" we'd guessed from cluster ordering. +# Other ids haven't been observed on the wire yet — names are placeholder +# until further pcap traffic confirms them. +_EFFECT_ID_NAMES = { + 0x0000: "Static", + 0x0001: "Effect 0x0001", + 0x0006: "Effect 0x0006", + 0x0007: "Effect 0x0007", + 0x000F: "Effect 0x000F", + 0x007F: "Effect 0x007F", +} + + +def _decode_cluster_info(resp) -> str | None: + """Decode a 0x0621 fn 0x10 getRGBClusterInfo reply into a readable + summary. Best-effort — returns None on unexpected length/shape. + + Observed shape on G522: 4-byte records (effect_id LE u16, slot_idx + LE u16). The effect_id at slot 0 is 0x0000 = "Static" (with RGB), + confirmed by pcap of G HUB color-set traffic. Records continue + until trailing-zero padding. + + Note: most HID++ multi-byte fields are BE, but this particular + response uses LE — confirmed against captured factory-default bytes + on G522 where the values 0x0001 / 0x000F / 0x007F appear at byte 0 + of each record with byte 1 = 0x00 (consistent with LE u16). + """ + if not resp or len(resp) < 4: + return None + effects = [] + seen_static = False + for i in range(0, len(resp) - 3, 4): + eid = resp[i] | (resp[i + 1] << 8) + slot = resp[i + 2] | (resp[i + 3] << 8) + # Skip purely-zero padding once we've seen the (effect=0, slot=0) entry. + if eid == 0 and slot == 0: + if seen_static: + continue + seen_static = True + name = _EFFECT_ID_NAMES.get(eid, f"0x{eid:04X}") + effects.append(f"slot={slot}:{name}") + return ", ".join(effects) if effects else None + + def probe_onboard_effects(device) -> None: """Probe 0x0621 RGBOnboardEffects read-side functions.""" feature = SupportedFeature.HEADSET_RGB_ONBOARD_EFFECTS @@ -109,6 +166,9 @@ def probe_onboard_effects(device) -> None: resp = _call(device, feature, 0x10, cluster_idx) if resp is None: break + decoded = _decode_cluster_info(resp) + if decoded: + logger.info("RGB probe: 0x0621.fn10(%02X) decoded: %s", cluster_idx, decoded) # fn 0x20 getRGBClusterEffect — current state per cluster. for cluster_idx in range(8): @@ -120,6 +180,24 @@ def probe_onboard_effects(device) -> None: _call(device, feature, 0x40) +def probe_unknown_0623(device) -> None: + """Probe 0x0623 (purpose unmapped) — present on G522 sub-device. + + Function set unknown. Try a small window of low function indexes to + capture whatever responds. Strictly read-side; we don't know what + arguments the functions take so we just call each with no payload + and let the device 0x0A any function that needs args. + """ + feature = SupportedFeature.HEADSET_RGB_0623 + if not device.features or feature not in device.features: + return + logger.info("RGB probe: 0x0623 HEADSET_RGB_0623 present on %s", device) + # Functions 0..7 covers the typical "info / get* / get*" range; if + # anything responds we'll have first bytes to triangulate against. + for fn_idx in range(8): + _call(device, feature, fn_idx << 4) + + def probe_signature_effects(device) -> None: """Probe 0x0622 RGBSignatureEffects read-side functions.""" feature = SupportedFeature.HEADSET_RGB_SIGNATURE_EFFECTS @@ -158,3 +236,7 @@ def probe(device) -> None: probe_signature_effects(device) except Exception as e: logger.info("RGB probe: signature-effects probe raised %r", e) + try: + probe_unknown_0623(device) + except Exception as e: + logger.info("RGB probe: 0x0623 probe raised %r", e) diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index fb389a55..89b67918 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -1623,15 +1623,19 @@ class HeadsetMicMute(settings.Setting): @classmethod def build(cls, device): - # G522 advertises 0x0601 in its FeatureSet but the firmware returns - # 0x0A UNSUPPORTED for both GetState and SetState. The physical mute - # switch doesn't drive this feature anyway — G HUB attempts and - # silently ignores failures. Hide the toggle on G522 PIDs (0x0B18 - # wireless, 0x0B19 wired) so users don't see a permanently-broken UI. - # product_id is the uppercase hex string form set by hidapi_impl - # (`f"{pid:04X}"`), so compare against strings rather than ints. + # G522 firmware uses non-standard fnids for HEADSET_MIC_MUTE: + # fn 0x10 (function 1) emits state-change events from the device + # (physical mute switch + host writes); also serves as GetState. + # fn 0x20 (function 2) is SetState — the standard fn 0x10 SetState + # returns 0x0A UNSUPPORTED on this firmware. + # Other headsets are presumably standard so we only override the + # rw_options on the known-quirky G522 PIDs (0x0B18 wireless, 0x0B19 + # wired-mode firmware). product_id is the uppercase hex string set + # by hidapi_impl (`f"{pid:04X}"`), so compare against strings. if getattr(device, "product_id", None) in ("0B18", "0B19"): - return None + rw = settings.FeatureRW(cls.feature, read_fnid=0x10, write_fnid=0x20) + validator = settings_validator.BooleanValidator() + return cls(device, rw, validator) return super().build(device)