From 7c73c8886777b92110c6cffe43aa07fc3ed20a18 Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Sun, 10 May 2026 17:21:09 -0700 Subject: [PATCH] AdvancedParaEQ: correct V2 band-stride and header length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The V2 parser was using stride [filter, gain_hi, gain_lo, freq_hi, freq_lo] with a 3-byte header. Pcap traces of LGHUB ↔ G522 LIGHTSPEED show the actual wire is [freq_hi, freq_lo, filter, gain_hi, gain_lo] with a 1-byte header (just dir_echo; the centurion bridge omits the slot_echo the protocol spec describes for getCustomEQ). With the old stride, slot 0's 10-band response on G522 parsed as 9 bands: the highest band (19 kHz, +2.75 dB on the active preset) was silently swallowed because byte alignment landed on what looked like a freq=0 end-sentinel. The displayed bands were also shifted — 20 Hz was missing from the front because the parser started one full band late. See ~/ghub/solaar_0x020D_slot_investigation.md for the RE notes. --- lib/logitech_receiver/advanced_para_eq.py | 44 +++++++++++------------ 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/lib/logitech_receiver/advanced_para_eq.py b/lib/logitech_receiver/advanced_para_eq.py index 23c1854a..75b2a98a 100644 --- a/lib/logitech_receiver/advanced_para_eq.py +++ b/lib/logitech_receiver/advanced_para_eq.py @@ -23,16 +23,20 @@ V0/V1 wire format: 3-byte band stride [freq_hi, freq_lo, gain_i8], gain is whole dB; getEQInfos returns 5 bytes [bandCount, dbRange, caps, dbMin, dbMax]. -V2 wire format: 3-byte header [direction_echo, slot_echo, -band_count_max], then N × 5-byte band stride [filter_type, gain_hi, -gain_lo, freq_hi, freq_lo], then 0..2 trailer bytes (opaque, ignored). -band_count_max is the device's max-bands capacity, NOT how many bands -are populated — the parser consumes 5-byte chunks until <5 bytes -remain. Frequency u16 BE in Hz, with freq=0 marking end-of-bands. -Gain is **offset-binary**: raw 0..(steps-1) maps linearly to -gain_min..gain_max (so on G522 with steps=241 / gain=[-6..6], raw=120 -= 0 dB). getEQInfos returns 13 bytes with gain bounds + step count, -format enum, XY-support flag, and onboard preset counts. +V2 wire format: 1-byte header [direction_echo], then N × 5-byte band +stride [freq_hi, freq_lo, filter_type, gain_hi, gain_lo], with 0..3 +trailer bytes (opaque, ignored). The parser consumes 5-byte chunks +until <5 bytes remain or a freq=0 sentinel is hit. Frequency u16 BE +in Hz; gain u16 BE in **offset-binary**: raw 0..(steps-1) maps +linearly to gain_min..gain_max (so on G522 with steps=241 / +gain=[-6..6], raw=120 = 0 dB). getEQInfos returns 13 bytes with gain +bounds + step count, format enum, XY-support flag, and onboard preset +counts. + +(The protocol spec lists a 2-byte header [dir_echo, slot_echo] for +getCustomEQ, but G522 firmware via the centurion bridge omits the +slot_echo and emits a 1-byte header that matches getEQDefaults. +Verified against pcap traces of LGHUB ↔ G522 LIGHTSPEED traffic.) """ from __future__ import annotations @@ -178,9 +182,9 @@ def parse_v2_bands(result: bytes, info: dict | None): """Parse a V2 getCustomEQ / getEQDefaults response. Wire layout (see module docstring): - [direction_echo, slot_echo, band_count_max] (3-byte header) - N × [filter_type, gain_hi, gain_lo, freq_hi, freq_lo] (5 bytes) - [trailer …] (0..2 bytes, ignored) + [direction_echo] (1-byte header) + N × [freq_hi, freq_lo, filter_type, gain_hi, gain_lo] (5 bytes) + [trailer …] (0..3 bytes, ignored) Gain is offset-binary against `info`'s gain bounds: gain_db = gain_min + (gain_max - gain_min) * raw / (steps - 1) @@ -194,9 +198,9 @@ def parse_v2_bands(result: bytes, info: dict | None): valid header returns []. Bands with freq=0 are treated as the end-of-bands sentinel (matches V0/V1 behavior at lines below). """ - if result is None or len(result) < 3: + if result is None or len(result) < 1: return None - payload = result[3:] # skip [dir_echo, slot_echo, band_count_max] + payload = result[1:] # skip [dir_echo] band_size = 5 if info: gain_min = info.get("gain_min_db", -6) @@ -207,9 +211,9 @@ def parse_v2_bands(result: bytes, info: dict | None): bands = [] for i in range(len(payload) // band_size): e = payload[i * band_size : (i + 1) * band_size] - filter_type = e[0] - gain_raw = (e[1] << 8) | e[2] - freq_hz = (e[3] << 8) | e[4] + freq_hz = (e[0] << 8) | e[1] + filter_type = e[2] + gain_raw = (e[3] << 8) | e[4] if freq_hz == 0: break # disabled band — end-of-bands sentinel if steps > 1: @@ -255,10 +259,6 @@ def get_advanced_eq_defaults(device, direction=DIRECTION_PLAYBACK, slot=0): result.hex(), ) return None - # Log raw=... too — getEQDefaults appears to use a different header - # framing than getCustomEQ on G522 (decoded values come out shifted - # by a byte). Capture the raw bytes so we can pin down the actual - # layout difference and adjust the parser accordingly. logger.info( "AdvancedParaEQ getEQDefaults V2 (dir=%d slot=%d): %d band(s) raw=%s %s", direction,