G522 mic-mute fix + cluster-info decode + 0x0623 probe

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.
This commit is contained in:
Ken Sanislo 2026-04-27 20:12:41 -07:00
parent 2e07b112ff
commit 56bce3fc1b
3 changed files with 101 additions and 11 deletions

View File

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

View File

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

View File

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