Add diagnostic logging for EQ setting build failures

Centurion feature discovery now logs each parent + sub-device feature at
INFO with name/index/version/flags. `check_feature` logs INFO when it
skips a setting for min_version or the INTERNAL flag. HeadsetAdvancedEQ
and HeadsetOnboardEQ `build()` paths log at every failure branch. The
three AdvancedParaEQ helpers log raw response bytes.

On the G522, `HEADSET_ADVANCED_PARA_EQ` (0x020D) is present in the
feature set but no settings panel appears and current logging gives us
no way to tell which step silently returns None.
This commit is contained in:
Ken Sanislo 2026-04-19 00:50:20 -07:00
parent 92e70d277f
commit 63e01b2984
4 changed files with 121 additions and 24 deletions

View File

@ -24,10 +24,13 @@ per band and the device applies them. Band entries are 3 bytes each:
from __future__ import annotations
import logging
import struct
from .hidpp20_constants import SupportedFeature
logger = logging.getLogger(__name__)
# Direction parameter for getCustomEQ / getActiveEQ etc.
DIRECTION_PLAYBACK = 0
DIRECTION_CAPTURE = 1
@ -39,7 +42,11 @@ def get_advanced_eq_info(device):
Returns (band_count, db_range, capabilities, db_min, db_max) or None.
"""
result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x00)
if result is None or len(result) < 5:
if result is None:
logger.info("AdvancedParaEQ getEQInfos: feature_request returned None")
return None
if len(result) < 5:
logger.info("AdvancedParaEQ getEQInfos: short response (len=%d) %s", len(result), result.hex())
return None
band_count = result[0]
db_range = result[1]
@ -47,14 +54,28 @@ def get_advanced_eq_info(device):
# dbMin / dbMax are signed int8 in the doc.
db_min = struct.unpack("b", bytes([result[3]]))[0]
db_max = struct.unpack("b", bytes([result[4]]))[0]
logger.info(
"AdvancedParaEQ getEQInfos: bands=%d dbRange=%d caps=0x%02X dbMin=%d dbMax=%d raw=%s",
band_count,
db_range,
capabilities,
db_min,
db_max,
result.hex(),
)
return (band_count, db_range, capabilities, db_min, db_max)
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 or len(result) < 1:
if result is None:
logger.info("AdvancedParaEQ getActiveEQ(dir=%d): feature_request returned None", direction)
return None
if len(result) < 1:
logger.info("AdvancedParaEQ getActiveEQ(dir=%d): empty response", direction)
return None
logger.info("AdvancedParaEQ getActiveEQ(dir=%d): slot=%d raw=%s", direction, result[0], result.hex())
return result[0]
@ -65,6 +86,7 @@ def get_advanced_eq_params(device, direction=DIRECTION_PLAYBACK, slot=0):
"""
result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x10, direction, slot)
if result is None:
logger.info("AdvancedParaEQ getCustomEQ(dir=%d slot=%d): feature_request returned None", direction, slot)
return None
bands = []
offset = 0
@ -78,4 +100,12 @@ def get_advanced_eq_params(device, direction=DIRECTION_PLAYBACK, slot=0):
gain_db = struct.unpack("b", bytes([result[offset + 2]]))[0]
bands.append((freq, gain_db))
offset += 3
logger.info(
"AdvancedParaEQ getCustomEQ(dir=%d slot=%d): parsed %d band(s) %s raw=%s",
direction,
slot,
len(bands),
bands,
result.hex(),
)
return bands

View File

@ -785,7 +785,17 @@ class Device:
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
@ -803,7 +813,17 @@ class Device:
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

View File

@ -209,6 +209,13 @@ class FeaturesArray(dict):
# use the correct payload format on direct USB Centurion devices too.
self.version[feature] = feat_version
self.flags[feature] = feat_type
logger.info(
"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
@ -275,14 +282,16 @@ class FeaturesArray(dict):
# payload format. get_feature_version(feature) reads self.version[feature].
self.version[feature] = feat_version
self.flags[feature] = feat_type
if feat_version > 0 and logger.isEnabledFor(logging.DEBUG):
logger.debug(
"Centurion sub-device feature: %s at sub-index %d, version=%d, flags=0x%02X",
feature,
sub_feat_idx,
feat_version,
feat_type,
)
# Log every sub-device feature at INFO so field diagnostics can see
# both the version (V-gated payload formats) and flags (INTERNAL/HIDDEN
# bits silently suppress settings panels in check_feature).
logger.info(
"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
logger.info("Centurion sub-device: discovered %d features total", sub_feat_idx)

View File

@ -1748,25 +1748,23 @@ class HeadsetAutoSleep(settings.Setting):
rw_options = {"read_fnid": 0x00, "write_fnid": 0x10}
validator_class = settings_validator.RangeValidator
min_value = 0
# Timer byte count depends on feature version:
# V<3 : 1 byte (0-255)
# V=3 : 2 bytes (0-65535)
# V>=4: 3 bytes (0-16777215)
# build() picks the correct width based on the device's reported version.
max_value = 0xFFFFFF
# Wire format byte count depends on feature version (V<3: 1B, V=3: 2B, V>=4: 3B).
# UI slider is capped at 240 min regardless — firmware accepts larger values, but
# a 31-year slider (24-bit max) is not useful.
max_value = 240
validator_options = {"byte_count": 1}
@classmethod
def build(cls, device):
version = device.features.get_feature_version(cls.feature) or 0
if version >= 4:
byte_count, max_value = 3, 0xFFFFFF
byte_count = 3
elif version >= 3:
byte_count, max_value = 2, 0xFFFF
byte_count = 2
else:
byte_count, max_value = 1, 0xFF
byte_count = 1
rw = settings.FeatureRW(cls.feature, **cls.rw_options)
validator = settings_validator.RangeValidator(min_value=0, max_value=max_value, byte_count=byte_count)
validator = settings_validator.RangeValidator(min_value=0, max_value=cls.max_value, byte_count=byte_count)
return cls(device, rw, validator)
@ -1785,10 +1783,19 @@ class HeadsetOnboardEQ(settings.RangeFieldSetting):
def build(cls, setting_class, device):
info = hidpp20.get_onboard_eq_info(device)
if not info:
logger.info("HeadsetOnboardEQ.build: getEQInfo failed, no panel will be built")
return None
_has_hw_eq, num_bands = info
bands = hidpp20.get_onboard_eq_params(device, slot=0x00)
if not bands or len(bands) != num_bands:
if not bands:
logger.info("HeadsetOnboardEQ.build: getEQParameters returned no bands, no panel will be built")
return None
if len(bands) != num_bands:
logger.info(
"HeadsetOnboardEQ.build: band count mismatch — EQInfo=%d getEQParameters=%d; skipping",
num_bands,
len(bands),
)
return None
keys = common.NamedInts()
for i, (freq, _gain, _q) in enumerate(bands):
@ -1796,6 +1803,7 @@ class HeadsetOnboardEQ(settings.RangeFieldSetting):
v = cls(keys, min_value=-12, max_value=12, count=num_bands, byte_count=1)
v._band_freqs = [freq for freq, _g, _q in bands]
v._band_qs = [q for _f, _g, q in bands]
logger.info("HeadsetOnboardEQ.build: panel built with %d band(s)", num_bands)
return v
def validate_read(self, reply_bytes):
@ -1872,13 +1880,22 @@ class HeadsetAdvancedEQ(settings.RangeFieldSetting):
def build(cls, setting_class, device):
info = hidpp20.get_advanced_eq_info(device)
if not info:
logger.info("HeadsetAdvancedEQ.build: getEQInfos failed, no panel will be built")
return None
band_count, _db_range, _caps, db_min, db_max = info
# Use the active slot on playback direction so the displayed EQ
# matches what the user is actually hearing.
active_slot = hidpp20.get_advanced_eq_active_slot(device, direction=0) or 0
bands = hidpp20.get_advanced_eq_params(device, direction=0, slot=active_slot)
if not bands or len(bands) != band_count:
if not bands:
logger.info("HeadsetAdvancedEQ.build: getCustomEQ returned no bands, no panel will be built")
return None
if len(bands) != band_count:
logger.info(
"HeadsetAdvancedEQ.build: band count mismatch — EQInfos=%d getCustomEQ=%d; skipping",
band_count,
len(bands),
)
return None
keys = common.NamedInts()
for i, (freq, _gain) in enumerate(bands):
@ -1886,6 +1903,13 @@ class HeadsetAdvancedEQ(settings.RangeFieldSetting):
v = cls(keys, min_value=db_min, max_value=db_max, count=band_count, byte_count=1)
v._band_freqs = [freq for freq, _g in bands]
v._active_slot = active_slot
logger.info(
"HeadsetAdvancedEQ.build: panel built with %d band(s), slot=%d, range=[%d,%d]",
band_count,
active_slot,
db_min,
db_max,
)
return v
def validate_read(self, reply_bytes):
@ -2697,8 +2721,22 @@ def check_feature(device, settings_class: SettingsProtocol) -> None | bool | Set
if settings_class.feature not in device.features:
return
if settings_class.min_version > device.features.get_feature_version(settings_class.feature):
logger.info(
"check_feature %s [%s]: min_version=%d > device feature version=%d; skipping",
settings_class.name,
settings_class.feature,
settings_class.min_version,
device.features.get_feature_version(settings_class.feature) or 0,
)
return
if device.features.get_hidden(settings_class.feature):
flags = device.features.flags.get(settings_class.feature, 0)
logger.info(
"check_feature %s [%s]: feature has INTERNAL flag set (flags=0x%02X); skipping",
settings_class.name,
settings_class.feature,
flags,
)
return
try:
detected = settings_class.build(device)