G522 LIGHTSPEED headphones support

End-to-end support for the G522 LIGHTSPEED gaming headset (and the
infrastructure that comes with it for related Centurion-bridged headsets).

Hardware/protocol layer:

- centurion.py — CenturionReceiver: lightweight receiver-like wrapper
  for Centurion-bridged dongles (PRO X 2 LIGHTSPEED already, G522 added
  here). Handles deferred init for 0x50 devices that don't respond to
  the initial probe, sub-device feature discovery via bridge, online
  detection via ping.

- base.py — Centurion handle state, device_addr probe (256-candidate
  scan), 0x50/0x51 frame routing, bridge TX/RX framing.

- hidpp20.py — Centurion sub-device feature query and bridge request
  routing. CENTURION_DEVICE_INFO / CENTURION_BATTERY_SOC / CENTURION_*
  query helpers in centurion.py for parent + sub paths.

- device.py — Centurion device creation path, USB product-string
  fallback for naming, bridge sub-device error handling.

- notifications.py — HEADSET_ADVANCED_PARA_EQ band-change events,
  HEADSET_MIC_MUTE state-change events; both route to the relevant
  setting via setting_callback.

Headset settings:

- HeadsetEcoMode, HeadsetDoNotDisturb, HeadsetMicMute, HeadsetMicSNR,
  HeadsetAINR / HeadsetAINRLevel, HeadsetSidetone, HeadsetMicGain,
  HeadsetMixBalance, HeadsetAutoSleep — bool / range / choice settings
  for the headset-specific feature pages.

- HeadsetOnboardEQ + HeadsetActiveEQPreset — onboard EQ slot picker
  with active-preset tracking via the EQ change event subscription.

- HeadsetAdvancedEQ — multi-band parametric EQ (advanced_para_eq.py
  module handles getEQInfos / getCustomEQ / band-change subscription;
  setting builds the per-band sliders).

- HeadsetLEDControl + HeadsetLedsPrimary + HeadsetPerZoneLighting —
  RGB feature set (0x0620 HEADSET_RGB_HOSTMODE) with shared zone-write
  helper (headset_rgb.py) and the G522 layout for the per-key painter
  (perkey/layouts/headset_g522.py).

- LogiVoice family (12 settings: NR / NG / Compressor / De-esser /
  Depopper / Limiter / HPF, each with state + parameters) — voice
  processing pipeline. logivoice.py module handles probe + parsing.

Probes:

- rgb_effects_probe.py — runtime probe for headset RGB feature
  variants (0x0621 onboard-effects, 0x0622 signature-effects, 0x0623).

Tests:

- test_base.py / test_device.py / test_hidpp20_complex.py — Centurion
  framing, device-creation path, sub-device feature parsing.

- test_setting_templates.py — fixtures for the new headset settings.

Closes pwr-Solaar/Solaar#3181.
This commit is contained in:
Ken Sanislo 2026-05-13 15:04:20 -07:00 committed by Peter F. Patel-Schneider
parent a5d12f9039
commit ac7add6297
23 changed files with 2949 additions and 168 deletions

View File

@ -19,3 +19,4 @@ class DeviceInfo:
hidpp_short: str | None
hidpp_long: str | None
centurion: bool = False
centurion_report_id: int | None = None # 0x50 or 0x51 when centurion=True

View File

@ -102,6 +102,7 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo
from hid_parser import ReportDescriptor
hidpp_short = hidpp_long = centurion = False
centurion_report_id = None
devfile = "/sys" + hid_device.properties.get("DEVPATH") + "/report_descriptor"
with fileopen(devfile, "rb") as fd:
with warnings.catch_warnings():
@ -111,16 +112,22 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo
# and _Usage(0xFF00, 0x0001) in rd.get_input_items(0x10)[0].usages # be more permissive
hidpp_long = 0x11 in rd.input_report_ids and 19 * 8 == int(rd.get_input_report_size(0x11))
# and _Usage(0xFF00, 0x0002) in rd.get_input_items(0x11)[0].usages # be more permissive
# Centurion transport: report ID 0x51, 63-byte reports (usage page 0xFFA0)
centurion = (
0x51 in rd.input_report_ids and 63 * 8 == int(rd.get_input_report_size(0x51)) and 0x51 in rd.output_report_ids
)
# Centurion transport: 63-byte reports on usage page 0xFFA0 (both input and output)
# 0x51 = PRO X 2 LIGHTSPEED variant, 0x50 = G522 LIGHTSPEED variant (with device address byte)
if 0x51 in rd.input_report_ids and 63 * 8 == int(rd.get_input_report_size(0x51)) and 0x51 in rd.output_report_ids:
centurion_report_id = 0x51
elif (
0x50 in rd.input_report_ids and 63 * 8 == int(rd.get_input_report_size(0x50)) and 0x50 in rd.output_report_ids
):
centurion_report_id = 0x50
centurion = centurion_report_id is not None
if not hidpp_short and not hidpp_long and not centurion:
return
except Exception as e: # if can't process report descriptor fall back to old scheme
hidpp_short = None
hidpp_long = None
centurion = False
centurion_report_id = None
logger.info(
"Report Descriptor not processed for DEVICE %s BID %s VID %s PID %s: %s",
device.device_node,
@ -171,6 +178,7 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo
hidpp_short=hidpp_short,
hidpp_long=hidpp_long,
centurion=centurion if centurion else False,
centurion_report_id=centurion_report_id,
)
return d_info

View File

@ -0,0 +1,456 @@
## Copyright (C) 2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""AdvancedParaEQ (0x020D) helpers.
The device handles biquad coefficient computation we transmit only
per-band filter-type + frequency + gain; the DSP does the rest.
V0/V1 wire format: 3-byte band stride [freq_hi, freq_lo, gain_i8],
gain is whole dB; getEQInfos returns 5 bytes [bandCount, dbRange,
caps, dbMin, dbMax].
V2 wire format: 1-byte header [direction_echo], then N × 5-byte band
stride [freq_hi, freq_lo, filter_type, gain_hi, gain_lo], with 0..3
trailer bytes (opaque, ignored). The parser consumes 5-byte chunks
until <5 bytes remain or a freq=0 sentinel is hit. Frequency u16 BE
in Hz; gain u16 BE in **offset-binary**: raw 0..(steps-1) maps
linearly to gain_min..gain_max (so on G522 with steps=241 /
gain=[-6..6], raw=120 = 0 dB). getEQInfos returns 13 bytes with gain
bounds + step count, format enum, XY-support flag, and onboard preset
counts.
(The protocol spec lists a 2-byte header [dir_echo, slot_echo] for
getCustomEQ, but G522 firmware via the centurion bridge omits the
slot_echo and emits a 1-byte header that matches getEQDefaults.
Verified against pcap traces of LGHUB G522 LIGHTSPEED traffic.)
"""
from __future__ import annotations
import logging
import struct
from .hidpp20_constants import SupportedFeature
logger = logging.getLogger(__name__)
DIRECTION_PLAYBACK = 0
DIRECTION_CAPTURE = 1
# V2 filter-type taxonomy (byte [+0] of each band). 0x16 is observed on
# G522 for every band of its factory custom slot at ISO third-octave
# centers, treated as peaking. Other filter kinds (LP, shelf, notch …)
# need a live probe per device firmware to enumerate.
FILTER_TYPE_HP = 0x00
FILTER_TYPE_PEAKING_G522 = 0x16
FILTER_TYPE_PEAKING = 0x78
FILTER_TYPE_NAMES = {
FILTER_TYPE_HP: "HP",
FILTER_TYPE_PEAKING_G522: "peaking",
FILTER_TYPE_PEAKING: "peaking",
}
def _get_version(device) -> int:
return device.features.get_feature_version(SupportedFeature.HEADSET_ADVANCED_PARA_EQ) or 0
def get_advanced_eq_info(device):
"""Query getEQInfos (function 0). Returns a dict or None.
Common fields:
version int feature version (0, 1, 2)
gain_min_db int signed whole-dB min
gain_max_db int signed whole-dB max
step_db float dB per raw LSB (1.0 on V0/V1)
V0/V1 only:
band_count int number of bands (from wire byte 0)
db_range int raw byte 1
capabilities int raw byte 2
V2 only:
gain_steps int discrete gain positions
format int 0=CLASSIC, 1=STYLES
supports_xy bool
onboard_ro_preset_count int factory preset slots
onboard_custom_preset_count int user-writable preset slots
"""
version = _get_version(device)
result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x00)
if result is None:
logger.debug("AdvancedParaEQ getEQInfos V%d: feature_request returned None", version)
return None
if version >= 2:
if len(result) < 13:
logger.debug("AdvancedParaEQ getEQInfos V2: short response len=%d %s", len(result), result.hex())
return None
gain_min = struct.unpack("b", bytes([result[2]]))[0]
gain_max = struct.unpack("b", bytes([result[3]]))[0]
gain_steps = struct.unpack(">H", result[4:6])[0]
fmt = result[6]
supports_xy = bool(result[7])
ro_presets = result[9]
custom_presets = result[10]
step_db = (gain_max - gain_min) / max(1, gain_steps - 1)
info = {
"version": 2,
"gain_min_db": gain_min,
"gain_max_db": gain_max,
"gain_steps": gain_steps,
"step_db": step_db,
"format": fmt,
"supports_xy": supports_xy,
"onboard_ro_preset_count": ro_presets,
"onboard_custom_preset_count": custom_presets,
}
logger.debug(
"AdvancedParaEQ getEQInfos V2: gain=[%d,%d] steps=%d step_db=%.4f format=%d xy=%s "
"presets_ro=%d presets_custom=%d",
gain_min,
gain_max,
gain_steps,
step_db,
fmt,
supports_xy,
ro_presets,
custom_presets,
)
device._advanced_eq_info = info
return info
# V0 / V1
if len(result) < 5:
logger.debug("AdvancedParaEQ getEQInfos V%d: short response len=%d %s", version, len(result), result.hex())
return None
band_count = result[0]
db_range = result[1]
caps = result[2]
gain_min = struct.unpack("b", bytes([result[3]]))[0]
gain_max = struct.unpack("b", bytes([result[4]]))[0]
info = {
"version": version,
"band_count": band_count,
"db_range": db_range,
"capabilities": caps,
"gain_min_db": gain_min,
"gain_max_db": gain_max,
"step_db": 1.0,
}
logger.debug(
"AdvancedParaEQ getEQInfos V%d: bands=%d dbRange=%d caps=0x%02X gain=[%d,%d]",
version,
band_count,
db_range,
caps,
gain_min,
gain_max,
)
device._advanced_eq_info = info
return info
def get_advanced_eq_active_slot(device, direction=DIRECTION_PLAYBACK):
"""Query getActiveEQ (function 3). Returns the active slot index, or None."""
result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x30, direction)
if result is None:
logger.debug("AdvancedParaEQ getActiveEQ(dir=%d): feature_request returned None", direction)
return None
if len(result) < 1:
logger.debug("AdvancedParaEQ getActiveEQ(dir=%d): empty response", direction)
return None
logger.debug("AdvancedParaEQ getActiveEQ(dir=%d): slot=%d", direction, result[0])
return result[0]
def parse_v2_bands(result: bytes, info: dict | None):
"""Parse a V2 getCustomEQ / getEQDefaults response.
Wire layout (see module docstring):
[direction_echo] (1-byte header)
N × [freq_hi, freq_lo, filter_type, gain_hi, gain_lo] (5 bytes)
[trailer ] (0..3 bytes, ignored)
Gain is offset-binary against `info`'s gain bounds:
gain_db = gain_min + (gain_max - gain_min) * raw / (steps - 1)
`info` is the dict returned by `get_advanced_eq_info`. If absent we
fall back to step_db=1.0 (and log via the caller, not here) which is
wrong but won't crash.
Returns list of (filter_type_byte, freq_hz, gain_db) tuples, or None
if the payload is too short to contain a header. Empty payload with
valid header returns []. Bands with freq=0 are treated as the
end-of-bands sentinel (matches V0/V1 behavior at lines below).
"""
if result is None or len(result) < 1:
return None
payload = result[1:] # skip [dir_echo]
band_size = 5
if info:
gain_min = info.get("gain_min_db", -6)
gain_max = info.get("gain_max_db", 6)
steps = info.get("gain_steps", 241)
else:
gain_min, gain_max, steps = 0, 0, 1 # produces gain_db=0 for any raw
bands = []
for i in range(len(payload) // band_size):
e = payload[i * band_size : (i + 1) * band_size]
freq_hz = (e[0] << 8) | e[1]
filter_type = e[2]
gain_raw = (e[3] << 8) | e[4]
if freq_hz == 0:
break # disabled band — end-of-bands sentinel
if steps > 1:
gain_db = gain_min + (gain_max - gain_min) * gain_raw / (steps - 1)
else:
gain_db = 0.0
bands.append((filter_type, freq_hz, float(gain_db)))
return bands
def _band_label(filter_type_byte: int, freq_hz: int) -> str:
kind = FILTER_TYPE_NAMES.get(filter_type_byte, f"type-0x{filter_type_byte:02X}")
if filter_type_byte == FILTER_TYPE_HP:
return f"HP {freq_hz} Hz"
return f"{freq_hz} Hz" if kind == "peaking" else f"{kind} {freq_hz} Hz"
def get_advanced_eq_defaults(device, direction=DIRECTION_PLAYBACK, slot=0):
"""Query getEQDefaults (function 5). Same per-band layout as getCustomEQ.
Returns list of (filter_type_byte, freq_hz, gain_db) tuples, or None.
V0/V1 callers receive (FILTER_TYPE_PEAKING, freq_hz, gain_db) for
compatibility with the V2 tuple shape.
"""
version = _get_version(device)
result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x50, direction, slot)
if result is None:
logger.debug(
"AdvancedParaEQ getEQDefaults V%d (dir=%d slot=%d): feature_request returned None",
version,
direction,
slot,
)
return None
if version >= 2:
info = getattr(device, "_advanced_eq_info", None)
bands = parse_v2_bands(result, info)
if bands is None:
logger.debug(
"AdvancedParaEQ getEQDefaults V2 (dir=%d slot=%d): payload too short raw=%s",
direction,
slot,
result.hex(),
)
return None
logger.debug(
"AdvancedParaEQ getEQDefaults V2 (dir=%d slot=%d): %d band(s) raw=%s %s",
direction,
slot,
len(bands),
result.hex(),
[_band_label(t, f) + f" {round(g, 2)}dB" for t, f, g in bands],
)
return bands
# V0/V1 legacy 3-byte stride.
bands = []
offset = 0
while offset + 3 <= len(result):
freq = struct.unpack(">H", result[offset : offset + 2])[0]
if freq == 0:
break
gain_db = struct.unpack("b", bytes([result[offset + 2]]))[0]
bands.append((FILTER_TYPE_PEAKING, freq, float(gain_db)))
offset += 3
logger.debug(
"AdvancedParaEQ getEQDefaults V%d (dir=%d slot=%d): %d band(s)",
version,
direction,
slot,
len(bands),
)
return bands
def get_advanced_eq_friendly_name(device, direction=DIRECTION_PLAYBACK, slot=0):
"""Query getEQFriendlyName (function 6). Returns the UTF-8 preset name or None."""
result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x60, direction, slot)
if result is None or len(result) < 1:
return None
name_len = result[0]
if 1 + name_len > len(result):
name_len = len(result) - 1
try:
name = bytes(result[1 : 1 + name_len]).decode("utf-8", errors="replace")
except Exception:
name = result[1 : 1 + name_len].hex()
return name
def probe_advanced_eq_slots(device, direction=DIRECTION_PLAYBACK, info=None):
"""Probe every advertised EQ slot via getCustomEQ and cache which respond.
Some firmware (G522) advertises N slots via getEQInfos but only honors a
subset for getCustomEQ / setActiveEQ the rest return 0x0B NOT_SUPPORTED.
This iterates 0..total-1 and records which slots actually have data.
Result is cached on `device._advanced_eq_working_slots` as a list of
`(slot_index, name, bands)` tuples. The HeadsetActiveEQPreset selector
builds its choices from this list; the HeadsetAdvancedEQ panel uses it
to skip dead slots in its diagnostic output.
Logs each working slot's bands at INFO and a summary line indicating
how many of the advertised slots are actually accessible.
"""
cached = getattr(device, "_advanced_eq_working_slots", None)
if cached is not None:
return cached
if info is None:
info = getattr(device, "_advanced_eq_info", None) or get_advanced_eq_info(device)
if not info:
return []
ro_count = info.get("onboard_ro_preset_count", 0) or 0
custom_count = info.get("onboard_custom_preset_count", 0) or 0
total = ro_count + custom_count
if total == 0:
return []
def probe(slot):
bands = get_advanced_eq_params(device, direction=direction, slot=slot)
if bands is None:
return None
name = get_advanced_eq_friendly_name(device, direction=direction, slot=slot)
kind = "factory" if slot < ro_count else "custom"
logger.debug(
"AdvancedParaEQ %s preset slot=%d (dir=%d) name=%r: %s",
kind,
slot,
direction,
name,
[f"{_band_label(t, f)} {round(g, 2)}dB" for t, f, g in bands],
)
return (slot, name, bands)
working = []
# Slot 0 is canonical. If it fails the device is unusable; bail.
entry = probe(0)
if entry is None:
device._advanced_eq_working_slots = working
return working
working.append(entry)
# Slot 1 acts as a "multi-slot capable?" canary. G522 firmware
# advertises 16 slots but only honors slot 0; LGHUB itself never
# touches slots > 0 on this device. When the canary fails, skip the
# remaining 14 NOT_SUPPORTED probes.
if total > 1:
entry = probe(1)
if entry is None:
logger.debug(
"AdvancedParaEQ: slot 1 returned NOT_SUPPORTED; " "firmware advertises %d slots but only honors slot 0",
total,
)
device._advanced_eq_working_slots = working
return working
working.append(entry)
for slot in range(2, total):
entry = probe(slot)
if entry is not None:
working.append(entry)
device._advanced_eq_working_slots = working
logger.debug(
"AdvancedParaEQ working slots on dir=%d: %d of %d advertised %s",
direction,
len(working),
total,
[w[0] for w in working],
)
return working
# Backward-compat alias kept until external callers are migrated.
probe_all_presets = probe_advanced_eq_slots
def get_advanced_eq_params(device, direction=DIRECTION_PLAYBACK, slot=0):
"""Query getCustomEQ (function 1). Returns list of (filter_type, freq_hz, gain_db) or None.
V0/V1: filter_type is always FILTER_TYPE_PEAKING (synthesized), freq is
raw Hz from wire, gain is whole dB.
V2: filter_type comes from the wire (0x00=HP, 0x78=peaking), freq is raw
Hz, gain is int16 × step_db.
step_db for V2 is cached on the device by get_advanced_eq_info.
"""
version = _get_version(device)
result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x10, direction, slot)
if result is None:
logger.debug(
"AdvancedParaEQ getCustomEQ V%d (dir=%d slot=%d): feature_request returned None",
version,
direction,
slot,
)
return None
if version >= 2:
info = getattr(device, "_advanced_eq_info", None)
if not info:
logger.warning("AdvancedParaEQ getCustomEQ V2: no cached getEQInfos — gain values will be wrong")
bands = parse_v2_bands(result, info)
if bands is None:
logger.debug(
"AdvancedParaEQ getCustomEQ V2 (dir=%d slot=%d): payload too short raw=%s",
direction,
slot,
result.hex(),
)
return None
step_db = info["step_db"] if info and "step_db" in info else 1.0
# Log raw=... too so we can compare wire shapes across firmware
# variants and across get-fns (getCustomEQ vs getEQDefaults).
logger.debug(
"AdvancedParaEQ getCustomEQ V2 (dir=%d slot=%d): %d band(s) step_db=%.4f raw=%s %s",
direction,
slot,
len(bands),
step_db,
result.hex(),
[f"{_band_label(t, f)} {round(g, 2)}dB" for t, f, g in bands],
)
return bands
# V0 / V1
bands = []
offset = 0
while offset + 3 <= len(result):
freq = struct.unpack(">H", result[offset : offset + 2])[0]
if freq == 0:
break
gain_db = struct.unpack("b", bytes([result[offset + 2]]))[0]
bands.append((FILTER_TYPE_PEAKING, freq, float(gain_db)))
offset += 3
logger.debug(
"AdvancedParaEQ getCustomEQ V%d (dir=%d slot=%d): parsed %d band(s) %s",
version,
direction,
slot,
len(bands),
bands,
)
return bands

View File

@ -97,18 +97,30 @@ HIDPP_LONG_MESSAGE_ID = 0x11
DJ_MESSAGE_ID = 0x20
# Centurion transport (used by PRO X 2 LIGHTSPEED headset and similar)
# Uses report ID 0x51 on usage page 0xFFA0, 64-byte frames.
# Wire format (CPL): [0x51, cpl_length, flags=0x00, feat_idx, func_sw, params..., pad]
# Two variants exist, distinguished by report ID:
# 0x51 (PRO X 2): [0x51, cpl_length, flags, feat_idx, func_sw, params..., pad]
# 0x50 (G522): [0x50, device_addr, cpl_length, flags, feat_idx, func_sw, params..., pad]
# The 0x50 variant adds a device_addr byte at position [1], shifting all CPL fields by +1.
# cpl_length = number of bytes from flags to end of meaningful data (includes flags byte).
# The device_index byte from standard HID++ is NOT present in Centurion framing.
CENTURION_REPORT_ID = 0x51
CENTURION_ADDRESSED_REPORT_ID = 0x50 # addressed variant with device_addr byte at frame[1] (G522 etc.)
CENTURION_FRAME_SIZE = 64 # 1 byte report ID + 63 bytes payload
_CENTURION_MSG_SIZE = 63 # max reconstructed message size after unwrapping (2 + 61 payload bytes)
# Set of handles that use Centurion framing
_centurion_handles: set[int] = set()
# Raw Centurion protocol version (major, minor) by handle, from ping response
_centurion_protocol_versions: dict[int, tuple[int, int]] = {}
@dataclasses.dataclass
class CenturionHandleState:
"""Per-handle state for Centurion devices."""
report_id: int = CENTURION_REPORT_ID # 0x50 or 0x51
device_addr: int | None = None # learned from first RX (0x50 only)
protocol_version: tuple[int, int] | None = None # from ping response
# All centurion per-handle state in a single dict.
# Membership test (ihandle in _centurion_handles) gates centurion-specific code paths.
_centurion_handles: dict[int, CenturionHandleState] = {}
"""Default timeout on read (in seconds)."""
@ -301,8 +313,7 @@ def close(handle):
if handle:
try:
if isinstance(handle, int):
_centurion_handles.discard(handle)
_centurion_protocol_versions.pop(handle, None)
_centurion_handles.pop(handle, None)
hidapi.close(handle)
else:
handle.close()
@ -313,6 +324,115 @@ def close(handle):
return False
def _centurion_frame_header(state: CenturionHandleState, cpl_length: int, flags: int) -> bytes:
"""Build the fixed prefix of a centurion frame.
0x51: [0x51, cpl_length, flags] (3 bytes)
0x50: [0x50, device_addr, cpl_length, flags] (4 bytes)
"""
if state.report_id == CENTURION_ADDRESSED_REPORT_ID:
device_addr = state.device_addr if state.device_addr is not None else 0x00
return struct.pack("!BBBB", CENTURION_ADDRESSED_REPORT_ID, device_addr, cpl_length, flags)
return struct.pack("!BBB", CENTURION_REPORT_ID, cpl_length, flags)
_CENTURION_REPORT_IDS = (CENTURION_REPORT_ID, CENTURION_ADDRESSED_REPORT_ID)
# Per-candidate read timeout (ms) for the device_addr probe.
# USB round-trip is <1ms; 5ms gives 5x margin.
_CENTURION_PROBE_PER_ADDR_TIMEOUT_MS = 5
def probe_centurion_device_addr(handle, state: CenturionHandleState) -> bool:
"""Brute-force probe the device address byte for a 0x50-variant Centurion handle.
Sends a ROOT.GetProtocolVersion request for each candidate device_addr
(0x000xFF), reading briefly after each write. The dongle silently ignores
wrong addresses and responds only to the correct one. Stops on first hit.
Worst case (no response): 256 × 5ms = ~1.3s.
Typical G522 (addr=0x23): 36 × 5ms = ~180ms.
No-op for 0x51 (no device_addr byte) or when an address is already known.
Returns True if the address was learned.
"""
if state.report_id != CENTURION_ADDRESSED_REPORT_ID or state.device_addr is not None:
return False
ihandle = int(handle)
logger.debug("(%s) probing centurion device_addr: scanning 0x00-0xFF", handle)
# ROOT.GetProtocolVersion: feat_idx=0x00, func=0x10, 3 zero param bytes
payload = bytes([0x00, 0x10, 0x00, 0x00, 0x00])
cpl_length = len(payload) + 1 # +1 for flags byte
write_errors = 0
for addr in range(256):
frame = struct.pack("!BBBB", CENTURION_ADDRESSED_REPORT_ID, addr, cpl_length, 0x00) + payload
frame = frame + b"\x00" * (CENTURION_FRAME_SIZE - len(frame))
try:
hidapi.write(ihandle, frame)
except Exception:
write_errors += 1
if write_errors > 3:
logger.debug("(%s) centurion device_addr probe: too many write failures, aborting", handle)
return False
continue
try:
data = hidapi.read(ihandle, CENTURION_FRAME_SIZE, _CENTURION_PROBE_PER_ADDR_TIMEOUT_MS)
except Exception as reason:
logger.debug("(%s) centurion device_addr probe read failed at addr 0x%02X: %s", handle, addr, reason)
return False
if data and len(data) >= 2 and ord(data[:1]) == state.report_id:
state.device_addr = ord(data[1:2])
logger.debug(
"(%s) probed centurion device addr 0x%02X (after %d candidates)",
handle,
state.device_addr,
addr + 1,
)
return True
logger.debug("(%s) centurion device_addr probe: no response from any of 256 candidates", handle)
return False
def _unwrap_centurion_frame(data: bytes, ihandle: int, handle) -> bytes:
"""Unwrap a Centurion CPL frame (0x50 or 0x51) into a standard HID++ long message.
Auto-detects the variant from the raw report ID byte (self-describing),
matching how _read() handles 0x10 vs 0x11.
For 0x50, learns the device address from byte[1] on first receive.
"""
raw_report_id = ord(data[:1])
if raw_report_id == CENTURION_ADDRESSED_REPORT_ID:
# 0x50: [report_id, device_addr, cpl_length, flags, feat_idx, func_sw, data...]
device_addr = ord(data[1:2])
state = _centurion_handles.get(ihandle)
if state is not None and state.device_addr is None:
state.device_addr = device_addr
if logger.isEnabledFor(logging.DEBUG):
logger.debug("(%s) learned centurion device addr 0x%02X", handle, device_addr)
cpl_length = ord(data[2:3])
inner_payload = data[4 : 3 + cpl_length] # cpl_length - 1 bytes (skip flags)
elif raw_report_id == CENTURION_REPORT_ID:
# 0x51: [report_id, cpl_length, flags, feat_idx, func_sw, data...]
cpl_length = ord(data[1:2])
inner_payload = data[3 : 2 + cpl_length] # cpl_length - 1 bytes (skip flags)
else:
return data # not a centurion frame
data = bytes([HIDPP_LONG_MESSAGE_ID, 0xFF]) + inner_payload
# Pad to a valid message size: standard long (20) or Centurion extended (63)
if len(data) <= _LONG_MESSAGE_SIZE:
data = data + b"\x00" * (_LONG_MESSAGE_SIZE - len(data))
elif len(data) <= _CENTURION_MSG_SIZE:
data = data + b"\x00" * (_CENTURION_MSG_SIZE - len(data))
else:
data = data[:_CENTURION_MSG_SIZE]
return data
def write(handle, devnumber, data, long_message=False):
"""Writes some data to the receiver, addressed to a certain device.
@ -337,12 +457,12 @@ def write(handle, devnumber, data, long_message=False):
ihandle = int(handle)
if ihandle in _centurion_handles:
# Centurion CPL framing: [0x51, cpl_length, flags=0x00, feat_idx, func_sw, params...]
# cpl_length = len(meaningful_payload) + 1 (the +1 counts the flags byte)
# The device_index is stripped — only the HID++ payload (feat_idx + func_sw + params) remains.
# Centurion CPL framing — strip device_index from HID++ and wrap in CPL header.
# cpl_length = len(meaningful_payload) + 1 (the +1 counts the flags byte).
state = _centurion_handles[ihandle]
payload = wdata[2:] # skip report_id and devnumber from standard frame
cpl_length = len(data) + 1 # data is the unpadded payload; +1 for flags byte
wdata = struct.pack("!BBB", CENTURION_REPORT_ID, cpl_length, 0x00) + payload
wdata = _centurion_frame_header(state, cpl_length, 0x00) + payload
wdata = wdata + b"\x00" * (CENTURION_FRAME_SIZE - len(wdata))
if logger.isEnabledFor(logging.DEBUG):
@ -366,7 +486,9 @@ def write(handle, devnumber, data, long_message=False):
def write_centurion_cpl(handle, layer3_payload, flags=0x00):
"""Send a Centurion CPL frame with the given Layer 3+ payload.
Builds: [0x51, cpl_length, flags, layer3_payload..., zero-pad to 64 bytes]
Builds the appropriate header for the handle's report ID variant:
0x51: [0x51, cpl_length, flags, layer3_payload..., pad to 64]
0x50: [0x50, device_addr, cpl_length, flags, layer3_payload..., pad to 64]
where cpl_length = len(layer3_payload) + 1 (the +1 counts the flags byte).
For multi-fragment sends, flags encodes fragment index and continuation:
@ -376,11 +498,13 @@ def write_centurion_cpl(handle, layer3_payload, flags=0x00):
ihandle = int(handle)
if ihandle not in _centurion_handles:
raise ValueError("write_centurion_cpl called on non-Centurion handle")
state = _centurion_handles[ihandle]
cpl_length = len(layer3_payload) + 1 # +1 for flags byte
wdata = struct.pack("!BBB", CENTURION_REPORT_ID, cpl_length, flags) + layer3_payload
header = _centurion_frame_header(state, cpl_length, flags)
wdata = header + layer3_payload
wdata = wdata + b"\x00" * (CENTURION_FRAME_SIZE - len(wdata))
if logger.isEnabledFor(logging.DEBUG):
logger.debug("(%s) <= centurion_cpl[%s]", handle, common.strhex(wdata[: cpl_length + 2]))
logger.debug("(%s) <= centurion_cpl[%s]", handle, common.strhex(wdata[: len(header) + cpl_length - 1]))
try:
hidapi.write(ihandle, wdata)
except Exception as reason:
@ -452,22 +576,8 @@ def _read(handle, timeout) -> tuple[int, int, bytes]:
close(handle)
raise exceptions.NoReceiver(reason=reason) from reason
if data and is_centurion and ord(data[:1]) == CENTURION_REPORT_ID:
# Unwrap Centurion CPL framing:
# RX: [0x51, cpl_length, flags=0x00, feat_idx, func_sw, data...]
# cpl_length includes the flags byte, so meaningful payload starts at byte 3
# and has (cpl_length - 1) bytes.
# Reconstruct as HID++ long message: [0x11, devnumber=0xFF, feat_idx, func_sw, data...]
cpl_length = ord(data[1:2])
inner_payload = data[3 : 2 + cpl_length] # bytes 3..2+cpl_length-1 = cpl_length-1 bytes
data = bytes([HIDPP_LONG_MESSAGE_ID, 0xFF]) + inner_payload
# Pad to a valid message size: standard long (20) or Centurion extended (63)
if len(data) <= _LONG_MESSAGE_SIZE:
data = data + b"\x00" * (_LONG_MESSAGE_SIZE - len(data))
elif len(data) <= _CENTURION_MSG_SIZE:
data = data + b"\x00" * (_CENTURION_MSG_SIZE - len(data))
else:
data = data[:_CENTURION_MSG_SIZE]
if data and is_centurion and ord(data[:1]) in _CENTURION_REPORT_IDS:
data = _unwrap_centurion_frame(data, ihandle, handle)
if data and _is_relevant_message(data): # ignore messages that fail check
report_id = ord(data[:1])
@ -725,7 +835,7 @@ def ping(handle, devnumber, long_message: bool = False):
major = ord(reply_data[2:3])
minor = ord(reply_data[3:4])
if is_centurion:
_centurion_protocol_versions[int(handle)] = (major, minor)
_centurion_handles[int(handle)].protocol_version = (major, minor)
return major + minor / 10.0
if (
@ -771,17 +881,8 @@ def _read_input_buffer(handle, ihandle, notifications_hook):
raise exceptions.NoReceiver(reason=reason) from reason
if data:
if is_centurion and ord(data[:1]) == CENTURION_REPORT_ID:
# Unwrap Centurion CPL framing same as in _read()
cpl_length = ord(data[1:2])
inner_payload = data[3 : 2 + cpl_length]
data = bytes([HIDPP_LONG_MESSAGE_ID, 0xFF]) + inner_payload
if len(data) <= _LONG_MESSAGE_SIZE:
data = data + b"\x00" * (_LONG_MESSAGE_SIZE - len(data))
elif len(data) <= _CENTURION_MSG_SIZE:
data = data + b"\x00" * (_CENTURION_MSG_SIZE - len(data))
else:
data = data[:_CENTURION_MSG_SIZE]
if is_centurion and ord(data[:1]) in _CENTURION_REPORT_IDS:
data = _unwrap_centurion_frame(data, ihandle, handle)
if _is_relevant_message(data): # only process messages that pass check
# report_id = ord(data[:1])
if notifications_hook:

View File

@ -266,6 +266,7 @@ class CenturionReceiver:
self._devices = {}
self._firmware = None
self._dongle_features = None # independently probed dongle features
self._pending = False # True when device_addr unknown; deferred init completes on first RX
self.cleanups = []
# Receiver identity
@ -319,7 +320,7 @@ class CenturionReceiver:
if feat_id == feature_int:
request_id = (index << 8) | (function & 0xFF)
return self.request(request_id, *params, no_reply=no_reply)
raise exceptions.FeatureNotSupported(feature)
raise exceptions.FeatureNotSupported(feature=feature)
def _discover_dongle_features(self):
"""Independently discover features on the dongle hardware."""
@ -363,15 +364,67 @@ class CenturionReceiver:
@property
def firmware(self):
if self._firmware is None and self.handle:
if self._firmware is None and self.handle and not self._pending:
self._firmware = get_firmware_centurion(self)
return self._firmware or ()
def _complete_deferred_init(self):
"""Re-run feature discovery after device_addr has been learned.
Called once from the notification handler when the first 0x50 frame
arrives on a pending CenturionReceiver.
"""
if not self._pending:
return False
self._pending = False
ihandle = int(self.handle)
state = base._centurion_handles.get(ihandle)
learned_addr = state.device_addr if state else None
logger.debug(
"CenturionReceiver %s: completing deferred init (device_addr=0x%02X)",
self.path,
learned_addr or 0,
)
self._dongle_features = None
self._discover_dongle_features()
logger.debug(
"CenturionReceiver %s: deferred discovery found %d feature(s): %s",
self.path,
len(self._dongle_features or []),
[(f"{feat_id:#06x}", idx) for _, feat_id, idx in (self._dongle_features or [])],
)
if self.serial is None:
try:
s = get_serial_centurion(self)
if s and s.strip() and s.strip().isprintable():
self.serial = s.strip()
except Exception:
pass
has_bridge = any(feat_id == CenturionCoreFeature.CENT_PP_BRIDGE for _, feat_id, _ in (self._dongle_features or []))
if has_bridge:
self.notify_devices()
return True
logger.warning(
"CenturionReceiver %s: deferred init completed but no bridge found " "(features: %s)",
self.path,
[f"{feat_id:#06x}" for _, feat_id, _ in (self._dongle_features or [])],
)
return False
def notify_devices(self):
"""Create child Device for the headset and trigger its initialization."""
# Import Device locally to avoid circular import (centurion.py ↔ device.py)
from .device import Device
if self._pending:
# Don't create children yet — feature discovery hasn't succeeded.
# Signal receiver to UI so the tray entry exists.
self.changed(alert=Alert.NONE)
return
# Signal receiver to UI first — tray/window need the receiver entry
# before a child device can be added under it.
self.changed(alert=Alert.NONE)
@ -410,6 +463,13 @@ class CenturionReceiver:
# Ping to determine online status.
# Notify UI either way — offline devices show as greyed out (matching receiver behavior).
online = dev.ping()
logger.debug(
"CenturionReceiver %s: child device created, bridge_idx=%s, online=%s, protocol=%s",
self.path,
getattr(dev, "_centurion_bridge_index", None),
online,
dev._protocol,
)
dev.changed(active=online)
if self.status_callback is not None:
self.status_callback(dev)
@ -495,17 +555,33 @@ def create_centurion_receiver(low_level, device_info, setting_callback=None):
try:
handle = low_level.open_path(device_info.path)
if handle:
base._centurion_handles.add(int(handle))
report_id = getattr(device_info, "centurion_report_id", None) or base.CENTURION_REPORT_ID
state = base.CenturionHandleState(report_id=report_id)
base._centurion_handles[int(handle)] = state
base.probe_centurion_device_addr(handle, state)
cr = CenturionReceiver(low_level, handle, device_info, setting_callback)
# Check if any discovered feature is CentPPBridge (0x0003)
has_bridge = any(feat_id == CenturionCoreFeature.CENT_PP_BRIDGE for _, feat_id, _ in (cr.dongle_features or []))
if not has_bridge:
if has_bridge:
return cr
# No bridge found. Distinguish "silent 0x50 dongle" (device_addr
# unknown, headset not yet powered on) from "wired 0x50 device"
# (responded to probe, features found, but no bridge).
is_0x50 = state.report_id == base.CENTURION_ADDRESSED_REPORT_ID
if is_0x50 and state.device_addr is None and not cr.dongle_features:
logger.debug(
"Centurion 0x50 device %s: probe and discovery failed, " "deferring init until first RX frame",
device_info.path,
)
cr._pending = True
return cr
logger.info("Centurion device %s has no bridge, treating as direct device", device_info.path)
base._centurion_handles.discard(int(handle))
base._centurion_handles.pop(int(handle), None)
cr.handle = None # prevent __del__ from double-closing
low_level.close(handle)
return None
return cr
except OSError as e:
logger.exception("open %s", device_info)
if e.errno == errno.EACCES:

View File

@ -466,3 +466,4 @@ _D(
usbid=0x0ABA,
)
# PRO X 2 LIGHTSPEED Gaming Headset (0x0AF7) — fully probed via Centurion transport, no static descriptor needed
# G522 LIGHTSPEED Gaming Headset (0x0B18 dongle, 0x0B19 wired) — Centurion 0x50 variant, no static descriptor needed

View File

@ -79,7 +79,10 @@ def create_device(low_level: LowLevelInterface, device_info, setting_callback=No
handle = low_level.open_path(device_info.path)
if handle:
if getattr(device_info, "centurion", False):
base._centurion_handles.add(int(handle))
report_id = getattr(device_info, "centurion_report_id", None) or base.CENTURION_REPORT_ID
state = base.CenturionHandleState(report_id=report_id)
base._centurion_handles[int(handle)] = state
base.probe_centurion_device_addr(handle, state)
# a direct connected device might not be online (as reported by user)
return Device(
low_level,
@ -223,7 +226,9 @@ class Device:
self._protocol = self.descriptor.protocol if self.descriptor.protocol else None
self.registers = self.descriptor.registers if self.descriptor.registers else []
if self._protocol is not None:
# Centurion devices always use HID++ 2.0 features regardless of the
# protocol version the dongle reports (e.g. G522 reports 1.1).
if self._protocol is not None and not self.centurion:
self.features = {} if self._protocol < 2.0 else hidpp20.FeaturesArray(self)
else:
self.features = hidpp20.FeaturesArray(self) # may be a 2.0 device; if not, it will fix itself later
@ -243,7 +248,13 @@ class Device:
self.ping()
except exceptions.NoSuchDevice:
logger.warning("device %s inaccessible - no protocol set", self)
return self._protocol or 0
result = self._protocol or 0
# Centurion devices always use HID++ 2.0 features regardless of the
# protocol version the dongle reports (e.g. G522 reports 1.1).
# Ensure all `protocol < 2.0` gates route through the 2.0 code path.
if self.centurion and result < 2.0:
return 2.0
return result
@property
def codename(self):
@ -523,6 +534,15 @@ class Device:
def battery(self): # None or level, next, status, voltage
if self.protocol < 2.0:
if self.centurion:
logger.debug(
"%s: battery() dispatching HID++ 1.0 path for a Centurion device "
"(protocol=%s, _protocol=%s) — device_addr probe likely failed, "
"expect INVALID_SUB_ID_COMMAND",
self,
self.protocol,
self._protocol,
)
return _hidpp10.get_battery(self)
else:
battery_feature = self.persister.get("_battery", None) if self.persister else None
@ -690,26 +710,40 @@ class Device:
# Ensure sub-device features are discovered before routing decision
if self.features is not None:
self.features._check()
# Guard against Centurion/HID++ 2.0 feature ID collisions. IntEnum
# members with the same int value hash equal, so a dict lookup for
# SupportedFeature.DEVICE_NAME (0x0005) succeeds even when the
# device actually has CenturionCoreFeature.MULTI_HOST_CONTROL at
# that slot. If the type of the stored enum differs from what the
# caller asked for, treat the feature as unsupported.
if self.features is not None:
idx = self.features.get(feature)
if idx is not None:
stored = self.features.inverse.get(idx)
if stored is not None and type(stored) is not type(feature):
return None
if feature in getattr(self, "_centurion_sub_features", ()):
sub_idx = self.features.get(feature)
if sub_idx is not None:
return self.centurion_bridge_request(sub_idx, function, *params, no_reply=no_reply)
return hidpp20.feature_request(self, feature, function, *params, no_reply=no_reply)
# Max sub-message bytes in the first bridge fragment:
# 64 - 1 (report ID) - 1 (cpl_len) - 1 (flags) - 2 (bridge prefix) - 2 (bridge hdr) = 57
# LGHUB uses 56 for first fragment (60 byte payload - 4 bridge overhead)
# Max sub-message bytes in the first bridge fragment (for 0x51):
# 64 - 1 (report ID) - 1 (cpl_len) - 1 (flags) - 2 (bridge prefix) - 2 (bridge hdr) = 57;
# one byte of conservative margin gives 56. For 0x50 the device_addr byte
# eats one more, so first_chunk = 55 (handled dynamically below).
_BRIDGE_FIRST_CHUNK = 56
# Continuation fragments carry raw sub_msg data (no bridge prefix/hdr):
# 64 - 1 (report ID) - 1 (cpl_len) - 1 (flags) = 61, but LGHUB uses 60
# 64 - 1 (report ID) - 1 (cpl_len) - 1 (flags) = 61; one byte of margin
# gives 60.
_BRIDGE_CONT_CHUNK = 60
def centurion_bridge_request(self, sub_feat_idx, sub_function=0x00, *params, no_reply=False):
"""Send a request to a Centurion sub-device via CentPPBridge.
Builds the 4-layer nested message:
Layer 1: [0x51]
Layer 2: [cpl_length, flags]
Layer 1: [report_id] (0x51 or 0x50)
Layer 2: [device_addr (0x50 only),] cpl_length, flags
Layer 3: [bridge_idx, sendFragment_func|swid, bridge_hdr...]
Layer 4: [sub_cpl=0x00, sub_feat_idx, sub_func|swid, params...]
@ -730,6 +764,12 @@ class Device:
if not handle:
return None
# Adjust bridge chunk sizes for 0x50 variant (device_addr byte takes 1 frame byte)
cent_state = base._centurion_handles.get(int(handle))
addr_overhead = 1 if cent_state and cent_state.report_id == base.CENTURION_ADDRESSED_REPORT_ID else 0
first_chunk = self._BRIDGE_FIRST_CHUNK - addr_overhead
cont_chunk = self._BRIDGE_CONT_CHUNK - addr_overhead
sw_id = base._get_next_sw_id()
# Build sub-device message: [sub_cpl=0x00, sub_feat_idx, sub_func|swid, params...]
@ -745,7 +785,15 @@ class Device:
timeout = base.DEFAULT_TIMEOUT
with base.acquire_timeout(base.handle_lock(handle), handle, timeout):
if sub_len <= self._BRIDGE_FIRST_CHUNK:
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"bridge TX: sub_idx=%d func=0x%02X sw_id=%d payload=%s",
sub_feat_idx,
sub_function,
sw_id,
sub_params.hex() if sub_params else "",
)
if sub_len <= first_chunk:
# Single-frame path
layer3 = bridge_prefix + bridge_hdr + sub_msg
base.write_centurion_cpl(handle, layer3)
@ -755,18 +803,17 @@ class Device:
# Fragments 1+: raw sub_msg continuation data (no bridge overhead)
# CPL flags = (frag_index << 1) | (1 if more_fragments else 0)
# All fragments are sent back-to-back without waiting for
# intermediate ACKs (verified via LGHUB pcap). The device
# reassembles internally and sends a single ACK + MessageEvent
# after the last fragment.
# intermediate ACKs. The device reassembles internally and
# sends a single ACK + MessageEvent after the last fragment.
frag_index = 0
offset = 0
while offset < sub_len:
if frag_index == 0:
chunk_size = self._BRIDGE_FIRST_CHUNK
chunk_size = first_chunk
chunk = sub_msg[offset : offset + chunk_size]
layer3 = bridge_prefix + bridge_hdr + chunk
else:
chunk_size = self._BRIDGE_CONT_CHUNK
chunk_size = cont_chunk
chunk = sub_msg[offset : offset + chunk_size]
layer3 = chunk
has_more = (offset + chunk_size) < sub_len
@ -778,6 +825,13 @@ class Device:
if no_reply:
return None
# The device echoes our exact sub-device function+swid byte in
# MessageEvent responses. Match on that to reject cross-contamination
# from late-arriving responses to other function calls on the same
# feature (e.g. GetRGBZoneInfo response showing up on a later
# GetHostModeState read).
expected_sub_func_sw = (sub_function & 0xF0) | sw_id
# Read ACK + MessageEvent response
request_started = time.time()
ack_received = False
@ -794,11 +848,21 @@ class Device:
break
if (func_sw >> 4) == 0x01 and (func_sw & 0x0F) == 0:
# MessageEvent arrived before ACK — validate it's for our request
if self._is_bridge_response_for(reply_data, sub_feat_idx):
if self._is_bridge_response_for(reply_data, sub_feat_idx, expected_sub_func_sw):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("bridge idx=%d fn=0x%02X -> OK", sub_feat_idx, sub_function)
return self._parse_bridge_response(reply_data)
# Unsolicited notification, skip it
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"bridge skipping reply (pre-ACK): got sub_cpl=0x%02X sub_idx=0x%02X func_sw=0x%02X"
" (expected idx=0x%02X func_sw=0x%02X) data=%s",
reply_data[4] if len(reply_data) > 4 else 0,
reply_data[5] if len(reply_data) > 5 else 0,
reply_data[6] if len(reply_data) > 6 else 0,
sub_feat_idx,
expected_sub_func_sw,
reply_data.hex(),
)
if not ack_received:
logger.warning("centurion_bridge_request: no ACK received")
return None
@ -812,11 +876,21 @@ class Device:
if len(reply_data) >= 2 and reply_data[0] == bridge_idx:
func_sw = reply_data[1]
if (func_sw >> 4) == 0x01 and (func_sw & 0x0F) == 0:
if self._is_bridge_response_for(reply_data, sub_feat_idx):
if self._is_bridge_response_for(reply_data, sub_feat_idx, expected_sub_func_sw):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("bridge idx=%d fn=0x%02X -> OK", sub_feat_idx, sub_function)
return self._parse_bridge_response(reply_data)
# Unsolicited notification for a different feature, skip it
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"bridge skipping reply (post-ACK): got sub_cpl=0x%02X sub_idx=0x%02X func_sw=0x%02X"
" (expected idx=0x%02X func_sw=0x%02X) data=%s",
reply_data[4] if len(reply_data) > 4 else 0,
reply_data[5] if len(reply_data) > 5 else 0,
reply_data[6] if len(reply_data) > 6 else 0,
sub_feat_idx,
expected_sub_func_sw,
reply_data.hex(),
)
logger.warning("centurion_bridge_request: no MessageEvent received")
return None
@ -836,12 +910,19 @@ class Device:
return False
@staticmethod
def _is_bridge_response_for(reply_data, expected_sub_feat_idx):
def _is_bridge_response_for(reply_data, expected_sub_feat_idx, expected_sub_func_sw=None):
"""Check if a bridge MessageEvent is a response for our specific sub-feature request.
Accepts both normal responses (sub_feat_idx matches) and error responses
(sub_feat_idx=0xFF with original feat_idx in next byte).
Unsolicited notifications (sub_cpl=0xFF) are rejected.
If `expected_sub_func_sw` is provided, also matches on the echoed
sub-device function byte (`(function << 4) | sw_id`). This prevents
cross-talk between different function calls on the SAME feature, which
can happen when a late-arriving response for one function gets picked
up by a later request on the same feature (observed on G522 where a
GetRGBZoneInfo response contaminated a subsequent GetHostModeState).
"""
if len(reply_data) < 6:
return False
@ -851,9 +932,15 @@ class Device:
if sub_cpl != 0x00:
return False
if sub_feat_idx == expected_sub_feat_idx:
if expected_sub_func_sw is not None and len(reply_data) >= 7:
if reply_data[6] != expected_sub_func_sw:
return False
return True
# Error response: sub_feat_idx=0xFF, next byte is the original feat_idx that errored
if sub_feat_idx == 0xFF and len(reply_data) >= 7 and reply_data[6] == expected_sub_feat_idx:
if expected_sub_func_sw is not None and len(reply_data) >= 8:
if reply_data[7] != expected_sub_func_sw:
return False
return True
return False
@ -873,18 +960,25 @@ class Device:
sub_feat_idx = reply_data[5]
# Error response from sub-device
if sub_feat_idx == 0xFF:
error_code = reply_data[8] if len(reply_data) > 8 else 0
# Error frame layout after sub_cpl: [0xFF, orig_feat_idx, orig_func_sw, error_code, ...]
orig_feat_idx = reply_data[6] if len(reply_data) > 6 else 0
logger.debug("bridge sub-device error: feat_idx=%d error=0x%02X", orig_feat_idx, error_code)
orig_func_sw = reply_data[7] if len(reply_data) > 7 else 0
error_code = reply_data[8] if len(reply_data) > 8 else 0
logger.debug(
"bridge sub-device error: orig_feat_idx=%d orig_func=0x%02X error=0x%02X",
orig_feat_idx,
orig_func_sw,
error_code,
)
return None
return reply_data[7:] # response data after sub_cpl, sub_feat_idx, sub_func_sw
def _record_ping_protocol(self, handle, protocol):
"""Record a successful ping's protocol version, including raw Centurion (major, minor)."""
self._protocol = protocol
cent_ver = base._centurion_protocol_versions.get(int(handle))
if cent_ver:
self._centurion_protocol = cent_ver
cent_state = base._centurion_handles.get(int(handle))
if cent_state and cent_state.protocol_version:
self._centurion_protocol = cent_state.protocol_version
def ping(self):
"""Checks if the device is online and present, returns True of False.

View File

@ -0,0 +1,211 @@
## Copyright (C) 2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Shared helpers for devices exposing Feature 0x0620 HEADSET_RGB_HOSTMODE.
The G522 is currently the only Solaar-supported device advertising this
feature, but anything else presenting 0x0620 will pick the same code path
automatically. The module deliberately avoids G522-specific assumptions
so future RGB-capable headsets can reuse it.
Two entry points the settings templates rely on:
- `discover_zones(device)` one-shot zone enumeration run at setting
build time. Briefly claims Solaar host control so GetRGBZoneInfo
returns a non-empty zone list, then restores the previous host-mode
state. Result is cached on the device.
- `write_zone_map(device, zone_color_map)` the shared write path used
by both the "LEDs Primary" and "Per-zone Lighting" settings. Groups
zones by final RGB color and emits one SetRgbZonesSingleValue per
unique color, then a single FrameEnd to commit.
"""
from __future__ import annotations
import logging
from typing import Iterable
from .hidpp20_constants import SupportedFeature
logger = logging.getLogger(__name__)
# Function IDs on Feature 0x0620 we actually use.
FN_GET_RGB_ZONE_INFO = 0x10
FN_SET_RGB_ZONES_SINGLE = 0x50
FN_FRAME_END = 0x60
FN_GET_HOST_MODE_STATE = 0x70
FN_SET_HOST_MODE_STATE = 0x80
# Frame type sent with FrameEnd. 0x01 = transient commit (re-applies on the
# next refresh). 0x02 would be persistent, but G522 firmware rejects it
# with LOGITECH_INTERNAL (0x05) unless an onboard profile precondition we
# haven't mapped yet is satisfied.
FRAME_TYPE_TRANSIENT = 0x01
_HOST_MODE_SOLAAR = 1
_HOST_MODE_DEVICE = 0
def _device_cache_attr() -> str:
return "_headset_rgb_zone_ids"
def _read_host_mode(device) -> int | None:
"""Read the current host-mode state byte, or None on any failure."""
try:
resp = device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_GET_HOST_MODE_STATE)
except Exception as e:
logger.debug("headset_rgb: GetHostModeState raised %s", e)
return None
if not resp or len(resp) < 1:
return None
return resp[0]
def _set_host_mode(device, value: int) -> bool:
try:
device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_SET_HOST_MODE_STATE, bytes([value & 0xFF]))
except Exception as e:
logger.debug("headset_rgb: SetHostModeState(%d) raised %s", value, e)
return False
return True
def _parse_zone_info(resp: bytes) -> list[int]:
"""Parse a GetRGBZoneInfo response into a zone-id list.
Two formats observed: "tight" ([count, zone_ids...]) on G522, and
the canonical protocol-doc layout (3-byte gap + 1-byte reserved
before zone IDs). Both are tried; whichever yields exactly `count`
IDs wins. Zone id 0 isn't filtered — some devices may use it.
"""
if not resp or len(resp) < 1:
return []
zone_count = resp[0]
tight = list(resp[1 : 1 + zone_count]) if 1 <= zone_count <= len(resp) - 1 else []
if tight and len(tight) == zone_count:
return tight
gap = list(resp[5 : 5 + zone_count]) if len(resp) >= 5 + zone_count else []
if gap and len(gap) == zone_count:
return gap
return []
def discover_zones(device) -> list[int] | None:
"""Return the list of RGB zone IDs on `device`, or None on failure.
Caches the result on `device._headset_rgb_zone_ids` so subsequent
callers don't repeat the round-trip. Briefly claims Solaar host mode
if needed GetRGBZoneInfo has been observed to return count=0 when
the device is still under firmware control and restores the prior
state afterward so user-configured onboard effects resume.
"""
cached = getattr(device, _device_cache_attr(), None)
if cached:
return cached
if not getattr(device, "online", False):
return None
prior_mode = _read_host_mode(device)
claimed = False
if prior_mode != _HOST_MODE_SOLAAR:
if not _set_host_mode(device, _HOST_MODE_SOLAAR):
return None
claimed = True
try:
try:
resp = device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_GET_RGB_ZONE_INFO)
except Exception as e:
logger.debug("headset_rgb: GetRGBZoneInfo raised %s", e)
return None
zones = _parse_zone_info(bytes(resp) if resp else b"")
if not zones:
logger.debug(
"headset_rgb: GetRGBZoneInfo returned no zones (raw=%s)",
resp.hex() if resp else resp,
)
return None
logger.debug("headset_rgb: discovered %d zone(s) %s", len(zones), [f"0x{z:02X}" for z in zones])
setattr(device, _device_cache_attr(), zones)
return zones
finally:
if claimed and prior_mode is not None:
_set_host_mode(device, prior_mode)
def _split_rgb(color_int: int) -> tuple[int, int, int]:
return (color_int >> 16) & 0xFF, (color_int >> 8) & 0xFF, color_int & 0xFF
def write_zone_map(device, zone_color_map: dict) -> bool:
"""Apply a zone->RGB mapping to the device.
`zone_color_map` maps zone id (int) to 24-bit RGB color (int,
`(r<<16)|(g<<8)|b`). Claims host mode, groups zones by color,
emits one SetRgbZonesSingleValue per unique color, then a single
FrameEnd. Returns True on success, False on any transport error.
"""
if not zone_color_map:
return False
if not getattr(device, "online", False):
logger.debug("headset_rgb: device offline, skipping write")
return False
# Group zones by color for batched writes.
groups: dict[int, list[int]] = {}
for zone, color in zone_color_map.items():
groups.setdefault(int(color), []).append(int(zone))
try:
_set_host_mode(device, _HOST_MODE_SOLAAR)
for color_int, zones in groups.items():
r, g, b = _split_rgb(color_int)
# SetRgbZonesSingleValue: [R, G, B, count, zone_ids...]
payload = bytes([r, g, b, len(zones)]) + bytes(zones)
device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_SET_RGB_ZONES_SINGLE, payload)
logger.debug(
"headset_rgb: set (%02X,%02X,%02X) on %d zone(s) %s",
r,
g,
b,
len(zones),
[f"0x{z:02X}" for z in zones],
)
# FrameEnd commits the pending per-zone updates. Transient commit
# only — persistent (0x02) requires onboard-profile preconditions
# that aren't mapped yet.
device.feature_request(
SupportedFeature.HEADSET_RGB_HOSTMODE,
FN_FRAME_END,
bytes([FRAME_TYPE_TRANSIENT, 0x00, 0x00, 0x00]),
)
except Exception as e:
logger.warning("headset_rgb: write_zone_map failed: %s", e)
return False
return True
def zone_named_ints(zones: Iterable[int]):
"""Build a list of NamedInt keys suitable for a ChoicesMap setting.
Factored out so settings code can import without pulling common.NamedInt
at module-load time if preferred.
"""
from . import common
return [common.NamedInt(int(z), f"Zone {int(z)}") for z in zones]

View File

@ -197,13 +197,27 @@ class FeaturesArray(dict):
response = self.device.request((fs_index << 8) | 0x10, index)
if response is None or len(response) < 3:
continue
# Centurion FeatureSet response: [remaining_count, feat_hi, feat_lo, type, flags]
# Centurion FeatureSet response: [remaining_count, feat_hi, feat_lo, type, version]
feat_id = struct.unpack("!H", response[1:3])[0]
feat_type = response[3] if len(response) > 3 else 0
feat_version = response[4] if len(response) > 4 else 0
feature = resolve_feature(feat_id, centurion=True)
if feature is None:
feature = f"unknown:{feat_id:04X}"
self[feature] = index
self.inverse[index] = feature
# Record version/flags so version-gated settings (sidetone, auto-sleep)
# use the correct payload format on direct USB Centurion devices too.
self.version[feature] = feat_version
self.flags[feature] = feat_type
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"Centurion parent feature: %s at index %d, version=%d, flags=0x%02X",
feature,
index,
feat_version,
feat_type,
)
if feature is CenturionCoreFeature.CENT_PP_BRIDGE:
bridge_index = index
@ -216,8 +230,11 @@ class FeaturesArray(dict):
def _discover_sub_device_features(self, bridge_index):
"""Phase B: Discover sub-device features via CentPPBridge.
Uses CenturionFeatureSet bulk query (function 1, index 0) routed through
the bridge to get all sub-device features at once.
Uses per-index queries: GetCount (func 0) returns total count, then
GetFeatureId (func 1) returns one feature per call. Avoids the
single-frame truncation of bulk queries a Centurion frame is 64
bytes so a bulk reply can only fit ~13 features regardless of how
many the sub-device actually has.
"""
# First, find the sub-device's FeatureSet index via CenturionRoot (sub_feat_idx=0)
# Query: CenturionRoot.GetFeature(0x0001) to find FeatureSet index on sub-device
@ -232,44 +249,62 @@ class FeaturesArray(dict):
logger.warning("Sub-device FeatureSet not found (index=0)")
return
# Bulk enumerate: CenturionFeatureSet.GetFeatureId(func=1=0x10, start_index=0)
# Response: [count, (feat_hi, feat_lo, type, flags) × count]
response = self.device.centurion_bridge_request(sub_fs_index, 0x10, 0x00)
if response is None or len(response) < 1:
logger.warning("Failed to enumerate sub-device features")
# Query feature count (function 0 = GetCount). Response: [count, ...].
count_resp = self.device.centurion_bridge_request(sub_fs_index, 0x00)
if count_resp is None or len(count_resp) < 1:
logger.warning("Failed to read Centurion sub-device feature count")
return
total_count = count_resp[0]
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Centurion sub-device: FeatureSet reports %d features", total_count)
entry_count = response[0]
entries = response[1:]
sub_feat_idx = 0 # sub-device feature indices start at 0
for i in range(entry_count):
offset = i * 4
if offset + 2 > len(entries):
break
feat_id = struct.unpack("!H", entries[offset : offset + 2])[0]
# Per-index query: GetFeatureId (function 1 = 0x10).
# Response: [remaining, feat_hi, feat_lo, type, version].
# We now also record `type` (flags) and `version` for each feature so
# version-gated settings (sidetone, auto-sleep, etc.) can use the
# correct payload format instead of defaulting to V0.
sub_feat_idx = 0
for idx in range(total_count):
response = self.device.centurion_bridge_request(sub_fs_index, 0x10, idx)
if response is None or len(response) < 3:
logger.debug("Centurion sub-device: no response at index %d", idx)
continue
feat_id = struct.unpack("!H", response[1:3])[0]
feat_type = response[3] if len(response) > 3 else 0
feat_version = response[4] if len(response) > 4 else 0
try:
feature = SupportedFeature(feat_id)
except ValueError:
feature = f"unknown:{feat_id:04X}"
# Store sub-device index for ALL features (including parent overlaps)
# This enables querying the sub-device's copy of shared features via bridge
self.device._centurion_sub_indices[feature] = sub_feat_idx
# Only store unique sub-device features in dict (skip parent overlaps like ROOT, FEATURE_SET)
# This avoids clobbering parent inverse entries via __setitem__
if dict.get(self, feature) is None:
dict.__setitem__(self, feature, sub_feat_idx)
self.device._centurion_sub_features.add(feature)
# Always store in sub_inverse for sub-device enumerate/display
self.sub_inverse[sub_feat_idx] = feature
# Record version/flags so downstream settings can version-gate their
# payload format. get_feature_version(feature) reads self.version[feature].
self.version[feature] = feat_version
self.flags[feature] = feat_type
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Centurion sub-device feature: %s at sub-index %d", feature, sub_feat_idx)
logger.debug(
"Centurion sub-device feature: %s at sub-index %d, version=%d, flags=0x%02X",
feature,
sub_feat_idx,
feat_version,
feat_type,
)
sub_feat_idx += 1
self._sub_feature_count = sub_feat_idx
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Centurion sub-device: discovered %d features total", sub_feat_idx)
def get_feature(self, index: int) -> SupportedFeature | None:
feature = self.inverse.get(index)
if feature is not None:
return feature
# Sub-device index; bridge unwrap offsets by 0x100 (see listener).
if index >= 0x100:
return self.sub_inverse.get(index - 0x100)
elif self._check():
feature = self.inverse.get(index)
if feature is not None:
@ -334,6 +369,12 @@ class FeaturesArray(dict):
index = super().get(feature)
if index is not None:
return index
# Centurion devices enumerate all features upfront in _check_centurion().
# If the feature isn't in the dict after _check(), it genuinely doesn't
# exist — skip the raw ROOT.GetFeature query that the dongle rejects
# with LOGITECH_ERROR and that creates cycling log spam during settings init.
if getattr(self.device, "centurion", False):
return None
try:
response = self.device.request(0x0000, struct.pack("!H", feature))
except exceptions.FeatureCallError:
@ -2365,6 +2406,18 @@ class ForceSensingButtonArray(UserDict):
# --- OnboardEQ (0x0636) — re-exported from onboard_eq.py ---
# --- AdvancedParaEQ (0x020D) — re-exported from advanced_para_eq.py ---
from .advanced_para_eq import FILTER_TYPE_HP # noqa: E402, F401
from .advanced_para_eq import FILTER_TYPE_PEAKING # noqa: E402, F401
from .advanced_para_eq import FILTER_TYPE_PEAKING_G522 # noqa: E402, F401
from .advanced_para_eq import get_advanced_eq_active_slot # noqa: E402, F401
from .advanced_para_eq import get_advanced_eq_defaults # noqa: E402, F401
from .advanced_para_eq import get_advanced_eq_friendly_name # noqa: E402, F401
from .advanced_para_eq import get_advanced_eq_info # noqa: E402, F401
from .advanced_para_eq import get_advanced_eq_params # noqa: E402, F401
from .advanced_para_eq import parse_v2_bands # noqa: E402, F401
from .advanced_para_eq import probe_advanced_eq_slots # noqa: E402, F401
from .advanced_para_eq import probe_all_presets as probe_advanced_eq_presets # noqa: E402, F401
from .onboard_eq import _build_set_eq_payload # noqa: E402, F401
from .onboard_eq import get_onboard_eq_info # noqa: E402, F401
from .onboard_eq import get_onboard_eq_params # noqa: E402, F401

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

@ -15,6 +15,7 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import dataclasses
import logging
import queue
import threading
@ -52,9 +53,11 @@ class _ThreadedHandle:
else:
# if logger.isEnabledFor(logging.DEBUG):
# logger.debug("%r opened new handle %d", self, handle)
# If original handle was centurion, register new per-thread handle too
if any(h in base._centurion_handles for h in self._handles):
base._centurion_handles.add(handle)
# If original handle was centurion, copy state to new per-thread handle
for h in self._handles:
if h in base._centurion_handles:
base._centurion_handles[handle] = dataclasses.replace(base._centurion_handles[h])
break
self._local.handle = handle
self._handles.append(handle)
return handle

View File

@ -0,0 +1,300 @@
## Copyright (C) 2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""LogiVoice (0x0900 + 0x0901-0x0907) read helpers.
Each LogiVoice processing module exposes the same 5-function API:
fn 0 SetState
fn 1 GetState -> u8 state (boolean)
fn 2 SetParameters
fn 3 GetParameters -> module-specific payload (see PARAMETERS_FIELDS)
fn 4 GetInfo -> per-field [min, max] bounds (see parse_info)
All multi-byte integers on the wire are big-endian. Parameters layouts are
module-specific; PARAMETERS_FIELDS encodes per-field offset / width /
signedness / range / label metadata. The first field is at offset 0 there
is no leading "state" byte (the state toggle is on fn 0/1 only).
Writes are NOT implemented yet. State toggles via fn 0x00/0x10 are
shipping as boolean settings; per-field Parameters writes need a live
round-trip verification before they're safe to expose.
"""
from __future__ import annotations
import logging
import struct
from typing import Iterable
from .hidpp20_constants import SupportedFeature
logger = logging.getLogger(__name__)
# Wire function IDs (standard across all LogiVoice modules).
FN_SET_STATE = 0x00
FN_GET_STATE = 0x10
FN_SET_PARAMETERS = 0x20
FN_GET_PARAMETERS = 0x30
FN_GET_INFO = 0x40
# Human-readable names for the modules Solaar may see on a LogiVoice device.
MODULE_NAMES = {
SupportedFeature.LOGIVOICE: "LogiVoice",
SupportedFeature.LOGIVOICE_NOISE_REDUCTION: "Noise Reduction",
SupportedFeature.LOGIVOICE_NOISE_GATE: "Noise Gate",
SupportedFeature.LOGIVOICE_COMPRESSOR: "Compressor",
SupportedFeature.LOGIVOICE_DE_ESSER: "De-esser",
SupportedFeature.LOGIVOICE_DE_POPPER: "De-popper",
SupportedFeature.LOGIVOICE_LIMITER: "Limiter",
SupportedFeature.LOGIVOICE_HIGH_PASS_FILTER: "High Pass Filter",
}
# Short slugs used in Solaar setting IDs (`logivoice-<slug>-<field>`).
MODULE_SLUGS = {
SupportedFeature.LOGIVOICE_NOISE_REDUCTION: "nr",
SupportedFeature.LOGIVOICE_NOISE_GATE: "ng",
SupportedFeature.LOGIVOICE_COMPRESSOR: "comp",
SupportedFeature.LOGIVOICE_DE_ESSER: "deesser",
SupportedFeature.LOGIVOICE_DE_POPPER: "depopper",
SupportedFeature.LOGIVOICE_LIMITER: "limiter",
SupportedFeature.LOGIVOICE_HIGH_PASS_FILTER: "hpf",
}
class Field:
"""Metadata for one decoded Parameters field.
offset: byte offset within the GetParameters payload.
byte_count: width (1 or 2 for fields we currently decode).
signed: whether to interpret as signed int.
min_value/max_value: range for the Solaar slider validator. For opaque
fields, use the full representable range (0..255 or 0..65535).
label: human-readable name for UI.
opaque: True if the field's wire encoding isn't pinned down label
shows raw units and the caller should treat as round-trip.
"""
def __init__(self, name, offset, byte_count, signed, min_value, max_value, label, opaque=False):
self.name = name
self.offset = offset
self.byte_count = byte_count
self.signed = signed
self.min_value = min_value
self.max_value = max_value
self.label = label
self.opaque = opaque
# Per-module field layout for GetParameters / SetParameters payload. Each
# module's struct is the union of named fields below; there is no separate
# "state" byte at offset 0 — that toggle is only on fn 0x00/0x10. Field
# encodings (signedness, byte order, units) and value ranges come from the
# device's GetInfo response (see parse_info) and are confirmed against
# captured bring-up bytes; ranges hardcoded here are the bounds the device
# reports and the values it ships as factory defaults.
#
# `opaque=True` is reserved for fields whose unit scale isn't pinned down
# (currently width_q on De-esser / De-popper — the host-side scale constant
# is loaded at runtime and not statically resolvable). Treat opaque values
# as monotonic raw integers until a live probe anchors the units.
PARAMETERS_FIELDS: dict[SupportedFeature, list[Field]] = {
SupportedFeature.LOGIVOICE_NOISE_REDUCTION: [
Field("sensitivity", 0, 1, False, 0, 40, "Sensitivity"),
Field("release", 1, 2, False, 1, 1000, "Release (ms)"),
Field("bias", 3, 1, False, 0, 5, "Bias"),
Field("attenuation", 4, 1, True, -20, 0, "Attenuation (dB)"),
],
SupportedFeature.LOGIVOICE_NOISE_GATE: [
Field("threshold", 0, 1, True, -60, -35, "Threshold (dB)"),
Field("attenuation", 1, 1, True, -50, -3, "Attenuation (dB)"),
Field("attack", 2, 2, False, 1, 200, "Attack (ms)"),
Field("hold", 4, 2, False, 1, 1000, "Hold (ms)"),
Field("release", 6, 2, False, 1, 1000, "Release (ms)"),
],
SupportedFeature.LOGIVOICE_COMPRESSOR: [
Field("threshold", 0, 1, True, -40, 0, "Threshold (dB)"),
Field("attack", 1, 2, False, 1, 200, "Attack (ms)"),
Field("release", 3, 2, False, 50, 1000, "Release (ms)"),
Field("post_gain", 5, 1, True, -12, 12, "Post Gain (dB)"),
Field("pre_gain", 6, 1, True, -12, 12, "Pre Gain (dB)"),
# Ratio reports min=1 max=20 from GetInfo; whether the device interprets
# it as a literal X:1 ratio or a curve-table index is unconfirmed.
Field("ratio", 7, 1, False, 1, 20, "Ratio"),
],
SupportedFeature.LOGIVOICE_DE_ESSER: [
Field("threshold", 0, 1, True, -50, 0, "Threshold (dB)"),
Field("frequency", 1, 2, False, 1000, 10000, "Frequency (Hz)"),
# width_q is a Q-format quantization with a device-loaded scale we
# don't know; range/default come straight from GetInfo.
Field("width_q", 3, 1, False, 2, 120, "Width/Q", opaque=True),
Field("attack", 4, 2, False, 1, 200, "Attack (ms)"),
Field("release", 6, 2, False, 20, 1000, "Release (ms)"),
Field("attenuation", 8, 1, True, -40, 0, "Attenuation (dB)"),
],
SupportedFeature.LOGIVOICE_DE_POPPER: [
Field("threshold", 0, 1, True, -50, 0, "Threshold (dB)"),
Field("frequency", 1, 2, False, 60, 500, "Frequency (Hz)"),
Field("width_q", 3, 1, False, 2, 120, "Width/Q", opaque=True),
Field("attack", 4, 2, False, 1, 200, "Attack (ms)"),
Field("release", 6, 2, False, 20, 1000, "Release (ms)"),
Field("attenuation", 8, 1, True, -40, 0, "Attenuation (dB)"),
],
SupportedFeature.LOGIVOICE_LIMITER: [
Field("boost", 0, 1, True, -128, 127, "Boost (dB)"),
Field("attack", 1, 2, False, 1, 65535, "Attack (ms)"),
Field("release", 3, 2, False, 1, 65535, "Release (ms)"),
],
SupportedFeature.LOGIVOICE_HIGH_PASS_FILTER: [
Field("frequency", 0, 2, False, 60, 300, "Cutoff (Hz)"),
],
}
def expected_payload_length(feature: SupportedFeature) -> int:
fields = PARAMETERS_FIELDS.get(feature)
if not fields:
return 0
return max(f.offset + f.byte_count for f in fields)
def get_state(device, feature: SupportedFeature):
"""Read the module's on/off state via fn 1. Returns int 0-255 or None."""
result = device.feature_request(feature, FN_GET_STATE)
if result is None or len(result) < 1:
return None
return result[0]
def get_parameters(device, feature: SupportedFeature):
"""Read the module's Parameters struct via fn 3. Returns raw bytes or None."""
result = device.feature_request(feature, FN_GET_PARAMETERS)
if result is None:
return None
return bytes(result)
def get_info(device, feature: SupportedFeature):
"""Read module capability info via fn 4. Returns raw bytes or None.
Decoded per-field bounds are available via parse_info().
"""
result = device.feature_request(feature, FN_GET_INFO)
if result is None:
return None
return bytes(result)
def _decode_field(chunk: bytes, byte_count: int, signed: bool) -> int:
"""Decode `byte_count` bytes from `chunk` as an integer per the field's wire
encoding. Multi-byte values are big-endian (matches Parameters)."""
if byte_count == 1:
return struct.unpack("b" if signed else "B", chunk[:1])[0]
if byte_count == 2:
return struct.unpack(">h" if signed else ">H", chunk[:2])[0]
return int.from_bytes(chunk[:byte_count], "big", signed=signed)
def parse_info(feature: SupportedFeature, payload: bytes) -> dict:
"""Decode a GetInfo response into per-field {min, max} bounds.
Layout: for each field in PARAMETERS_FIELDS in order, the payload carries
[min_value, max_value] back-to-back using the field's wire encoding (so
a u16 field contributes 4 bytes 2 for min, 2 for max). Trailing bytes
in the response are pad/zero.
Returns a dict mapping field name to {"min": int, "max": int}. Fields
that don't fit in the payload are omitted.
"""
fields = PARAMETERS_FIELDS.get(feature)
if not fields or not payload:
return {}
out = {}
offset = 0
for f in fields:
end = offset + 2 * f.byte_count
if end > len(payload):
break
min_val = _decode_field(payload[offset : offset + f.byte_count], f.byte_count, f.signed)
max_val = _decode_field(payload[offset + f.byte_count : end], f.byte_count, f.signed)
out[f.name] = {"min": min_val, "max": max_val}
offset = end
return out
def parse_parameters(feature: SupportedFeature, payload: bytes) -> dict:
"""Decode Parameters bytes into a dict per the per-module field table.
Returns {} on unknown feature or short payload caller still has the raw
hex via get_parameters() for corpus logging.
"""
fields = PARAMETERS_FIELDS.get(feature)
if not fields or payload is None:
return {}
parsed = {}
for f in fields:
end = f.offset + f.byte_count
if end > len(payload):
continue
chunk = payload[f.offset : end]
if f.byte_count == 1:
val = struct.unpack("b" if f.signed else "B", chunk)[0]
elif f.byte_count == 2:
val = struct.unpack(">h" if f.signed else ">H", chunk)[0]
else:
val = int.from_bytes(chunk, "big", signed=f.signed)
parsed[f.name] = val
return parsed
def probe_module(device, feature: SupportedFeature) -> None:
"""One-shot corpus probe. Logs state + raw parameters + parsed + raw info
+ decoded info bounds."""
name = MODULE_NAMES.get(feature, f"0x{int(feature):04X}")
state = get_state(device, feature)
params = get_parameters(device, feature)
info = get_info(device, feature)
logger.debug(
"LogiVoice %s [0x%04X]: state=%s parameters=%s info=%s",
name,
int(feature),
state,
params.hex() if params else None,
info.hex() if info else None,
)
parsed = parse_parameters(feature, params) if params else {}
if parsed:
logger.debug("LogiVoice %s parsed: %s", name, parsed)
bounds = parse_info(feature, info) if info else {}
if bounds:
logger.debug("LogiVoice %s info bounds: %s", name, bounds)
def probe_all_modules(device, features: Iterable[SupportedFeature]) -> None:
"""Probe every LogiVoice module present on the device.
Call once at device-bring-up so the -dd corpus has a full snapshot.
Caller passes whichever subset of LogiVoice features are actually
discovered (usually derived from device.features).
"""
for feature in features:
if feature not in PARAMETERS_FIELDS and feature != SupportedFeature.LOGIVOICE:
continue
try:
probe_module(device, feature)
except Exception as e:
logger.debug("LogiVoice probe_module(%s) raised %s", feature, e)

View File

@ -436,6 +436,47 @@ def _process_feature_notification(device: Device, notification: HIDPPNotificatio
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: RGB_EFFECTS notification addr=%02x: %s", device, notification.address, notification)
elif feature == SupportedFeature.HEADSET_ADVANCED_PARA_EQ:
# G522 emits change events with the same payload shape as the
# corresponding setter request:
# fn 0 — band change (3-byte header [dir, slot, pad] + bands)
# fn 2 — friendly-name change (header + nameLen + name)
# fn 3 — UUID change (header + 16-byte UUID)
# Low nibble of `address` is the swid the firmware echoes back —
# match on the function index only.
fn = notification.address >> 4
if fn == 0:
info = getattr(device, "_advanced_eq_info", None)
payload = notification.data[3:] if notification.data else b""
if info and len(payload) >= 5:
bands = hidpp20.parse_v2_bands(b"\x00" + payload, info)
if bands and device.setting_callback:
band_map = {i: int(round(g)) for i, (_t, _f, g) in enumerate(bands)}
device.setting_callback(device, settings_templates.HeadsetAdvancedEQ, [band_map])
elif logger.isEnabledFor(logging.DEBUG):
logger.debug(
"%s: HEADSET_ADVANCED_PARA_EQ band-change event with no parseable payload %s", device, notification
)
elif fn in (2, 3) and logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: HEADSET_ADVANCED_PARA_EQ fn=%d change event %s", device, fn, notification)
elif logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: unknown HEADSET_ADVANCED_PARA_EQ %s", device, notification)
elif feature == SupportedFeature.HEADSET_MIC_MUTE:
# G522 emits state-change events on two function indices, both carrying
# the new state in data[0] (0 = unmuted, 1 = muted):
# fn 0 — physical mute switch press
# fn 1 — echo following a host-driven SetState (fn 2) write
# Low nibble of `address` is the swid the firmware echoes back, which
# varies with the request — match on the function index only.
fn = notification.address >> 4
if fn in (0, 1) and notification.data:
muted = bool(notification.data[0])
if device.setting_callback:
device.setting_callback(device, settings_templates.HeadsetMicMute, [muted])
elif logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: unknown HEADSET_MIC_MUTE %s", device, notification)
diversion.process_notification(device, notification, feature)
return True

View File

@ -27,11 +27,9 @@ import struct
from .hidpp20_constants import SupportedFeature
# Mystery bytes observed in every LGHUB pcap EQ write between band params
# and coefficient header. Purpose not fully understood — possibly a null-band
# terminator for DSPs that support >5 bands (advanced 10-band mode).
# First byte matches band_count; bytes 2-3 look like LE16 coeff blob size.
# Hardcoded from pcap for initial bring-up; revisit once device-tested.
# Opaque bytes observed between band params and coefficient header. First
# byte matches band_count; bytes 2-3 look like LE16 coeff blob size. Keep
# verbatim until a device counter-example forces a re-derivation.
_EQ_MYSTERY_BYTES = b"\x05\x5a\xe3\x00"
@ -85,7 +83,7 @@ def _build_coeff_section(bands, sample_rate, section_type=1):
coefficients (a1, a2) are left unchanged. The DSP multiplies the output by
rescale to restore correct gain.
"""
_HEADROOM = 1.19 # 19% headroom margin (matches LGHUB)
_HEADROOM = 1.19 # 19% headroom margin before quantization
num_bands = len(bands)
all_words = [num_bands] # first uint16 = num_bands

View File

@ -0,0 +1,242 @@
"""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 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
import logging
from . import exceptions
from .hidpp20_constants import SupportedFeature
logger = logging.getLogger(__name__)
def _hex_or_none(data) -> str | None:
return data.hex() if data else None
def _format_feature(feat) -> str:
"""Render a feature for the log: 0x{id:04X}{:NAME} when known, else raw.
Unknown features are stored as the string "unknown:HHHH" by the feature
discovery code, so handle that shape explicitly int(feat) on those
raises ValueError. Wrap the rest in a broad except so a future unhandled
feature shape can't kill the whole table dump.
"""
if feat is None:
return "?"
if isinstance(feat, str):
if feat.startswith("unknown:") and len(feat) > 8:
return f"0x{feat[8:].upper()}"
return feat
try:
return f"0x{int(feat):04X}:{feat.name}"
except (AttributeError, TypeError, ValueError):
try:
return f"0x{int(feat):04X}"
except (TypeError, ValueError):
return repr(feat)
def _log_feature_table(device) -> None:
if not device.features:
return
try:
# Parent features live in FeaturesArray.inverse, indexed by their
# parent feature-set position. On Centurion devices these are the
# ones the dongle itself exposes (typically 5-6 entries).
parent = []
for idx in range(len(device.features)):
parent.append(f"{idx}:{_format_feature(device.features[idx])}")
logger.debug("RGB probe: parent features for %s: %s", device, ", ".join(parent))
# Centurion sub-device features live in FeaturesArray.sub_inverse,
# keyed by sub-device feature index. These are where the actual
# headset features (0x0620/0x0621/0x0622, LogiVoice, EQ, mic mute,
# …) live — without dumping them the log shows only the dongle's
# parent features and gives the wrong impression that the device
# has nothing else.
sub_inverse = getattr(device.features, "sub_inverse", None)
if sub_inverse:
sub = [f"{idx}:{_format_feature(feat)}" for idx, feat in sorted(sub_inverse.items())]
logger.debug("RGB probe: sub-device features for %s: %s", device, ", ".join(sub))
except Exception as e:
logger.debug("RGB probe: feature-table dump failed: %s", e)
def _call(device, feature: SupportedFeature, fn: int, *params):
"""Wrap feature_request with uniform INFO logging.
Returns the raw bytes on success, None on transport/no-feature, and
doesn't raise — FeatureCallError is caught and logged as an error code.
"""
label = f"0x{int(feature):04X}.fn{fn:02X}"
if params:
label += "(" + ",".join(f"{b:02X}" for b in params) + ")"
try:
resp = device.feature_request(feature, fn, *params)
except exceptions.FeatureCallError as e:
logger.debug("RGB probe: %s err=0x%02X", label, getattr(e, "error", 0) & 0xFF)
return None
except Exception as e:
logger.debug("RGB probe: %s raised %r", label, e)
return None
if resp is None:
logger.debug("RGB probe: %s no reply (feature unsupported or transport failure)", label)
return None
logger.debug("RGB probe: %s resp=%s len=%d", label, _hex_or_none(resp), len(resp))
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
if not device.features or feature not in device.features:
return
logger.debug("RGB probe: 0x0621 HEADSET_RGB_ONBOARD_EFFECTS present on %s", device)
# fn 0x00 getInfo — empty payload
_call(device, feature, 0x00)
# fn 0x10 getRGBClusterInfo — iterate cluster indexes 0..7, stop on error.
for cluster_idx in range(8):
resp = _call(device, feature, 0x10, cluster_idx)
if resp is None:
break
decoded = _decode_cluster_info(resp)
if decoded:
logger.debug("RGB probe: 0x0621.fn10(%02X) decoded: %s", cluster_idx, decoded)
# fn 0x20 getRGBClusterEffect — current state per cluster.
for cluster_idx in range(8):
resp = _call(device, feature, 0x20, cluster_idx)
if resp is None:
break
# fn 0x40 getRGBCustomEffectName — single call, documented.
_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.debug("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
if not device.features or feature not in device.features:
return
logger.debug("RGB probe: 0x0622 HEADSET_RGB_SIGNATURE_EFFECTS present on %s", device)
# fn 0x00 getSignatureEffectsInfo.
_call(device, feature, 0x00)
# fn 0x10 getSignatureEffectParams — iterate effectId 0..2 (Startup/Shutdown/Passive).
# effectId is u16 BE.
for eid in range(3):
_call(device, feature, 0x10, (eid >> 8) & 0xFF, eid & 0xFF)
# fn 0x30 getSignatureEffectState — same effectId range.
for eid in range(3):
_call(device, feature, 0x30, (eid >> 8) & 0xFF, eid & 0xFF)
def probe(device) -> None:
"""Run both read-only RGB-effects probes once per device.
Gated via ``_rgb_effects_probed`` so re-entry on reconnect / setting
rebuild doesn't spam the log with duplicate corpus dumps.
"""
if getattr(device, "_rgb_effects_probed", False):
return
device._rgb_effects_probed = True
_log_feature_table(device)
try:
probe_onboard_effects(device)
except Exception as e:
logger.debug("RGB probe: onboard-effects probe raised %r", e)
try:
probe_signature_effects(device)
except Exception as e:
logger.debug("RGB probe: signature-effects probe raised %r", e)
try:
probe_unknown_0623(device)
except Exception as e:
logger.debug("RGB probe: 0x0623 probe raised %r", e)

View File

@ -181,8 +181,16 @@ class Setting:
logger.debug("%s: prepare write(%s) => %r", self.name, value, data_bytes)
reply = self._rw.write(self._device, data_bytes)
if not reply:
# tell whomever is calling that the write failed
# HID++ 2.0 "set" operations often return an empty ACK (b"").
# Treating empty bytes as failure (`not reply`) would misreport
# successful writes as errors to the GUI. Only report failure
# when the transport actually returned None (error or timeout).
if reply is None:
logger.info(
"%s: write on %s returned no reply (transport error/timeout)",
self.name,
self._device,
)
return None
return value

View File

@ -37,9 +37,12 @@ from . import desktop_notifications
from . import device_quirks
from . import diversion
from . import exceptions
from . import headset_rgb
from . import hidpp10_constants
from . import hidpp20
from . import hidpp20_constants
from . import logivoice
from . import rgb_effects_probe
from . import rgb_power
from . import settings
from . import settings_new
@ -1633,6 +1636,17 @@ class HeadsetEcoMode(settings.Setting):
feature = _F.HEADSET_BATTERY_SAVER
validator_class = settings_validator.BooleanValidator
@classmethod
def build(cls, device):
# G522 firmware rejects no-op writes with device-specific NACK 0x0B.
# BooleanValidator.prepare_write already skips writes that match the
# current value when needs_current_value=True; default-mask (0xFF)
# BooleanValidators get needs_current_value=False, so flip it here.
rw = settings.FeatureRW(cls.feature)
validator = settings_validator.BooleanValidator()
validator.needs_current_value = True
return cls(device, rw, validator)
class HeadsetDoNotDisturb(settings.Setting):
name = "headset-do-not-disturb"
@ -1648,6 +1662,18 @@ class HeadsetMicMute(settings.Setting):
description = _("Mute the microphone.")
feature = _F.HEADSET_MIC_MUTE
validator_class = settings_validator.BooleanValidator
# HEADSET_MIC_MUTE (0x0601) doesn't follow the typical fn 0 GetState /
# fn 1 SetState pattern that BooleanValidator defaults to. Function
# layout (confirmed via G HUB pcap on G522):
# fn 0 — physical-mute-switch state-change events from the device
# fn 1 — state-change events emitted as the device's echo of a
# host-driven SetState; also serves as the host-callable
# GetState read
# fn 2 — host-callable SetState (single byte: 0=unmuted, 1=muted)
# The standard fn 0/1 write path returns 0x0A UNSUPPORTED. State-change
# events from both fn 0 and fn 1 are handled by _process_feature_notification
# so the toggle reflects physical mute presses too.
rw_options = {"read_fnid": 0x10, "write_fnid": 0x20}
class HeadsetMicSNR(settings.Setting):
@ -1708,10 +1734,51 @@ class HeadsetMicGain(settings.Setting):
feature = _F.HEADSET_MIC_GAIN
rw_options = {"read_fnid": 0x10, "write_fnid": 0x20}
validator_class = settings_validator.RangeValidator
# Fallback range covers int8; build() overrides with device-reported bounds
# from GetInfo (fn 0) so SetMicGain doesn't get device-specific
# out-of-range NACK (error 0x0B) on devices that use a small signed range
# (e.g. G522 reports a narrow window like -12..+12).
min_value = -128
max_value = 127
validator_options = {"byte_count": 1, "signed": True}
@classmethod
def build(cls, device):
# GetInfo (function 0) returns [min_gain (int8), max_gain (int8)].
# Query once at build time so the slider range reflects the device's
# actual supported range rather than a generic int8 window.
try:
info = device.feature_request(cls.feature, 0x00)
except Exception as e:
logger.debug("HeadsetMicGain: GetInfo raised %s, using fallback int8 range", e)
info = None
if info and len(info) >= 2:
min_gain = struct.unpack("b", bytes([info[0]]))[0]
max_gain = struct.unpack("b", bytes([info[1]]))[0]
if max_gain <= min_gain: # sanity — fall back to class defaults
logger.debug(
"HeadsetMicGain: GetInfo returned nonsense range [%d, %d] (hex=%s), using fallback int8 range",
min_gain,
max_gain,
info.hex(),
)
min_gain, max_gain = cls.min_value, cls.max_value
elif logger.isEnabledFor(logging.DEBUG):
logger.debug(
"HeadsetMicGain: device reports gain range [%d, %d]",
min_gain,
max_gain,
)
else:
logger.debug(
"HeadsetMicGain: GetInfo returned %s, using fallback int8 range",
info.hex() if info else info,
)
min_gain, max_gain = cls.min_value, cls.max_value
rw = settings.FeatureRW(cls.feature, **cls.rw_options)
validator = settings_validator.RangeValidator(min_value=min_gain, max_value=max_gain, byte_count=1, signed=True)
return cls(device, rw, validator)
class HeadsetMixBalance(settings.Setting):
name = "headset-mix-balance"
@ -1724,17 +1791,71 @@ class HeadsetMixBalance(settings.Setting):
validator_options = {"byte_count": 1}
class _AutoSleepRangeValidator(settings_validator.RangeValidator):
"""Single-slot read-modify-write validator for HID++ 0x0108 AutoSleep.
0x0108 is not a single timer: V3 has two uint8 bytes, V4+ has three. Each
byte is an independent timer slot. Solaar exposes only the user-facing slot
today and preserves the others via RMW; writing zero into the other slots
causes the firmware to reject the request.
Wire byte layout per feature version:
V<3: [timer]
V3: [reserved, timer] preserve byte[0]
V4+: [timer_a, timer_b, timer_c] preserve byte[1], byte[2]
"""
def __init__(self, byte_count, **kwargs):
super().__init__(byte_count=byte_count, **kwargs)
# V3 sources the user-controllable timer from byte[1] per LGHUB.
self._slot = 1 if byte_count == 2 else 0
def validate_read(self, reply_bytes):
if len(reply_bytes) <= self._slot:
raise AssertionError(
f"{self.__class__.__name__}: read returned {len(reply_bytes)} bytes, expected ≥ {self._slot + 1}"
)
return reply_bytes[self._slot]
def prepare_write(self, new_value, current_value=None):
if new_value < self.min_value or new_value > self.max_value:
raise ValueError(f"invalid choice {new_value!r}")
if current_value is None:
payload = bytearray(self._byte_count)
else:
payload = bytearray(current_value[: self._byte_count])
if len(payload) < self._byte_count:
payload.extend(b"\x00" * (self._byte_count - len(payload)))
if payload[self._slot] == new_value:
return None
payload[self._slot] = new_value
return bytes(payload)
class HeadsetAutoSleep(settings.Setting):
name = "headset-auto-sleep"
label = _("Auto Sleep Timeout")
description = _("Idle time in minutes before the headset enters sleep mode (0 = disabled).")
feature = _F.CENTURION_AUTO_SLEEP
rw_options = {"read_fnid": 0x00, "write_fnid": 0x10}
validator_class = settings_validator.RangeValidator
validator_class = _AutoSleepRangeValidator
min_value = 0
max_value = 255
max_value = 255 # uint8 slot
validator_options = {"byte_count": 1}
@classmethod
def build(cls, device):
version = device.features.get_feature_version(cls.feature) or 0
if version >= 4:
byte_count = 3
elif version >= 3:
byte_count = 2
else:
byte_count = 1
rw = settings.FeatureRW(cls.feature, **cls.rw_options)
validator = _AutoSleepRangeValidator(min_value=0, max_value=cls.max_value, byte_count=byte_count)
return cls(device, rw, validator)
class HeadsetOnboardEQ(settings.RangeFieldSetting):
name = "headset-onboard-eq"
@ -1751,10 +1872,19 @@ class HeadsetOnboardEQ(settings.RangeFieldSetting):
def build(cls, setting_class, device):
info = hidpp20.get_onboard_eq_info(device)
if not info:
logger.debug("HeadsetOnboardEQ.build: getEQInfo failed, no panel will be built")
return None
_has_hw_eq, num_bands = info
bands = hidpp20.get_onboard_eq_params(device, slot=0x00)
if not bands or len(bands) != num_bands:
if not bands:
logger.debug("HeadsetOnboardEQ.build: getEQParameters returned no bands, no panel will be built")
return None
if len(bands) != num_bands:
logger.debug(
"HeadsetOnboardEQ.build: band count mismatch — EQInfo=%d getEQParameters=%d; skipping",
num_bands,
len(bands),
)
return None
keys = common.NamedInts()
for i, (freq, _gain, _q) in enumerate(bands):
@ -1762,6 +1892,7 @@ class HeadsetOnboardEQ(settings.RangeFieldSetting):
v = cls(keys, min_value=-12, max_value=12, count=num_bands, byte_count=1)
v._band_freqs = [freq for freq, _g, _q in bands]
v._band_qs = [q for _f, _g, q in bands]
logger.debug("HeadsetOnboardEQ.build: panel built with %d band(s)", num_bands)
return v
def validate_read(self, reply_bytes):
@ -1810,6 +1941,728 @@ class HeadsetOnboardEQ(settings.RangeFieldSetting):
return result
class HeadsetAdvancedEQ(settings.RangeFieldSetting):
"""Per-band gain editor for the headset's active AdvancedParaEQ (0x020D) slot.
V2 wire format (pcap-verified against G522 LIGHTSPEED):
getCustomEQ response: [dir_echo] + N × [freq_hi, freq_lo, filter, gain_hi, gain_lo]
setCustomEQ request: [dir, slot, pad=0] + N × [freq_hi, freq_lo, filter, gain_hi, gain_lo]
Gain is offset-binary against gain_min..gain_max with `gain_steps`
discrete positions (raw=120 0 dB on G522's [-6, +6] / 241-step
grid). Frequency and filter type are read at build time and not
user-editable today UI only exposes per-band gain.
"""
name = "headset-advanced-eq"
label = _("Headset Advanced EQ")
description = _("Per-band gain for the headset's active parametric EQ.")
feature = _F.HEADSET_ADVANCED_PARA_EQ
rw_options = {"read_fnid": 0x10, "write_fnid": 0x20}
keys_universe = []
class rw_class(settings.FeatureRW):
"""get/setCustomEQ both take [direction, slot]; on writes the
device additionally expects a single 0x00 padding byte before
the band payload. The slot is the *active* EQ preset, which the
device may have switched while we weren't looking — re-query it
on every read and write instead of caching at build time.
Direction is hardcoded to 0 (playback); mic-side EQ isn't
exposed yet.
"""
def read(self, device, data_bytes=b""):
active_slot = hidpp20.get_advanced_eq_active_slot(device, direction=0)
self.read_prefix = bytes([0, active_slot if active_slot is not None else 0])
return super().read(device, data_bytes)
def write(self, device, data_bytes):
active_slot = hidpp20.get_advanced_eq_active_slot(device, direction=0)
slot = active_slot if active_slot is not None else 0
write_bytes = bytes([0, slot, 0]) + data_bytes
return device.feature_request(self.feature, self.write_fnid, write_bytes)
class validator_class(settings_validator.PackedRangeValidator):
kind = settings.Kind.GRAPHIC_EQ
@classmethod
def build(cls, setting_class, device):
info = hidpp20.get_advanced_eq_info(device)
if not info:
logger.debug("HeadsetAdvancedEQ.build: getEQInfos failed, no panel will be built")
return None
device._advanced_eq_info = info
version = info["version"]
gain_min = info["gain_min_db"]
gain_max = info["gain_max_db"]
step_db = info["step_db"]
active_slot = hidpp20.get_advanced_eq_active_slot(device, direction=0) or 0
bands = hidpp20.get_advanced_eq_params(device, direction=0, slot=active_slot)
if not bands:
logger.debug("HeadsetAdvancedEQ.build: getCustomEQ returned no bands, no panel will be built")
return None
band_count = len(bands)
expected = info.get("band_count")
if expected is not None and expected != band_count:
logger.debug(
"HeadsetAdvancedEQ.build: V%d band count mismatch — EQInfos=%d getCustomEQ=%d; trusting getCustomEQ",
version,
expected,
band_count,
)
keys = common.NamedInts()
for i, (filter_type, freq_hz, _gain_db) in enumerate(bands):
if filter_type == hidpp20.FILTER_TYPE_HP:
keys[i] = "HP " + str(freq_hz) + _("Hz")
else:
keys[i] = str(freq_hz) + _("Hz")
v = cls(
keys,
min_value=int(round(gain_min)),
max_value=int(round(gain_max)),
count=band_count,
byte_count=1,
)
v._version = version
v._step_db = step_db
v._gain_min = gain_min
v._gain_max = gain_max
v._gain_steps = info.get("gain_steps", 241)
v._band_types = [band[0] for band in bands]
v._band_freqs = [band[1] for band in bands]
v._active_slot = active_slot
logger.debug(
"HeadsetAdvancedEQ.build: panel built V%d with %d band(s), slot=%d, range=[%d,%d], step_db=%.4f",
version,
band_count,
active_slot,
gain_min,
gain_max,
step_db,
)
# One-shot per-slot probe — logs band data for each slot the
# firmware actually honors and caches the working-slot list on
# `device._advanced_eq_working_slots`. Cheap if HeadsetActiveEQPreset
# already populated the cache (it usually has at this point);
# otherwise this is the first-time probe.
if version >= 2:
try:
hidpp20.probe_advanced_eq_slots(device, direction=0, info=info)
except Exception as e:
logger.debug("HeadsetAdvancedEQ.build: preset corpus probe failed: %s", e)
return v
def validate_read(self, reply_bytes):
if reply_bytes is None:
return {}
version = getattr(self, "_version", 0)
if version >= 2:
info = {
"gain_min_db": getattr(self, "_gain_min", -6),
"gain_max_db": getattr(self, "_gain_max", 6),
"gain_steps": getattr(self, "_gain_steps", 241),
"step_db": getattr(self, "_step_db", 0.05),
}
bands = hidpp20.parse_v2_bands(reply_bytes, info)
if bands is None:
return {}
result = {}
for i, (filter_type, freq_hz, gain_db) in enumerate(bands):
if i >= self.count:
break
result[i] = int(round(gain_db))
if hasattr(self, "_band_types") and i < len(self._band_types):
self._band_types[i] = filter_type
if hasattr(self, "_band_freqs") and i < len(self._band_freqs):
self._band_freqs[i] = freq_hz
return result
# V0/V1: 3-byte stride.
result = {}
offset = 0
i = 0
while offset + 3 <= len(reply_bytes) and i < self.count:
freq = struct.unpack(">H", reply_bytes[offset : offset + 2])[0]
if freq == 0:
break
gain = struct.unpack("b", bytes([reply_bytes[offset + 2]]))[0]
result[i] = gain
if hasattr(self, "_band_freqs") and i < len(self._band_freqs):
self._band_freqs[i] = freq
offset += 3
i += 1
return result
def prepare_write(self, new_values):
"""Encode N × [freq_hi, freq_lo, filter, gain_hi, gain_lo].
new_values is {band_idx: int_gain_dB}. freq and filter type
come from the cache captured at build time; gain is mapped
from integer dB back to the device's offset-binary raw u16
against the [_gain_min, _gain_max] / _gain_steps grid.
"""
version = getattr(self, "_version", 0)
if version < 2:
return None
gain_min = getattr(self, "_gain_min", -6)
gain_max = getattr(self, "_gain_max", 6)
steps = getattr(self, "_gain_steps", 241)
freqs = getattr(self, "_band_freqs", None) or []
types = getattr(self, "_band_types", None) or []
if not freqs or not types:
return None
span = gain_max - gain_min
payload = bytearray()
for i in range(self.count):
freq = freqs[i] if i < len(freqs) else 0
filt = types[i] if i < len(types) else hidpp20.FILTER_TYPE_PEAKING_G522
gain_db = new_values.get(i, 0)
if steps > 1 and span > 0:
raw = int(round((gain_db - gain_min) / span * (steps - 1)))
else:
raw = 0
raw = max(0, min(steps - 1, raw))
payload += bytes([(freq >> 8) & 0xFF, freq & 0xFF, filt & 0xFF, (raw >> 8) & 0xFF, raw & 0xFF])
return bytes(payload)
def write(self, map, save=True):
# RangeFieldSetting.write treats an empty-bytes reply (`not reply`)
# as failure, but setCustomEQ returns an empty ACK on success.
# Override to treat only `reply is None` (transport error/timeout)
# as failure.
assert hasattr(self, "_value")
assert hasattr(self, "_device")
assert map is not None
if self._device.online:
self.update(map, save)
data_bytes = self._validator.prepare_write(self._value)
if data_bytes is not None:
reply = self._rw.write(self._device, data_bytes)
if reply is None:
return None
return map
class HeadsetActiveEQPreset(settings.Setting):
"""Choose which AdvancedParaEQ slot drives live audio.
Activation works for any slot read-only factory presets and
user-custom slots alike. The "(factory)" tag in the slot label
distinguishes the read-only ones; that distinction matters for
band-editing (not supported yet), not for activation today.
"""
name = "headset-eq-active-preset"
label = _("EQ Preset")
description = _("Switch the active EQ preset. Factory presets are read-only.")
feature = _F.HEADSET_ADVANCED_PARA_EQ
rw_options = {"read_fnid": 0x30, "write_fnid": 0x40, "prefix": b"\x00"}
validator_class = settings_validator.ChoicesValidator
@classmethod
def build(cls, device):
info = getattr(device, "_advanced_eq_info", None) or hidpp20.get_advanced_eq_info(device)
if not info:
return None
ro_count = info.get("onboard_ro_preset_count", 0) or 0
# Probe each advertised slot — getEQInfos may report capacity that
# the firmware doesn't actually back (G522 advertises 16 slots but
# only honors slot 0). Only include slots that responded with band
# data; the result is cached on device._advanced_eq_working_slots
# so HeadsetAdvancedEQ.build can reuse it without re-probing.
working = hidpp20.probe_advanced_eq_slots(device, direction=0, info=info)
if len(working) <= 1:
# One option (or zero) is meaningless as a selector — there's
# nothing for the user to choose between. The active EQ is
# whatever slot 0 has, no preset switching is available.
return None
choices = common.NamedInts()
for slot, slot_name, _bands in working:
if not slot_name:
slot_name = _("Slot") + " " + str(slot)
if slot < ro_count:
slot_name = slot_name + " " + _("(factory)")
choices[slot] = slot_name
rw = settings.FeatureRW(cls.feature, **cls.rw_options)
validator = settings_validator.ChoicesValidator(choices=choices)
return cls(device, rw, validator)
def write(self, value, save=True):
result = super().write(value, save)
if result is not None:
# After setActiveEQ, repopulate the AdvancedParaEQ band-display
# cache so the panel reflects the newly-active slot. Force a
# fresh read so _value is a real dict — leaving it as None
# would let a UI band-click hit `_value[item]` on None and
# crash (config_panel.py:589 'NoneType' is not subscriptable).
# The visible widget redraw still waits for a manual refresh /
# panel reopen — auto-redraw would need UI-side plumbing.
eq_panel = _headset_setting_by_name(self._device, HeadsetAdvancedEQ.name)
if eq_panel is not None:
try:
eq_panel._value = None
eq_panel.read(cached=False)
except Exception as e:
logger.debug("HeadsetActiveEQPreset: failed to refresh EQ panel: %s", e)
return result
_NO_CHANGE_COLOR = int(special_keys.COLORSPLUS["No change"])
def _headset_setting_by_name(device, name):
for s in getattr(device, "settings", None) or []:
if getattr(s, "name", None) == name:
return s
return None
def _headset_primary_color(device, default=0xFFFFFF):
"""Resolve the currently-saved Primary color, or `default` if absent."""
s = _headset_setting_by_name(device, HeadsetLEDsPrimary.name)
if s is None:
return default
value = getattr(s, "_value", None)
color = getattr(value, "color", None) if value is not None else None
return int(color) if color is not None else default
def _headset_per_zone_overrides(device):
"""Return `{zone_id: color_int}` for zones with explicit (non-'No change')
colors set via the Per-zone Lighting setting, or `None` if the setting
isn't built/present."""
s = _headset_setting_by_name(device, HeadsetPerZoneLighting.name)
if s is None:
return None
value = getattr(s, "_value", None)
if not isinstance(value, dict):
return None
overrides = {}
for zone, color in value.items():
try:
color_int = int(color)
except (TypeError, ValueError):
continue
if color_int != _NO_CHANGE_COLOR:
overrides[int(zone)] = color_int
return overrides
class _HeadsetStaticEffectOption:
"""Minimal stand-in for `hidpp20.LEDEffectInfo`.
`HeteroValidator` only inspects `.ID` and `.index` on its `options`
list; we don't need the full device-query machinery here because the
headset wire protocol is handled by `headset_rgb.write_zone_map`.
"""
ID = 0x01 # matches hidpp20.LEDEffects[0x01] = Static
index = 0x01
class HeadsetLEDControl(settings.Setting):
"""Switch headset LED control between device and Solaar.
Mirrors the `LEDControl` / `RGBControl` pattern used for keyboards and
mice. When set to Solaar, the `LEDs Primary` and `Per-zone Lighting`
settings drive the LEDs; when set to Device, firmware-driven onboard
and signature effects resume.
"""
name = "headset_led_control"
label = _("LED Control")
description = _("Switch control of LED zones between device and Solaar")
feature = _F.HEADSET_RGB_HOSTMODE
rw_options = {"read_fnid": 0x70, "write_fnid": 0x80}
choices_universe = common.NamedInts(Device=0, Solaar=1)
validator_class = settings_validator.ChoicesValidator
validator_options = {"choices": choices_universe}
@classmethod
def build(cls, device):
# One-shot read-only probe of 0x0621 / 0x0622 — logs the data the RE
# pass needs to pin down RGB onboard/signature effect structures.
# Skip cleanly if neither feature is exposed.
try:
rgb_effects_probe.probe(device)
except Exception as e:
logger.debug("RGB effects probe raised %r", e)
return super().build(device)
def write(self, value, save=True):
# After switching to Solaar control, the firmware drops whatever
# colors we'd programmed — so reassert the saved Primary + per-zone
# overrides immediately. Otherwise the LEDs stay on whatever
# device-driven effect was last shown until the user edits a color.
result = super().write(value, save)
if result is not None and int(value) == 1 and self._device.online:
primary = _headset_primary_color(self._device)
zones = headset_rgb.discover_zones(self._device)
if zones:
zone_map = {int(z): primary for z in zones}
zone_map.update(_headset_per_zone_overrides(self._device) or {})
headset_rgb.write_zone_map(self._device, zone_map)
return result
class HeadsetLEDsPrimary(settings.Setting):
"""Primary headset LED color, rendered as a GTK color picker.
Mirrors the `LEDZoneSetting` / `RGBEffectSetting` shape: a
`HeteroValidator` with a single "Static" effect whose only visible
field is the color. Write applies the chosen color across all zones
discovered at build time, then re-applies any per-zone overrides on
top so they aren't clobbered.
Read support is deliberately disabled the feature exposes no "get
current color" function, so we rely on the persister.
"""
name = "headset_leds_primary"
label = _("LEDs") + " " + _("Primary")
description = _(
"Set the primary color applied to every headset LED zone.\n" "LED Control needs to be set to Solaar to be effective."
)
feature = _F.HEADSET_RGB_HOSTMODE
persist = True
rw_options = {"read_fnid": None, "write_fnid": None}
# HeteroKeyControl renders exactly these fields; ID is hidden
# (`label=None`) but kept so setup_visibles can key off it.
color_field = {"name": hidpp20.LEDParam.color, "kind": settings.Kind.COLOR, "label": _("Color")}
possible_fields = [
{
"name": "ID",
"kind": settings.Kind.CHOICE,
"label": None,
"choices": [common.NamedInt(0x01, _("Static"))],
},
color_field,
]
# HeteroKeyControl.setup_visibles looks up fields_map[effect_id][1] to
# decide which fields to show — we only expose the color.
fields_map = {0x01: [common.NamedInt(0x01, _("Static")), {hidpp20.LEDParam.color: 0}]}
@classmethod
def build(cls, device):
zones = headset_rgb.discover_zones(device)
if not zones:
return None
rw = settings.FeatureRW(cls.feature)
validator = settings_validator.HeteroValidator(
data_class=hidpp20.LEDEffectSetting,
options=[_HeadsetStaticEffectOption()],
readable=False,
)
return cls(device, rw, validator)
def read(self, cached=True):
# Feature 0x0620 doesn't expose a "current primary color" read —
# pull from the persister via _pre_read, fall back to white so
# the picker opens on a sane starting color.
self._pre_read(cached)
if self._value is not None:
return self._value
self._value = hidpp20.LEDEffectSetting(ID=common.NamedInt(0x01, _("Static")), color=0xFFFFFF)
return self._value
def write(self, value, save=True):
color = getattr(value, "color", None)
if color is None:
return None
device = self._device
if not device.online:
return None
zones = headset_rgb.discover_zones(device)
if not zones:
return None
primary = int(color)
zone_map = {int(z): primary for z in zones}
# Re-apply any non-"No change" per-zone overrides on top of the
# fresh Primary baseline so the user's explicit zone choices stick
# when they change the bulk color.
overrides = _headset_per_zone_overrides(device) or {}
zone_map.update(overrides)
if headset_rgb.write_zone_map(device, zone_map):
self.update(value, save)
return value
return None
class HeadsetPerZoneLighting(settings.Settings):
"""Per-zone LED color overrides.
Mirrors `PerKeyLighting` keys are firmware zone IDs, values are
24-bit RGB ints with the `-1` sentinel meaning "inherit the current
`LEDs Primary` color." Surfaces in the UI via the per-key painter.
"""
name = "headset_per_zone_lighting"
label = _("Per-zone Lighting")
description = _(
"Override individual zone colors. 'No change' inherits the LEDs Primary color.\n"
"LED Control needs to be set to Solaar to be effective."
)
feature = _F.HEADSET_RGB_HOSTMODE
persist = True
editor_class = "solaar.ui.perkey.control:PerKeyControl"
class rw_class(settings.FeatureRWMap):
pass
class validator_class(settings_validator.MapRangeValidator):
_COLOR_RANGE = settings_validator.Range(min=0, max=0xFFFFFF, byte_count=3)
@classmethod
def build(cls, setting_class, device):
zones = headset_rgb.discover_zones(device)
if not zones:
return None
choices_map = {common.NamedInt(int(z), _("Zone") + " " + str(int(z))): cls._COLOR_RANGE for z in zones}
return cls(choices_map) if choices_map else None
def read(self, cached=True):
self._pre_read(cached)
if cached and self._value is not None:
return self._value
# Device doesn't expose current per-zone state; default every
# zone to "No change" so the primary color shows through.
reply_map = {int(key): _NO_CHANGE_COLOR for key in self._validator.choices}
self._value = reply_map
return reply_map
def _resolve_zone_map(self, map_, primary):
"""Substitute 'No change' entries with the primary color."""
resolved = {}
for key, value in map_.items():
try:
v = int(value)
except (TypeError, ValueError):
continue
resolved[int(key)] = primary if v == _NO_CHANGE_COLOR else v
return resolved
def write(self, map_, save=True):
device = self._device
if not device.online:
return None
self.update(map_, save)
primary = _headset_primary_color(device)
zone_map = self._resolve_zone_map(map_, primary)
if not zone_map:
return None
if headset_rgb.write_zone_map(device, zone_map):
return map_
return None
def write_key_value(self, key, value, save=True):
result = super().write_key_value(int(key), value, save)
device = self._device
if not device.online:
return result
try:
v = int(value)
except (TypeError, ValueError):
return result
effective = _headset_primary_color(device) if v == _NO_CHANGE_COLOR else v
headset_rgb.write_zone_map(device, {int(key): int(effective)})
return result
# ----------------------------------------------------------------------------
# LogiVoice (0x0900 + 0x0901..0x0907) — read-only presentation pass.
#
# Per module we auto-generate two settings:
# 1. A flat State toggle — reads GetState (fn 1), renders as a boolean.
# Top-level so users see a direct on/off indicator at a glance.
# 2. A collapsible "Parameters" panel — one MULTIPLE_RANGE-kind setting
# that reads GetParameters (fn 3) once and distributes the bytes to
# per-field sliders. The existing MultipleRangeControl widget is
# collapsible by default, so the field-level clutter stays folded.
#
# Writes are disabled — the Parameters struct carries fields whose wire
# encodings are still ambiguous (see logivoice.py) and a SetParameters
# write must bundle all fields at once. A write pass can be added once
# each field's encoding is confirmed live.
# ----------------------------------------------------------------------------
class _LogiVoiceStateSetting(settings.Setting):
"""Per-module State toggle. Reads GetState (fn 1) and writes SetState (fn 0).
State wire format is unambiguous (one byte: 0 = off, 1 = on), so this is
the one piece of the LogiVoice surface we enable for writes. The per-module
Parameters struct stays read-only until each field's encoding is confirmed.
"""
rw_options = {"read_fnid": logivoice.FN_GET_STATE, "write_fnid": logivoice.FN_SET_STATE}
validator_class = settings_validator.BooleanValidator
@classmethod
def build(cls, device):
# Corpus probe runs here (once per module) so -dd users get a full
# snapshot of state + raw Parameters + raw Info for future decoding.
try:
logivoice.probe_module(device, cls.feature)
except Exception as e:
logger.debug("LogiVoice probe_module(%s) raised %s", cls.feature, e)
return super().build(device)
class _LogiVoiceModuleItem:
"""Top-level MULTIPLE_RANGE item representing one LogiVoice module.
One `item` per setting the module itself. `__int__` returns the feature
id so the Setting's reply dict is keyed predictably.
"""
def __init__(self, feature: hidpp20_constants.SupportedFeature):
self._feature = feature
self.id = logivoice.MODULE_SLUGS.get(feature, f"0x{int(feature):04X}")
self.index = 0
def __int__(self):
return int(self._feature)
def __str__(self):
return logivoice.MODULE_NAMES.get(self._feature, f"0x{int(self._feature):04X}")
class _LogiVoiceFieldSubItem:
"""MULTIPLE_RANGE sub-item wrapping one decoded Parameters field.
MultipleRangeControl reads minimum/maximum/length/widget/str(). We pick
SpinButton for wide ranges (0..65535) where a 64k-step slider is useless,
and Scale for small ranges (e.g. signed int8 thresholds).
"""
def __init__(self, field: logivoice.Field):
self._field = field
self.id = field.name
self.minimum = field.min_value
self.maximum = field.max_value
self.length = field.byte_count
self.widget = "SpinButton" if (field.max_value - field.min_value) > 512 else "Scale"
def __int__(self):
return hash(self.id) & 0xFFFFFF
def __str__(self):
return self._field.label + (" (raw)" if self._field.opaque else "")
class _LogiVoiceParametersValidator(settings_validator.MultipleRangeValidator):
"""Reads the whole GetParameters struct once and distributes bytes to fields.
MULTIPLE_RANGE's default read loop fires prepare_read_item once per top-
level item; we have exactly one item (the module), so this issues a single
GetParameters call. validate_read_item parses the shared reply into a
{field_name: value} dict. Writes are blocked.
"""
def __init__(self, feature: hidpp20_constants.SupportedFeature):
fields = logivoice.PARAMETERS_FIELDS.get(feature, [])
self._fields = list(fields)
item = _LogiVoiceModuleItem(feature)
sub_items = {item: [_LogiVoiceFieldSubItem(f) for f in fields]}
super().__init__(items=[item], sub_items=sub_items)
def prepare_read_item(self, item):
return b"" # GetParameters takes no wire arguments
def validate_read(self, reply_bytes):
# Setting.read() calls validate_read with the raw GetParameters reply.
# MultipleRangeValidator only defines validate_read_item, so wrap that
# call — we have a single item (the module) so one call suffices.
item = self.items[0]
return {int(item): self.validate_read_item(reply_bytes, item)}
def validate_read_item(self, reply_bytes, item):
parsed = {}
# Key by str(sub_item) so MultipleRangeControl.set_value can look up
# values via v[str(sub_item)] — the UI uses the label as the dict key.
for sub in self.sub_items[item]:
f = sub._field
end = f.offset + f.byte_count
if end > len(reply_bytes):
continue
chunk = reply_bytes[f.offset : end]
if f.byte_count == 1:
v = struct.unpack("b" if f.signed else "B", chunk)[0]
elif f.byte_count == 2:
v = struct.unpack(">h" if f.signed else ">H", chunk)[0]
else:
v = int.from_bytes(chunk, "big", signed=f.signed)
parsed[str(sub)] = v
return parsed
def prepare_write_item(self, item, value):
return None
def prepare_write(self, value):
return None
class _LogiVoiceParametersSetting(settings.Setting):
"""Collapsible read-only display of one module's GetParameters struct."""
rw_options = {"read_fnid": logivoice.FN_GET_PARAMETERS}
persist = False
kind = settings.Kind.MULTIPLE_RANGE
@classmethod
def build(cls, device):
if not logivoice.PARAMETERS_FIELDS.get(cls.feature):
return None
rw = settings.FeatureRW(cls.feature, **cls.rw_options)
validator = _LogiVoiceParametersValidator(cls.feature)
return cls(device, rw, validator)
def write(self, map, save=True):
return None
def _logivoice_make_state_class(feature: hidpp20_constants.SupportedFeature):
slug = logivoice.MODULE_SLUGS.get(feature)
if not slug:
return None
module_name = logivoice.MODULE_NAMES.get(feature, f"0x{int(feature):04X}")
attrs = {
"name": f"logivoice-{slug}-state",
"label": f"LogiVoice {module_name}",
"description": f"Enable the headset {module_name} processing block.",
"feature": feature,
}
return type(f"LogiVoice_{slug}_State", (_LogiVoiceStateSetting,), attrs)
def _logivoice_make_parameters_class(feature: hidpp20_constants.SupportedFeature):
slug = logivoice.MODULE_SLUGS.get(feature)
if not slug or not logivoice.PARAMETERS_FIELDS.get(feature):
return None
module_name = logivoice.MODULE_NAMES.get(feature, f"0x{int(feature):04X}")
attrs = {
"name": f"logivoice-{slug}-parameters",
"label": f"LogiVoice {module_name}: Parameters (read-only)",
"description": (
f"Decoded {module_name} GetParameters fields. "
"Opaque raw values shown where the wire encoding isn't confirmed yet."
),
"feature": feature,
}
return type(f"LogiVoice_{slug}_Parameters", (_LogiVoiceParametersSetting,), attrs)
_LOGIVOICE_SETTINGS: list[type] = []
for _feature in logivoice.PARAMETERS_FIELDS:
_state_cls = _logivoice_make_state_class(_feature)
if _state_cls is not None:
_LOGIVOICE_SETTINGS.append(_state_cls)
_params_cls = _logivoice_make_parameters_class(_feature)
if _params_cls is not None:
_LOGIVOICE_SETTINGS.append(_params_cls)
class BrightnessControl(settings.Setting):
name = "brightness_control"
label = _("Brightness Control")
@ -3178,6 +4031,12 @@ SETTINGS: list[settings.Setting] = [
HeadsetMixBalance,
HeadsetAutoSleep,
HeadsetOnboardEQ,
HeadsetActiveEQPreset,
HeadsetAdvancedEQ,
HeadsetLEDControl,
HeadsetLEDsPrimary,
HeadsetPerZoneLighting,
*_LOGIVOICE_SETTINGS,
]
@ -3273,8 +4132,22 @@ def check_feature(device, settings_class: SettingsProtocol) -> None | bool | Set
if settings_class.feature not in device.features:
return
if settings_class.min_version > device.features.get_feature_version(settings_class.feature):
logger.debug(
"check_feature %s [%s]: min_version=%d > device feature version=%d; skipping",
settings_class.name,
settings_class.feature,
settings_class.min_version,
device.features.get_feature_version(settings_class.feature) or 0,
)
return
if device.features.get_hidden(settings_class.feature):
flags = device.features.flags.get(settings_class.feature, 0)
logger.debug(
"check_feature %s [%s]: feature has INTERNAL flag set (flags=0x%02X); skipping",
settings_class.name,
settings_class.feature,
flags,
)
return
try:
detected = settings_class.build(device)
@ -3313,15 +4186,30 @@ def check_feature_settings(device, already_known) -> bool:
known_present = sclass.name in device.persister
else:
known_present = False
if not any(s.name == sclass.name for s in already_known) and (known_present or sclass.name not in absent):
already = any(s.name == sclass.name for s in already_known)
if already:
continue
if not known_present and sclass.name in absent:
# Silent-skip cache from an earlier run's failed build(). If the
# feature is actually present on this device now, the cache is
# stale (e.g. from a prior build that returned None for a
# feature that currently works) — drop it and retry the probe.
if sclass.feature in device.features:
logger.debug(
"check_feature_settings: retrying %s — cached in _absent but feature %s is present now",
sclass.name,
sclass.feature,
)
absent.remove(sclass.name)
if device.persister:
device.persister["_absent"] = absent
else:
continue
try:
setting = check_feature(device, sclass)
except Exception as err:
# on an internal HID++ error, assume offline and stop further checking
if (
isinstance(err, exceptions.FeatureCallError)
and err.error == hidpp20_constants.ErrorCode.LOGITECH_ERROR
):
if isinstance(err, exceptions.FeatureCallError) and err.error == hidpp20_constants.ErrorCode.LOGITECH_ERROR:
logger.warning(f"HID++ internal error checking feature {sclass.name}: make device not present")
device.online = False
device.present = False

View File

@ -152,6 +152,12 @@ class SolaarListener(listener.EventsListener):
from logitech_receiver.device import CenturionReceiver
if isinstance(self.receiver, CenturionReceiver):
if self.receiver._pending:
ihandle = int(self.receiver.handle)
state = base._centurion_handles.get(ihandle)
if state and state.device_addr is not None:
self.receiver._complete_deferred_init()
self._status_changed(self.receiver)
self._handle_centurion_notification(n)
return
# a receiver notification

View File

@ -26,6 +26,7 @@ from __future__ import annotations
from collections.abc import Callable
from ..layout import Layout
from . import headset_g522
from . import keyboard_ansi
from . import keyboard_iso_azerty
from . import keyboard_iso_qwerty
@ -140,3 +141,5 @@ for _family, (_full, _tkl) in _FAMILY_LAYOUTS.items():
register_layout(0x8081, _keyboard_matcher(_family, full_size=False), _tkl)
register_layout(0x8081, _name_contains("G502 X"), mouse_g502x.LAYOUT)
# HEADSET_RGB_HOSTMODE = 0x0620
register_layout(0x0620, _name_contains("G522"), headset_g522.LAYOUT)

View File

@ -0,0 +1,53 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""LED layout for the G522 LIGHTSPEED headset.
Eight LEDs in two 2×2 grids one per earcup, viewed from outside:
Left earcup Right earcup
8 7 6 5
4 3 2 1
"""
from __future__ import annotations
from ..layout import Cell
from ..layout import Layout
_CELLS: tuple[Cell, ...] = (
# Left earcup (cols 0-1, outer view): top-left=8, top-right=7, bottom-left=4, bottom-right=3
Cell(zone_id=8, row=0, col=0, group="main", label="8"),
Cell(zone_id=7, row=0, col=1, group="main", label="7"),
Cell(zone_id=4, row=1, col=0, group="main", label="4"),
Cell(zone_id=3, row=1, col=1, group="main", label="3"),
# Right earcup (cols 3-4, outer view): top-left=6, top-right=5, bottom-left=2, bottom-right=1
Cell(zone_id=6, row=0, col=3, group="main", label="6"),
Cell(zone_id=5, row=0, col=4, group="main", label="5"),
Cell(zone_id=2, row=1, col=3, group="main", label="2"),
Cell(zone_id=1, row=1, col=4, group="main", label="1"),
)
LAYOUT: Layout = Layout(
cells=_CELLS,
rows=2,
cols=5,
description="Logitech G522 LIGHTSPEED headset (8 LEDs, 4 per earcup)",
)

View File

@ -8,7 +8,10 @@ import pytest
from logitech_receiver import base
from logitech_receiver import exceptions
from logitech_receiver.base import CENTURION_ADDRESSED_REPORT_ID
from logitech_receiver.base import CENTURION_REPORT_ID
from logitech_receiver.base import HIDPP_SHORT_MESSAGE_ID
from logitech_receiver.base import CenturionHandleState
from logitech_receiver.common import LOGITECH_VENDOR_ID
from logitech_receiver.common import BusID
from logitech_receiver.hidpp10_constants import ErrorCode as Hidpp10Error
@ -197,3 +200,217 @@ def test_ping_errors(simulated_error: Hidpp10Error, expected_result):
else:
result = base.ping(handle=handle, devnumber=device_number)
assert result == expected_result
# --- Centurion transport tests ---
class TestCenturionFrameHeader:
"""Test _centurion_frame_header builds correct headers for both variants."""
def test_0x51_header(self):
state = CenturionHandleState(report_id=CENTURION_REPORT_ID)
header = base._centurion_frame_header(state, cpl_length=5, flags=0x00)
assert header == bytes([0x51, 5, 0x00])
def test_0x51_header_with_flags(self):
state = CenturionHandleState(report_id=CENTURION_REPORT_ID)
header = base._centurion_frame_header(state, cpl_length=10, flags=0x03)
assert header == bytes([0x51, 10, 0x03])
def test_0x50_header_unknown_addr(self):
state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID, device_addr=None)
header = base._centurion_frame_header(state, cpl_length=5, flags=0x00)
# device_addr defaults to 0x00 when unknown
assert header == bytes([0x50, 0x00, 5, 0x00])
def test_0x50_header_known_addr(self):
state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID, device_addr=0x23)
header = base._centurion_frame_header(state, cpl_length=5, flags=0x00)
assert header == bytes([0x50, 0x23, 5, 0x00])
def test_0x50_header_with_flags(self):
state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID, device_addr=0x23)
header = base._centurion_frame_header(state, cpl_length=10, flags=0x07)
assert header == bytes([0x50, 0x23, 10, 0x07])
class TestUnwrapCenturionFrame:
"""Test _unwrap_centurion_frame for both 0x51 and 0x50 variants."""
HANDLE = 99
def setup_method(self):
"""Ensure no leftover centurion state between tests."""
base._centurion_handles.pop(self.HANDLE, None)
def teardown_method(self):
base._centurion_handles.pop(self.HANDLE, None)
def test_unwrap_0x51_frame(self):
"""0x51 frame with feat_idx=0x02, func_sw=0x1A, 2 data bytes."""
# cpl_length = 1(flags) + 1(feat_idx) + 1(func_sw) + 2(data) = 5
raw = bytes([0x51, 5, 0x00, 0x02, 0x1A, 0xAA, 0xBB]) + b"\x00" * 57
result = base._unwrap_centurion_frame(raw, self.HANDLE, self.HANDLE)
# Should reconstruct as [0x11, 0xFF, feat_idx, func_sw, data..., pad to 20]
assert result[0] == 0x11
assert result[1] == 0xFF
assert result[2] == 0x02 # feat_idx
assert result[3] == 0x1A # func_sw
assert result[4] == 0xAA
assert result[5] == 0xBB
assert len(result) == 20 # padded to standard long
def test_unwrap_0x50_frame(self):
"""0x50 frame with device_addr=0x23, same payload as above."""
# Frame: [0x50, device_addr, cpl_length, flags, feat_idx, func_sw, data...]
raw = bytes([0x50, 0x23, 5, 0x00, 0x02, 0x1A, 0xAA, 0xBB]) + b"\x00" * 56
base._centurion_handles[self.HANDLE] = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID)
result = base._unwrap_centurion_frame(raw, self.HANDLE, self.HANDLE)
assert result[0] == 0x11
assert result[1] == 0xFF
assert result[2] == 0x02 # feat_idx
assert result[3] == 0x1A # func_sw
assert result[4] == 0xAA
assert result[5] == 0xBB
assert len(result) == 20
def test_0x50_learns_device_addr(self):
"""First RX on a 0x50 handle should learn the device address."""
base._centurion_handles[self.HANDLE] = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID)
assert base._centurion_handles[self.HANDLE].device_addr is None
raw = bytes([0x50, 0x23, 3, 0x00, 0x02, 0x1A]) + b"\x00" * 58
base._unwrap_centurion_frame(raw, self.HANDLE, self.HANDLE)
assert base._centurion_handles[self.HANDLE].device_addr == 0x23
def test_0x50_does_not_overwrite_addr(self):
"""Once learned, device address should not be overwritten."""
base._centurion_handles[self.HANDLE] = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID, device_addr=0x23)
raw = bytes([0x50, 0xFF, 3, 0x00, 0x02, 0x1A]) + b"\x00" * 58
base._unwrap_centurion_frame(raw, self.HANDLE, self.HANDLE)
# Should keep the original address, not overwrite with 0xFF
assert base._centurion_handles[self.HANDLE].device_addr == 0x23
def test_non_centurion_frame_passthrough(self):
"""Non-centurion report IDs should be returned unchanged."""
raw = bytes([0x11, 0x01, 0x02, 0x1A]) + b"\x00" * 16
result = base._unwrap_centurion_frame(raw, self.HANDLE, self.HANDLE)
assert result == raw
def test_unwrap_0x51_large_payload(self):
"""0x51 frame with payload large enough to need 63-byte padding."""
# cpl_length covers all 61 payload bytes + flags = 62
payload = bytes(range(61))
raw = bytes([0x51, 62, 0x00]) + payload
result = base._unwrap_centurion_frame(raw, self.HANDLE, self.HANDLE)
assert len(result) == 63 # padded to centurion extended
assert result[0] == 0x11
assert result[1] == 0xFF
assert result[2:63] == payload
class TestProbeCenturionDeviceAddr:
"""Test probe_centurion_device_addr: brute-force write for all 256 addrs, then read."""
HANDLE = 101
def setup_method(self):
base._centurion_handles.pop(self.HANDLE, None)
def teardown_method(self):
base._centurion_handles.pop(self.HANDLE, None)
def test_learns_addr_on_first_hit(self):
"""Probe finds addr=0x23 on candidate #36 (0-indexed 0x23=35) and stops."""
state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID)
reply = bytes([0x50, 0x23, 0x03, 0x00]) + b"\x00" * 60
def read_side_effect(_handle, _size, _timeout):
# Return a response only after the write with addr=0x23
if mock_write.call_count == 0x24: # 0x23 is the 36th write (1-indexed)
return reply
return None
with (
mock.patch.object(base.hidapi, "write") as mock_write,
mock.patch.object(base.hidapi, "read", side_effect=read_side_effect),
):
result = base.probe_centurion_device_addr(self.HANDLE, state)
assert result is True
assert state.device_addr == 0x23
# Short-circuit: stopped at candidate 0x23 (36 writes), not all 256
assert mock_write.call_count == 0x24
def test_skips_non_matching_read_until_match(self):
"""Non-0x50 frames in the read are ignored; next candidate's read succeeds."""
state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID)
noise = b"\x11\xff" + b"\x00" * 62
match = bytes([0x50, 0x42, 0x03, 0x00]) + b"\x00" * 60
# Reads cycle: noise, noise, match — so addr is found on 3rd candidate
with (
mock.patch.object(base.hidapi, "write"),
mock.patch.object(base.hidapi, "read", side_effect=[noise, noise, match]),
):
result = base.probe_centurion_device_addr(self.HANDLE, state)
assert result is True
assert state.device_addr == 0x42
def test_returns_false_when_no_response(self):
state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID)
with (
mock.patch.object(base.hidapi, "write"),
mock.patch.object(base.hidapi, "read", return_value=None),
):
result = base.probe_centurion_device_addr(self.HANDLE, state)
assert result is False
assert state.device_addr is None
def test_noop_for_0x51_variant(self):
state = CenturionHandleState(report_id=CENTURION_REPORT_ID)
with (
mock.patch.object(base.hidapi, "write") as mock_write,
mock.patch.object(base.hidapi, "read") as mock_read,
):
result = base.probe_centurion_device_addr(self.HANDLE, state)
assert result is False
assert state.device_addr is None
mock_write.assert_not_called()
mock_read.assert_not_called()
def test_noop_when_addr_already_known(self):
state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID, device_addr=0x23)
with (
mock.patch.object(base.hidapi, "write") as mock_write,
mock.patch.object(base.hidapi, "read") as mock_read,
):
result = base.probe_centurion_device_addr(self.HANDLE, state)
assert result is False
assert state.device_addr == 0x23
mock_write.assert_not_called()
mock_read.assert_not_called()
def test_aborts_on_repeated_write_failure(self):
state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID)
with (
mock.patch.object(base.hidapi, "write", side_effect=OSError("no device")),
mock.patch.object(base.hidapi, "read") as mock_read,
):
result = base.probe_centurion_device_addr(self.HANDLE, state)
assert result is False
assert state.device_addr is None
mock_read.assert_not_called()
def test_write_frames_have_sequential_addrs(self):
"""Verify each write uses a different device_addr from 0x00 to 0xFF."""
state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID)
with (
mock.patch.object(base.hidapi, "write") as mock_write,
mock.patch.object(base.hidapi, "read", return_value=None), # no response → scans all 256
):
base.probe_centurion_device_addr(self.HANDLE, state)
assert mock_write.call_count == 256
addrs_sent = [mock_write.call_args_list[i][0][1][1] for i in range(256)]
assert addrs_sent == list(range(256))

View File

@ -17,6 +17,7 @@
from dataclasses import dataclass
from functools import partial
from typing import Optional
from unittest import mock
import pytest
@ -61,6 +62,7 @@ class DeviceInfoStub:
bus_id: int = 0x0003 # USB
serial: str = "aa:aa:aa;aa"
centurion: bool = False
centurion_report_id: int | None = None
di_bad_handle = DeviceInfoStub(None, product_id="CCCC")
@ -100,6 +102,7 @@ def test_create_centurion_device():
"""Test that a centurion device gets hidpp_long forced to True and centurion flag set."""
from logitech_receiver import base
with mock.patch.object(base, "probe_centurion_device_addr", return_value=False):
low_level_mock = LowLevelInterfaceFake(fake_hidpp.r_empty)
test_device = device.create_device(low_level_mock, di_0AF7)
@ -107,13 +110,38 @@ def test_create_centurion_device():
assert test_device.centurion is True
assert test_device.hidpp_long is True
assert int(test_device.handle) in base._centurion_handles
state = base._centurion_handles[int(test_device.handle)]
assert state.report_id == base.CENTURION_REPORT_ID # 0x51 default
# kind is seeded at construction, so the headset icon shows even offline
test_device.online = False
assert test_device.kind == "headset"
# Clean up
base._centurion_handles.discard(int(test_device.handle))
base._centurion_handles.pop(int(test_device.handle), None)
di_0B18 = DeviceInfoStub("11", product_id="0B18", centurion=True, centurion_report_id=0x50)
def test_create_centurion_0x50_device():
"""Test that a 0x50 centurion device gets the correct report ID registered."""
from logitech_receiver import base
with mock.patch.object(base, "probe_centurion_device_addr", return_value=False):
low_level_mock = LowLevelInterfaceFake(fake_hidpp.r_empty)
test_device = device.create_device(low_level_mock, di_0B18)
assert test_device is not None
assert test_device.centurion is True
assert test_device.hidpp_long is True
assert int(test_device.handle) in base._centurion_handles
state = base._centurion_handles[int(test_device.handle)]
assert state.report_id == base.CENTURION_ADDRESSED_REPORT_ID # 0x50
assert state.device_addr is None # not yet learned
# Clean up
base._centurion_handles.pop(int(test_device.handle), None)
@pytest.mark.parametrize(

View File

@ -956,29 +956,18 @@ def test_centurion_sub_device_feature_discovery():
dev = fake_hidpp.Device("CENT_SUB", True, 2.6, fake_hidpp.r_centurion_headset, centurion=True)
# Set up bridge responses for sub-device discovery:
# 1. CenturionRoot.GetFeature(0x0001) -> FeatureSet at sub-index 1
# 2. CenturionFeatureSet.GetFeatureId(index=0) -> bulk feature list
# 2. CenturionFeatureSet.GetCount (func 0) -> total feature count
# 3. CenturionFeatureSet.GetFeatureId (func 0x10) per-index -> one feature each
dev._bridge_responses = {
# CenturionRoot(idx=0).GetFeature(func=0) with feature_id=0x0001 -> sub_fs_index=1
(0x00, 0x00, "0001"): bytes([0x01, 0x00, 0x00]),
# CenturionFeatureSet(idx=1).GetFeatureId(func=0x10, start=0) -> 3 features
# Response: [count, (feat_hi, feat_lo, type, flags) × count]
(0x01, 0x10, "00"): bytes(
[
0x03, # 3 features
0x06,
0x04,
0x00,
0x00, # HEADSET_AUDIO_SIDETONE (0x0604) at sub-idx 0
0x06,
0x01,
0x00,
0x00, # HEADSET_MIC_MUTE (0x0601) at sub-idx 1
0x06,
0x11,
0x00,
0x00, # HEADSET_MIC_GAIN (0x0611) at sub-idx 2
]
),
# CenturionFeatureSet(idx=1).GetCount (func=0) -> 3 features
(0x01, 0x00, ""): bytes([0x03, 0x00, 0x00]),
# CenturionFeatureSet(idx=1).GetFeatureId (func=0x10, index=N) -> one feature per response.
# Response format: [remaining, feat_hi, feat_lo, type, flags]
(0x01, 0x10, "00"): bytes([0x02, 0x06, 0x04, 0x00, 0x00]), # HEADSET_AUDIO_SIDETONE at sub-idx 0
(0x01, 0x10, "01"): bytes([0x01, 0x06, 0x01, 0x00, 0x00]), # HEADSET_MIC_MUTE at sub-idx 1
(0x01, 0x10, "02"): bytes([0x00, 0x06, 0x11, 0x00, 0x00]), # HEADSET_MIC_GAIN at sub-idx 2
}
featuresarray = hidpp20.FeaturesArray(dev)
dev.features = featuresarray