AdvancedParaEQ: probe all factory + custom presets at build time

Adds get_advanced_eq_defaults (function 5), get_advanced_eq_friendly_name
(function 6), and a probe_all_presets helper that reads every factory
and custom preset slot and logs its name + band data at INFO.

The G522 exposes 6 factory presets and 10 custom slots. Reading each
and dumping (freq_u16, gain_db, q_u16) across a corpus of named presets
(Flat, Bass Boost, Vocal, etc.) should reveal the u16->Hz and u16->Q
encodings by pattern-matching — without needing a LGHUB pcap. The
results land in the normal -dd log so the next tester run gives us the
data.

One-shot probe runs at HeadsetAdvancedEQ.build() for V2 devices only.
Failures are logged at INFO and don't block panel construction.
This commit is contained in:
Ken Sanislo 2026-04-19 11:42:19 -07:00
parent 41db76bc81
commit 0520ab40ba
3 changed files with 131 additions and 0 deletions

View File

@ -193,6 +193,125 @@ def parse_v2_bands(result: bytes, step_db: float):
return bands, header_len
def get_advanced_eq_defaults(device, direction=DIRECTION_PLAYBACK, slot=0):
"""Query getEQDefaults (function 5). Same per-band layout as getCustomEQ.
Factory presets, read-only. Reading them across all factory slots gives
us a corpus of (name, freq_u16[], q_u16[]) tuples that may reveal the
u16->Hz and u16->Q scaling without needing a pcap. Returns list of
(freq_u16, gain_db, q_u16) or None.
"""
version = _get_version(device)
result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x50, direction, slot)
if result is None:
logger.info(
"AdvancedParaEQ getEQDefaults V%d (dir=%d slot=%d): feature_request returned None",
version,
direction,
slot,
)
return None
if version >= 2:
info = getattr(device, "_advanced_eq_info", None)
step_db = info["step_db"] if info and "step_db" in info else 1.0
bands, header_len = parse_v2_bands(result, step_db)
if bands is None:
logger.info(
"AdvancedParaEQ getEQDefaults V2 (dir=%d slot=%d): couldn't locate band payload raw=%s",
direction,
slot,
result.hex(),
)
return None
logger.info(
"AdvancedParaEQ getEQDefaults V2 (dir=%d slot=%d): %d band(s) header_len=%d raw=%s",
direction,
slot,
len(bands),
header_len,
result.hex(),
)
return bands
# V0/V1: 3-byte stride, gain is whole dB, no Q.
bands = []
offset = 0
while offset + 3 <= len(result):
freq = struct.unpack(">H", result[offset : offset + 2])[0]
if freq == 0:
break
gain_db = struct.unpack("b", bytes([result[offset + 2]]))[0]
bands.append((freq, float(gain_db), 0))
offset += 3
logger.info(
"AdvancedParaEQ getEQDefaults V%d (dir=%d slot=%d): %d band(s) raw=%s",
version,
direction,
slot,
len(bands),
result.hex(),
)
return bands
def get_advanced_eq_friendly_name(device, direction=DIRECTION_PLAYBACK, slot=0):
"""Query getEQFriendlyName (function 6). Returns the UTF-8 preset name or None."""
result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x60, direction, slot)
if result is None or len(result) < 1:
return None
name_len = result[0]
if 1 + name_len > len(result):
name_len = len(result) - 1
try:
name = bytes(result[1 : 1 + name_len]).decode("utf-8", errors="replace")
except Exception:
name = result[1 : 1 + name_len].hex()
return name
def probe_all_presets(device, direction=DIRECTION_PLAYBACK):
"""Read every factory and custom preset slot's name + band data and log it.
Diagnostic probe intended to run once at HeadsetAdvancedEQ.build() time
so we accumulate a corpus of (name, freq_u16[], q_u16[]) tuples across
presets. Patterns in that corpus should reveal the u16->Hz and u16->Q
mappings without needing a live pcap from LGHUB.
"""
info = getattr(device, "_advanced_eq_info", None)
if not info:
return
ro_count = info.get("onboard_ro_preset_count", 0)
custom_count = info.get("onboard_custom_preset_count", 0)
# Factory presets: read via getEQDefaults
for slot in range(ro_count):
name = get_advanced_eq_friendly_name(device, direction=direction, slot=slot)
bands = get_advanced_eq_defaults(device, direction=direction, slot=slot)
if bands is None:
continue
logger.info(
"AdvancedParaEQ factory preset %d/%d (dir=%d) name=%r: %s",
slot,
ro_count,
direction,
name,
[(f"0x{f:04X}", round(g, 3), f"0x{q:04X}") for f, g, q in bands],
)
# Custom preset slots: via getCustomEQ. Most will be empty/default, but
# any user-authored slots could give additional freq/Q samples.
for slot in range(custom_count):
name = get_advanced_eq_friendly_name(device, direction=direction, slot=slot)
bands = get_advanced_eq_params(device, direction=direction, slot=slot)
if bands is None:
continue
logger.info(
"AdvancedParaEQ custom preset %d/%d (dir=%d) name=%r: %s",
slot,
custom_count,
direction,
name,
[(f"0x{f:04X}", round(g, 3), f"0x{q:04X}") for f, g, q in bands],
)
def get_advanced_eq_params(device, direction=DIRECTION_PLAYBACK, slot=0):
"""Query getCustomEQ (function 1). Returns list of (freq, gain_db, q) or None.

View File

@ -2317,9 +2317,12 @@ 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_defaults # noqa: E402, F401
from .advanced_para_eq import get_advanced_eq_friendly_name # 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 .advanced_para_eq import parse_v2_bands # noqa: E402, F401
from .advanced_para_eq import probe_all_presets as probe_advanced_eq_presets # 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

@ -1934,6 +1934,15 @@ class HeadsetAdvancedEQ(settings.RangeFieldSetting):
gain_max,
step_db,
)
# One-shot corpus probe: read every factory/custom preset's name
# and band data. Comparing freq_u16 and q_u16 values across named
# presets ("Flat" vs "Bass Boost" etc.) may reveal the u16->Hz
# and u16->Q scalings without requiring a LGHUB pcap.
if version >= 2:
try:
hidpp20.probe_advanced_eq_presets(device, direction=0)
except Exception as e:
logger.info("HeadsetAdvancedEQ.build: preset corpus probe failed: %s", e)
return v
def validate_read(self, reply_bytes):