From bde3c3bc8674b040dc1eef06475b3be8b0122da5 Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Fri, 17 Apr 2026 17:44:33 -0700 Subject: [PATCH] Add read-only HeadsetAdvancedEQ (0x020D) display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The G522 exposes AdvancedParaEQ (0x020D), a different EQ feature than the PRO X 2's OnboardEQ (0x0636). Key differences: - 3-byte-per-band wire format ([freq_hi, freq_lo, gain]) vs 0x0636's 4-byte-per-band ([freq_hi, freq_lo, gain, Q]) - Device handles biquad coefficient computation — no host-side DSP math - Has explicit direction (playback/capture), multiple preset slots with getActiveEQ/setActiveEQ for switching, preset friendly names Adds new advanced_para_eq.py module with getInfos, getActiveEQ, and getCustomEQ helpers (function 0, 3, 1 respectively). Re-exported from hidpp20.py following the onboard_eq.py pattern. HeadsetAdvancedEQ setting displays the currently-active playback EQ using the same RangeFieldSetting + PackedRangeValidator UI pattern as HeadsetOnboardEQ, so the graphic EQ widget looks the same. **Writes are intentionally disabled for now** — prepare_write returns None and write() logs "read-only mode" without sending anything. This lets us verify the wire format matches the protocol doc against real hardware before risking a write that could misconfigure the DSP. Once read output is confirmed sensible, we'll wire up setCustomEQ (function 2) to enable writes. --- lib/logitech_receiver/advanced_para_eq.py | 81 +++++++++++++++++++++ lib/logitech_receiver/hidpp20.py | 4 + lib/logitech_receiver/settings_templates.py | 74 +++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 lib/logitech_receiver/advanced_para_eq.py diff --git a/lib/logitech_receiver/advanced_para_eq.py b/lib/logitech_receiver/advanced_para_eq.py new file mode 100644 index 00000000..eaa7212e --- /dev/null +++ b/lib/logitech_receiver/advanced_para_eq.py @@ -0,0 +1,81 @@ +## 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. + +Unlike OnboardEQ (0x0636) which requires host-computed biquad coefficients, +AdvancedParaEQ is handled entirely by the device — we send frequency + gain +per band and the device applies them. Band entries are 3 bytes each: +[freq_hi, freq_lo, value] where value is the signed int8 gain in dB. +""" + +from __future__ import annotations + +import struct + +from .hidpp20_constants import SupportedFeature + +# Direction parameter for getCustomEQ / getActiveEQ etc. +DIRECTION_PLAYBACK = 0 +DIRECTION_CAPTURE = 1 + + +def get_advanced_eq_info(device): + """Query HEADSET_ADVANCED_PARA_EQ getEQInfos (function 0). + + 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: + return None + band_count = result[0] + db_range = result[1] + capabilities = result[2] + # 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] + 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: + return None + return result[0] + + +def get_advanced_eq_params(device, direction=DIRECTION_PLAYBACK, slot=0): + """Query getCustomEQ (function 1) for the given direction and slot. + + Returns a list of (freq_hz, gain_db) tuples, or None. + """ + result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x10, direction, slot) + if result is None: + return None + bands = [] + offset = 0 + # Response is a tight-packed series of 3-byte band entries: + # [freq_hi, freq_lo, value]. + while offset + 3 <= len(result): + freq = struct.unpack(">H", result[offset : offset + 2])[0] + if freq == 0: + # Trailing padding — stop parsing. + break + gain_db = struct.unpack("b", bytes([result[offset + 2]]))[0] + bands.append((freq, gain_db)) + offset += 3 + return bands diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 4224d270..cc84c61e 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -2284,6 +2284,10 @@ 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 get_advanced_eq_active_slot # 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 .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 diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index 0251ca8d..6cce7d39 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -1768,6 +1768,79 @@ class HeadsetOnboardEQ(settings.RangeFieldSetting): return result +class HeadsetAdvancedEQ(settings.RangeFieldSetting): + """Read-only display of the headset's active AdvancedParaEQ (0x020D) bands. + + Writes are intentionally disabled for now — we want to verify the wire + format matches the protocol doc on real hardware before allowing any + changes that could misconfigure the device DSP. Once confirmed working + we'll wire up write() to call setCustomEQ. + + Bands come from getActiveEQ → getCustomEQ on the playback direction. + Each band entry is [freq_hi, freq_lo, gain_db] (3-byte stride). Unlike + OnboardEQ (0x0636), the device handles biquad coefficient computation + — we just send frequency and gain. + """ + + name = "headset-advanced-eq" + label = _("Headset Advanced EQ (read-only)") + description = _("Display the headset's active parametric EQ. Writes are disabled pending verification.") + feature = _F.HEADSET_ADVANCED_PARA_EQ + rw_options = {"read_fnid": 0x10, "write_fnid": 0x20, "read_prefix": b"\x00\x00"} + keys_universe = [] + + class validator_class(settings_validator.PackedRangeValidator): + kind = settings.Kind.GRAPHIC_EQ + + @classmethod + def build(cls, setting_class, device): + info = hidpp20.get_advanced_eq_info(device) + if not info: + 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: + return None + keys = common.NamedInts() + for i, (freq, _gain) in enumerate(bands): + keys[i] = str(freq) + _("Hz") + 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 + return v + + def validate_read(self, reply_bytes): + # Response: tight-packed 3-byte entries [freq_hi, freq_lo, gain]. + if reply_bytes is None: + return {} + result = {} + offset = 0 + i = 0 + while offset + 3 <= len(reply_bytes) and i < self.count: + freq = struct.unpack(">H", reply_bytes[offset : offset + 2])[0] + if freq == 0: + break + gain = struct.unpack("b", bytes([reply_bytes[offset + 2]]))[0] + result[i] = gain + if hasattr(self, "_band_freqs") and i < len(self._band_freqs): + self._band_freqs[i] = freq + offset += 3 + i += 1 + return result + + def prepare_write(self, new_values): + # Read-only mode: never actually build a write payload. + return None + + def write(self, map, save=True): + # Read-only for now — log attempted writes but don't transmit anything. + logger.info("HeadsetAdvancedEQ: write ignored (read-only mode); requested=%s", map) + return None + + class HeadsetRGBHostMode(settings.Setting): """Toggle host control of headset RGB lighting. @@ -2438,6 +2511,7 @@ SETTINGS: list[settings.Setting] = [ HeadsetMixBalance, HeadsetAutoSleep, HeadsetOnboardEQ, + HeadsetAdvancedEQ, HeadsetRGBHostMode, HeadsetRGBColor, ]