Add read-only HeadsetAdvancedEQ (0x020D) display

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.
This commit is contained in:
Ken Sanislo 2026-04-17 17:44:33 -07:00
parent b90bbf8af3
commit bde3c3bc86
3 changed files with 159 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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,
]