Add OnboardEQ (0x0636) support for Centurion headsets

Implement host-computed biquad EQ coefficient generation and multi-fragment
bridge writes for the PRO X 2 LIGHTSPEED headset's 5-band parametric EQ.

The coefficient algorithm uses standard Audio EQ Cookbook peaking EQ formulas
with a simplified rescale normalization (max_b0 × 1.19 headroom). This is our
own implementation — not an exact replica of LGHUB's ~350-line per-band cascade
normalization — but it produces functionally correct results. The DSP
compensates via the rescale factor, and the EQ changes are audible and working
on real hardware.

Wire format verified against 38 LGHUB pcap writes:
- 4-byte LE section headers, LE uint16 coefficient words
- Mixed Q1.31/Q2.30 fixed-point with 24-bit precision
- Only b-coefficients divided by rescale; a-coefficients unchanged
- Two sections: 48kHz playback + 16kHz mic
- No trailing padding, no extra words between sections

Changes:
- base.py: Add flags parameter to write_centurion_cpl() for multi-fragment CPL
- device.py: Rewrite multi-fragment bridge send — proper CPL fragmentation with
  fragment 0 carrying bridge prefix/hdr and continuations carrying raw sub_msg,
  all fragments sent back-to-back without intermediate ACKs
- hidpp20.py: Replace placeholder coefficient code with full biquad math,
  mixed Q-format quantization, rescale normalization, and dual-section output
- settings_templates.py: Persist EQ to slot 0x80 after writing to slot 0x00
  so settings survive power cycle
- tests: Update expected SetEQParameters payloads for new coefficient format
This commit is contained in:
Ken Sanislo 2026-03-04 12:52:27 -07:00
parent 0cc3b01aec
commit 14807fdf28
5 changed files with 174 additions and 49 deletions

View File

@ -363,17 +363,21 @@ def write(handle, devnumber, data, long_message=False):
raise exceptions.NoReceiver(reason=reason) from reason
def write_centurion_cpl(handle, layer3_payload):
def write_centurion_cpl(handle, layer3_payload, flags=0x00):
"""Send a Centurion CPL frame with the given Layer 3+ payload.
Builds: [0x51, cpl_length, flags=0x00, layer3_payload..., zero-pad to 64 bytes]
Builds: [0x51, cpl_length, flags, layer3_payload..., zero-pad to 64 bytes]
where cpl_length = len(layer3_payload) + 1 (the +1 counts the flags byte).
For multi-fragment sends, flags encodes fragment index and continuation:
flags = (fragment_index << 1) | (1 if more_fragments else 0)
Single-frame messages use flags=0x00 (default).
"""
ihandle = int(handle)
if ihandle not in _centurion_handles:
raise ValueError("write_centurion_cpl called on non-Centurion handle")
cpl_length = len(layer3_payload) + 1 # +1 for flags byte
wdata = struct.pack("!BBB", CENTURION_REPORT_ID, cpl_length, 0x00) + layer3_payload
wdata = struct.pack("!BBB", CENTURION_REPORT_ID, cpl_length, flags) + layer3_payload
wdata = wdata + b"\x00" * (CENTURION_FRAME_SIZE - len(wdata))
if logger.isEnabledFor(logging.DEBUG):
logger.debug("(%s) <= centurion_cpl[%s]", handle, common.strhex(wdata[: cpl_length + 2]))

View File

@ -914,21 +914,28 @@ class Device:
return self.centurion_bridge_request(sub_idx, function, *params, no_reply=no_reply)
return hidpp20.feature_request(self, feature, function, *params, no_reply=no_reply)
# Maximum sub-message bytes per bridge frame: 64 - 1 (report ID) - 2 (CPL) - 4 (bridge hdr) = 57
_MAX_BRIDGE_CHUNK = 57
# Max sub-message bytes in the first bridge fragment:
# 64 - 1 (report ID) - 1 (cpl_len) - 1 (flags) - 2 (bridge prefix) - 2 (bridge hdr) = 57
# LGHUB uses 56 for first fragment (60 byte payload - 4 bridge overhead)
_BRIDGE_FIRST_CHUNK = 56
# Continuation fragments carry raw sub_msg data (no bridge prefix/hdr):
# 64 - 1 (report ID) - 1 (cpl_len) - 1 (flags) = 61, but LGHUB uses 60
_BRIDGE_CONT_CHUNK = 60
def centurion_bridge_request(self, sub_feat_idx, sub_function=0x00, *params, no_reply=False):
"""Send a request to a Centurion sub-device via CentPPBridge.
Builds the 4-layer nested message:
Layer 1: [0x51]
Layer 2: [cpl_length, flags=0x00]
Layer 2: [cpl_length, flags]
Layer 3: [bridge_idx, sendFragment_func|swid, bridge_hdr...]
Layer 4: [sub_cpl=0x00, sub_feat_idx, sub_func|swid, params...]
When sub_msg exceeds 57 bytes, splits into multiple fragments.
Each fragment carries the same bridge_hdr (total sub_len).
The device accumulates until complete.
For multi-fragment sends, only the first fragment includes the bridge
prefix and header. Continuation fragments carry raw sub_msg data.
The CPL flags byte encodes fragment index and continuation:
flags = (fragment_index << 1) | (1 if more_fragments else 0)
Single-frame messages use flags=0x00.
Returns the sub-device response data (after bridge header), or None.
"""
@ -956,21 +963,35 @@ class Device:
timeout = base.DEFAULT_TIMEOUT
with base.acquire_timeout(base.handle_lock(handle), handle, timeout):
if sub_len <= self._MAX_BRIDGE_CHUNK:
if sub_len <= self._BRIDGE_FIRST_CHUNK:
# Single-frame path
layer3 = bridge_prefix + bridge_hdr + sub_msg
base.write_centurion_cpl(handle, layer3)
else:
# Multi-fragment send: split sub_msg into chunks
for offset in range(0, sub_len, self._MAX_BRIDGE_CHUNK):
chunk = sub_msg[offset : offset + self._MAX_BRIDGE_CHUNK]
layer3 = bridge_prefix + bridge_hdr + chunk
base.write_centurion_cpl(handle, layer3)
# Wait for ACK between fragments (not after the last one)
if offset + self._MAX_BRIDGE_CHUNK < sub_len:
if not self._wait_for_bridge_ack(handle, bridge_idx, sw_id, timeout):
logger.warning("centurion_bridge_request: no ACK for fragment at offset %d", offset)
return None
# Multi-fragment send
# Fragment 0: bridge_prefix + bridge_hdr + first chunk of sub_msg
# Fragments 1+: raw sub_msg continuation data (no bridge overhead)
# CPL flags = (frag_index << 1) | (1 if more_fragments else 0)
# All fragments are sent back-to-back without waiting for
# intermediate ACKs (verified via LGHUB pcap). The device
# reassembles internally and sends a single ACK + MessageEvent
# after the last fragment.
frag_index = 0
offset = 0
while offset < sub_len:
if frag_index == 0:
chunk_size = self._BRIDGE_FIRST_CHUNK
chunk = sub_msg[offset : offset + chunk_size]
layer3 = bridge_prefix + bridge_hdr + chunk
else:
chunk_size = self._BRIDGE_CONT_CHUNK
chunk = sub_msg[offset : offset + chunk_size]
layer3 = chunk
has_more = (offset + chunk_size) < sub_len
flags = (frag_index << 1) | (1 if has_more else 0)
base.write_centurion_cpl(handle, layer3, flags=flags)
offset += len(chunk)
frag_index += 1
if no_reply:
return None

View File

@ -2402,6 +2402,13 @@ class ForceSensingButtonArray(UserDict):
# --- OnboardEQ (0x0636) biquad coefficient math and helpers ---
# Mystery bytes observed in every LGHUB pcap EQ write between band params
# and coefficient header. Purpose not fully understood — possibly a null-band
# terminator for DSPs that support >5 bands (advanced 10-band mode).
# First byte matches band_count; bytes 2-3 look like LE16 coeff blob size.
# Hardcoded from pcap for initial bring-up; revisit once device-tested.
_EQ_MYSTERY_BYTES = b"\x05\x5a\xe3\x00"
def _peaking_eq_biquad(freq_hz, gain_db, Q, sample_rate=48000.0):
"""Compute peaking EQ biquad coefficients (Audio EQ Cookbook).
@ -2422,34 +2429,77 @@ def _peaking_eq_biquad(freq_hz, gain_db, Q, sample_rate=48000.0):
)
def _float_to_q626(value):
"""Convert a float to Q6.26 fixed-point unsigned 32-bit representation."""
scaled = max(-(1 << 31), min((1 << 31) - 1, int(round(value * (1 << 26)))))
return scaled & 0xFFFFFFFF
def _quantize_coeffs(b0, b1, b2, a1, a2):
"""Quantize biquad coefficients to mixed Q1.31 / Q2.30 fixed-point.
b0, b2, a2 use Q1.31 (x 2^31); b1, a1 use Q2.30 (x 2^30).
Values are truncated to 24-bit precision (low byte zeroed) matching
the device DSP's internal format.
Returns list of 10 uint16 values (5 coefficients x 2 LE words each,
high word first).
"""
scales = [2**31, 2**30, 2**31, 2**30, 2**31] # b0, b1, b2, a1, a2
words = []
for val, scale in zip([b0, b1, b2, a1, a2], scales):
q = int(round(val * scale))
q = max(-(1 << 31), min((1 << 31) - 1, q))
q = q & 0xFFFFFF00 # 24-bit precision (low byte always zero)
words.append((q >> 16) & 0xFFFF) # high word
words.append(q & 0xFFFF) # low word
return words
def _build_coeff_section(bands, sample_rate, section_type=1):
"""Build one coefficient section for a DSP processing block.
Returns bytes: 4-byte section header + coefficient data as LE uint16 words.
Section header: [type, 0x00, count_lo, count_hi].
Coefficients are normalized by a rescale factor to prevent Q1.31 overflow.
Only feedforward coefficients (b0, b1, b2) are divided by rescale; feedback
coefficients (a1, a2) are left unchanged. The DSP multiplies the output by
rescale to restore correct gain.
"""
_HEADROOM = 1.19 # 19% headroom margin (matches LGHUB)
num_bands = len(bands)
all_words = [num_bands] # first uint16 = num_bands
# First pass: compute raw biquad coefficients for all bands
raw_coeffs = []
for freq, gain, Q in bands:
raw_coeffs.append(_peaking_eq_biquad(freq, gain, max(Q, 0.1), sample_rate))
# Compute rescale: ensure max |b0| fits in Q1.31 with headroom
max_b0 = max(abs(c[0]) for c in raw_coeffs)
rescale = max(1.0, max_b0) * _HEADROOM
# Second pass: normalize b-coefficients and quantize
for b0, b1, b2, a1, a2 in raw_coeffs:
all_words.extend(_quantize_coeffs(b0 / rescale, b1 / rescale, b2 / rescale, a1, a2))
# Rescale factor as Q6.26, 24-bit precision
rs = int(round(rescale * (1 << 26)))
rs = max(-(1 << 31), min((1 << 31) - 1, rs)) & 0xFFFFFF00
all_words.append((rs >> 16) & 0xFFFF)
all_words.append(rs & 0xFFFF)
coeff_count = num_bands * 10 + 3 # num_bands word + 10 per band + 2 rescale words
hdr = bytes([section_type, 0x00, coeff_count & 0xFF, (coeff_count >> 8) & 0xFF])
data = struct.pack(f"<{len(all_words)}H", *all_words)
return hdr + data
def _build_eq_coeffs_payload(bands):
"""Build the biquad coefficient blob for SetEQParameters.
"""Build the full EQCoeffs wire payload for SetEQParameters.
bands: list of (freq_hz, gain_db, Q) tuples.
Returns bytes containing header + section + per-band coefficients + global gain.
Two coefficient sections: type=1 (48 kHz playback) and type=2 (16 kHz mic).
Returns bytes: 7-byte header + sections (no trailing padding).
"""
num_bands = len(bands)
# 7-byte header
payload = bytes([0x03, 0x0E, 0x00, 0x01, 0x00, 0x00, 0x00])
# Section header: filter_type=0x01, reserved=0x00, then coeff count as uint16 LE
total_words = num_bands * 10 + 3 # 5 coeffs * 2 words each per band, plus 2 global gain words + 1 count
payload += struct.pack("<BBH", 0x01, 0x00, total_words)
# Per-band: 5 coefficients, each as Q6.26 split into 2 uint16 LE words
for freq, gain, Q in bands:
coeffs = _peaking_eq_biquad(freq, gain, Q)
for c in coeffs:
q626 = _float_to_q626(c)
payload += struct.pack("<HH", q626 & 0xFFFF, (q626 >> 16) & 0xFFFF)
# Global gain: Q6.26(1.0) = 0x04000000
unity = _float_to_q626(1.0)
payload += struct.pack("<HH", unity & 0xFFFF, (unity >> 16) & 0xFFFF)
return payload
section_count = 2
header = bytes([0x03, 0x0E, 0x00, section_count, 0x00, 0x00, 0x00])
sections = _build_coeff_section(bands, 48000.0, section_type=1)
sections += _build_coeff_section(bands, 16000.0, section_type=2)
return header + sections
def _build_set_eq_payload(slot, bands):
@ -2461,6 +2511,7 @@ def _build_set_eq_payload(slot, bands):
params = bytes([slot, len(bands)])
for freq, gain, Q in bands:
params += struct.pack(">H", freq) + bytes([gain & 0xFF, Q & 0xFF])
params += _EQ_MYSTERY_BYTES
params += _build_eq_coeffs_payload(bands)
return params

View File

@ -1750,8 +1750,21 @@ class HeadsetOnboardEQ(settings.RangeFieldSetting):
q = self._band_qs[i] if i < len(self._band_qs) else 10
gain = new_values.get(i, 0)
bands.append((freq, gain, q))
self._pending_bands = bands # stash for persist step
return hidpp20._build_set_eq_payload(0x00, bands)
def write(self, map, save=True):
result = super().write(map, save)
# Also persist to device flash (slot 0x80) so EQ survives power cycle
if result is not None and hasattr(self._validator, "_pending_bands"):
bands = self._validator._pending_bands
del self._validator._pending_bands
try:
self._device.feature_request(_F.HEADSET_ONBOARD_EQ, 0x20, hidpp20._build_set_eq_payload(0x80, bands))
except Exception:
logger.warning("HeadsetOnboardEQ: failed to persist EQ to slot 0x80")
return result
class BrightnessControl(settings.Setting):
name = "brightness_control"

View File

@ -643,17 +643,53 @@ key_tests = [
[-12, 12],
fake_hidpp.Response("8000000002", 0x0400), # GetEQInfos: has_hw_eq, 2 bands
fake_hidpp.Response("00020080FE0A0100030A", 0x0410, "00"), # GetEQParameters
fake_hidpp.Response( # SetEQParameters: write initial values back
fake_hidpp.Response( # SetEQParameters: write initial values back (slot 0x00)
"00",
0x0420,
"00020080FE0A0100030A030E0001000000010017005FCDFF03DB3502F84C46FE03DB3502F8AA13FE03"
"37980004E10704F8D685FC03E10704F80D1EFD0300000004",
"00020080FE0A0100030A"
"055AE300" # mystery bytes (from pcap)
"030E00020000000100170002"
"007A6B00D69D94008C516B00C82380005DC27F0075"
"906B0022B6940002226B00B44080007EA37F00C1C3040044"
"0200170002"
"00506B00900F95006ED56A00D185800060477F00CA"
"906B00229D950052496A00A12E810085EC7E0075C40400AC",
),
fake_hidpp.Response( # SetEQParameters: write updated band 1 gain=5
fake_hidpp.Response( # SetEQParameters: persist initial values (slot 0x80)
"00",
0x0420,
"00020080FE0A0100050A030E0001000000010017005FCDFF03DB3502F84C46FE03DB3502F8AA13FE03"
"FAFF0004C6B703F83A6EFC03C6B703F8346EFD0300000004",
"80020080FE0A0100030A"
"055AE300"
"030E00020000000100170002"
"007A6B00D69D94008C516B00C82380005DC27F0075"
"906B0022B6940002226B00B44080007EA37F00C1C3040044"
"0200170002"
"00506B00900F95006ED56A00D185800060477F00CA"
"906B00229D950052496A00A12E810085EC7E0075C40400AC",
),
fake_hidpp.Response( # SetEQParameters: write updated band 1 gain=5 (slot 0x00)
"00",
0x0420,
"00020080FE0A0100050A"
"055AE300"
"030E00020000000100170002"
"006F6B00F5A894006A466B00EB2380005DC27F0075"
"906B0022BC9400AA156B00613B80007CAD7F00C6C30400BF"
"0200170002"
"00306B00272F9500BAB56A008C85800060477F00CA"
"906B0022B1950002226A000E1F8100AB0A7F004FC604001D",
),
fake_hidpp.Response( # SetEQParameters: persist updated values (slot 0x80)
"00",
0x0420,
"80020080FE0A0100050A"
"055AE300"
"030E00020000000100170002"
"006F6B00F5A894006A466B00EB2380005DC27F0075"
"906B0022BC9400AA156B00613B80007CAD7F00C6C30400BF"
"0200170002"
"00306B00272F9500BAB56A008C85800060477F00CA"
"906B0022B1950002226A000E1F8100AB0A7F004FC604001D",
),
),
Setup(