diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 6b96d821..484fa57d 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -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 diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index d567e809..9ed89b81 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -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):