From 63e01b298432b59c16fdbffa8f8fbea1e6e63aec Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Sun, 19 Apr 2026 00:50:20 -0700 Subject: [PATCH] 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. --- lib/logitech_receiver/advanced_para_eq.py | 34 ++++++++++- lib/logitech_receiver/device.py | 24 +++++++- lib/logitech_receiver/hidpp20.py | 25 ++++++--- lib/logitech_receiver/settings_templates.py | 62 +++++++++++++++++---- 4 files changed, 121 insertions(+), 24 deletions(-) diff --git a/lib/logitech_receiver/advanced_para_eq.py b/lib/logitech_receiver/advanced_para_eq.py index eaa7212e..2ac30272 100644 --- a/lib/logitech_receiver/advanced_para_eq.py +++ b/lib/logitech_receiver/advanced_para_eq.py @@ -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 diff --git a/lib/logitech_receiver/device.py b/lib/logitech_receiver/device.py index 058f1db2..8e018f96 100644 --- a/lib/logitech_receiver/device.py +++ b/lib/logitech_receiver/device.py @@ -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 diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 6b3c940a..5d11b34c 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -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) diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index 3fdedec4..630984f1 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -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)