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:
parent
a5d12f9039
commit
ac7add6297
|
|
@ -19,3 +19,4 @@ class DeviceInfo:
|
|||
hidpp_short: str | None
|
||||
hidpp_long: str | None
|
||||
centurion: bool = False
|
||||
centurion_report_id: int | None = None # 0x50 or 0x51 when centurion=True
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo
|
|||
from hid_parser import ReportDescriptor
|
||||
|
||||
hidpp_short = hidpp_long = centurion = False
|
||||
centurion_report_id = None
|
||||
devfile = "/sys" + hid_device.properties.get("DEVPATH") + "/report_descriptor"
|
||||
with fileopen(devfile, "rb") as fd:
|
||||
with warnings.catch_warnings():
|
||||
|
|
@ -111,16 +112,22 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo
|
|||
# and _Usage(0xFF00, 0x0001) in rd.get_input_items(0x10)[0].usages # be more permissive
|
||||
hidpp_long = 0x11 in rd.input_report_ids and 19 * 8 == int(rd.get_input_report_size(0x11))
|
||||
# and _Usage(0xFF00, 0x0002) in rd.get_input_items(0x11)[0].usages # be more permissive
|
||||
# Centurion transport: report ID 0x51, 63-byte reports (usage page 0xFFA0)
|
||||
centurion = (
|
||||
0x51 in rd.input_report_ids and 63 * 8 == int(rd.get_input_report_size(0x51)) and 0x51 in rd.output_report_ids
|
||||
)
|
||||
# Centurion transport: 63-byte reports on usage page 0xFFA0 (both input and output)
|
||||
# 0x51 = PRO X 2 LIGHTSPEED variant, 0x50 = G522 LIGHTSPEED variant (with device address byte)
|
||||
if 0x51 in rd.input_report_ids and 63 * 8 == int(rd.get_input_report_size(0x51)) and 0x51 in rd.output_report_ids:
|
||||
centurion_report_id = 0x51
|
||||
elif (
|
||||
0x50 in rd.input_report_ids and 63 * 8 == int(rd.get_input_report_size(0x50)) and 0x50 in rd.output_report_ids
|
||||
):
|
||||
centurion_report_id = 0x50
|
||||
centurion = centurion_report_id is not None
|
||||
if not hidpp_short and not hidpp_long and not centurion:
|
||||
return
|
||||
except Exception as e: # if can't process report descriptor fall back to old scheme
|
||||
hidpp_short = None
|
||||
hidpp_long = None
|
||||
centurion = False
|
||||
centurion_report_id = None
|
||||
logger.info(
|
||||
"Report Descriptor not processed for DEVICE %s BID %s VID %s PID %s: %s",
|
||||
device.device_node,
|
||||
|
|
@ -171,6 +178,7 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo
|
|||
hidpp_short=hidpp_short,
|
||||
hidpp_long=hidpp_long,
|
||||
centurion=centurion if centurion else False,
|
||||
centurion_report_id=centurion_report_id,
|
||||
)
|
||||
return d_info
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -97,18 +97,30 @@ HIDPP_LONG_MESSAGE_ID = 0x11
|
|||
DJ_MESSAGE_ID = 0x20
|
||||
|
||||
# Centurion transport (used by PRO X 2 LIGHTSPEED headset and similar)
|
||||
# Uses report ID 0x51 on usage page 0xFFA0, 64-byte frames.
|
||||
# Wire format (CPL): [0x51, cpl_length, flags=0x00, feat_idx, func_sw, params..., pad]
|
||||
# Two variants exist, distinguished by report ID:
|
||||
# 0x51 (PRO X 2): [0x51, cpl_length, flags, feat_idx, func_sw, params..., pad]
|
||||
# 0x50 (G522): [0x50, device_addr, cpl_length, flags, feat_idx, func_sw, params..., pad]
|
||||
# The 0x50 variant adds a device_addr byte at position [1], shifting all CPL fields by +1.
|
||||
# cpl_length = number of bytes from flags to end of meaningful data (includes flags byte).
|
||||
# The device_index byte from standard HID++ is NOT present in Centurion framing.
|
||||
CENTURION_REPORT_ID = 0x51
|
||||
CENTURION_ADDRESSED_REPORT_ID = 0x50 # addressed variant with device_addr byte at frame[1] (G522 etc.)
|
||||
CENTURION_FRAME_SIZE = 64 # 1 byte report ID + 63 bytes payload
|
||||
_CENTURION_MSG_SIZE = 63 # max reconstructed message size after unwrapping (2 + 61 payload bytes)
|
||||
|
||||
# Set of handles that use Centurion framing
|
||||
_centurion_handles: set[int] = set()
|
||||
# Raw Centurion protocol version (major, minor) by handle, from ping response
|
||||
_centurion_protocol_versions: dict[int, tuple[int, int]] = {}
|
||||
|
||||
@dataclasses.dataclass
|
||||
class CenturionHandleState:
|
||||
"""Per-handle state for Centurion devices."""
|
||||
|
||||
report_id: int = CENTURION_REPORT_ID # 0x50 or 0x51
|
||||
device_addr: int | None = None # learned from first RX (0x50 only)
|
||||
protocol_version: tuple[int, int] | None = None # from ping response
|
||||
|
||||
|
||||
# All centurion per-handle state in a single dict.
|
||||
# Membership test (ihandle in _centurion_handles) gates centurion-specific code paths.
|
||||
_centurion_handles: dict[int, CenturionHandleState] = {}
|
||||
|
||||
|
||||
"""Default timeout on read (in seconds)."""
|
||||
|
|
@ -301,8 +313,7 @@ def close(handle):
|
|||
if handle:
|
||||
try:
|
||||
if isinstance(handle, int):
|
||||
_centurion_handles.discard(handle)
|
||||
_centurion_protocol_versions.pop(handle, None)
|
||||
_centurion_handles.pop(handle, None)
|
||||
hidapi.close(handle)
|
||||
else:
|
||||
handle.close()
|
||||
|
|
@ -313,6 +324,115 @@ def close(handle):
|
|||
return False
|
||||
|
||||
|
||||
def _centurion_frame_header(state: CenturionHandleState, cpl_length: int, flags: int) -> bytes:
|
||||
"""Build the fixed prefix of a centurion frame.
|
||||
|
||||
0x51: [0x51, cpl_length, flags] (3 bytes)
|
||||
0x50: [0x50, device_addr, cpl_length, flags] (4 bytes)
|
||||
"""
|
||||
if state.report_id == CENTURION_ADDRESSED_REPORT_ID:
|
||||
device_addr = state.device_addr if state.device_addr is not None else 0x00
|
||||
return struct.pack("!BBBB", CENTURION_ADDRESSED_REPORT_ID, device_addr, cpl_length, flags)
|
||||
return struct.pack("!BBB", CENTURION_REPORT_ID, cpl_length, flags)
|
||||
|
||||
|
||||
_CENTURION_REPORT_IDS = (CENTURION_REPORT_ID, CENTURION_ADDRESSED_REPORT_ID)
|
||||
|
||||
# Per-candidate read timeout (ms) for the device_addr probe.
|
||||
# USB round-trip is <1ms; 5ms gives 5x margin.
|
||||
_CENTURION_PROBE_PER_ADDR_TIMEOUT_MS = 5
|
||||
|
||||
|
||||
def probe_centurion_device_addr(handle, state: CenturionHandleState) -> bool:
|
||||
"""Brute-force probe the device address byte for a 0x50-variant Centurion handle.
|
||||
|
||||
Sends a ROOT.GetProtocolVersion request for each candidate device_addr
|
||||
(0x00–0xFF), reading briefly after each write. The dongle silently ignores
|
||||
wrong addresses and responds only to the correct one. Stops on first hit.
|
||||
|
||||
Worst case (no response): 256 × 5ms = ~1.3s.
|
||||
Typical G522 (addr=0x23): 36 × 5ms = ~180ms.
|
||||
|
||||
No-op for 0x51 (no device_addr byte) or when an address is already known.
|
||||
Returns True if the address was learned.
|
||||
"""
|
||||
if state.report_id != CENTURION_ADDRESSED_REPORT_ID or state.device_addr is not None:
|
||||
return False
|
||||
ihandle = int(handle)
|
||||
logger.debug("(%s) probing centurion device_addr: scanning 0x00-0xFF", handle)
|
||||
|
||||
# ROOT.GetProtocolVersion: feat_idx=0x00, func=0x10, 3 zero param bytes
|
||||
payload = bytes([0x00, 0x10, 0x00, 0x00, 0x00])
|
||||
cpl_length = len(payload) + 1 # +1 for flags byte
|
||||
write_errors = 0
|
||||
|
||||
for addr in range(256):
|
||||
frame = struct.pack("!BBBB", CENTURION_ADDRESSED_REPORT_ID, addr, cpl_length, 0x00) + payload
|
||||
frame = frame + b"\x00" * (CENTURION_FRAME_SIZE - len(frame))
|
||||
try:
|
||||
hidapi.write(ihandle, frame)
|
||||
except Exception:
|
||||
write_errors += 1
|
||||
if write_errors > 3:
|
||||
logger.debug("(%s) centurion device_addr probe: too many write failures, aborting", handle)
|
||||
return False
|
||||
continue
|
||||
try:
|
||||
data = hidapi.read(ihandle, CENTURION_FRAME_SIZE, _CENTURION_PROBE_PER_ADDR_TIMEOUT_MS)
|
||||
except Exception as reason:
|
||||
logger.debug("(%s) centurion device_addr probe read failed at addr 0x%02X: %s", handle, addr, reason)
|
||||
return False
|
||||
if data and len(data) >= 2 and ord(data[:1]) == state.report_id:
|
||||
state.device_addr = ord(data[1:2])
|
||||
logger.debug(
|
||||
"(%s) probed centurion device addr 0x%02X (after %d candidates)",
|
||||
handle,
|
||||
state.device_addr,
|
||||
addr + 1,
|
||||
)
|
||||
return True
|
||||
|
||||
logger.debug("(%s) centurion device_addr probe: no response from any of 256 candidates", handle)
|
||||
return False
|
||||
|
||||
|
||||
def _unwrap_centurion_frame(data: bytes, ihandle: int, handle) -> bytes:
|
||||
"""Unwrap a Centurion CPL frame (0x50 or 0x51) into a standard HID++ long message.
|
||||
|
||||
Auto-detects the variant from the raw report ID byte (self-describing),
|
||||
matching how _read() handles 0x10 vs 0x11.
|
||||
|
||||
For 0x50, learns the device address from byte[1] on first receive.
|
||||
"""
|
||||
raw_report_id = ord(data[:1])
|
||||
if raw_report_id == CENTURION_ADDRESSED_REPORT_ID:
|
||||
# 0x50: [report_id, device_addr, cpl_length, flags, feat_idx, func_sw, data...]
|
||||
device_addr = ord(data[1:2])
|
||||
state = _centurion_handles.get(ihandle)
|
||||
if state is not None and state.device_addr is None:
|
||||
state.device_addr = device_addr
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("(%s) learned centurion device addr 0x%02X", handle, device_addr)
|
||||
cpl_length = ord(data[2:3])
|
||||
inner_payload = data[4 : 3 + cpl_length] # cpl_length - 1 bytes (skip flags)
|
||||
elif raw_report_id == CENTURION_REPORT_ID:
|
||||
# 0x51: [report_id, cpl_length, flags, feat_idx, func_sw, data...]
|
||||
cpl_length = ord(data[1:2])
|
||||
inner_payload = data[3 : 2 + cpl_length] # cpl_length - 1 bytes (skip flags)
|
||||
else:
|
||||
return data # not a centurion frame
|
||||
|
||||
data = bytes([HIDPP_LONG_MESSAGE_ID, 0xFF]) + inner_payload
|
||||
# Pad to a valid message size: standard long (20) or Centurion extended (63)
|
||||
if len(data) <= _LONG_MESSAGE_SIZE:
|
||||
data = data + b"\x00" * (_LONG_MESSAGE_SIZE - len(data))
|
||||
elif len(data) <= _CENTURION_MSG_SIZE:
|
||||
data = data + b"\x00" * (_CENTURION_MSG_SIZE - len(data))
|
||||
else:
|
||||
data = data[:_CENTURION_MSG_SIZE]
|
||||
return data
|
||||
|
||||
|
||||
def write(handle, devnumber, data, long_message=False):
|
||||
"""Writes some data to the receiver, addressed to a certain device.
|
||||
|
||||
|
|
@ -337,12 +457,12 @@ def write(handle, devnumber, data, long_message=False):
|
|||
|
||||
ihandle = int(handle)
|
||||
if ihandle in _centurion_handles:
|
||||
# Centurion CPL framing: [0x51, cpl_length, flags=0x00, feat_idx, func_sw, params...]
|
||||
# cpl_length = len(meaningful_payload) + 1 (the +1 counts the flags byte)
|
||||
# The device_index is stripped — only the HID++ payload (feat_idx + func_sw + params) remains.
|
||||
# Centurion CPL framing — strip device_index from HID++ and wrap in CPL header.
|
||||
# cpl_length = len(meaningful_payload) + 1 (the +1 counts the flags byte).
|
||||
state = _centurion_handles[ihandle]
|
||||
payload = wdata[2:] # skip report_id and devnumber from standard frame
|
||||
cpl_length = len(data) + 1 # data is the unpadded payload; +1 for flags byte
|
||||
wdata = struct.pack("!BBB", CENTURION_REPORT_ID, cpl_length, 0x00) + payload
|
||||
wdata = _centurion_frame_header(state, cpl_length, 0x00) + payload
|
||||
wdata = wdata + b"\x00" * (CENTURION_FRAME_SIZE - len(wdata))
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
|
|
@ -366,7 +486,9 @@ def write(handle, devnumber, data, long_message=False):
|
|||
def write_centurion_cpl(handle, layer3_payload, flags=0x00):
|
||||
"""Send a Centurion CPL frame with the given Layer 3+ payload.
|
||||
|
||||
Builds: [0x51, cpl_length, flags, layer3_payload..., zero-pad to 64 bytes]
|
||||
Builds the appropriate header for the handle's report ID variant:
|
||||
0x51: [0x51, cpl_length, flags, layer3_payload..., pad to 64]
|
||||
0x50: [0x50, device_addr, cpl_length, flags, layer3_payload..., pad to 64]
|
||||
where cpl_length = len(layer3_payload) + 1 (the +1 counts the flags byte).
|
||||
|
||||
For multi-fragment sends, flags encodes fragment index and continuation:
|
||||
|
|
@ -376,11 +498,13 @@ def write_centurion_cpl(handle, layer3_payload, flags=0x00):
|
|||
ihandle = int(handle)
|
||||
if ihandle not in _centurion_handles:
|
||||
raise ValueError("write_centurion_cpl called on non-Centurion handle")
|
||||
state = _centurion_handles[ihandle]
|
||||
cpl_length = len(layer3_payload) + 1 # +1 for flags byte
|
||||
wdata = struct.pack("!BBB", CENTURION_REPORT_ID, cpl_length, flags) + layer3_payload
|
||||
header = _centurion_frame_header(state, cpl_length, flags)
|
||||
wdata = header + layer3_payload
|
||||
wdata = wdata + b"\x00" * (CENTURION_FRAME_SIZE - len(wdata))
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("(%s) <= centurion_cpl[%s]", handle, common.strhex(wdata[: cpl_length + 2]))
|
||||
logger.debug("(%s) <= centurion_cpl[%s]", handle, common.strhex(wdata[: len(header) + cpl_length - 1]))
|
||||
try:
|
||||
hidapi.write(ihandle, wdata)
|
||||
except Exception as reason:
|
||||
|
|
@ -452,22 +576,8 @@ def _read(handle, timeout) -> tuple[int, int, bytes]:
|
|||
close(handle)
|
||||
raise exceptions.NoReceiver(reason=reason) from reason
|
||||
|
||||
if data and is_centurion and ord(data[:1]) == CENTURION_REPORT_ID:
|
||||
# Unwrap Centurion CPL framing:
|
||||
# RX: [0x51, cpl_length, flags=0x00, feat_idx, func_sw, data...]
|
||||
# cpl_length includes the flags byte, so meaningful payload starts at byte 3
|
||||
# and has (cpl_length - 1) bytes.
|
||||
# Reconstruct as HID++ long message: [0x11, devnumber=0xFF, feat_idx, func_sw, data...]
|
||||
cpl_length = ord(data[1:2])
|
||||
inner_payload = data[3 : 2 + cpl_length] # bytes 3..2+cpl_length-1 = cpl_length-1 bytes
|
||||
data = bytes([HIDPP_LONG_MESSAGE_ID, 0xFF]) + inner_payload
|
||||
# Pad to a valid message size: standard long (20) or Centurion extended (63)
|
||||
if len(data) <= _LONG_MESSAGE_SIZE:
|
||||
data = data + b"\x00" * (_LONG_MESSAGE_SIZE - len(data))
|
||||
elif len(data) <= _CENTURION_MSG_SIZE:
|
||||
data = data + b"\x00" * (_CENTURION_MSG_SIZE - len(data))
|
||||
else:
|
||||
data = data[:_CENTURION_MSG_SIZE]
|
||||
if data and is_centurion and ord(data[:1]) in _CENTURION_REPORT_IDS:
|
||||
data = _unwrap_centurion_frame(data, ihandle, handle)
|
||||
|
||||
if data and _is_relevant_message(data): # ignore messages that fail check
|
||||
report_id = ord(data[:1])
|
||||
|
|
@ -725,7 +835,7 @@ def ping(handle, devnumber, long_message: bool = False):
|
|||
major = ord(reply_data[2:3])
|
||||
minor = ord(reply_data[3:4])
|
||||
if is_centurion:
|
||||
_centurion_protocol_versions[int(handle)] = (major, minor)
|
||||
_centurion_handles[int(handle)].protocol_version = (major, minor)
|
||||
return major + minor / 10.0
|
||||
|
||||
if (
|
||||
|
|
@ -771,17 +881,8 @@ def _read_input_buffer(handle, ihandle, notifications_hook):
|
|||
raise exceptions.NoReceiver(reason=reason) from reason
|
||||
|
||||
if data:
|
||||
if is_centurion and ord(data[:1]) == CENTURION_REPORT_ID:
|
||||
# Unwrap Centurion CPL framing same as in _read()
|
||||
cpl_length = ord(data[1:2])
|
||||
inner_payload = data[3 : 2 + cpl_length]
|
||||
data = bytes([HIDPP_LONG_MESSAGE_ID, 0xFF]) + inner_payload
|
||||
if len(data) <= _LONG_MESSAGE_SIZE:
|
||||
data = data + b"\x00" * (_LONG_MESSAGE_SIZE - len(data))
|
||||
elif len(data) <= _CENTURION_MSG_SIZE:
|
||||
data = data + b"\x00" * (_CENTURION_MSG_SIZE - len(data))
|
||||
else:
|
||||
data = data[:_CENTURION_MSG_SIZE]
|
||||
if is_centurion and ord(data[:1]) in _CENTURION_REPORT_IDS:
|
||||
data = _unwrap_centurion_frame(data, ihandle, handle)
|
||||
if _is_relevant_message(data): # only process messages that pass check
|
||||
# report_id = ord(data[:1])
|
||||
if notifications_hook:
|
||||
|
|
|
|||
|
|
@ -266,6 +266,7 @@ class CenturionReceiver:
|
|||
self._devices = {}
|
||||
self._firmware = None
|
||||
self._dongle_features = None # independently probed dongle features
|
||||
self._pending = False # True when device_addr unknown; deferred init completes on first RX
|
||||
self.cleanups = []
|
||||
|
||||
# Receiver identity
|
||||
|
|
@ -319,7 +320,7 @@ class CenturionReceiver:
|
|||
if feat_id == feature_int:
|
||||
request_id = (index << 8) | (function & 0xFF)
|
||||
return self.request(request_id, *params, no_reply=no_reply)
|
||||
raise exceptions.FeatureNotSupported(feature)
|
||||
raise exceptions.FeatureNotSupported(feature=feature)
|
||||
|
||||
def _discover_dongle_features(self):
|
||||
"""Independently discover features on the dongle hardware."""
|
||||
|
|
@ -363,15 +364,67 @@ class CenturionReceiver:
|
|||
|
||||
@property
|
||||
def firmware(self):
|
||||
if self._firmware is None and self.handle:
|
||||
if self._firmware is None and self.handle and not self._pending:
|
||||
self._firmware = get_firmware_centurion(self)
|
||||
return self._firmware or ()
|
||||
|
||||
def _complete_deferred_init(self):
|
||||
"""Re-run feature discovery after device_addr has been learned.
|
||||
|
||||
Called once from the notification handler when the first 0x50 frame
|
||||
arrives on a pending CenturionReceiver.
|
||||
"""
|
||||
if not self._pending:
|
||||
return False
|
||||
self._pending = False
|
||||
ihandle = int(self.handle)
|
||||
state = base._centurion_handles.get(ihandle)
|
||||
learned_addr = state.device_addr if state else None
|
||||
logger.debug(
|
||||
"CenturionReceiver %s: completing deferred init (device_addr=0x%02X)",
|
||||
self.path,
|
||||
learned_addr or 0,
|
||||
)
|
||||
|
||||
self._dongle_features = None
|
||||
self._discover_dongle_features()
|
||||
logger.debug(
|
||||
"CenturionReceiver %s: deferred discovery found %d feature(s): %s",
|
||||
self.path,
|
||||
len(self._dongle_features or []),
|
||||
[(f"{feat_id:#06x}", idx) for _, feat_id, idx in (self._dongle_features or [])],
|
||||
)
|
||||
|
||||
if self.serial is None:
|
||||
try:
|
||||
s = get_serial_centurion(self)
|
||||
if s and s.strip() and s.strip().isprintable():
|
||||
self.serial = s.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
has_bridge = any(feat_id == CenturionCoreFeature.CENT_PP_BRIDGE for _, feat_id, _ in (self._dongle_features or []))
|
||||
if has_bridge:
|
||||
self.notify_devices()
|
||||
return True
|
||||
logger.warning(
|
||||
"CenturionReceiver %s: deferred init completed but no bridge found " "(features: %s)",
|
||||
self.path,
|
||||
[f"{feat_id:#06x}" for _, feat_id, _ in (self._dongle_features or [])],
|
||||
)
|
||||
return False
|
||||
|
||||
def notify_devices(self):
|
||||
"""Create child Device for the headset and trigger its initialization."""
|
||||
# Import Device locally to avoid circular import (centurion.py ↔ device.py)
|
||||
from .device import Device
|
||||
|
||||
if self._pending:
|
||||
# Don't create children yet — feature discovery hasn't succeeded.
|
||||
# Signal receiver to UI so the tray entry exists.
|
||||
self.changed(alert=Alert.NONE)
|
||||
return
|
||||
|
||||
# Signal receiver to UI first — tray/window need the receiver entry
|
||||
# before a child device can be added under it.
|
||||
self.changed(alert=Alert.NONE)
|
||||
|
|
@ -410,6 +463,13 @@ class CenturionReceiver:
|
|||
# Ping to determine online status.
|
||||
# Notify UI either way — offline devices show as greyed out (matching receiver behavior).
|
||||
online = dev.ping()
|
||||
logger.debug(
|
||||
"CenturionReceiver %s: child device created, bridge_idx=%s, online=%s, protocol=%s",
|
||||
self.path,
|
||||
getattr(dev, "_centurion_bridge_index", None),
|
||||
online,
|
||||
dev._protocol,
|
||||
)
|
||||
dev.changed(active=online)
|
||||
if self.status_callback is not None:
|
||||
self.status_callback(dev)
|
||||
|
|
@ -495,17 +555,33 @@ def create_centurion_receiver(low_level, device_info, setting_callback=None):
|
|||
try:
|
||||
handle = low_level.open_path(device_info.path)
|
||||
if handle:
|
||||
base._centurion_handles.add(int(handle))
|
||||
report_id = getattr(device_info, "centurion_report_id", None) or base.CENTURION_REPORT_ID
|
||||
state = base.CenturionHandleState(report_id=report_id)
|
||||
base._centurion_handles[int(handle)] = state
|
||||
base.probe_centurion_device_addr(handle, state)
|
||||
cr = CenturionReceiver(low_level, handle, device_info, setting_callback)
|
||||
# Check if any discovered feature is CentPPBridge (0x0003)
|
||||
has_bridge = any(feat_id == CenturionCoreFeature.CENT_PP_BRIDGE for _, feat_id, _ in (cr.dongle_features or []))
|
||||
if not has_bridge:
|
||||
logger.info("Centurion device %s has no bridge, treating as direct device", device_info.path)
|
||||
base._centurion_handles.discard(int(handle))
|
||||
cr.handle = None # prevent __del__ from double-closing
|
||||
low_level.close(handle)
|
||||
return None
|
||||
return cr
|
||||
if has_bridge:
|
||||
return cr
|
||||
|
||||
# No bridge found. Distinguish "silent 0x50 dongle" (device_addr
|
||||
# unknown, headset not yet powered on) from "wired 0x50 device"
|
||||
# (responded to probe, features found, but no bridge).
|
||||
is_0x50 = state.report_id == base.CENTURION_ADDRESSED_REPORT_ID
|
||||
if is_0x50 and state.device_addr is None and not cr.dongle_features:
|
||||
logger.debug(
|
||||
"Centurion 0x50 device %s: probe and discovery failed, " "deferring init until first RX frame",
|
||||
device_info.path,
|
||||
)
|
||||
cr._pending = True
|
||||
return cr
|
||||
|
||||
logger.info("Centurion device %s has no bridge, treating as direct device", device_info.path)
|
||||
base._centurion_handles.pop(int(handle), None)
|
||||
cr.handle = None # prevent __del__ from double-closing
|
||||
low_level.close(handle)
|
||||
return None
|
||||
except OSError as e:
|
||||
logger.exception("open %s", device_info)
|
||||
if e.errno == errno.EACCES:
|
||||
|
|
|
|||
|
|
@ -466,3 +466,4 @@ _D(
|
|||
usbid=0x0ABA,
|
||||
)
|
||||
# PRO X 2 LIGHTSPEED Gaming Headset (0x0AF7) — fully probed via Centurion transport, no static descriptor needed
|
||||
# G522 LIGHTSPEED Gaming Headset (0x0B18 dongle, 0x0B19 wired) — Centurion 0x50 variant, no static descriptor needed
|
||||
|
|
|
|||
|
|
@ -79,7 +79,10 @@ def create_device(low_level: LowLevelInterface, device_info, setting_callback=No
|
|||
handle = low_level.open_path(device_info.path)
|
||||
if handle:
|
||||
if getattr(device_info, "centurion", False):
|
||||
base._centurion_handles.add(int(handle))
|
||||
report_id = getattr(device_info, "centurion_report_id", None) or base.CENTURION_REPORT_ID
|
||||
state = base.CenturionHandleState(report_id=report_id)
|
||||
base._centurion_handles[int(handle)] = state
|
||||
base.probe_centurion_device_addr(handle, state)
|
||||
# a direct connected device might not be online (as reported by user)
|
||||
return Device(
|
||||
low_level,
|
||||
|
|
@ -223,7 +226,9 @@ class Device:
|
|||
self._protocol = self.descriptor.protocol if self.descriptor.protocol else None
|
||||
self.registers = self.descriptor.registers if self.descriptor.registers else []
|
||||
|
||||
if self._protocol is not None:
|
||||
# Centurion devices always use HID++ 2.0 features regardless of the
|
||||
# protocol version the dongle reports (e.g. G522 reports 1.1).
|
||||
if self._protocol is not None and not self.centurion:
|
||||
self.features = {} if self._protocol < 2.0 else hidpp20.FeaturesArray(self)
|
||||
else:
|
||||
self.features = hidpp20.FeaturesArray(self) # may be a 2.0 device; if not, it will fix itself later
|
||||
|
|
@ -243,7 +248,13 @@ class Device:
|
|||
self.ping()
|
||||
except exceptions.NoSuchDevice:
|
||||
logger.warning("device %s inaccessible - no protocol set", self)
|
||||
return self._protocol or 0
|
||||
result = self._protocol or 0
|
||||
# Centurion devices always use HID++ 2.0 features regardless of the
|
||||
# protocol version the dongle reports (e.g. G522 reports 1.1).
|
||||
# Ensure all `protocol < 2.0` gates route through the 2.0 code path.
|
||||
if self.centurion and result < 2.0:
|
||||
return 2.0
|
||||
return result
|
||||
|
||||
@property
|
||||
def codename(self):
|
||||
|
|
@ -523,6 +534,15 @@ class Device:
|
|||
|
||||
def battery(self): # None or level, next, status, voltage
|
||||
if self.protocol < 2.0:
|
||||
if self.centurion:
|
||||
logger.debug(
|
||||
"%s: battery() dispatching HID++ 1.0 path for a Centurion device "
|
||||
"(protocol=%s, _protocol=%s) — device_addr probe likely failed, "
|
||||
"expect INVALID_SUB_ID_COMMAND",
|
||||
self,
|
||||
self.protocol,
|
||||
self._protocol,
|
||||
)
|
||||
return _hidpp10.get_battery(self)
|
||||
else:
|
||||
battery_feature = self.persister.get("_battery", None) if self.persister else None
|
||||
|
|
@ -690,26 +710,40 @@ class Device:
|
|||
# Ensure sub-device features are discovered before routing decision
|
||||
if self.features is not None:
|
||||
self.features._check()
|
||||
# Guard against Centurion/HID++ 2.0 feature ID collisions. IntEnum
|
||||
# members with the same int value hash equal, so a dict lookup for
|
||||
# SupportedFeature.DEVICE_NAME (0x0005) succeeds even when the
|
||||
# device actually has CenturionCoreFeature.MULTI_HOST_CONTROL at
|
||||
# that slot. If the type of the stored enum differs from what the
|
||||
# caller asked for, treat the feature as unsupported.
|
||||
if self.features is not None:
|
||||
idx = self.features.get(feature)
|
||||
if idx is not None:
|
||||
stored = self.features.inverse.get(idx)
|
||||
if stored is not None and type(stored) is not type(feature):
|
||||
return None
|
||||
if feature in getattr(self, "_centurion_sub_features", ()):
|
||||
sub_idx = self.features.get(feature)
|
||||
if sub_idx is not None:
|
||||
return self.centurion_bridge_request(sub_idx, function, *params, no_reply=no_reply)
|
||||
return hidpp20.feature_request(self, feature, function, *params, no_reply=no_reply)
|
||||
|
||||
# Max sub-message bytes in the first bridge fragment:
|
||||
# 64 - 1 (report ID) - 1 (cpl_len) - 1 (flags) - 2 (bridge prefix) - 2 (bridge hdr) = 57
|
||||
# LGHUB uses 56 for first fragment (60 byte payload - 4 bridge overhead)
|
||||
# Max sub-message bytes in the first bridge fragment (for 0x51):
|
||||
# 64 - 1 (report ID) - 1 (cpl_len) - 1 (flags) - 2 (bridge prefix) - 2 (bridge hdr) = 57;
|
||||
# one byte of conservative margin gives 56. For 0x50 the device_addr byte
|
||||
# eats one more, so first_chunk = 55 (handled dynamically below).
|
||||
_BRIDGE_FIRST_CHUNK = 56
|
||||
# Continuation fragments carry raw sub_msg data (no bridge prefix/hdr):
|
||||
# 64 - 1 (report ID) - 1 (cpl_len) - 1 (flags) = 61, but LGHUB uses 60
|
||||
# 64 - 1 (report ID) - 1 (cpl_len) - 1 (flags) = 61; one byte of margin
|
||||
# gives 60.
|
||||
_BRIDGE_CONT_CHUNK = 60
|
||||
|
||||
def centurion_bridge_request(self, sub_feat_idx, sub_function=0x00, *params, no_reply=False):
|
||||
"""Send a request to a Centurion sub-device via CentPPBridge.
|
||||
|
||||
Builds the 4-layer nested message:
|
||||
Layer 1: [0x51]
|
||||
Layer 2: [cpl_length, flags]
|
||||
Layer 1: [report_id] (0x51 or 0x50)
|
||||
Layer 2: [device_addr (0x50 only),] cpl_length, flags
|
||||
Layer 3: [bridge_idx, sendFragment_func|swid, bridge_hdr...]
|
||||
Layer 4: [sub_cpl=0x00, sub_feat_idx, sub_func|swid, params...]
|
||||
|
||||
|
|
@ -730,6 +764,12 @@ class Device:
|
|||
if not handle:
|
||||
return None
|
||||
|
||||
# Adjust bridge chunk sizes for 0x50 variant (device_addr byte takes 1 frame byte)
|
||||
cent_state = base._centurion_handles.get(int(handle))
|
||||
addr_overhead = 1 if cent_state and cent_state.report_id == base.CENTURION_ADDRESSED_REPORT_ID else 0
|
||||
first_chunk = self._BRIDGE_FIRST_CHUNK - addr_overhead
|
||||
cont_chunk = self._BRIDGE_CONT_CHUNK - addr_overhead
|
||||
|
||||
sw_id = base._get_next_sw_id()
|
||||
|
||||
# Build sub-device message: [sub_cpl=0x00, sub_feat_idx, sub_func|swid, params...]
|
||||
|
|
@ -745,7 +785,15 @@ class Device:
|
|||
|
||||
timeout = base.DEFAULT_TIMEOUT
|
||||
with base.acquire_timeout(base.handle_lock(handle), handle, timeout):
|
||||
if sub_len <= self._BRIDGE_FIRST_CHUNK:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
"bridge TX: sub_idx=%d func=0x%02X sw_id=%d payload=%s",
|
||||
sub_feat_idx,
|
||||
sub_function,
|
||||
sw_id,
|
||||
sub_params.hex() if sub_params else "",
|
||||
)
|
||||
if sub_len <= first_chunk:
|
||||
# Single-frame path
|
||||
layer3 = bridge_prefix + bridge_hdr + sub_msg
|
||||
base.write_centurion_cpl(handle, layer3)
|
||||
|
|
@ -755,18 +803,17 @@ class Device:
|
|||
# Fragments 1+: raw sub_msg continuation data (no bridge overhead)
|
||||
# CPL flags = (frag_index << 1) | (1 if more_fragments else 0)
|
||||
# All fragments are sent back-to-back without waiting for
|
||||
# intermediate ACKs (verified via LGHUB pcap). The device
|
||||
# reassembles internally and sends a single ACK + MessageEvent
|
||||
# after the last fragment.
|
||||
# intermediate ACKs. The device reassembles internally and
|
||||
# sends a single ACK + MessageEvent after the last fragment.
|
||||
frag_index = 0
|
||||
offset = 0
|
||||
while offset < sub_len:
|
||||
if frag_index == 0:
|
||||
chunk_size = self._BRIDGE_FIRST_CHUNK
|
||||
chunk_size = first_chunk
|
||||
chunk = sub_msg[offset : offset + chunk_size]
|
||||
layer3 = bridge_prefix + bridge_hdr + chunk
|
||||
else:
|
||||
chunk_size = self._BRIDGE_CONT_CHUNK
|
||||
chunk_size = cont_chunk
|
||||
chunk = sub_msg[offset : offset + chunk_size]
|
||||
layer3 = chunk
|
||||
has_more = (offset + chunk_size) < sub_len
|
||||
|
|
@ -778,6 +825,13 @@ class Device:
|
|||
if no_reply:
|
||||
return None
|
||||
|
||||
# The device echoes our exact sub-device function+swid byte in
|
||||
# MessageEvent responses. Match on that to reject cross-contamination
|
||||
# from late-arriving responses to other function calls on the same
|
||||
# feature (e.g. GetRGBZoneInfo response showing up on a later
|
||||
# GetHostModeState read).
|
||||
expected_sub_func_sw = (sub_function & 0xF0) | sw_id
|
||||
|
||||
# Read ACK + MessageEvent response
|
||||
request_started = time.time()
|
||||
ack_received = False
|
||||
|
|
@ -794,11 +848,21 @@ class Device:
|
|||
break
|
||||
if (func_sw >> 4) == 0x01 and (func_sw & 0x0F) == 0:
|
||||
# MessageEvent arrived before ACK — validate it's for our request
|
||||
if self._is_bridge_response_for(reply_data, sub_feat_idx):
|
||||
if self._is_bridge_response_for(reply_data, sub_feat_idx, expected_sub_func_sw):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("bridge idx=%d fn=0x%02X -> OK", sub_feat_idx, sub_function)
|
||||
return self._parse_bridge_response(reply_data)
|
||||
# Unsolicited notification, skip it
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
"bridge skipping reply (pre-ACK): got sub_cpl=0x%02X sub_idx=0x%02X func_sw=0x%02X"
|
||||
" (expected idx=0x%02X func_sw=0x%02X) data=%s",
|
||||
reply_data[4] if len(reply_data) > 4 else 0,
|
||||
reply_data[5] if len(reply_data) > 5 else 0,
|
||||
reply_data[6] if len(reply_data) > 6 else 0,
|
||||
sub_feat_idx,
|
||||
expected_sub_func_sw,
|
||||
reply_data.hex(),
|
||||
)
|
||||
if not ack_received:
|
||||
logger.warning("centurion_bridge_request: no ACK received")
|
||||
return None
|
||||
|
|
@ -812,11 +876,21 @@ class Device:
|
|||
if len(reply_data) >= 2 and reply_data[0] == bridge_idx:
|
||||
func_sw = reply_data[1]
|
||||
if (func_sw >> 4) == 0x01 and (func_sw & 0x0F) == 0:
|
||||
if self._is_bridge_response_for(reply_data, sub_feat_idx):
|
||||
if self._is_bridge_response_for(reply_data, sub_feat_idx, expected_sub_func_sw):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("bridge idx=%d fn=0x%02X -> OK", sub_feat_idx, sub_function)
|
||||
return self._parse_bridge_response(reply_data)
|
||||
# Unsolicited notification for a different feature, skip it
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
"bridge skipping reply (post-ACK): got sub_cpl=0x%02X sub_idx=0x%02X func_sw=0x%02X"
|
||||
" (expected idx=0x%02X func_sw=0x%02X) data=%s",
|
||||
reply_data[4] if len(reply_data) > 4 else 0,
|
||||
reply_data[5] if len(reply_data) > 5 else 0,
|
||||
reply_data[6] if len(reply_data) > 6 else 0,
|
||||
sub_feat_idx,
|
||||
expected_sub_func_sw,
|
||||
reply_data.hex(),
|
||||
)
|
||||
logger.warning("centurion_bridge_request: no MessageEvent received")
|
||||
return None
|
||||
|
||||
|
|
@ -836,12 +910,19 @@ class Device:
|
|||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_bridge_response_for(reply_data, expected_sub_feat_idx):
|
||||
def _is_bridge_response_for(reply_data, expected_sub_feat_idx, expected_sub_func_sw=None):
|
||||
"""Check if a bridge MessageEvent is a response for our specific sub-feature request.
|
||||
|
||||
Accepts both normal responses (sub_feat_idx matches) and error responses
|
||||
(sub_feat_idx=0xFF with original feat_idx in next byte).
|
||||
Unsolicited notifications (sub_cpl=0xFF) are rejected.
|
||||
|
||||
If `expected_sub_func_sw` is provided, also matches on the echoed
|
||||
sub-device function byte (`(function << 4) | sw_id`). This prevents
|
||||
cross-talk between different function calls on the SAME feature, which
|
||||
can happen when a late-arriving response for one function gets picked
|
||||
up by a later request on the same feature (observed on G522 where a
|
||||
GetRGBZoneInfo response contaminated a subsequent GetHostModeState).
|
||||
"""
|
||||
if len(reply_data) < 6:
|
||||
return False
|
||||
|
|
@ -851,9 +932,15 @@ class Device:
|
|||
if sub_cpl != 0x00:
|
||||
return False
|
||||
if sub_feat_idx == expected_sub_feat_idx:
|
||||
if expected_sub_func_sw is not None and len(reply_data) >= 7:
|
||||
if reply_data[6] != expected_sub_func_sw:
|
||||
return False
|
||||
return True
|
||||
# Error response: sub_feat_idx=0xFF, next byte is the original feat_idx that errored
|
||||
if sub_feat_idx == 0xFF and len(reply_data) >= 7 and reply_data[6] == expected_sub_feat_idx:
|
||||
if expected_sub_func_sw is not None and len(reply_data) >= 8:
|
||||
if reply_data[7] != expected_sub_func_sw:
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
||||
|
|
@ -873,18 +960,25 @@ class Device:
|
|||
sub_feat_idx = reply_data[5]
|
||||
# Error response from sub-device
|
||||
if sub_feat_idx == 0xFF:
|
||||
error_code = reply_data[8] if len(reply_data) > 8 else 0
|
||||
# Error frame layout after sub_cpl: [0xFF, orig_feat_idx, orig_func_sw, error_code, ...]
|
||||
orig_feat_idx = reply_data[6] if len(reply_data) > 6 else 0
|
||||
logger.debug("bridge sub-device error: feat_idx=%d error=0x%02X", orig_feat_idx, error_code)
|
||||
orig_func_sw = reply_data[7] if len(reply_data) > 7 else 0
|
||||
error_code = reply_data[8] if len(reply_data) > 8 else 0
|
||||
logger.debug(
|
||||
"bridge sub-device error: orig_feat_idx=%d orig_func=0x%02X error=0x%02X",
|
||||
orig_feat_idx,
|
||||
orig_func_sw,
|
||||
error_code,
|
||||
)
|
||||
return None
|
||||
return reply_data[7:] # response data after sub_cpl, sub_feat_idx, sub_func_sw
|
||||
|
||||
def _record_ping_protocol(self, handle, protocol):
|
||||
"""Record a successful ping's protocol version, including raw Centurion (major, minor)."""
|
||||
self._protocol = protocol
|
||||
cent_ver = base._centurion_protocol_versions.get(int(handle))
|
||||
if cent_ver:
|
||||
self._centurion_protocol = cent_ver
|
||||
cent_state = base._centurion_handles.get(int(handle))
|
||||
if cent_state and cent_state.protocol_version:
|
||||
self._centurion_protocol = cent_state.protocol_version
|
||||
|
||||
def ping(self):
|
||||
"""Checks if the device is online and present, returns True of False.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
@ -197,13 +197,27 @@ class FeaturesArray(dict):
|
|||
response = self.device.request((fs_index << 8) | 0x10, index)
|
||||
if response is None or len(response) < 3:
|
||||
continue
|
||||
# Centurion FeatureSet response: [remaining_count, feat_hi, feat_lo, type, flags]
|
||||
# Centurion FeatureSet response: [remaining_count, feat_hi, feat_lo, type, version]
|
||||
feat_id = struct.unpack("!H", response[1:3])[0]
|
||||
feat_type = response[3] if len(response) > 3 else 0
|
||||
feat_version = response[4] if len(response) > 4 else 0
|
||||
feature = resolve_feature(feat_id, centurion=True)
|
||||
if feature is None:
|
||||
feature = f"unknown:{feat_id:04X}"
|
||||
self[feature] = index
|
||||
self.inverse[index] = feature
|
||||
# Record version/flags so version-gated settings (sidetone, auto-sleep)
|
||||
# use the correct payload format on direct USB Centurion devices too.
|
||||
self.version[feature] = feat_version
|
||||
self.flags[feature] = feat_type
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
"Centurion parent feature: %s at index %d, version=%d, flags=0x%02X",
|
||||
feature,
|
||||
index,
|
||||
feat_version,
|
||||
feat_type,
|
||||
)
|
||||
if feature is CenturionCoreFeature.CENT_PP_BRIDGE:
|
||||
bridge_index = index
|
||||
|
||||
|
|
@ -216,8 +230,11 @@ class FeaturesArray(dict):
|
|||
def _discover_sub_device_features(self, bridge_index):
|
||||
"""Phase B: Discover sub-device features via CentPPBridge.
|
||||
|
||||
Uses CenturionFeatureSet bulk query (function 1, index 0) routed through
|
||||
the bridge to get all sub-device features at once.
|
||||
Uses per-index queries: GetCount (func 0) returns total count, then
|
||||
GetFeatureId (func 1) returns one feature per call. Avoids the
|
||||
single-frame truncation of bulk queries — a Centurion frame is 64
|
||||
bytes so a bulk reply can only fit ~13 features regardless of how
|
||||
many the sub-device actually has.
|
||||
"""
|
||||
# First, find the sub-device's FeatureSet index via CenturionRoot (sub_feat_idx=0)
|
||||
# Query: CenturionRoot.GetFeature(0x0001) to find FeatureSet index on sub-device
|
||||
|
|
@ -232,44 +249,62 @@ class FeaturesArray(dict):
|
|||
logger.warning("Sub-device FeatureSet not found (index=0)")
|
||||
return
|
||||
|
||||
# Bulk enumerate: CenturionFeatureSet.GetFeatureId(func=1=0x10, start_index=0)
|
||||
# Response: [count, (feat_hi, feat_lo, type, flags) × count]
|
||||
response = self.device.centurion_bridge_request(sub_fs_index, 0x10, 0x00)
|
||||
if response is None or len(response) < 1:
|
||||
logger.warning("Failed to enumerate sub-device features")
|
||||
# Query feature count (function 0 = GetCount). Response: [count, ...].
|
||||
count_resp = self.device.centurion_bridge_request(sub_fs_index, 0x00)
|
||||
if count_resp is None or len(count_resp) < 1:
|
||||
logger.warning("Failed to read Centurion sub-device feature count")
|
||||
return
|
||||
total_count = count_resp[0]
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Centurion sub-device: FeatureSet reports %d features", total_count)
|
||||
|
||||
entry_count = response[0]
|
||||
entries = response[1:]
|
||||
sub_feat_idx = 0 # sub-device feature indices start at 0
|
||||
for i in range(entry_count):
|
||||
offset = i * 4
|
||||
if offset + 2 > len(entries):
|
||||
break
|
||||
feat_id = struct.unpack("!H", entries[offset : offset + 2])[0]
|
||||
# Per-index query: GetFeatureId (function 1 = 0x10).
|
||||
# Response: [remaining, feat_hi, feat_lo, type, version].
|
||||
# We now also record `type` (flags) and `version` for each feature so
|
||||
# version-gated settings (sidetone, auto-sleep, etc.) can use the
|
||||
# correct payload format instead of defaulting to V0.
|
||||
sub_feat_idx = 0
|
||||
for idx in range(total_count):
|
||||
response = self.device.centurion_bridge_request(sub_fs_index, 0x10, idx)
|
||||
if response is None or len(response) < 3:
|
||||
logger.debug("Centurion sub-device: no response at index %d", idx)
|
||||
continue
|
||||
feat_id = struct.unpack("!H", response[1:3])[0]
|
||||
feat_type = response[3] if len(response) > 3 else 0
|
||||
feat_version = response[4] if len(response) > 4 else 0
|
||||
try:
|
||||
feature = SupportedFeature(feat_id)
|
||||
except ValueError:
|
||||
feature = f"unknown:{feat_id:04X}"
|
||||
# Store sub-device index for ALL features (including parent overlaps)
|
||||
# This enables querying the sub-device's copy of shared features via bridge
|
||||
self.device._centurion_sub_indices[feature] = sub_feat_idx
|
||||
# Only store unique sub-device features in dict (skip parent overlaps like ROOT, FEATURE_SET)
|
||||
# This avoids clobbering parent inverse entries via __setitem__
|
||||
if dict.get(self, feature) is None:
|
||||
dict.__setitem__(self, feature, sub_feat_idx)
|
||||
self.device._centurion_sub_features.add(feature)
|
||||
# Always store in sub_inverse for sub-device enumerate/display
|
||||
self.sub_inverse[sub_feat_idx] = feature
|
||||
# Record version/flags so downstream settings can version-gate their
|
||||
# payload format. get_feature_version(feature) reads self.version[feature].
|
||||
self.version[feature] = feat_version
|
||||
self.flags[feature] = feat_type
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Centurion sub-device feature: %s at sub-index %d", feature, sub_feat_idx)
|
||||
logger.debug(
|
||||
"Centurion sub-device feature: %s at sub-index %d, version=%d, flags=0x%02X",
|
||||
feature,
|
||||
sub_feat_idx,
|
||||
feat_version,
|
||||
feat_type,
|
||||
)
|
||||
sub_feat_idx += 1
|
||||
self._sub_feature_count = sub_feat_idx
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Centurion sub-device: discovered %d features total", sub_feat_idx)
|
||||
|
||||
def get_feature(self, index: int) -> SupportedFeature | None:
|
||||
feature = self.inverse.get(index)
|
||||
if feature is not None:
|
||||
return feature
|
||||
# Sub-device index; bridge unwrap offsets by 0x100 (see listener).
|
||||
if index >= 0x100:
|
||||
return self.sub_inverse.get(index - 0x100)
|
||||
elif self._check():
|
||||
feature = self.inverse.get(index)
|
||||
if feature is not None:
|
||||
|
|
@ -334,6 +369,12 @@ class FeaturesArray(dict):
|
|||
index = super().get(feature)
|
||||
if index is not None:
|
||||
return index
|
||||
# Centurion devices enumerate all features upfront in _check_centurion().
|
||||
# If the feature isn't in the dict after _check(), it genuinely doesn't
|
||||
# exist — skip the raw ROOT.GetFeature query that the dongle rejects
|
||||
# with LOGITECH_ERROR and that creates cycling log spam during settings init.
|
||||
if getattr(self.device, "centurion", False):
|
||||
return None
|
||||
try:
|
||||
response = self.device.request(0x0000, struct.pack("!H", feature))
|
||||
except exceptions.FeatureCallError:
|
||||
|
|
@ -2365,6 +2406,18 @@ class ForceSensingButtonArray(UserDict):
|
|||
|
||||
|
||||
# --- OnboardEQ (0x0636) — re-exported from onboard_eq.py ---
|
||||
# --- AdvancedParaEQ (0x020D) — re-exported from advanced_para_eq.py ---
|
||||
from .advanced_para_eq import FILTER_TYPE_HP # noqa: E402, F401
|
||||
from .advanced_para_eq import FILTER_TYPE_PEAKING # noqa: E402, F401
|
||||
from .advanced_para_eq import FILTER_TYPE_PEAKING_G522 # noqa: E402, F401
|
||||
from .advanced_para_eq import get_advanced_eq_active_slot # noqa: E402, F401
|
||||
from .advanced_para_eq import get_advanced_eq_defaults # noqa: E402, F401
|
||||
from .advanced_para_eq import get_advanced_eq_friendly_name # noqa: E402, F401
|
||||
from .advanced_para_eq import get_advanced_eq_info # noqa: E402, F401
|
||||
from .advanced_para_eq import get_advanced_eq_params # noqa: E402, F401
|
||||
from .advanced_para_eq import parse_v2_bands # noqa: E402, F401
|
||||
from .advanced_para_eq import probe_advanced_eq_slots # noqa: E402, F401
|
||||
from .advanced_para_eq import probe_all_presets as probe_advanced_eq_presets # noqa: E402, F401
|
||||
from .onboard_eq import _build_set_eq_payload # noqa: E402, F401
|
||||
from .onboard_eq import get_onboard_eq_info # noqa: E402, F401
|
||||
from .onboard_eq import get_onboard_eq_params # noqa: E402, F401
|
||||
|
|
|
|||
|
|
@ -214,6 +214,10 @@ class SupportedFeature(IntEnum):
|
|||
HEADSET_RGB_HOSTMODE = 0x0620
|
||||
HEADSET_RGB_ONBOARD_EFFECTS = 0x0621
|
||||
HEADSET_RGB_SIGNATURE_EFFECTS = 0x0622
|
||||
# 0x0623 is present on G522 sub-device but its function set is unmapped;
|
||||
# add probe coverage in rgb_effects_probe so the next bring-up captures
|
||||
# whatever read functions respond.
|
||||
HEADSET_RGB_0623 = 0x0623
|
||||
HEADSET_DO_NOT_DISTURB = 0x0631
|
||||
CENTURION_ONBOARD_PROFILES = 0x0634
|
||||
HEADSET_RGB_STREAMING = 0x0635
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
|
|
@ -52,9 +53,11 @@ class _ThreadedHandle:
|
|||
else:
|
||||
# if logger.isEnabledFor(logging.DEBUG):
|
||||
# logger.debug("%r opened new handle %d", self, handle)
|
||||
# If original handle was centurion, register new per-thread handle too
|
||||
if any(h in base._centurion_handles for h in self._handles):
|
||||
base._centurion_handles.add(handle)
|
||||
# If original handle was centurion, copy state to new per-thread handle
|
||||
for h in self._handles:
|
||||
if h in base._centurion_handles:
|
||||
base._centurion_handles[handle] = dataclasses.replace(base._centurion_handles[h])
|
||||
break
|
||||
self._local.handle = handle
|
||||
self._handles.append(handle)
|
||||
return handle
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -436,6 +436,47 @@ def _process_feature_notification(device: Device, notification: HIDPPNotificatio
|
|||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: RGB_EFFECTS notification addr=%02x: %s", device, notification.address, notification)
|
||||
|
||||
elif feature == SupportedFeature.HEADSET_ADVANCED_PARA_EQ:
|
||||
# G522 emits change events with the same payload shape as the
|
||||
# corresponding setter request:
|
||||
# fn 0 — band change (3-byte header [dir, slot, pad] + bands)
|
||||
# fn 2 — friendly-name change (header + nameLen + name)
|
||||
# fn 3 — UUID change (header + 16-byte UUID)
|
||||
# Low nibble of `address` is the swid the firmware echoes back —
|
||||
# match on the function index only.
|
||||
fn = notification.address >> 4
|
||||
if fn == 0:
|
||||
info = getattr(device, "_advanced_eq_info", None)
|
||||
payload = notification.data[3:] if notification.data else b""
|
||||
if info and len(payload) >= 5:
|
||||
bands = hidpp20.parse_v2_bands(b"\x00" + payload, info)
|
||||
if bands and device.setting_callback:
|
||||
band_map = {i: int(round(g)) for i, (_t, _f, g) in enumerate(bands)}
|
||||
device.setting_callback(device, settings_templates.HeadsetAdvancedEQ, [band_map])
|
||||
elif logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
"%s: HEADSET_ADVANCED_PARA_EQ band-change event with no parseable payload %s", device, notification
|
||||
)
|
||||
elif fn in (2, 3) and logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: HEADSET_ADVANCED_PARA_EQ fn=%d change event %s", device, fn, notification)
|
||||
elif logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: unknown HEADSET_ADVANCED_PARA_EQ %s", device, notification)
|
||||
|
||||
elif feature == SupportedFeature.HEADSET_MIC_MUTE:
|
||||
# G522 emits state-change events on two function indices, both carrying
|
||||
# the new state in data[0] (0 = unmuted, 1 = muted):
|
||||
# fn 0 — physical mute switch press
|
||||
# fn 1 — echo following a host-driven SetState (fn 2) write
|
||||
# Low nibble of `address` is the swid the firmware echoes back, which
|
||||
# varies with the request — match on the function index only.
|
||||
fn = notification.address >> 4
|
||||
if fn in (0, 1) and notification.data:
|
||||
muted = bool(notification.data[0])
|
||||
if device.setting_callback:
|
||||
device.setting_callback(device, settings_templates.HeadsetMicMute, [muted])
|
||||
elif logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: unknown HEADSET_MIC_MUTE %s", device, notification)
|
||||
|
||||
diversion.process_notification(device, notification, feature)
|
||||
return True
|
||||
|
||||
|
|
|
|||
|
|
@ -27,11 +27,9 @@ import struct
|
|||
|
||||
from .hidpp20_constants import SupportedFeature
|
||||
|
||||
# Mystery bytes observed in every LGHUB pcap EQ write between band params
|
||||
# and coefficient header. Purpose not fully understood — possibly a null-band
|
||||
# terminator for DSPs that support >5 bands (advanced 10-band mode).
|
||||
# First byte matches band_count; bytes 2-3 look like LE16 coeff blob size.
|
||||
# Hardcoded from pcap for initial bring-up; revisit once device-tested.
|
||||
# Opaque bytes observed between band params and coefficient header. First
|
||||
# byte matches band_count; bytes 2-3 look like LE16 coeff blob size. Keep
|
||||
# verbatim until a device counter-example forces a re-derivation.
|
||||
_EQ_MYSTERY_BYTES = b"\x05\x5a\xe3\x00"
|
||||
|
||||
|
||||
|
|
@ -85,7 +83,7 @@ def _build_coeff_section(bands, sample_rate, section_type=1):
|
|||
coefficients (a1, a2) are left unchanged. The DSP multiplies the output by
|
||||
rescale to restore correct gain.
|
||||
"""
|
||||
_HEADROOM = 1.19 # 19% headroom margin (matches LGHUB)
|
||||
_HEADROOM = 1.19 # 19% headroom margin before quantization
|
||||
num_bands = len(bands)
|
||||
all_words = [num_bands] # first uint16 = num_bands
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -181,8 +181,16 @@ class Setting:
|
|||
logger.debug("%s: prepare write(%s) => %r", self.name, value, data_bytes)
|
||||
|
||||
reply = self._rw.write(self._device, data_bytes)
|
||||
if not reply:
|
||||
# tell whomever is calling that the write failed
|
||||
# HID++ 2.0 "set" operations often return an empty ACK (b"").
|
||||
# Treating empty bytes as failure (`not reply`) would misreport
|
||||
# successful writes as errors to the GUI. Only report failure
|
||||
# when the transport actually returned None (error or timeout).
|
||||
if reply is None:
|
||||
logger.info(
|
||||
"%s: write on %s returned no reply (transport error/timeout)",
|
||||
self.name,
|
||||
self._device,
|
||||
)
|
||||
return None
|
||||
|
||||
return value
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -152,6 +152,12 @@ class SolaarListener(listener.EventsListener):
|
|||
from logitech_receiver.device import CenturionReceiver
|
||||
|
||||
if isinstance(self.receiver, CenturionReceiver):
|
||||
if self.receiver._pending:
|
||||
ihandle = int(self.receiver.handle)
|
||||
state = base._centurion_handles.get(ihandle)
|
||||
if state and state.device_addr is not None:
|
||||
self.receiver._complete_deferred_init()
|
||||
self._status_changed(self.receiver)
|
||||
self._handle_centurion_notification(n)
|
||||
return
|
||||
# a receiver notification
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from __future__ import annotations
|
|||
from collections.abc import Callable
|
||||
|
||||
from ..layout import Layout
|
||||
from . import headset_g522
|
||||
from . import keyboard_ansi
|
||||
from . import keyboard_iso_azerty
|
||||
from . import keyboard_iso_qwerty
|
||||
|
|
@ -140,3 +141,5 @@ for _family, (_full, _tkl) in _FAMILY_LAYOUTS.items():
|
|||
register_layout(0x8081, _keyboard_matcher(_family, full_size=False), _tkl)
|
||||
|
||||
register_layout(0x8081, _name_contains("G502 X"), mouse_g502x.LAYOUT)
|
||||
# HEADSET_RGB_HOSTMODE = 0x0620
|
||||
register_layout(0x0620, _name_contains("G522"), headset_g522.LAYOUT)
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
)
|
||||
|
|
@ -8,7 +8,10 @@ import pytest
|
|||
|
||||
from logitech_receiver import base
|
||||
from logitech_receiver import exceptions
|
||||
from logitech_receiver.base import CENTURION_ADDRESSED_REPORT_ID
|
||||
from logitech_receiver.base import CENTURION_REPORT_ID
|
||||
from logitech_receiver.base import HIDPP_SHORT_MESSAGE_ID
|
||||
from logitech_receiver.base import CenturionHandleState
|
||||
from logitech_receiver.common import LOGITECH_VENDOR_ID
|
||||
from logitech_receiver.common import BusID
|
||||
from logitech_receiver.hidpp10_constants import ErrorCode as Hidpp10Error
|
||||
|
|
@ -197,3 +200,217 @@ def test_ping_errors(simulated_error: Hidpp10Error, expected_result):
|
|||
else:
|
||||
result = base.ping(handle=handle, devnumber=device_number)
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
# --- Centurion transport tests ---
|
||||
|
||||
|
||||
class TestCenturionFrameHeader:
|
||||
"""Test _centurion_frame_header builds correct headers for both variants."""
|
||||
|
||||
def test_0x51_header(self):
|
||||
state = CenturionHandleState(report_id=CENTURION_REPORT_ID)
|
||||
header = base._centurion_frame_header(state, cpl_length=5, flags=0x00)
|
||||
assert header == bytes([0x51, 5, 0x00])
|
||||
|
||||
def test_0x51_header_with_flags(self):
|
||||
state = CenturionHandleState(report_id=CENTURION_REPORT_ID)
|
||||
header = base._centurion_frame_header(state, cpl_length=10, flags=0x03)
|
||||
assert header == bytes([0x51, 10, 0x03])
|
||||
|
||||
def test_0x50_header_unknown_addr(self):
|
||||
state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID, device_addr=None)
|
||||
header = base._centurion_frame_header(state, cpl_length=5, flags=0x00)
|
||||
# device_addr defaults to 0x00 when unknown
|
||||
assert header == bytes([0x50, 0x00, 5, 0x00])
|
||||
|
||||
def test_0x50_header_known_addr(self):
|
||||
state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID, device_addr=0x23)
|
||||
header = base._centurion_frame_header(state, cpl_length=5, flags=0x00)
|
||||
assert header == bytes([0x50, 0x23, 5, 0x00])
|
||||
|
||||
def test_0x50_header_with_flags(self):
|
||||
state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID, device_addr=0x23)
|
||||
header = base._centurion_frame_header(state, cpl_length=10, flags=0x07)
|
||||
assert header == bytes([0x50, 0x23, 10, 0x07])
|
||||
|
||||
|
||||
class TestUnwrapCenturionFrame:
|
||||
"""Test _unwrap_centurion_frame for both 0x51 and 0x50 variants."""
|
||||
|
||||
HANDLE = 99
|
||||
|
||||
def setup_method(self):
|
||||
"""Ensure no leftover centurion state between tests."""
|
||||
base._centurion_handles.pop(self.HANDLE, None)
|
||||
|
||||
def teardown_method(self):
|
||||
base._centurion_handles.pop(self.HANDLE, None)
|
||||
|
||||
def test_unwrap_0x51_frame(self):
|
||||
"""0x51 frame with feat_idx=0x02, func_sw=0x1A, 2 data bytes."""
|
||||
# cpl_length = 1(flags) + 1(feat_idx) + 1(func_sw) + 2(data) = 5
|
||||
raw = bytes([0x51, 5, 0x00, 0x02, 0x1A, 0xAA, 0xBB]) + b"\x00" * 57
|
||||
result = base._unwrap_centurion_frame(raw, self.HANDLE, self.HANDLE)
|
||||
# Should reconstruct as [0x11, 0xFF, feat_idx, func_sw, data..., pad to 20]
|
||||
assert result[0] == 0x11
|
||||
assert result[1] == 0xFF
|
||||
assert result[2] == 0x02 # feat_idx
|
||||
assert result[3] == 0x1A # func_sw
|
||||
assert result[4] == 0xAA
|
||||
assert result[5] == 0xBB
|
||||
assert len(result) == 20 # padded to standard long
|
||||
|
||||
def test_unwrap_0x50_frame(self):
|
||||
"""0x50 frame with device_addr=0x23, same payload as above."""
|
||||
# Frame: [0x50, device_addr, cpl_length, flags, feat_idx, func_sw, data...]
|
||||
raw = bytes([0x50, 0x23, 5, 0x00, 0x02, 0x1A, 0xAA, 0xBB]) + b"\x00" * 56
|
||||
base._centurion_handles[self.HANDLE] = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID)
|
||||
result = base._unwrap_centurion_frame(raw, self.HANDLE, self.HANDLE)
|
||||
assert result[0] == 0x11
|
||||
assert result[1] == 0xFF
|
||||
assert result[2] == 0x02 # feat_idx
|
||||
assert result[3] == 0x1A # func_sw
|
||||
assert result[4] == 0xAA
|
||||
assert result[5] == 0xBB
|
||||
assert len(result) == 20
|
||||
|
||||
def test_0x50_learns_device_addr(self):
|
||||
"""First RX on a 0x50 handle should learn the device address."""
|
||||
base._centurion_handles[self.HANDLE] = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID)
|
||||
assert base._centurion_handles[self.HANDLE].device_addr is None
|
||||
|
||||
raw = bytes([0x50, 0x23, 3, 0x00, 0x02, 0x1A]) + b"\x00" * 58
|
||||
base._unwrap_centurion_frame(raw, self.HANDLE, self.HANDLE)
|
||||
|
||||
assert base._centurion_handles[self.HANDLE].device_addr == 0x23
|
||||
|
||||
def test_0x50_does_not_overwrite_addr(self):
|
||||
"""Once learned, device address should not be overwritten."""
|
||||
base._centurion_handles[self.HANDLE] = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID, device_addr=0x23)
|
||||
raw = bytes([0x50, 0xFF, 3, 0x00, 0x02, 0x1A]) + b"\x00" * 58
|
||||
base._unwrap_centurion_frame(raw, self.HANDLE, self.HANDLE)
|
||||
|
||||
# Should keep the original address, not overwrite with 0xFF
|
||||
assert base._centurion_handles[self.HANDLE].device_addr == 0x23
|
||||
|
||||
def test_non_centurion_frame_passthrough(self):
|
||||
"""Non-centurion report IDs should be returned unchanged."""
|
||||
raw = bytes([0x11, 0x01, 0x02, 0x1A]) + b"\x00" * 16
|
||||
result = base._unwrap_centurion_frame(raw, self.HANDLE, self.HANDLE)
|
||||
assert result == raw
|
||||
|
||||
def test_unwrap_0x51_large_payload(self):
|
||||
"""0x51 frame with payload large enough to need 63-byte padding."""
|
||||
# cpl_length covers all 61 payload bytes + flags = 62
|
||||
payload = bytes(range(61))
|
||||
raw = bytes([0x51, 62, 0x00]) + payload
|
||||
result = base._unwrap_centurion_frame(raw, self.HANDLE, self.HANDLE)
|
||||
assert len(result) == 63 # padded to centurion extended
|
||||
assert result[0] == 0x11
|
||||
assert result[1] == 0xFF
|
||||
assert result[2:63] == payload
|
||||
|
||||
|
||||
class TestProbeCenturionDeviceAddr:
|
||||
"""Test probe_centurion_device_addr: brute-force write for all 256 addrs, then read."""
|
||||
|
||||
HANDLE = 101
|
||||
|
||||
def setup_method(self):
|
||||
base._centurion_handles.pop(self.HANDLE, None)
|
||||
|
||||
def teardown_method(self):
|
||||
base._centurion_handles.pop(self.HANDLE, None)
|
||||
|
||||
def test_learns_addr_on_first_hit(self):
|
||||
"""Probe finds addr=0x23 on candidate #36 (0-indexed 0x23=35) and stops."""
|
||||
state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID)
|
||||
reply = bytes([0x50, 0x23, 0x03, 0x00]) + b"\x00" * 60
|
||||
|
||||
def read_side_effect(_handle, _size, _timeout):
|
||||
# Return a response only after the write with addr=0x23
|
||||
if mock_write.call_count == 0x24: # 0x23 is the 36th write (1-indexed)
|
||||
return reply
|
||||
return None
|
||||
|
||||
with (
|
||||
mock.patch.object(base.hidapi, "write") as mock_write,
|
||||
mock.patch.object(base.hidapi, "read", side_effect=read_side_effect),
|
||||
):
|
||||
result = base.probe_centurion_device_addr(self.HANDLE, state)
|
||||
assert result is True
|
||||
assert state.device_addr == 0x23
|
||||
# Short-circuit: stopped at candidate 0x23 (36 writes), not all 256
|
||||
assert mock_write.call_count == 0x24
|
||||
|
||||
def test_skips_non_matching_read_until_match(self):
|
||||
"""Non-0x50 frames in the read are ignored; next candidate's read succeeds."""
|
||||
state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID)
|
||||
noise = b"\x11\xff" + b"\x00" * 62
|
||||
match = bytes([0x50, 0x42, 0x03, 0x00]) + b"\x00" * 60
|
||||
# Reads cycle: noise, noise, match — so addr is found on 3rd candidate
|
||||
with (
|
||||
mock.patch.object(base.hidapi, "write"),
|
||||
mock.patch.object(base.hidapi, "read", side_effect=[noise, noise, match]),
|
||||
):
|
||||
result = base.probe_centurion_device_addr(self.HANDLE, state)
|
||||
assert result is True
|
||||
assert state.device_addr == 0x42
|
||||
|
||||
def test_returns_false_when_no_response(self):
|
||||
state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID)
|
||||
with (
|
||||
mock.patch.object(base.hidapi, "write"),
|
||||
mock.patch.object(base.hidapi, "read", return_value=None),
|
||||
):
|
||||
result = base.probe_centurion_device_addr(self.HANDLE, state)
|
||||
assert result is False
|
||||
assert state.device_addr is None
|
||||
|
||||
def test_noop_for_0x51_variant(self):
|
||||
state = CenturionHandleState(report_id=CENTURION_REPORT_ID)
|
||||
with (
|
||||
mock.patch.object(base.hidapi, "write") as mock_write,
|
||||
mock.patch.object(base.hidapi, "read") as mock_read,
|
||||
):
|
||||
result = base.probe_centurion_device_addr(self.HANDLE, state)
|
||||
assert result is False
|
||||
assert state.device_addr is None
|
||||
mock_write.assert_not_called()
|
||||
mock_read.assert_not_called()
|
||||
|
||||
def test_noop_when_addr_already_known(self):
|
||||
state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID, device_addr=0x23)
|
||||
with (
|
||||
mock.patch.object(base.hidapi, "write") as mock_write,
|
||||
mock.patch.object(base.hidapi, "read") as mock_read,
|
||||
):
|
||||
result = base.probe_centurion_device_addr(self.HANDLE, state)
|
||||
assert result is False
|
||||
assert state.device_addr == 0x23
|
||||
mock_write.assert_not_called()
|
||||
mock_read.assert_not_called()
|
||||
|
||||
def test_aborts_on_repeated_write_failure(self):
|
||||
state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID)
|
||||
with (
|
||||
mock.patch.object(base.hidapi, "write", side_effect=OSError("no device")),
|
||||
mock.patch.object(base.hidapi, "read") as mock_read,
|
||||
):
|
||||
result = base.probe_centurion_device_addr(self.HANDLE, state)
|
||||
assert result is False
|
||||
assert state.device_addr is None
|
||||
mock_read.assert_not_called()
|
||||
|
||||
def test_write_frames_have_sequential_addrs(self):
|
||||
"""Verify each write uses a different device_addr from 0x00 to 0xFF."""
|
||||
state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID)
|
||||
with (
|
||||
mock.patch.object(base.hidapi, "write") as mock_write,
|
||||
mock.patch.object(base.hidapi, "read", return_value=None), # no response → scans all 256
|
||||
):
|
||||
base.probe_centurion_device_addr(self.HANDLE, state)
|
||||
assert mock_write.call_count == 256
|
||||
addrs_sent = [mock_write.call_args_list[i][0][1][1] for i in range(256)]
|
||||
assert addrs_sent == list(range(256))
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from typing import Optional
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -61,6 +62,7 @@ class DeviceInfoStub:
|
|||
bus_id: int = 0x0003 # USB
|
||||
serial: str = "aa:aa:aa;aa"
|
||||
centurion: bool = False
|
||||
centurion_report_id: int | None = None
|
||||
|
||||
|
||||
di_bad_handle = DeviceInfoStub(None, product_id="CCCC")
|
||||
|
|
@ -100,20 +102,46 @@ def test_create_centurion_device():
|
|||
"""Test that a centurion device gets hidpp_long forced to True and centurion flag set."""
|
||||
from logitech_receiver import base
|
||||
|
||||
low_level_mock = LowLevelInterfaceFake(fake_hidpp.r_empty)
|
||||
test_device = device.create_device(low_level_mock, di_0AF7)
|
||||
with mock.patch.object(base, "probe_centurion_device_addr", return_value=False):
|
||||
low_level_mock = LowLevelInterfaceFake(fake_hidpp.r_empty)
|
||||
test_device = device.create_device(low_level_mock, di_0AF7)
|
||||
|
||||
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_REPORT_ID # 0x51 default
|
||||
|
||||
# kind is seeded at construction, so the headset icon shows even offline
|
||||
test_device.online = False
|
||||
assert test_device.kind == "headset"
|
||||
|
||||
# Clean up
|
||||
base._centurion_handles.discard(int(test_device.handle))
|
||||
base._centurion_handles.pop(int(test_device.handle), None)
|
||||
|
||||
|
||||
di_0B18 = DeviceInfoStub("11", product_id="0B18", centurion=True, centurion_report_id=0x50)
|
||||
|
||||
|
||||
def test_create_centurion_0x50_device():
|
||||
"""Test that a 0x50 centurion device gets the correct report ID registered."""
|
||||
from logitech_receiver import base
|
||||
|
||||
with mock.patch.object(base, "probe_centurion_device_addr", return_value=False):
|
||||
low_level_mock = LowLevelInterfaceFake(fake_hidpp.r_empty)
|
||||
test_device = device.create_device(low_level_mock, di_0B18)
|
||||
|
||||
assert test_device is not None
|
||||
assert test_device.centurion is True
|
||||
assert test_device.hidpp_long is True
|
||||
assert int(test_device.handle) in base._centurion_handles
|
||||
state = base._centurion_handles[int(test_device.handle)]
|
||||
assert state.report_id == base.CENTURION_ADDRESSED_REPORT_ID # 0x50
|
||||
assert state.device_addr is None # not yet learned
|
||||
|
||||
# Clean up
|
||||
base._centurion_handles.pop(int(test_device.handle), None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
|
|||
|
|
@ -956,29 +956,18 @@ def test_centurion_sub_device_feature_discovery():
|
|||
dev = fake_hidpp.Device("CENT_SUB", True, 2.6, fake_hidpp.r_centurion_headset, centurion=True)
|
||||
# Set up bridge responses for sub-device discovery:
|
||||
# 1. CenturionRoot.GetFeature(0x0001) -> FeatureSet at sub-index 1
|
||||
# 2. CenturionFeatureSet.GetFeatureId(index=0) -> bulk feature list
|
||||
# 2. CenturionFeatureSet.GetCount (func 0) -> total feature count
|
||||
# 3. CenturionFeatureSet.GetFeatureId (func 0x10) per-index -> one feature each
|
||||
dev._bridge_responses = {
|
||||
# CenturionRoot(idx=0).GetFeature(func=0) with feature_id=0x0001 -> sub_fs_index=1
|
||||
(0x00, 0x00, "0001"): bytes([0x01, 0x00, 0x00]),
|
||||
# CenturionFeatureSet(idx=1).GetFeatureId(func=0x10, start=0) -> 3 features
|
||||
# Response: [count, (feat_hi, feat_lo, type, flags) × count]
|
||||
(0x01, 0x10, "00"): bytes(
|
||||
[
|
||||
0x03, # 3 features
|
||||
0x06,
|
||||
0x04,
|
||||
0x00,
|
||||
0x00, # HEADSET_AUDIO_SIDETONE (0x0604) at sub-idx 0
|
||||
0x06,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00, # HEADSET_MIC_MUTE (0x0601) at sub-idx 1
|
||||
0x06,
|
||||
0x11,
|
||||
0x00,
|
||||
0x00, # HEADSET_MIC_GAIN (0x0611) at sub-idx 2
|
||||
]
|
||||
),
|
||||
# CenturionFeatureSet(idx=1).GetCount (func=0) -> 3 features
|
||||
(0x01, 0x00, ""): bytes([0x03, 0x00, 0x00]),
|
||||
# CenturionFeatureSet(idx=1).GetFeatureId (func=0x10, index=N) -> one feature per response.
|
||||
# Response format: [remaining, feat_hi, feat_lo, type, flags]
|
||||
(0x01, 0x10, "00"): bytes([0x02, 0x06, 0x04, 0x00, 0x00]), # HEADSET_AUDIO_SIDETONE at sub-idx 0
|
||||
(0x01, 0x10, "01"): bytes([0x01, 0x06, 0x01, 0x00, 0x00]), # HEADSET_MIC_MUTE at sub-idx 1
|
||||
(0x01, 0x10, "02"): bytes([0x00, 0x06, 0x11, 0x00, 0x00]), # HEADSET_MIC_GAIN at sub-idx 2
|
||||
}
|
||||
featuresarray = hidpp20.FeaturesArray(dev)
|
||||
dev.features = featuresarray
|
||||
|
|
|
|||
Loading…
Reference in New Issue