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:
parent
b90bbf8af3
commit
bde3c3bc86
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in New Issue