HeadsetAdvancedEQ: enable slot-0 band writes

V2 setCustomEQ wire format is pcap-verified: request payload is
[dir, slot, pad=0] + N × [freq_hi, freq_lo, filter, gain_hi, gain_lo].
Gain is offset-binary against the [gain_min, gain_max] / gain_steps
grid from getEQInfos. Freq and filter type aren't user-editable today;
they're sourced from the build-time cache.

Changes:
- rw_class.write: injects [dir, slot=active, pad=0] before band bytes
  and re-queries the active slot each call (matches the read path).
- validator.prepare_write: encodes the band payload from the dict the
  UI hands us, converting int dB → raw u16 against the gain grid.
- Setting.write: overrides RangeFieldSetting.write to treat empty-bytes
  replies as success (HID++ set ops ACK with b""), only None as failure.
- Drop the read-only override and stale docstring.
This commit is contained in:
Ken Sanislo 2026-05-10 17:26:33 -07:00
parent 6b61e0ef19
commit be047fd949
2 changed files with 70 additions and 22 deletions

View File

@ -2322,6 +2322,7 @@ class ForceSensingButtonArray(UserDict):
# --- AdvancedParaEQ (0x020D) — re-exported from advanced_para_eq.py ---
from .advanced_para_eq import FILTER_TYPE_HP # noqa: E402, F401
from .advanced_para_eq import FILTER_TYPE_PEAKING # noqa: E402, F401
from .advanced_para_eq import FILTER_TYPE_PEAKING_G522 # noqa: E402, F401
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

View File

@ -1900,38 +1900,45 @@ class HeadsetOnboardEQ(settings.RangeFieldSetting):
class HeadsetAdvancedEQ(settings.RangeFieldSetting):
"""Read-only display of the headset's active AdvancedParaEQ (0x020D) bands.
"""Per-band gain editor for the headset's active AdvancedParaEQ (0x020D) slot.
Writes are intentionally disabled for now. The V2 wire format is known
(see advanced_para_eq.py) but we want a round-trip test on real hardware
before enabling user-facing writes that could misconfigure the DSP.
V0/V1: 3-byte band stride [freq_hi, freq_lo, gain_i8], gain is whole dB.
V2: 5-byte band stride [filter_type, freq_hi, freq_lo, gain_hi, gain_lo],
filter_type 0x00=HP 0x78=peaking, freq is raw Hz, gain is signed
int16 × step_db (step_db from getEQInfos).
V2 wire format (pcap-verified against G522 LIGHTSPEED):
getCustomEQ response: [dir_echo] + N × [freq_hi, freq_lo, filter, gain_hi, gain_lo]
setCustomEQ request: [dir, slot, pad=0] + N × [freq_hi, freq_lo, filter, gain_hi, gain_lo]
Gain is offset-binary against gain_min..gain_max with `gain_steps`
discrete positions (raw=120 0 dB on G522's [-6, +6] / 241-step
grid). Frequency and filter type are read at build time and not
user-editable today UI only exposes per-band gain.
"""
name = "headset-advanced-eq"
label = _("Headset Advanced EQ (read-only)")
description = _("Display the headset's active parametric EQ. Writes are disabled pending verification.")
label = _("Headset Advanced EQ")
description = _("Per-band gain for the headset's active parametric EQ.")
feature = _F.HEADSET_ADVANCED_PARA_EQ
rw_options = {"read_fnid": 0x10, "write_fnid": 0x20}
keys_universe = []
class rw_class(settings.FeatureRW):
"""getCustomEQ takes [direction, slot]; the slot is the *active*
EQ preset, which the device may have switched while we weren't
looking (G HUB on another machine, an onboard button, etc.).
Re-query it on every read instead of caching slot 0 at build
time. Direction is hardcoded to 0 (playback) mic-side EQ
isn't exposed yet."""
"""get/setCustomEQ both take [direction, slot]; on writes the
device additionally expects a single 0x00 padding byte before
the band payload. The slot is the *active* EQ preset, which the
device may have switched while we weren't looking — re-query it
on every read and write instead of caching at build time.
Direction is hardcoded to 0 (playback); mic-side EQ isn't
exposed yet.
"""
def read(self, device, data_bytes=b""):
active_slot = hidpp20.get_advanced_eq_active_slot(device, direction=0)
self.read_prefix = bytes([0, active_slot if active_slot is not None else 0])
return super().read(device, data_bytes)
def write(self, device, data_bytes):
active_slot = hidpp20.get_advanced_eq_active_slot(device, direction=0)
slot = active_slot if active_slot is not None else 0
write_bytes = bytes([0, slot, 0]) + data_bytes
return device.feature_request(self.feature, self.write_fnid, write_bytes)
class validator_class(settings_validator.PackedRangeValidator):
kind = settings.Kind.GRAPHIC_EQ
@ -2045,13 +2052,53 @@ class HeadsetAdvancedEQ(settings.RangeFieldSetting):
return result
def prepare_write(self, new_values):
# Read-only mode: never actually build a write payload.
return None
"""Encode N × [freq_hi, freq_lo, filter, gain_hi, gain_lo].
new_values is {band_idx: int_gain_dB}. freq and filter type
come from the cache captured at build time; gain is mapped
from integer dB back to the device's offset-binary raw u16
against the [_gain_min, _gain_max] / _gain_steps grid.
"""
version = getattr(self, "_version", 0)
if version < 2:
return None
gain_min = getattr(self, "_gain_min", -6)
gain_max = getattr(self, "_gain_max", 6)
steps = getattr(self, "_gain_steps", 241)
freqs = getattr(self, "_band_freqs", None) or []
types = getattr(self, "_band_types", None) or []
if not freqs or not types:
return None
span = gain_max - gain_min
payload = bytearray()
for i in range(self.count):
freq = freqs[i] if i < len(freqs) else 0
filt = types[i] if i < len(types) else hidpp20.FILTER_TYPE_PEAKING_G522
gain_db = new_values.get(i, 0)
if steps > 1 and span > 0:
raw = int(round((gain_db - gain_min) / span * (steps - 1)))
else:
raw = 0
raw = max(0, min(steps - 1, raw))
payload += bytes([(freq >> 8) & 0xFF, freq & 0xFF, filt & 0xFF, (raw >> 8) & 0xFF, raw & 0xFF])
return bytes(payload)
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
# RangeFieldSetting.write treats an empty-bytes reply (`not reply`)
# as failure, but setCustomEQ returns an empty ACK on success.
# Override to treat only `reply is None` (transport error/timeout)
# as failure.
assert hasattr(self, "_value")
assert hasattr(self, "_device")
assert map is not None
if self._device.online:
self.update(map, save)
data_bytes = self._validator.prepare_write(self._value)
if data_bytes is not None:
reply = self._rw.write(self._device, data_bytes)
if reply is None:
return None
return map
class HeadsetActiveEQPreset(settings.Setting):