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_short: str | None
hidpp_long: str | None hidpp_long: str | None
centurion: bool = False 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 from hid_parser import ReportDescriptor
hidpp_short = hidpp_long = centurion = False hidpp_short = hidpp_long = centurion = False
centurion_report_id = None
devfile = "/sys" + hid_device.properties.get("DEVPATH") + "/report_descriptor" devfile = "/sys" + hid_device.properties.get("DEVPATH") + "/report_descriptor"
with fileopen(devfile, "rb") as fd: with fileopen(devfile, "rb") as fd:
with warnings.catch_warnings(): 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 # 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)) 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 # 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 transport: 63-byte reports on usage page 0xFFA0 (both input and output)
centurion = ( # 0x51 = PRO X 2 LIGHTSPEED variant, 0x50 = G522 LIGHTSPEED variant (with device address byte)
0x51 in rd.input_report_ids and 63 * 8 == int(rd.get_input_report_size(0x51)) and 0x51 in rd.output_report_ids 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: if not hidpp_short and not hidpp_long and not centurion:
return return
except Exception as e: # if can't process report descriptor fall back to old scheme except Exception as e: # if can't process report descriptor fall back to old scheme
hidpp_short = None hidpp_short = None
hidpp_long = None hidpp_long = None
centurion = False centurion = False
centurion_report_id = None
logger.info( logger.info(
"Report Descriptor not processed for DEVICE %s BID %s VID %s PID %s: %s", "Report Descriptor not processed for DEVICE %s BID %s VID %s PID %s: %s",
device.device_node, 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_short=hidpp_short,
hidpp_long=hidpp_long, hidpp_long=hidpp_long,
centurion=centurion if centurion else False, centurion=centurion if centurion else False,
centurion_report_id=centurion_report_id,
) )
return d_info 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 DJ_MESSAGE_ID = 0x20
# Centurion transport (used by PRO X 2 LIGHTSPEED headset and similar) # Centurion transport (used by PRO X 2 LIGHTSPEED headset and similar)
# Uses report ID 0x51 on usage page 0xFFA0, 64-byte frames. # Two variants exist, distinguished by report ID:
# Wire format (CPL): [0x51, cpl_length, flags=0x00, feat_idx, func_sw, params..., pad] # 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). # 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. # The device_index byte from standard HID++ is NOT present in Centurion framing.
CENTURION_REPORT_ID = 0x51 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_FRAME_SIZE = 64 # 1 byte report ID + 63 bytes payload
_CENTURION_MSG_SIZE = 63 # max reconstructed message size after unwrapping (2 + 61 payload bytes) _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() @dataclasses.dataclass
# Raw Centurion protocol version (major, minor) by handle, from ping response class CenturionHandleState:
_centurion_protocol_versions: dict[int, tuple[int, int]] = {} """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).""" """Default timeout on read (in seconds)."""
@ -301,8 +313,7 @@ def close(handle):
if handle: if handle:
try: try:
if isinstance(handle, int): if isinstance(handle, int):
_centurion_handles.discard(handle) _centurion_handles.pop(handle, None)
_centurion_protocol_versions.pop(handle, None)
hidapi.close(handle) hidapi.close(handle)
else: else:
handle.close() handle.close()
@ -313,6 +324,115 @@ def close(handle):
return False 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): def write(handle, devnumber, data, long_message=False):
"""Writes some data to the receiver, addressed to a certain device. """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) ihandle = int(handle)
if ihandle in _centurion_handles: if ihandle in _centurion_handles:
# Centurion CPL framing: [0x51, cpl_length, flags=0x00, feat_idx, func_sw, params...] # 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) # 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. state = _centurion_handles[ihandle]
payload = wdata[2:] # skip report_id and devnumber from standard frame 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 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)) wdata = wdata + b"\x00" * (CENTURION_FRAME_SIZE - len(wdata))
if logger.isEnabledFor(logging.DEBUG): 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): def write_centurion_cpl(handle, layer3_payload, flags=0x00):
"""Send a Centurion CPL frame with the given Layer 3+ payload. """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). where cpl_length = len(layer3_payload) + 1 (the +1 counts the flags byte).
For multi-fragment sends, flags encodes fragment index and continuation: 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) ihandle = int(handle)
if ihandle not in _centurion_handles: if ihandle not in _centurion_handles:
raise ValueError("write_centurion_cpl called on non-Centurion handle") raise ValueError("write_centurion_cpl called on non-Centurion handle")
state = _centurion_handles[ihandle]
cpl_length = len(layer3_payload) + 1 # +1 for flags byte 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)) wdata = wdata + b"\x00" * (CENTURION_FRAME_SIZE - len(wdata))
if logger.isEnabledFor(logging.DEBUG): 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: try:
hidapi.write(ihandle, wdata) hidapi.write(ihandle, wdata)
except Exception as reason: except Exception as reason:
@ -452,22 +576,8 @@ def _read(handle, timeout) -> tuple[int, int, bytes]:
close(handle) close(handle)
raise exceptions.NoReceiver(reason=reason) from reason raise exceptions.NoReceiver(reason=reason) from reason
if data and is_centurion and ord(data[:1]) == CENTURION_REPORT_ID: if data and is_centurion and ord(data[:1]) in _CENTURION_REPORT_IDS:
# Unwrap Centurion CPL framing: data = _unwrap_centurion_frame(data, ihandle, handle)
# 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_relevant_message(data): # ignore messages that fail check if data and _is_relevant_message(data): # ignore messages that fail check
report_id = ord(data[:1]) report_id = ord(data[:1])
@ -725,7 +835,7 @@ def ping(handle, devnumber, long_message: bool = False):
major = ord(reply_data[2:3]) major = ord(reply_data[2:3])
minor = ord(reply_data[3:4]) minor = ord(reply_data[3:4])
if is_centurion: if is_centurion:
_centurion_protocol_versions[int(handle)] = (major, minor) _centurion_handles[int(handle)].protocol_version = (major, minor)
return major + minor / 10.0 return major + minor / 10.0
if ( if (
@ -771,17 +881,8 @@ def _read_input_buffer(handle, ihandle, notifications_hook):
raise exceptions.NoReceiver(reason=reason) from reason raise exceptions.NoReceiver(reason=reason) from reason
if data: if data:
if is_centurion and ord(data[:1]) == CENTURION_REPORT_ID: if is_centurion and ord(data[:1]) in _CENTURION_REPORT_IDS:
# Unwrap Centurion CPL framing same as in _read() data = _unwrap_centurion_frame(data, ihandle, handle)
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_relevant_message(data): # only process messages that pass check if _is_relevant_message(data): # only process messages that pass check
# report_id = ord(data[:1]) # report_id = ord(data[:1])
if notifications_hook: if notifications_hook:

View File

@ -266,6 +266,7 @@ class CenturionReceiver:
self._devices = {} self._devices = {}
self._firmware = None self._firmware = None
self._dongle_features = None # independently probed dongle features self._dongle_features = None # independently probed dongle features
self._pending = False # True when device_addr unknown; deferred init completes on first RX
self.cleanups = [] self.cleanups = []
# Receiver identity # Receiver identity
@ -319,7 +320,7 @@ class CenturionReceiver:
if feat_id == feature_int: if feat_id == feature_int:
request_id = (index << 8) | (function & 0xFF) request_id = (index << 8) | (function & 0xFF)
return self.request(request_id, *params, no_reply=no_reply) return self.request(request_id, *params, no_reply=no_reply)
raise exceptions.FeatureNotSupported(feature) raise exceptions.FeatureNotSupported(feature=feature)
def _discover_dongle_features(self): def _discover_dongle_features(self):
"""Independently discover features on the dongle hardware.""" """Independently discover features on the dongle hardware."""
@ -363,15 +364,67 @@ class CenturionReceiver:
@property @property
def firmware(self): 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) self._firmware = get_firmware_centurion(self)
return self._firmware or () 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): def notify_devices(self):
"""Create child Device for the headset and trigger its initialization.""" """Create child Device for the headset and trigger its initialization."""
# Import Device locally to avoid circular import (centurion.py ↔ device.py) # Import Device locally to avoid circular import (centurion.py ↔ device.py)
from .device import Device 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 # Signal receiver to UI first — tray/window need the receiver entry
# before a child device can be added under it. # before a child device can be added under it.
self.changed(alert=Alert.NONE) self.changed(alert=Alert.NONE)
@ -410,6 +463,13 @@ class CenturionReceiver:
# Ping to determine online status. # Ping to determine online status.
# Notify UI either way — offline devices show as greyed out (matching receiver behavior). # Notify UI either way — offline devices show as greyed out (matching receiver behavior).
online = dev.ping() 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) dev.changed(active=online)
if self.status_callback is not None: if self.status_callback is not None:
self.status_callback(dev) self.status_callback(dev)
@ -495,17 +555,33 @@ def create_centurion_receiver(low_level, device_info, setting_callback=None):
try: try:
handle = low_level.open_path(device_info.path) handle = low_level.open_path(device_info.path)
if handle: 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) cr = CenturionReceiver(low_level, handle, device_info, setting_callback)
# Check if any discovered feature is CentPPBridge (0x0003) # 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 [])) has_bridge = any(feat_id == CenturionCoreFeature.CENT_PP_BRIDGE for _, feat_id, _ in (cr.dongle_features or []))
if not has_bridge: if has_bridge:
logger.info("Centurion device %s has no bridge, treating as direct device", device_info.path) return cr
base._centurion_handles.discard(int(handle))
cr.handle = None # prevent __del__ from double-closing # No bridge found. Distinguish "silent 0x50 dongle" (device_addr
low_level.close(handle) # unknown, headset not yet powered on) from "wired 0x50 device"
return None # (responded to probe, features found, but no bridge).
return cr 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.pop(int(handle), None)
cr.handle = None # prevent __del__ from double-closing
low_level.close(handle)
return None
except OSError as e: except OSError as e:
logger.exception("open %s", device_info) logger.exception("open %s", device_info)
if e.errno == errno.EACCES: if e.errno == errno.EACCES:

View File

@ -466,3 +466,4 @@ _D(
usbid=0x0ABA, usbid=0x0ABA,
) )
# PRO X 2 LIGHTSPEED Gaming Headset (0x0AF7) — fully probed via Centurion transport, no static descriptor needed # 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) handle = low_level.open_path(device_info.path)
if handle: if handle:
if getattr(device_info, "centurion", False): 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) # a direct connected device might not be online (as reported by user)
return Device( return Device(
low_level, low_level,
@ -223,7 +226,9 @@ class Device:
self._protocol = self.descriptor.protocol if self.descriptor.protocol else None self._protocol = self.descriptor.protocol if self.descriptor.protocol else None
self.registers = self.descriptor.registers if self.descriptor.registers else [] 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) self.features = {} if self._protocol < 2.0 else hidpp20.FeaturesArray(self)
else: else:
self.features = hidpp20.FeaturesArray(self) # may be a 2.0 device; if not, it will fix itself later 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() self.ping()
except exceptions.NoSuchDevice: except exceptions.NoSuchDevice:
logger.warning("device %s inaccessible - no protocol set", self) 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 @property
def codename(self): def codename(self):
@ -523,6 +534,15 @@ class Device:
def battery(self): # None or level, next, status, voltage def battery(self): # None or level, next, status, voltage
if self.protocol < 2.0: 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) return _hidpp10.get_battery(self)
else: else:
battery_feature = self.persister.get("_battery", None) if self.persister else None 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 # Ensure sub-device features are discovered before routing decision
if self.features is not None: if self.features is not None:
self.features._check() 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", ()): if feature in getattr(self, "_centurion_sub_features", ()):
sub_idx = self.features.get(feature) sub_idx = self.features.get(feature)
if sub_idx is not None: if sub_idx is not None:
return self.centurion_bridge_request(sub_idx, function, *params, no_reply=no_reply) 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) return hidpp20.feature_request(self, feature, function, *params, no_reply=no_reply)
# Max sub-message bytes in the first bridge fragment: # 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 # 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) # 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 _BRIDGE_FIRST_CHUNK = 56
# Continuation fragments carry raw sub_msg data (no bridge prefix/hdr): # 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 _BRIDGE_CONT_CHUNK = 60
def centurion_bridge_request(self, sub_feat_idx, sub_function=0x00, *params, no_reply=False): 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. """Send a request to a Centurion sub-device via CentPPBridge.
Builds the 4-layer nested message: Builds the 4-layer nested message:
Layer 1: [0x51] Layer 1: [report_id] (0x51 or 0x50)
Layer 2: [cpl_length, flags] Layer 2: [device_addr (0x50 only),] cpl_length, flags
Layer 3: [bridge_idx, sendFragment_func|swid, bridge_hdr...] Layer 3: [bridge_idx, sendFragment_func|swid, bridge_hdr...]
Layer 4: [sub_cpl=0x00, sub_feat_idx, sub_func|swid, params...] Layer 4: [sub_cpl=0x00, sub_feat_idx, sub_func|swid, params...]
@ -730,6 +764,12 @@ class Device:
if not handle: if not handle:
return None 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() sw_id = base._get_next_sw_id()
# Build sub-device message: [sub_cpl=0x00, sub_feat_idx, sub_func|swid, params...] # Build sub-device message: [sub_cpl=0x00, sub_feat_idx, sub_func|swid, params...]
@ -745,7 +785,15 @@ class Device:
timeout = base.DEFAULT_TIMEOUT timeout = base.DEFAULT_TIMEOUT
with base.acquire_timeout(base.handle_lock(handle), handle, 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 # Single-frame path
layer3 = bridge_prefix + bridge_hdr + sub_msg layer3 = bridge_prefix + bridge_hdr + sub_msg
base.write_centurion_cpl(handle, layer3) base.write_centurion_cpl(handle, layer3)
@ -755,18 +803,17 @@ class Device:
# Fragments 1+: raw sub_msg continuation data (no bridge overhead) # Fragments 1+: raw sub_msg continuation data (no bridge overhead)
# CPL flags = (frag_index << 1) | (1 if more_fragments else 0) # CPL flags = (frag_index << 1) | (1 if more_fragments else 0)
# All fragments are sent back-to-back without waiting for # All fragments are sent back-to-back without waiting for
# intermediate ACKs (verified via LGHUB pcap). The device # intermediate ACKs. The device reassembles internally and
# reassembles internally and sends a single ACK + MessageEvent # sends a single ACK + MessageEvent after the last fragment.
# after the last fragment.
frag_index = 0 frag_index = 0
offset = 0 offset = 0
while offset < sub_len: while offset < sub_len:
if frag_index == 0: if frag_index == 0:
chunk_size = self._BRIDGE_FIRST_CHUNK chunk_size = first_chunk
chunk = sub_msg[offset : offset + chunk_size] chunk = sub_msg[offset : offset + chunk_size]
layer3 = bridge_prefix + bridge_hdr + chunk layer3 = bridge_prefix + bridge_hdr + chunk
else: else:
chunk_size = self._BRIDGE_CONT_CHUNK chunk_size = cont_chunk
chunk = sub_msg[offset : offset + chunk_size] chunk = sub_msg[offset : offset + chunk_size]
layer3 = chunk layer3 = chunk
has_more = (offset + chunk_size) < sub_len has_more = (offset + chunk_size) < sub_len
@ -778,6 +825,13 @@ class Device:
if no_reply: if no_reply:
return None 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 # Read ACK + MessageEvent response
request_started = time.time() request_started = time.time()
ack_received = False ack_received = False
@ -794,11 +848,21 @@ class Device:
break break
if (func_sw >> 4) == 0x01 and (func_sw & 0x0F) == 0: if (func_sw >> 4) == 0x01 and (func_sw & 0x0F) == 0:
# MessageEvent arrived before ACK — validate it's for our request # 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): if logger.isEnabledFor(logging.DEBUG):
logger.debug("bridge idx=%d fn=0x%02X -> OK", sub_feat_idx, sub_function) logger.debug("bridge idx=%d fn=0x%02X -> OK", sub_feat_idx, sub_function)
return self._parse_bridge_response(reply_data) 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: if not ack_received:
logger.warning("centurion_bridge_request: no ACK received") logger.warning("centurion_bridge_request: no ACK received")
return None return None
@ -812,11 +876,21 @@ class Device:
if len(reply_data) >= 2 and reply_data[0] == bridge_idx: if len(reply_data) >= 2 and reply_data[0] == bridge_idx:
func_sw = reply_data[1] func_sw = reply_data[1]
if (func_sw >> 4) == 0x01 and (func_sw & 0x0F) == 0: 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): if logger.isEnabledFor(logging.DEBUG):
logger.debug("bridge idx=%d fn=0x%02X -> OK", sub_feat_idx, sub_function) logger.debug("bridge idx=%d fn=0x%02X -> OK", sub_feat_idx, sub_function)
return self._parse_bridge_response(reply_data) 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") logger.warning("centurion_bridge_request: no MessageEvent received")
return None return None
@ -836,12 +910,19 @@ class Device:
return False return False
@staticmethod @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. """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 Accepts both normal responses (sub_feat_idx matches) and error responses
(sub_feat_idx=0xFF with original feat_idx in next byte). (sub_feat_idx=0xFF with original feat_idx in next byte).
Unsolicited notifications (sub_cpl=0xFF) are rejected. 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: if len(reply_data) < 6:
return False return False
@ -851,9 +932,15 @@ class Device:
if sub_cpl != 0x00: if sub_cpl != 0x00:
return False return False
if sub_feat_idx == expected_sub_feat_idx: 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 return True
# Error response: sub_feat_idx=0xFF, next byte is the original feat_idx that errored # 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 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 True
return False return False
@ -873,18 +960,25 @@ class Device:
sub_feat_idx = reply_data[5] sub_feat_idx = reply_data[5]
# Error response from sub-device # Error response from sub-device
if sub_feat_idx == 0xFF: 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 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 None
return reply_data[7:] # response data after sub_cpl, sub_feat_idx, sub_func_sw return reply_data[7:] # response data after sub_cpl, sub_feat_idx, sub_func_sw
def _record_ping_protocol(self, handle, protocol): def _record_ping_protocol(self, handle, protocol):
"""Record a successful ping's protocol version, including raw Centurion (major, minor).""" """Record a successful ping's protocol version, including raw Centurion (major, minor)."""
self._protocol = protocol self._protocol = protocol
cent_ver = base._centurion_protocol_versions.get(int(handle)) cent_state = base._centurion_handles.get(int(handle))
if cent_ver: if cent_state and cent_state.protocol_version:
self._centurion_protocol = cent_ver self._centurion_protocol = cent_state.protocol_version
def ping(self): def ping(self):
"""Checks if the device is online and present, returns True of False. """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) response = self.device.request((fs_index << 8) | 0x10, index)
if response is None or len(response) < 3: if response is None or len(response) < 3:
continue 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_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) feature = resolve_feature(feat_id, centurion=True)
if feature is None: if feature is None:
feature = f"unknown:{feat_id:04X}" feature = f"unknown:{feat_id:04X}"
self[feature] = index self[feature] = index
self.inverse[index] = feature 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: if feature is CenturionCoreFeature.CENT_PP_BRIDGE:
bridge_index = index bridge_index = index
@ -216,8 +230,11 @@ class FeaturesArray(dict):
def _discover_sub_device_features(self, bridge_index): def _discover_sub_device_features(self, bridge_index):
"""Phase B: Discover sub-device features via CentPPBridge. """Phase B: Discover sub-device features via CentPPBridge.
Uses CenturionFeatureSet bulk query (function 1, index 0) routed through Uses per-index queries: GetCount (func 0) returns total count, then
the bridge to get all sub-device features at once. 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) # 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 # 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)") logger.warning("Sub-device FeatureSet not found (index=0)")
return return
# Bulk enumerate: CenturionFeatureSet.GetFeatureId(func=1=0x10, start_index=0) # Query feature count (function 0 = GetCount). Response: [count, ...].
# Response: [count, (feat_hi, feat_lo, type, flags) × count] count_resp = self.device.centurion_bridge_request(sub_fs_index, 0x00)
response = self.device.centurion_bridge_request(sub_fs_index, 0x10, 0x00) if count_resp is None or len(count_resp) < 1:
if response is None or len(response) < 1: logger.warning("Failed to read Centurion sub-device feature count")
logger.warning("Failed to enumerate sub-device features")
return 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] # Per-index query: GetFeatureId (function 1 = 0x10).
entries = response[1:] # Response: [remaining, feat_hi, feat_lo, type, version].
sub_feat_idx = 0 # sub-device feature indices start at 0 # We now also record `type` (flags) and `version` for each feature so
for i in range(entry_count): # version-gated settings (sidetone, auto-sleep, etc.) can use the
offset = i * 4 # correct payload format instead of defaulting to V0.
if offset + 2 > len(entries): sub_feat_idx = 0
break for idx in range(total_count):
feat_id = struct.unpack("!H", entries[offset : offset + 2])[0] 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: try:
feature = SupportedFeature(feat_id) feature = SupportedFeature(feat_id)
except ValueError: except ValueError:
feature = f"unknown:{feat_id:04X}" 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 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: if dict.get(self, feature) is None:
dict.__setitem__(self, feature, sub_feat_idx) dict.__setitem__(self, feature, sub_feat_idx)
self.device._centurion_sub_features.add(feature) self.device._centurion_sub_features.add(feature)
# Always store in sub_inverse for sub-device enumerate/display
self.sub_inverse[sub_feat_idx] = feature 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): 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 sub_feat_idx += 1
self._sub_feature_count = sub_feat_idx 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: def get_feature(self, index: int) -> SupportedFeature | None:
feature = self.inverse.get(index) feature = self.inverse.get(index)
if feature is not None: if feature is not None:
return feature 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(): elif self._check():
feature = self.inverse.get(index) feature = self.inverse.get(index)
if feature is not None: if feature is not None:
@ -334,6 +369,12 @@ class FeaturesArray(dict):
index = super().get(feature) index = super().get(feature)
if index is not None: if index is not None:
return index 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: try:
response = self.device.request(0x0000, struct.pack("!H", feature)) response = self.device.request(0x0000, struct.pack("!H", feature))
except exceptions.FeatureCallError: except exceptions.FeatureCallError:
@ -2365,6 +2406,18 @@ class ForceSensingButtonArray(UserDict):
# --- OnboardEQ (0x0636) — re-exported from onboard_eq.py --- # --- 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 _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_info # noqa: E402, F401
from .onboard_eq import get_onboard_eq_params # 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_HOSTMODE = 0x0620
HEADSET_RGB_ONBOARD_EFFECTS = 0x0621 HEADSET_RGB_ONBOARD_EFFECTS = 0x0621
HEADSET_RGB_SIGNATURE_EFFECTS = 0x0622 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 HEADSET_DO_NOT_DISTURB = 0x0631
CENTURION_ONBOARD_PROFILES = 0x0634 CENTURION_ONBOARD_PROFILES = 0x0634
HEADSET_RGB_STREAMING = 0x0635 HEADSET_RGB_STREAMING = 0x0635

View File

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

View File

@ -27,11 +27,9 @@ import struct
from .hidpp20_constants import SupportedFeature from .hidpp20_constants import SupportedFeature
# Mystery bytes observed in every LGHUB pcap EQ write between band params # Opaque bytes observed between band params and coefficient header. First
# and coefficient header. Purpose not fully understood — possibly a null-band # byte matches band_count; bytes 2-3 look like LE16 coeff blob size. Keep
# terminator for DSPs that support >5 bands (advanced 10-band mode). # verbatim until a device counter-example forces a re-derivation.
# 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.
_EQ_MYSTERY_BYTES = b"\x05\x5a\xe3\x00" _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 coefficients (a1, a2) are left unchanged. The DSP multiplies the output by
rescale to restore correct gain. 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) num_bands = len(bands)
all_words = [num_bands] # first uint16 = num_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) logger.debug("%s: prepare write(%s) => %r", self.name, value, data_bytes)
reply = self._rw.write(self._device, data_bytes) reply = self._rw.write(self._device, data_bytes)
if not reply: # HID++ 2.0 "set" operations often return an empty ACK (b"").
# tell whomever is calling that the write failed # 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 None
return value return value

File diff suppressed because it is too large Load Diff

View File

@ -152,6 +152,12 @@ class SolaarListener(listener.EventsListener):
from logitech_receiver.device import CenturionReceiver from logitech_receiver.device import CenturionReceiver
if isinstance(self.receiver, 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) self._handle_centurion_notification(n)
return return
# a receiver notification # a receiver notification

View File

@ -26,6 +26,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from ..layout import Layout from ..layout import Layout
from . import headset_g522
from . import keyboard_ansi from . import keyboard_ansi
from . import keyboard_iso_azerty from . import keyboard_iso_azerty
from . import keyboard_iso_qwerty 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, _keyboard_matcher(_family, full_size=False), _tkl)
register_layout(0x8081, _name_contains("G502 X"), mouse_g502x.LAYOUT) 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 base
from logitech_receiver import exceptions 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 HIDPP_SHORT_MESSAGE_ID
from logitech_receiver.base import CenturionHandleState
from logitech_receiver.common import LOGITECH_VENDOR_ID from logitech_receiver.common import LOGITECH_VENDOR_ID
from logitech_receiver.common import BusID from logitech_receiver.common import BusID
from logitech_receiver.hidpp10_constants import ErrorCode as Hidpp10Error from logitech_receiver.hidpp10_constants import ErrorCode as Hidpp10Error
@ -197,3 +200,217 @@ def test_ping_errors(simulated_error: Hidpp10Error, expected_result):
else: else:
result = base.ping(handle=handle, devnumber=device_number) result = base.ping(handle=handle, devnumber=device_number)
assert result == expected_result 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 dataclasses import dataclass
from functools import partial from functools import partial
from typing import Optional from typing import Optional
from unittest import mock
import pytest import pytest
@ -61,6 +62,7 @@ class DeviceInfoStub:
bus_id: int = 0x0003 # USB bus_id: int = 0x0003 # USB
serial: str = "aa:aa:aa;aa" serial: str = "aa:aa:aa;aa"
centurion: bool = False centurion: bool = False
centurion_report_id: int | None = None
di_bad_handle = DeviceInfoStub(None, product_id="CCCC") di_bad_handle = DeviceInfoStub(None, product_id="CCCC")
@ -100,20 +102,46 @@ def test_create_centurion_device():
"""Test that a centurion device gets hidpp_long forced to True and centurion flag set.""" """Test that a centurion device gets hidpp_long forced to True and centurion flag set."""
from logitech_receiver import base from logitech_receiver import base
low_level_mock = LowLevelInterfaceFake(fake_hidpp.r_empty) with mock.patch.object(base, "probe_centurion_device_addr", return_value=False):
test_device = device.create_device(low_level_mock, di_0AF7) low_level_mock = LowLevelInterfaceFake(fake_hidpp.r_empty)
test_device = device.create_device(low_level_mock, di_0AF7)
assert test_device is not None assert test_device is not None
assert test_device.centurion is True assert test_device.centurion is True
assert test_device.hidpp_long is True assert test_device.hidpp_long is True
assert int(test_device.handle) in base._centurion_handles 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 # kind is seeded at construction, so the headset icon shows even offline
test_device.online = False test_device.online = False
assert test_device.kind == "headset" assert test_device.kind == "headset"
# Clean up # 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( @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) dev = fake_hidpp.Device("CENT_SUB", True, 2.6, fake_hidpp.r_centurion_headset, centurion=True)
# Set up bridge responses for sub-device discovery: # Set up bridge responses for sub-device discovery:
# 1. CenturionRoot.GetFeature(0x0001) -> FeatureSet at sub-index 1 # 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 = { dev._bridge_responses = {
# CenturionRoot(idx=0).GetFeature(func=0) with feature_id=0x0001 -> sub_fs_index=1 # CenturionRoot(idx=0).GetFeature(func=0) with feature_id=0x0001 -> sub_fs_index=1
(0x00, 0x00, "0001"): bytes([0x01, 0x00, 0x00]), (0x00, 0x00, "0001"): bytes([0x01, 0x00, 0x00]),
# CenturionFeatureSet(idx=1).GetFeatureId(func=0x10, start=0) -> 3 features # CenturionFeatureSet(idx=1).GetCount (func=0) -> 3 features
# Response: [count, (feat_hi, feat_lo, type, flags) × count] (0x01, 0x00, ""): bytes([0x03, 0x00, 0x00]),
(0x01, 0x10, "00"): bytes( # CenturionFeatureSet(idx=1).GetFeatureId (func=0x10, index=N) -> one feature per response.
[ # Response format: [remaining, feat_hi, feat_lo, type, flags]
0x03, # 3 features (0x01, 0x10, "00"): bytes([0x02, 0x06, 0x04, 0x00, 0x00]), # HEADSET_AUDIO_SIDETONE at sub-idx 0
0x06, (0x01, 0x10, "01"): bytes([0x01, 0x06, 0x01, 0x00, 0x00]), # HEADSET_MIC_MUTE at sub-idx 1
0x04, (0x01, 0x10, "02"): bytes([0x00, 0x06, 0x11, 0x00, 0x00]), # HEADSET_MIC_GAIN at sub-idx 2
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
]
),
} }
featuresarray = hidpp20.FeaturesArray(dev) featuresarray = hidpp20.FeaturesArray(dev)
dev.features = featuresarray dev.features = featuresarray