From ac7add62978116013064721dd13c384b9dd15a49 Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Wed, 13 May 2026 15:04:20 -0700 Subject: [PATCH] G522 LIGHTSPEED headphones support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end support for the G522 LIGHTSPEED gaming headset (and the infrastructure that comes with it for related Centurion-bridged headsets). Hardware/protocol layer: - centurion.py — CenturionReceiver: lightweight receiver-like wrapper for Centurion-bridged dongles (PRO X 2 LIGHTSPEED already, G522 added here). Handles deferred init for 0x50 devices that don't respond to the initial probe, sub-device feature discovery via bridge, online detection via ping. - base.py — Centurion handle state, device_addr probe (256-candidate scan), 0x50/0x51 frame routing, bridge TX/RX framing. - hidpp20.py — Centurion sub-device feature query and bridge request routing. CENTURION_DEVICE_INFO / CENTURION_BATTERY_SOC / CENTURION_* query helpers in centurion.py for parent + sub paths. - device.py — Centurion device creation path, USB product-string fallback for naming, bridge sub-device error handling. - notifications.py — HEADSET_ADVANCED_PARA_EQ band-change events, HEADSET_MIC_MUTE state-change events; both route to the relevant setting via setting_callback. Headset settings: - HeadsetEcoMode, HeadsetDoNotDisturb, HeadsetMicMute, HeadsetMicSNR, HeadsetAINR / HeadsetAINRLevel, HeadsetSidetone, HeadsetMicGain, HeadsetMixBalance, HeadsetAutoSleep — bool / range / choice settings for the headset-specific feature pages. - HeadsetOnboardEQ + HeadsetActiveEQPreset — onboard EQ slot picker with active-preset tracking via the EQ change event subscription. - HeadsetAdvancedEQ — multi-band parametric EQ (advanced_para_eq.py module handles getEQInfos / getCustomEQ / band-change subscription; setting builds the per-band sliders). - HeadsetLEDControl + HeadsetLedsPrimary + HeadsetPerZoneLighting — RGB feature set (0x0620 HEADSET_RGB_HOSTMODE) with shared zone-write helper (headset_rgb.py) and the G522 layout for the per-key painter (perkey/layouts/headset_g522.py). - LogiVoice family (12 settings: NR / NG / Compressor / De-esser / Depopper / Limiter / HPF, each with state + parameters) — voice processing pipeline. logivoice.py module handles probe + parsing. Probes: - rgb_effects_probe.py — runtime probe for headset RGB feature variants (0x0621 onboard-effects, 0x0622 signature-effects, 0x0623). Tests: - test_base.py / test_device.py / test_hidpp20_complex.py — Centurion framing, device-creation path, sub-device feature parsing. - test_setting_templates.py — fixtures for the new headset settings. Closes pwr-Solaar/Solaar#3181. --- lib/hidapi/common.py | 1 + lib/hidapi/udev_impl.py | 16 +- lib/logitech_receiver/advanced_para_eq.py | 456 +++++++++ lib/logitech_receiver/base.py | 187 +++- lib/logitech_receiver/centurion.py | 96 +- lib/logitech_receiver/descriptors.py | 1 + lib/logitech_receiver/device.py | 144 ++- lib/logitech_receiver/headset_rgb.py | 211 ++++ lib/logitech_receiver/hidpp20.py | 97 +- lib/logitech_receiver/hidpp20_constants.py | 4 + lib/logitech_receiver/listener.py | 9 +- lib/logitech_receiver/logivoice.py | 300 ++++++ lib/logitech_receiver/notifications.py | 41 + lib/logitech_receiver/onboard_eq.py | 10 +- lib/logitech_receiver/rgb_effects_probe.py | 242 +++++ lib/logitech_receiver/settings.py | 12 +- lib/logitech_receiver/settings_templates.py | 948 +++++++++++++++++- lib/solaar/listener.py | 6 + lib/solaar/ui/perkey/layouts/__init__.py | 3 + lib/solaar/ui/perkey/layouts/headset_g522.py | 53 + tests/logitech_receiver/test_base.py | 217 ++++ tests/logitech_receiver/test_device.py | 34 +- .../logitech_receiver/test_hidpp20_complex.py | 29 +- 23 files changed, 2949 insertions(+), 168 deletions(-) create mode 100644 lib/logitech_receiver/advanced_para_eq.py create mode 100644 lib/logitech_receiver/headset_rgb.py create mode 100644 lib/logitech_receiver/logivoice.py create mode 100644 lib/logitech_receiver/rgb_effects_probe.py create mode 100644 lib/solaar/ui/perkey/layouts/headset_g522.py diff --git a/lib/hidapi/common.py b/lib/hidapi/common.py index 6817511a..3946d98f 100644 --- a/lib/hidapi/common.py +++ b/lib/hidapi/common.py @@ -19,3 +19,4 @@ class DeviceInfo: hidpp_short: str | None hidpp_long: str | None centurion: bool = False + centurion_report_id: int | None = None # 0x50 or 0x51 when centurion=True diff --git a/lib/hidapi/udev_impl.py b/lib/hidapi/udev_impl.py index 4b5a6168..a5b2cf3f 100644 --- a/lib/hidapi/udev_impl.py +++ b/lib/hidapi/udev_impl.py @@ -102,6 +102,7 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo from hid_parser import ReportDescriptor hidpp_short = hidpp_long = centurion = False + centurion_report_id = None devfile = "/sys" + hid_device.properties.get("DEVPATH") + "/report_descriptor" with fileopen(devfile, "rb") as fd: with warnings.catch_warnings(): @@ -111,16 +112,22 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo # and _Usage(0xFF00, 0x0001) in rd.get_input_items(0x10)[0].usages # be more permissive hidpp_long = 0x11 in rd.input_report_ids and 19 * 8 == int(rd.get_input_report_size(0x11)) # and _Usage(0xFF00, 0x0002) in rd.get_input_items(0x11)[0].usages # be more permissive - # Centurion transport: report ID 0x51, 63-byte reports (usage page 0xFFA0) - centurion = ( - 0x51 in rd.input_report_ids and 63 * 8 == int(rd.get_input_report_size(0x51)) and 0x51 in rd.output_report_ids - ) + # Centurion transport: 63-byte reports on usage page 0xFFA0 (both input and output) + # 0x51 = PRO X 2 LIGHTSPEED variant, 0x50 = G522 LIGHTSPEED variant (with device address byte) + if 0x51 in rd.input_report_ids and 63 * 8 == int(rd.get_input_report_size(0x51)) and 0x51 in rd.output_report_ids: + centurion_report_id = 0x51 + elif ( + 0x50 in rd.input_report_ids and 63 * 8 == int(rd.get_input_report_size(0x50)) and 0x50 in rd.output_report_ids + ): + centurion_report_id = 0x50 + centurion = centurion_report_id is not None if not hidpp_short and not hidpp_long and not centurion: return except Exception as e: # if can't process report descriptor fall back to old scheme hidpp_short = None hidpp_long = None centurion = False + centurion_report_id = None logger.info( "Report Descriptor not processed for DEVICE %s BID %s VID %s PID %s: %s", device.device_node, @@ -171,6 +178,7 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo hidpp_short=hidpp_short, hidpp_long=hidpp_long, centurion=centurion if centurion else False, + centurion_report_id=centurion_report_id, ) return d_info diff --git a/lib/logitech_receiver/advanced_para_eq.py b/lib/logitech_receiver/advanced_para_eq.py new file mode 100644 index 00000000..40f71fb8 --- /dev/null +++ b/lib/logitech_receiver/advanced_para_eq.py @@ -0,0 +1,456 @@ +## Copyright (C) 2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/ +## +## This program is free software; you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation; either version 2 of the License, or +## (at your option) any later version. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License along +## with this program; if not, write to the Free Software Foundation, Inc., +## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +"""AdvancedParaEQ (0x020D) helpers. + +The device handles biquad coefficient computation — we transmit only +per-band filter-type + frequency + gain; the DSP does the rest. + +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: 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 + +import logging +import struct + +from .hidpp20_constants import SupportedFeature + +logger = logging.getLogger(__name__) + +DIRECTION_PLAYBACK = 0 +DIRECTION_CAPTURE = 1 + +# V2 filter-type taxonomy (byte [+0] of each band). 0x16 is observed on +# G522 for every band of its factory custom slot at ISO third-octave +# centers, treated as peaking. Other filter kinds (LP, shelf, notch …) +# need a live probe per device firmware to enumerate. +FILTER_TYPE_HP = 0x00 +FILTER_TYPE_PEAKING_G522 = 0x16 +FILTER_TYPE_PEAKING = 0x78 +FILTER_TYPE_NAMES = { + FILTER_TYPE_HP: "HP", + FILTER_TYPE_PEAKING_G522: "peaking", + FILTER_TYPE_PEAKING: "peaking", +} + + +def _get_version(device) -> int: + return device.features.get_feature_version(SupportedFeature.HEADSET_ADVANCED_PARA_EQ) or 0 + + +def get_advanced_eq_info(device): + """Query getEQInfos (function 0). Returns a dict or None. + + Common fields: + version int feature version (0, 1, 2) + gain_min_db int signed whole-dB min + gain_max_db int signed whole-dB max + step_db float dB per raw LSB (1.0 on V0/V1) + + V0/V1 only: + band_count int number of bands (from wire byte 0) + db_range int raw byte 1 + capabilities int raw byte 2 + + V2 only: + gain_steps int discrete gain positions + format int 0=CLASSIC, 1=STYLES + supports_xy bool + onboard_ro_preset_count int factory preset slots + onboard_custom_preset_count int user-writable preset slots + """ + version = _get_version(device) + result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x00) + if result is None: + logger.debug("AdvancedParaEQ getEQInfos V%d: feature_request returned None", version) + return None + + if version >= 2: + if len(result) < 13: + logger.debug("AdvancedParaEQ getEQInfos V2: short response len=%d %s", len(result), result.hex()) + return None + gain_min = struct.unpack("b", bytes([result[2]]))[0] + gain_max = struct.unpack("b", bytes([result[3]]))[0] + gain_steps = struct.unpack(">H", result[4:6])[0] + fmt = result[6] + supports_xy = bool(result[7]) + ro_presets = result[9] + custom_presets = result[10] + step_db = (gain_max - gain_min) / max(1, gain_steps - 1) + info = { + "version": 2, + "gain_min_db": gain_min, + "gain_max_db": gain_max, + "gain_steps": gain_steps, + "step_db": step_db, + "format": fmt, + "supports_xy": supports_xy, + "onboard_ro_preset_count": ro_presets, + "onboard_custom_preset_count": custom_presets, + } + logger.debug( + "AdvancedParaEQ getEQInfos V2: gain=[%d,%d] steps=%d step_db=%.4f format=%d xy=%s " + "presets_ro=%d presets_custom=%d", + gain_min, + gain_max, + gain_steps, + step_db, + fmt, + supports_xy, + ro_presets, + custom_presets, + ) + device._advanced_eq_info = info + return info + + # V0 / V1 + if len(result) < 5: + logger.debug("AdvancedParaEQ getEQInfos V%d: short response len=%d %s", version, len(result), result.hex()) + return None + band_count = result[0] + db_range = result[1] + caps = result[2] + gain_min = struct.unpack("b", bytes([result[3]]))[0] + gain_max = struct.unpack("b", bytes([result[4]]))[0] + info = { + "version": version, + "band_count": band_count, + "db_range": db_range, + "capabilities": caps, + "gain_min_db": gain_min, + "gain_max_db": gain_max, + "step_db": 1.0, + } + logger.debug( + "AdvancedParaEQ getEQInfos V%d: bands=%d dbRange=%d caps=0x%02X gain=[%d,%d]", + version, + band_count, + db_range, + caps, + gain_min, + gain_max, + ) + device._advanced_eq_info = info + return info + + +def get_advanced_eq_active_slot(device, direction=DIRECTION_PLAYBACK): + """Query getActiveEQ (function 3). Returns the active slot index, or None.""" + result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x30, direction) + if result is None: + logger.debug("AdvancedParaEQ getActiveEQ(dir=%d): feature_request returned None", direction) + return None + if len(result) < 1: + logger.debug("AdvancedParaEQ getActiveEQ(dir=%d): empty response", direction) + return None + logger.debug("AdvancedParaEQ getActiveEQ(dir=%d): slot=%d", direction, result[0]) + return result[0] + + +def parse_v2_bands(result: bytes, info: dict | None): + """Parse a V2 getCustomEQ / getEQDefaults response. + + Wire layout (see module docstring): + [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) + + `info` is the dict returned by `get_advanced_eq_info`. If absent we + fall back to step_db=1.0 (and log via the caller, not here) which is + wrong but won't crash. + + Returns list of (filter_type_byte, freq_hz, gain_db) tuples, or None + if the payload is too short to contain a header. Empty payload with + 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) < 1: + return None + payload = result[1:] # skip [dir_echo] + band_size = 5 + if info: + gain_min = info.get("gain_min_db", -6) + gain_max = info.get("gain_max_db", 6) + steps = info.get("gain_steps", 241) + else: + gain_min, gain_max, steps = 0, 0, 1 # produces gain_db=0 for any raw + bands = [] + for i in range(len(payload) // band_size): + e = payload[i * band_size : (i + 1) * band_size] + 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: + gain_db = gain_min + (gain_max - gain_min) * gain_raw / (steps - 1) + else: + gain_db = 0.0 + bands.append((filter_type, freq_hz, float(gain_db))) + return bands + + +def _band_label(filter_type_byte: int, freq_hz: int) -> str: + kind = FILTER_TYPE_NAMES.get(filter_type_byte, f"type-0x{filter_type_byte:02X}") + if filter_type_byte == FILTER_TYPE_HP: + return f"HP {freq_hz} Hz" + return f"{freq_hz} Hz" if kind == "peaking" else f"{kind} {freq_hz} Hz" + + +def get_advanced_eq_defaults(device, direction=DIRECTION_PLAYBACK, slot=0): + """Query getEQDefaults (function 5). Same per-band layout as getCustomEQ. + + Returns list of (filter_type_byte, freq_hz, gain_db) tuples, or None. + V0/V1 callers receive (FILTER_TYPE_PEAKING, freq_hz, gain_db) for + compatibility with the V2 tuple shape. + """ + version = _get_version(device) + result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x50, direction, slot) + if result is None: + logger.debug( + "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) + bands = parse_v2_bands(result, info) + if bands is None: + logger.debug( + "AdvancedParaEQ getEQDefaults V2 (dir=%d slot=%d): payload too short raw=%s", + direction, + slot, + result.hex(), + ) + return None + logger.debug( + "AdvancedParaEQ getEQDefaults V2 (dir=%d slot=%d): %d band(s) raw=%s %s", + direction, + slot, + len(bands), + result.hex(), + [_band_label(t, f) + f" {round(g, 2)}dB" for t, f, g in bands], + ) + return bands + # V0/V1 legacy 3-byte stride. + 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((FILTER_TYPE_PEAKING, freq, float(gain_db))) + offset += 3 + logger.debug( + "AdvancedParaEQ getEQDefaults V%d (dir=%d slot=%d): %d band(s)", + version, + direction, + slot, + len(bands), + ) + 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_advanced_eq_slots(device, direction=DIRECTION_PLAYBACK, info=None): + """Probe every advertised EQ slot via getCustomEQ and cache which respond. + + Some firmware (G522) advertises N slots via getEQInfos but only honors a + subset for getCustomEQ / setActiveEQ — the rest return 0x0B NOT_SUPPORTED. + This iterates 0..total-1 and records which slots actually have data. + + Result is cached on `device._advanced_eq_working_slots` as a list of + `(slot_index, name, bands)` tuples. The HeadsetActiveEQPreset selector + builds its choices from this list; the HeadsetAdvancedEQ panel uses it + to skip dead slots in its diagnostic output. + + Logs each working slot's bands at INFO and a summary line indicating + how many of the advertised slots are actually accessible. + """ + cached = getattr(device, "_advanced_eq_working_slots", None) + if cached is not None: + return cached + if info is None: + info = getattr(device, "_advanced_eq_info", None) or get_advanced_eq_info(device) + if not info: + return [] + ro_count = info.get("onboard_ro_preset_count", 0) or 0 + custom_count = info.get("onboard_custom_preset_count", 0) or 0 + total = ro_count + custom_count + if total == 0: + return [] + + def probe(slot): + bands = get_advanced_eq_params(device, direction=direction, slot=slot) + if bands is None: + return None + name = get_advanced_eq_friendly_name(device, direction=direction, slot=slot) + kind = "factory" if slot < ro_count else "custom" + logger.debug( + "AdvancedParaEQ %s preset slot=%d (dir=%d) name=%r: %s", + kind, + slot, + direction, + name, + [f"{_band_label(t, f)} {round(g, 2)}dB" for t, f, g in bands], + ) + return (slot, name, bands) + + working = [] + # Slot 0 is canonical. If it fails the device is unusable; bail. + entry = probe(0) + if entry is None: + device._advanced_eq_working_slots = working + return working + working.append(entry) + # Slot 1 acts as a "multi-slot capable?" canary. G522 firmware + # advertises 16 slots but only honors slot 0; LGHUB itself never + # touches slots > 0 on this device. When the canary fails, skip the + # remaining 14 NOT_SUPPORTED probes. + if total > 1: + entry = probe(1) + if entry is None: + logger.debug( + "AdvancedParaEQ: slot 1 returned NOT_SUPPORTED; " "firmware advertises %d slots but only honors slot 0", + total, + ) + device._advanced_eq_working_slots = working + return working + working.append(entry) + for slot in range(2, total): + entry = probe(slot) + if entry is not None: + working.append(entry) + device._advanced_eq_working_slots = working + logger.debug( + "AdvancedParaEQ working slots on dir=%d: %d of %d advertised %s", + direction, + len(working), + total, + [w[0] for w in working], + ) + return working + + +# Backward-compat alias kept until external callers are migrated. +probe_all_presets = probe_advanced_eq_slots + + +def get_advanced_eq_params(device, direction=DIRECTION_PLAYBACK, slot=0): + """Query getCustomEQ (function 1). Returns list of (filter_type, freq_hz, gain_db) or None. + + V0/V1: filter_type is always FILTER_TYPE_PEAKING (synthesized), freq is + raw Hz from wire, gain is whole dB. + V2: filter_type comes from the wire (0x00=HP, 0x78=peaking), freq is raw + Hz, gain is int16 × step_db. + + step_db for V2 is cached on the device by get_advanced_eq_info. + """ + version = _get_version(device) + result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x10, direction, slot) + if result is None: + logger.debug( + "AdvancedParaEQ getCustomEQ 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) + if not info: + logger.warning("AdvancedParaEQ getCustomEQ V2: no cached getEQInfos — gain values will be wrong") + bands = parse_v2_bands(result, info) + if bands is None: + logger.debug( + "AdvancedParaEQ getCustomEQ V2 (dir=%d slot=%d): payload too short raw=%s", + direction, + slot, + result.hex(), + ) + return None + step_db = info["step_db"] if info and "step_db" in info else 1.0 + # Log raw=... too so we can compare wire shapes across firmware + # variants and across get-fns (getCustomEQ vs getEQDefaults). + logger.debug( + "AdvancedParaEQ getCustomEQ V2 (dir=%d slot=%d): %d band(s) step_db=%.4f raw=%s %s", + direction, + slot, + len(bands), + step_db, + result.hex(), + [f"{_band_label(t, f)} {round(g, 2)}dB" for t, f, g in bands], + ) + return bands + + # V0 / V1 + 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((FILTER_TYPE_PEAKING, freq, float(gain_db))) + offset += 3 + logger.debug( + "AdvancedParaEQ getCustomEQ V%d (dir=%d slot=%d): parsed %d band(s) %s", + version, + direction, + slot, + len(bands), + bands, + ) + return bands diff --git a/lib/logitech_receiver/base.py b/lib/logitech_receiver/base.py index ca7bfba1..35467712 100644 --- a/lib/logitech_receiver/base.py +++ b/lib/logitech_receiver/base.py @@ -97,18 +97,30 @@ HIDPP_LONG_MESSAGE_ID = 0x11 DJ_MESSAGE_ID = 0x20 # Centurion transport (used by PRO X 2 LIGHTSPEED headset and similar) -# Uses report ID 0x51 on usage page 0xFFA0, 64-byte frames. -# Wire format (CPL): [0x51, cpl_length, flags=0x00, feat_idx, func_sw, params..., pad] +# Two variants exist, distinguished by report ID: +# 0x51 (PRO X 2): [0x51, cpl_length, flags, feat_idx, func_sw, params..., pad] +# 0x50 (G522): [0x50, device_addr, cpl_length, flags, feat_idx, func_sw, params..., pad] +# The 0x50 variant adds a device_addr byte at position [1], shifting all CPL fields by +1. # cpl_length = number of bytes from flags to end of meaningful data (includes flags byte). # The device_index byte from standard HID++ is NOT present in Centurion framing. CENTURION_REPORT_ID = 0x51 +CENTURION_ADDRESSED_REPORT_ID = 0x50 # addressed variant with device_addr byte at frame[1] (G522 etc.) CENTURION_FRAME_SIZE = 64 # 1 byte report ID + 63 bytes payload _CENTURION_MSG_SIZE = 63 # max reconstructed message size after unwrapping (2 + 61 payload bytes) -# Set of handles that use Centurion framing -_centurion_handles: set[int] = set() -# Raw Centurion protocol version (major, minor) by handle, from ping response -_centurion_protocol_versions: dict[int, tuple[int, int]] = {} + +@dataclasses.dataclass +class CenturionHandleState: + """Per-handle state for Centurion devices.""" + + report_id: int = CENTURION_REPORT_ID # 0x50 or 0x51 + device_addr: int | None = None # learned from first RX (0x50 only) + protocol_version: tuple[int, int] | None = None # from ping response + + +# All centurion per-handle state in a single dict. +# Membership test (ihandle in _centurion_handles) gates centurion-specific code paths. +_centurion_handles: dict[int, CenturionHandleState] = {} """Default timeout on read (in seconds).""" @@ -301,8 +313,7 @@ def close(handle): if handle: try: if isinstance(handle, int): - _centurion_handles.discard(handle) - _centurion_protocol_versions.pop(handle, None) + _centurion_handles.pop(handle, None) hidapi.close(handle) else: handle.close() @@ -313,6 +324,115 @@ def close(handle): return False +def _centurion_frame_header(state: CenturionHandleState, cpl_length: int, flags: int) -> bytes: + """Build the fixed prefix of a centurion frame. + + 0x51: [0x51, cpl_length, flags] (3 bytes) + 0x50: [0x50, device_addr, cpl_length, flags] (4 bytes) + """ + if state.report_id == CENTURION_ADDRESSED_REPORT_ID: + device_addr = state.device_addr if state.device_addr is not None else 0x00 + return struct.pack("!BBBB", CENTURION_ADDRESSED_REPORT_ID, device_addr, cpl_length, flags) + return struct.pack("!BBB", CENTURION_REPORT_ID, cpl_length, flags) + + +_CENTURION_REPORT_IDS = (CENTURION_REPORT_ID, CENTURION_ADDRESSED_REPORT_ID) + +# Per-candidate read timeout (ms) for the device_addr probe. +# USB round-trip is <1ms; 5ms gives 5x margin. +_CENTURION_PROBE_PER_ADDR_TIMEOUT_MS = 5 + + +def probe_centurion_device_addr(handle, state: CenturionHandleState) -> bool: + """Brute-force probe the device address byte for a 0x50-variant Centurion handle. + + Sends a ROOT.GetProtocolVersion request for each candidate device_addr + (0x00–0xFF), reading briefly after each write. The dongle silently ignores + wrong addresses and responds only to the correct one. Stops on first hit. + + Worst case (no response): 256 × 5ms = ~1.3s. + Typical G522 (addr=0x23): 36 × 5ms = ~180ms. + + No-op for 0x51 (no device_addr byte) or when an address is already known. + Returns True if the address was learned. + """ + if state.report_id != CENTURION_ADDRESSED_REPORT_ID or state.device_addr is not None: + return False + ihandle = int(handle) + logger.debug("(%s) probing centurion device_addr: scanning 0x00-0xFF", handle) + + # ROOT.GetProtocolVersion: feat_idx=0x00, func=0x10, 3 zero param bytes + payload = bytes([0x00, 0x10, 0x00, 0x00, 0x00]) + cpl_length = len(payload) + 1 # +1 for flags byte + write_errors = 0 + + for addr in range(256): + frame = struct.pack("!BBBB", CENTURION_ADDRESSED_REPORT_ID, addr, cpl_length, 0x00) + payload + frame = frame + b"\x00" * (CENTURION_FRAME_SIZE - len(frame)) + try: + hidapi.write(ihandle, frame) + except Exception: + write_errors += 1 + if write_errors > 3: + logger.debug("(%s) centurion device_addr probe: too many write failures, aborting", handle) + return False + continue + try: + data = hidapi.read(ihandle, CENTURION_FRAME_SIZE, _CENTURION_PROBE_PER_ADDR_TIMEOUT_MS) + except Exception as reason: + logger.debug("(%s) centurion device_addr probe read failed at addr 0x%02X: %s", handle, addr, reason) + return False + if data and len(data) >= 2 and ord(data[:1]) == state.report_id: + state.device_addr = ord(data[1:2]) + logger.debug( + "(%s) probed centurion device addr 0x%02X (after %d candidates)", + handle, + state.device_addr, + addr + 1, + ) + return True + + logger.debug("(%s) centurion device_addr probe: no response from any of 256 candidates", handle) + return False + + +def _unwrap_centurion_frame(data: bytes, ihandle: int, handle) -> bytes: + """Unwrap a Centurion CPL frame (0x50 or 0x51) into a standard HID++ long message. + + Auto-detects the variant from the raw report ID byte (self-describing), + matching how _read() handles 0x10 vs 0x11. + + For 0x50, learns the device address from byte[1] on first receive. + """ + raw_report_id = ord(data[:1]) + if raw_report_id == CENTURION_ADDRESSED_REPORT_ID: + # 0x50: [report_id, device_addr, cpl_length, flags, feat_idx, func_sw, data...] + device_addr = ord(data[1:2]) + state = _centurion_handles.get(ihandle) + if state is not None and state.device_addr is None: + state.device_addr = device_addr + if logger.isEnabledFor(logging.DEBUG): + logger.debug("(%s) learned centurion device addr 0x%02X", handle, device_addr) + cpl_length = ord(data[2:3]) + inner_payload = data[4 : 3 + cpl_length] # cpl_length - 1 bytes (skip flags) + elif raw_report_id == CENTURION_REPORT_ID: + # 0x51: [report_id, cpl_length, flags, feat_idx, func_sw, data...] + cpl_length = ord(data[1:2]) + inner_payload = data[3 : 2 + cpl_length] # cpl_length - 1 bytes (skip flags) + else: + return data # not a centurion frame + + data = bytes([HIDPP_LONG_MESSAGE_ID, 0xFF]) + inner_payload + # Pad to a valid message size: standard long (20) or Centurion extended (63) + if len(data) <= _LONG_MESSAGE_SIZE: + data = data + b"\x00" * (_LONG_MESSAGE_SIZE - len(data)) + elif len(data) <= _CENTURION_MSG_SIZE: + data = data + b"\x00" * (_CENTURION_MSG_SIZE - len(data)) + else: + data = data[:_CENTURION_MSG_SIZE] + return data + + def write(handle, devnumber, data, long_message=False): """Writes some data to the receiver, addressed to a certain device. @@ -337,12 +457,12 @@ def write(handle, devnumber, data, long_message=False): ihandle = int(handle) if ihandle in _centurion_handles: - # Centurion CPL framing: [0x51, cpl_length, flags=0x00, feat_idx, func_sw, params...] - # cpl_length = len(meaningful_payload) + 1 (the +1 counts the flags byte) - # The device_index is stripped — only the HID++ payload (feat_idx + func_sw + params) remains. + # Centurion CPL framing — strip device_index from HID++ and wrap in CPL header. + # cpl_length = len(meaningful_payload) + 1 (the +1 counts the flags byte). + state = _centurion_handles[ihandle] payload = wdata[2:] # skip report_id and devnumber from standard frame cpl_length = len(data) + 1 # data is the unpadded payload; +1 for flags byte - wdata = struct.pack("!BBB", CENTURION_REPORT_ID, cpl_length, 0x00) + payload + wdata = _centurion_frame_header(state, cpl_length, 0x00) + payload wdata = wdata + b"\x00" * (CENTURION_FRAME_SIZE - len(wdata)) if logger.isEnabledFor(logging.DEBUG): @@ -366,7 +486,9 @@ def write(handle, devnumber, data, long_message=False): 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, layer3_payload..., zero-pad to 64 bytes] + Builds the appropriate header for the handle's report ID variant: + 0x51: [0x51, cpl_length, flags, layer3_payload..., pad to 64] + 0x50: [0x50, device_addr, cpl_length, flags, layer3_payload..., pad to 64] where cpl_length = len(layer3_payload) + 1 (the +1 counts the flags byte). For multi-fragment sends, flags encodes fragment index and continuation: @@ -376,11 +498,13 @@ def write_centurion_cpl(handle, layer3_payload, flags=0x00): ihandle = int(handle) if ihandle not in _centurion_handles: raise ValueError("write_centurion_cpl called on non-Centurion handle") + state = _centurion_handles[ihandle] cpl_length = len(layer3_payload) + 1 # +1 for flags byte - wdata = struct.pack("!BBB", CENTURION_REPORT_ID, cpl_length, flags) + layer3_payload + header = _centurion_frame_header(state, cpl_length, flags) + wdata = header + 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])) + logger.debug("(%s) <= centurion_cpl[%s]", handle, common.strhex(wdata[: len(header) + cpl_length - 1])) try: hidapi.write(ihandle, wdata) except Exception as reason: @@ -452,22 +576,8 @@ def _read(handle, timeout) -> tuple[int, int, bytes]: close(handle) raise exceptions.NoReceiver(reason=reason) from reason - if data and is_centurion and ord(data[:1]) == CENTURION_REPORT_ID: - # Unwrap Centurion CPL framing: - # RX: [0x51, cpl_length, flags=0x00, feat_idx, func_sw, data...] - # cpl_length includes the flags byte, so meaningful payload starts at byte 3 - # and has (cpl_length - 1) bytes. - # Reconstruct as HID++ long message: [0x11, devnumber=0xFF, feat_idx, func_sw, data...] - cpl_length = ord(data[1:2]) - inner_payload = data[3 : 2 + cpl_length] # bytes 3..2+cpl_length-1 = cpl_length-1 bytes - data = bytes([HIDPP_LONG_MESSAGE_ID, 0xFF]) + inner_payload - # Pad to a valid message size: standard long (20) or Centurion extended (63) - if len(data) <= _LONG_MESSAGE_SIZE: - data = data + b"\x00" * (_LONG_MESSAGE_SIZE - len(data)) - elif len(data) <= _CENTURION_MSG_SIZE: - data = data + b"\x00" * (_CENTURION_MSG_SIZE - len(data)) - else: - data = data[:_CENTURION_MSG_SIZE] + if data and is_centurion and ord(data[:1]) in _CENTURION_REPORT_IDS: + data = _unwrap_centurion_frame(data, ihandle, handle) if data and _is_relevant_message(data): # ignore messages that fail check report_id = ord(data[:1]) @@ -725,7 +835,7 @@ def ping(handle, devnumber, long_message: bool = False): major = ord(reply_data[2:3]) minor = ord(reply_data[3:4]) if is_centurion: - _centurion_protocol_versions[int(handle)] = (major, minor) + _centurion_handles[int(handle)].protocol_version = (major, minor) return major + minor / 10.0 if ( @@ -771,17 +881,8 @@ def _read_input_buffer(handle, ihandle, notifications_hook): raise exceptions.NoReceiver(reason=reason) from reason if data: - if is_centurion and ord(data[:1]) == CENTURION_REPORT_ID: - # Unwrap Centurion CPL framing same as in _read() - cpl_length = ord(data[1:2]) - inner_payload = data[3 : 2 + cpl_length] - data = bytes([HIDPP_LONG_MESSAGE_ID, 0xFF]) + inner_payload - if len(data) <= _LONG_MESSAGE_SIZE: - data = data + b"\x00" * (_LONG_MESSAGE_SIZE - len(data)) - elif len(data) <= _CENTURION_MSG_SIZE: - data = data + b"\x00" * (_CENTURION_MSG_SIZE - len(data)) - else: - data = data[:_CENTURION_MSG_SIZE] + if is_centurion and ord(data[:1]) in _CENTURION_REPORT_IDS: + data = _unwrap_centurion_frame(data, ihandle, handle) if _is_relevant_message(data): # only process messages that pass check # report_id = ord(data[:1]) if notifications_hook: diff --git a/lib/logitech_receiver/centurion.py b/lib/logitech_receiver/centurion.py index 0056a5d2..185d07fc 100644 --- a/lib/logitech_receiver/centurion.py +++ b/lib/logitech_receiver/centurion.py @@ -266,6 +266,7 @@ class CenturionReceiver: self._devices = {} self._firmware = None self._dongle_features = None # independently probed dongle features + self._pending = False # True when device_addr unknown; deferred init completes on first RX self.cleanups = [] # Receiver identity @@ -319,7 +320,7 @@ class CenturionReceiver: if feat_id == feature_int: request_id = (index << 8) | (function & 0xFF) return self.request(request_id, *params, no_reply=no_reply) - raise exceptions.FeatureNotSupported(feature) + raise exceptions.FeatureNotSupported(feature=feature) def _discover_dongle_features(self): """Independently discover features on the dongle hardware.""" @@ -363,15 +364,67 @@ class CenturionReceiver: @property def firmware(self): - if self._firmware is None and self.handle: + if self._firmware is None and self.handle and not self._pending: self._firmware = get_firmware_centurion(self) return self._firmware or () + def _complete_deferred_init(self): + """Re-run feature discovery after device_addr has been learned. + + Called once from the notification handler when the first 0x50 frame + arrives on a pending CenturionReceiver. + """ + if not self._pending: + return False + self._pending = False + ihandle = int(self.handle) + state = base._centurion_handles.get(ihandle) + learned_addr = state.device_addr if state else None + logger.debug( + "CenturionReceiver %s: completing deferred init (device_addr=0x%02X)", + self.path, + learned_addr or 0, + ) + + self._dongle_features = None + self._discover_dongle_features() + logger.debug( + "CenturionReceiver %s: deferred discovery found %d feature(s): %s", + self.path, + len(self._dongle_features or []), + [(f"{feat_id:#06x}", idx) for _, feat_id, idx in (self._dongle_features or [])], + ) + + if self.serial is None: + try: + s = get_serial_centurion(self) + if s and s.strip() and s.strip().isprintable(): + self.serial = s.strip() + except Exception: + pass + + has_bridge = any(feat_id == CenturionCoreFeature.CENT_PP_BRIDGE for _, feat_id, _ in (self._dongle_features or [])) + if has_bridge: + self.notify_devices() + return True + logger.warning( + "CenturionReceiver %s: deferred init completed but no bridge found " "(features: %s)", + self.path, + [f"{feat_id:#06x}" for _, feat_id, _ in (self._dongle_features or [])], + ) + return False + def notify_devices(self): """Create child Device for the headset and trigger its initialization.""" # Import Device locally to avoid circular import (centurion.py ↔ device.py) from .device import Device + if self._pending: + # Don't create children yet — feature discovery hasn't succeeded. + # Signal receiver to UI so the tray entry exists. + self.changed(alert=Alert.NONE) + return + # Signal receiver to UI first — tray/window need the receiver entry # before a child device can be added under it. self.changed(alert=Alert.NONE) @@ -410,6 +463,13 @@ class CenturionReceiver: # Ping to determine online status. # Notify UI either way — offline devices show as greyed out (matching receiver behavior). online = dev.ping() + logger.debug( + "CenturionReceiver %s: child device created, bridge_idx=%s, online=%s, protocol=%s", + self.path, + getattr(dev, "_centurion_bridge_index", None), + online, + dev._protocol, + ) dev.changed(active=online) if self.status_callback is not None: self.status_callback(dev) @@ -495,17 +555,33 @@ def create_centurion_receiver(low_level, device_info, setting_callback=None): try: handle = low_level.open_path(device_info.path) if handle: - base._centurion_handles.add(int(handle)) + report_id = getattr(device_info, "centurion_report_id", None) or base.CENTURION_REPORT_ID + state = base.CenturionHandleState(report_id=report_id) + base._centurion_handles[int(handle)] = state + base.probe_centurion_device_addr(handle, state) cr = CenturionReceiver(low_level, handle, device_info, setting_callback) # Check if any discovered feature is CentPPBridge (0x0003) has_bridge = any(feat_id == CenturionCoreFeature.CENT_PP_BRIDGE for _, feat_id, _ in (cr.dongle_features or [])) - if not has_bridge: - logger.info("Centurion device %s has no bridge, treating as direct device", device_info.path) - base._centurion_handles.discard(int(handle)) - cr.handle = None # prevent __del__ from double-closing - low_level.close(handle) - return None - return cr + if has_bridge: + return cr + + # No bridge found. Distinguish "silent 0x50 dongle" (device_addr + # unknown, headset not yet powered on) from "wired 0x50 device" + # (responded to probe, features found, but no bridge). + is_0x50 = state.report_id == base.CENTURION_ADDRESSED_REPORT_ID + if is_0x50 and state.device_addr is None and not cr.dongle_features: + logger.debug( + "Centurion 0x50 device %s: probe and discovery failed, " "deferring init until first RX frame", + device_info.path, + ) + cr._pending = True + return cr + + logger.info("Centurion device %s has no bridge, treating as direct device", device_info.path) + base._centurion_handles.pop(int(handle), None) + cr.handle = None # prevent __del__ from double-closing + low_level.close(handle) + return None except OSError as e: logger.exception("open %s", device_info) if e.errno == errno.EACCES: diff --git a/lib/logitech_receiver/descriptors.py b/lib/logitech_receiver/descriptors.py index f2c9bbd3..4948b201 100644 --- a/lib/logitech_receiver/descriptors.py +++ b/lib/logitech_receiver/descriptors.py @@ -466,3 +466,4 @@ _D( usbid=0x0ABA, ) # PRO X 2 LIGHTSPEED Gaming Headset (0x0AF7) — fully probed via Centurion transport, no static descriptor needed +# G522 LIGHTSPEED Gaming Headset (0x0B18 dongle, 0x0B19 wired) — Centurion 0x50 variant, no static descriptor needed diff --git a/lib/logitech_receiver/device.py b/lib/logitech_receiver/device.py index b2db2feb..63376e75 100644 --- a/lib/logitech_receiver/device.py +++ b/lib/logitech_receiver/device.py @@ -79,7 +79,10 @@ def create_device(low_level: LowLevelInterface, device_info, setting_callback=No handle = low_level.open_path(device_info.path) if handle: if getattr(device_info, "centurion", False): - base._centurion_handles.add(int(handle)) + report_id = getattr(device_info, "centurion_report_id", None) or base.CENTURION_REPORT_ID + state = base.CenturionHandleState(report_id=report_id) + base._centurion_handles[int(handle)] = state + base.probe_centurion_device_addr(handle, state) # a direct connected device might not be online (as reported by user) return Device( low_level, @@ -223,7 +226,9 @@ class Device: self._protocol = self.descriptor.protocol if self.descriptor.protocol else None self.registers = self.descriptor.registers if self.descriptor.registers else [] - if self._protocol is not None: + # Centurion devices always use HID++ 2.0 features regardless of the + # protocol version the dongle reports (e.g. G522 reports 1.1). + if self._protocol is not None and not self.centurion: self.features = {} if self._protocol < 2.0 else hidpp20.FeaturesArray(self) else: self.features = hidpp20.FeaturesArray(self) # may be a 2.0 device; if not, it will fix itself later @@ -243,7 +248,13 @@ class Device: self.ping() except exceptions.NoSuchDevice: logger.warning("device %s inaccessible - no protocol set", self) - return self._protocol or 0 + result = self._protocol or 0 + # Centurion devices always use HID++ 2.0 features regardless of the + # protocol version the dongle reports (e.g. G522 reports 1.1). + # Ensure all `protocol < 2.0` gates route through the 2.0 code path. + if self.centurion and result < 2.0: + return 2.0 + return result @property def codename(self): @@ -523,6 +534,15 @@ class Device: def battery(self): # None or level, next, status, voltage if self.protocol < 2.0: + if self.centurion: + logger.debug( + "%s: battery() dispatching HID++ 1.0 path for a Centurion device " + "(protocol=%s, _protocol=%s) — device_addr probe likely failed, " + "expect INVALID_SUB_ID_COMMAND", + self, + self.protocol, + self._protocol, + ) return _hidpp10.get_battery(self) else: battery_feature = self.persister.get("_battery", None) if self.persister else None @@ -690,26 +710,40 @@ class Device: # Ensure sub-device features are discovered before routing decision if self.features is not None: self.features._check() + # Guard against Centurion/HID++ 2.0 feature ID collisions. IntEnum + # members with the same int value hash equal, so a dict lookup for + # SupportedFeature.DEVICE_NAME (0x0005) succeeds even when the + # device actually has CenturionCoreFeature.MULTI_HOST_CONTROL at + # that slot. If the type of the stored enum differs from what the + # caller asked for, treat the feature as unsupported. + if self.features is not None: + idx = self.features.get(feature) + if idx is not None: + stored = self.features.inverse.get(idx) + if stored is not None and type(stored) is not type(feature): + return None if feature in getattr(self, "_centurion_sub_features", ()): sub_idx = self.features.get(feature) if sub_idx is not None: 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) - # 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) + # Max sub-message bytes in the first bridge fragment (for 0x51): + # 64 - 1 (report ID) - 1 (cpl_len) - 1 (flags) - 2 (bridge prefix) - 2 (bridge hdr) = 57; + # one byte of conservative margin gives 56. For 0x50 the device_addr byte + # eats one more, so first_chunk = 55 (handled dynamically below). _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 + # 64 - 1 (report ID) - 1 (cpl_len) - 1 (flags) = 61; one byte of margin + # gives 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] + Layer 1: [report_id] (0x51 or 0x50) + Layer 2: [device_addr (0x50 only),] cpl_length, flags Layer 3: [bridge_idx, sendFragment_func|swid, bridge_hdr...] Layer 4: [sub_cpl=0x00, sub_feat_idx, sub_func|swid, params...] @@ -730,6 +764,12 @@ class Device: if not handle: return None + # Adjust bridge chunk sizes for 0x50 variant (device_addr byte takes 1 frame byte) + cent_state = base._centurion_handles.get(int(handle)) + addr_overhead = 1 if cent_state and cent_state.report_id == base.CENTURION_ADDRESSED_REPORT_ID else 0 + first_chunk = self._BRIDGE_FIRST_CHUNK - addr_overhead + cont_chunk = self._BRIDGE_CONT_CHUNK - addr_overhead + sw_id = base._get_next_sw_id() # Build sub-device message: [sub_cpl=0x00, sub_feat_idx, sub_func|swid, params...] @@ -745,7 +785,15 @@ class Device: timeout = base.DEFAULT_TIMEOUT with base.acquire_timeout(base.handle_lock(handle), handle, timeout): - if sub_len <= self._BRIDGE_FIRST_CHUNK: + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "bridge TX: sub_idx=%d func=0x%02X sw_id=%d payload=%s", + sub_feat_idx, + sub_function, + sw_id, + sub_params.hex() if sub_params else "", + ) + if sub_len <= first_chunk: # Single-frame path layer3 = bridge_prefix + bridge_hdr + sub_msg base.write_centurion_cpl(handle, layer3) @@ -755,18 +803,17 @@ class Device: # 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. + # intermediate ACKs. 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_size = first_chunk chunk = sub_msg[offset : offset + chunk_size] layer3 = bridge_prefix + bridge_hdr + chunk else: - chunk_size = self._BRIDGE_CONT_CHUNK + chunk_size = cont_chunk chunk = sub_msg[offset : offset + chunk_size] layer3 = chunk has_more = (offset + chunk_size) < sub_len @@ -778,6 +825,13 @@ class Device: if no_reply: return None + # The device echoes our exact sub-device function+swid byte in + # MessageEvent responses. Match on that to reject cross-contamination + # from late-arriving responses to other function calls on the same + # feature (e.g. GetRGBZoneInfo response showing up on a later + # GetHostModeState read). + expected_sub_func_sw = (sub_function & 0xF0) | sw_id + # Read ACK + MessageEvent response request_started = time.time() ack_received = False @@ -794,11 +848,21 @@ class Device: break if (func_sw >> 4) == 0x01 and (func_sw & 0x0F) == 0: # MessageEvent arrived before ACK — validate it's for our request - if self._is_bridge_response_for(reply_data, sub_feat_idx): + if self._is_bridge_response_for(reply_data, sub_feat_idx, expected_sub_func_sw): if logger.isEnabledFor(logging.DEBUG): logger.debug("bridge idx=%d fn=0x%02X -> OK", sub_feat_idx, sub_function) return self._parse_bridge_response(reply_data) - # Unsolicited notification, skip it + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "bridge skipping reply (pre-ACK): got sub_cpl=0x%02X sub_idx=0x%02X func_sw=0x%02X" + " (expected idx=0x%02X func_sw=0x%02X) data=%s", + reply_data[4] if len(reply_data) > 4 else 0, + reply_data[5] if len(reply_data) > 5 else 0, + reply_data[6] if len(reply_data) > 6 else 0, + sub_feat_idx, + expected_sub_func_sw, + reply_data.hex(), + ) if not ack_received: logger.warning("centurion_bridge_request: no ACK received") return None @@ -812,11 +876,21 @@ class Device: if len(reply_data) >= 2 and reply_data[0] == bridge_idx: func_sw = reply_data[1] if (func_sw >> 4) == 0x01 and (func_sw & 0x0F) == 0: - if self._is_bridge_response_for(reply_data, sub_feat_idx): + if self._is_bridge_response_for(reply_data, sub_feat_idx, expected_sub_func_sw): if logger.isEnabledFor(logging.DEBUG): logger.debug("bridge idx=%d fn=0x%02X -> OK", sub_feat_idx, sub_function) return self._parse_bridge_response(reply_data) - # Unsolicited notification for a different feature, skip it + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "bridge skipping reply (post-ACK): got sub_cpl=0x%02X sub_idx=0x%02X func_sw=0x%02X" + " (expected idx=0x%02X func_sw=0x%02X) data=%s", + reply_data[4] if len(reply_data) > 4 else 0, + reply_data[5] if len(reply_data) > 5 else 0, + reply_data[6] if len(reply_data) > 6 else 0, + sub_feat_idx, + expected_sub_func_sw, + reply_data.hex(), + ) logger.warning("centurion_bridge_request: no MessageEvent received") return None @@ -836,12 +910,19 @@ class Device: return False @staticmethod - def _is_bridge_response_for(reply_data, expected_sub_feat_idx): + def _is_bridge_response_for(reply_data, expected_sub_feat_idx, expected_sub_func_sw=None): """Check if a bridge MessageEvent is a response for our specific sub-feature request. Accepts both normal responses (sub_feat_idx matches) and error responses (sub_feat_idx=0xFF with original feat_idx in next byte). Unsolicited notifications (sub_cpl=0xFF) are rejected. + + If `expected_sub_func_sw` is provided, also matches on the echoed + sub-device function byte (`(function << 4) | sw_id`). This prevents + cross-talk between different function calls on the SAME feature, which + can happen when a late-arriving response for one function gets picked + up by a later request on the same feature (observed on G522 where a + GetRGBZoneInfo response contaminated a subsequent GetHostModeState). """ if len(reply_data) < 6: return False @@ -851,9 +932,15 @@ class Device: if sub_cpl != 0x00: return False if sub_feat_idx == expected_sub_feat_idx: + if expected_sub_func_sw is not None and len(reply_data) >= 7: + if reply_data[6] != expected_sub_func_sw: + return False return True # Error response: sub_feat_idx=0xFF, next byte is the original feat_idx that errored if sub_feat_idx == 0xFF and len(reply_data) >= 7 and reply_data[6] == expected_sub_feat_idx: + if expected_sub_func_sw is not None and len(reply_data) >= 8: + if reply_data[7] != expected_sub_func_sw: + return False return True return False @@ -873,18 +960,25 @@ class Device: sub_feat_idx = reply_data[5] # Error response from sub-device if sub_feat_idx == 0xFF: - error_code = reply_data[8] if len(reply_data) > 8 else 0 + # Error frame layout after sub_cpl: [0xFF, orig_feat_idx, orig_func_sw, error_code, ...] orig_feat_idx = reply_data[6] if len(reply_data) > 6 else 0 - logger.debug("bridge sub-device error: feat_idx=%d error=0x%02X", orig_feat_idx, error_code) + orig_func_sw = reply_data[7] if len(reply_data) > 7 else 0 + error_code = reply_data[8] if len(reply_data) > 8 else 0 + logger.debug( + "bridge sub-device error: orig_feat_idx=%d orig_func=0x%02X error=0x%02X", + orig_feat_idx, + orig_func_sw, + error_code, + ) return None return reply_data[7:] # response data after sub_cpl, sub_feat_idx, sub_func_sw def _record_ping_protocol(self, handle, protocol): """Record a successful ping's protocol version, including raw Centurion (major, minor).""" self._protocol = protocol - cent_ver = base._centurion_protocol_versions.get(int(handle)) - if cent_ver: - self._centurion_protocol = cent_ver + cent_state = base._centurion_handles.get(int(handle)) + if cent_state and cent_state.protocol_version: + self._centurion_protocol = cent_state.protocol_version def ping(self): """Checks if the device is online and present, returns True of False. diff --git a/lib/logitech_receiver/headset_rgb.py b/lib/logitech_receiver/headset_rgb.py new file mode 100644 index 00000000..6431c078 --- /dev/null +++ b/lib/logitech_receiver/headset_rgb.py @@ -0,0 +1,211 @@ +## Copyright (C) 2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/ +## +## This program is free software; you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation; either version 2 of the License, or +## (at your option) any later version. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License along +## with this program; if not, write to the Free Software Foundation, Inc., +## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +"""Shared helpers for devices exposing Feature 0x0620 HEADSET_RGB_HOSTMODE. + +The G522 is currently the only Solaar-supported device advertising this +feature, but anything else presenting 0x0620 will pick the same code path +automatically. The module deliberately avoids G522-specific assumptions +so future RGB-capable headsets can reuse it. + +Two entry points the settings templates rely on: + +- `discover_zones(device)` — one-shot zone enumeration run at setting + build time. Briefly claims Solaar host control so GetRGBZoneInfo + returns a non-empty zone list, then restores the previous host-mode + state. Result is cached on the device. +- `write_zone_map(device, zone_color_map)` — the shared write path used + by both the "LEDs Primary" and "Per-zone Lighting" settings. Groups + zones by final RGB color and emits one SetRgbZonesSingleValue per + unique color, then a single FrameEnd to commit. +""" + +from __future__ import annotations + +import logging + +from typing import Iterable + +from .hidpp20_constants import SupportedFeature + +logger = logging.getLogger(__name__) + +# Function IDs on Feature 0x0620 we actually use. +FN_GET_RGB_ZONE_INFO = 0x10 +FN_SET_RGB_ZONES_SINGLE = 0x50 +FN_FRAME_END = 0x60 +FN_GET_HOST_MODE_STATE = 0x70 +FN_SET_HOST_MODE_STATE = 0x80 + +# Frame type sent with FrameEnd. 0x01 = transient commit (re-applies on the +# next refresh). 0x02 would be persistent, but G522 firmware rejects it +# with LOGITECH_INTERNAL (0x05) unless an onboard profile precondition we +# haven't mapped yet is satisfied. +FRAME_TYPE_TRANSIENT = 0x01 + +_HOST_MODE_SOLAAR = 1 +_HOST_MODE_DEVICE = 0 + + +def _device_cache_attr() -> str: + return "_headset_rgb_zone_ids" + + +def _read_host_mode(device) -> int | None: + """Read the current host-mode state byte, or None on any failure.""" + try: + resp = device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_GET_HOST_MODE_STATE) + except Exception as e: + logger.debug("headset_rgb: GetHostModeState raised %s", e) + return None + if not resp or len(resp) < 1: + return None + return resp[0] + + +def _set_host_mode(device, value: int) -> bool: + try: + device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_SET_HOST_MODE_STATE, bytes([value & 0xFF])) + except Exception as e: + logger.debug("headset_rgb: SetHostModeState(%d) raised %s", value, e) + return False + return True + + +def _parse_zone_info(resp: bytes) -> list[int]: + """Parse a GetRGBZoneInfo response into a zone-id list. + + Two formats observed: "tight" ([count, zone_ids...]) on G522, and + the canonical protocol-doc layout (3-byte gap + 1-byte reserved + before zone IDs). Both are tried; whichever yields exactly `count` + IDs wins. Zone id 0 isn't filtered — some devices may use it. + """ + if not resp or len(resp) < 1: + return [] + zone_count = resp[0] + tight = list(resp[1 : 1 + zone_count]) if 1 <= zone_count <= len(resp) - 1 else [] + if tight and len(tight) == zone_count: + return tight + gap = list(resp[5 : 5 + zone_count]) if len(resp) >= 5 + zone_count else [] + if gap and len(gap) == zone_count: + return gap + return [] + + +def discover_zones(device) -> list[int] | None: + """Return the list of RGB zone IDs on `device`, or None on failure. + + Caches the result on `device._headset_rgb_zone_ids` so subsequent + callers don't repeat the round-trip. Briefly claims Solaar host mode + if needed — GetRGBZoneInfo has been observed to return count=0 when + the device is still under firmware control — and restores the prior + state afterward so user-configured onboard effects resume. + """ + cached = getattr(device, _device_cache_attr(), None) + if cached: + return cached + if not getattr(device, "online", False): + return None + + prior_mode = _read_host_mode(device) + claimed = False + if prior_mode != _HOST_MODE_SOLAAR: + if not _set_host_mode(device, _HOST_MODE_SOLAAR): + return None + claimed = True + + try: + try: + resp = device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_GET_RGB_ZONE_INFO) + except Exception as e: + logger.debug("headset_rgb: GetRGBZoneInfo raised %s", e) + return None + zones = _parse_zone_info(bytes(resp) if resp else b"") + if not zones: + logger.debug( + "headset_rgb: GetRGBZoneInfo returned no zones (raw=%s)", + resp.hex() if resp else resp, + ) + return None + logger.debug("headset_rgb: discovered %d zone(s) %s", len(zones), [f"0x{z:02X}" for z in zones]) + setattr(device, _device_cache_attr(), zones) + return zones + finally: + if claimed and prior_mode is not None: + _set_host_mode(device, prior_mode) + + +def _split_rgb(color_int: int) -> tuple[int, int, int]: + return (color_int >> 16) & 0xFF, (color_int >> 8) & 0xFF, color_int & 0xFF + + +def write_zone_map(device, zone_color_map: dict) -> bool: + """Apply a zone->RGB mapping to the device. + + `zone_color_map` maps zone id (int) to 24-bit RGB color (int, + `(r<<16)|(g<<8)|b`). Claims host mode, groups zones by color, + emits one SetRgbZonesSingleValue per unique color, then a single + FrameEnd. Returns True on success, False on any transport error. + """ + if not zone_color_map: + return False + if not getattr(device, "online", False): + logger.debug("headset_rgb: device offline, skipping write") + return False + + # Group zones by color for batched writes. + groups: dict[int, list[int]] = {} + for zone, color in zone_color_map.items(): + groups.setdefault(int(color), []).append(int(zone)) + + try: + _set_host_mode(device, _HOST_MODE_SOLAAR) + for color_int, zones in groups.items(): + r, g, b = _split_rgb(color_int) + # SetRgbZonesSingleValue: [R, G, B, count, zone_ids...] + payload = bytes([r, g, b, len(zones)]) + bytes(zones) + device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_SET_RGB_ZONES_SINGLE, payload) + logger.debug( + "headset_rgb: set (%02X,%02X,%02X) on %d zone(s) %s", + r, + g, + b, + len(zones), + [f"0x{z:02X}" for z in zones], + ) + # FrameEnd commits the pending per-zone updates. Transient commit + # only — persistent (0x02) requires onboard-profile preconditions + # that aren't mapped yet. + device.feature_request( + SupportedFeature.HEADSET_RGB_HOSTMODE, + FN_FRAME_END, + bytes([FRAME_TYPE_TRANSIENT, 0x00, 0x00, 0x00]), + ) + except Exception as e: + logger.warning("headset_rgb: write_zone_map failed: %s", e) + return False + return True + + +def zone_named_ints(zones: Iterable[int]): + """Build a list of NamedInt keys suitable for a ChoicesMap setting. + + Factored out so settings code can import without pulling common.NamedInt + at module-load time if preferred. + """ + from . import common + + return [common.NamedInt(int(z), f"Zone {int(z)}") for z in zones] diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 3c9d467e..d5b5dda9 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -197,13 +197,27 @@ class FeaturesArray(dict): response = self.device.request((fs_index << 8) | 0x10, index) if response is None or len(response) < 3: continue - # Centurion FeatureSet response: [remaining_count, feat_hi, feat_lo, type, flags] + # Centurion FeatureSet response: [remaining_count, feat_hi, feat_lo, type, version] feat_id = struct.unpack("!H", response[1:3])[0] + feat_type = response[3] if len(response) > 3 else 0 + feat_version = response[4] if len(response) > 4 else 0 feature = resolve_feature(feat_id, centurion=True) if feature is None: feature = f"unknown:{feat_id:04X}" self[feature] = index self.inverse[index] = feature + # Record version/flags so version-gated settings (sidetone, auto-sleep) + # use the correct payload format on direct USB Centurion devices too. + self.version[feature] = feat_version + self.flags[feature] = feat_type + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "Centurion parent feature: %s at index %d, version=%d, flags=0x%02X", + feature, + index, + feat_version, + feat_type, + ) if feature is CenturionCoreFeature.CENT_PP_BRIDGE: bridge_index = index @@ -216,8 +230,11 @@ class FeaturesArray(dict): def _discover_sub_device_features(self, bridge_index): """Phase B: Discover sub-device features via CentPPBridge. - Uses CenturionFeatureSet bulk query (function 1, index 0) routed through - the bridge to get all sub-device features at once. + Uses per-index queries: GetCount (func 0) returns total count, then + GetFeatureId (func 1) returns one feature per call. Avoids the + single-frame truncation of bulk queries — a Centurion frame is 64 + bytes so a bulk reply can only fit ~13 features regardless of how + many the sub-device actually has. """ # First, find the sub-device's FeatureSet index via CenturionRoot (sub_feat_idx=0) # Query: CenturionRoot.GetFeature(0x0001) to find FeatureSet index on sub-device @@ -232,44 +249,62 @@ class FeaturesArray(dict): logger.warning("Sub-device FeatureSet not found (index=0)") return - # Bulk enumerate: CenturionFeatureSet.GetFeatureId(func=1=0x10, start_index=0) - # Response: [count, (feat_hi, feat_lo, type, flags) × count] - response = self.device.centurion_bridge_request(sub_fs_index, 0x10, 0x00) - if response is None or len(response) < 1: - logger.warning("Failed to enumerate sub-device features") + # Query feature count (function 0 = GetCount). Response: [count, ...]. + count_resp = self.device.centurion_bridge_request(sub_fs_index, 0x00) + if count_resp is None or len(count_resp) < 1: + logger.warning("Failed to read Centurion sub-device feature count") return + total_count = count_resp[0] + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Centurion sub-device: FeatureSet reports %d features", total_count) - entry_count = response[0] - entries = response[1:] - sub_feat_idx = 0 # sub-device feature indices start at 0 - for i in range(entry_count): - offset = i * 4 - if offset + 2 > len(entries): - break - feat_id = struct.unpack("!H", entries[offset : offset + 2])[0] + # Per-index query: GetFeatureId (function 1 = 0x10). + # Response: [remaining, feat_hi, feat_lo, type, version]. + # We now also record `type` (flags) and `version` for each feature so + # version-gated settings (sidetone, auto-sleep, etc.) can use the + # correct payload format instead of defaulting to V0. + sub_feat_idx = 0 + for idx in range(total_count): + response = self.device.centurion_bridge_request(sub_fs_index, 0x10, idx) + if response is None or len(response) < 3: + logger.debug("Centurion sub-device: no response at index %d", idx) + continue + feat_id = struct.unpack("!H", response[1:3])[0] + feat_type = response[3] if len(response) > 3 else 0 + feat_version = response[4] if len(response) > 4 else 0 try: feature = SupportedFeature(feat_id) except ValueError: feature = f"unknown:{feat_id:04X}" - # Store sub-device index for ALL features (including parent overlaps) - # This enables querying the sub-device's copy of shared features via bridge self.device._centurion_sub_indices[feature] = sub_feat_idx - # Only store unique sub-device features in dict (skip parent overlaps like ROOT, FEATURE_SET) - # This avoids clobbering parent inverse entries via __setitem__ if dict.get(self, feature) is None: dict.__setitem__(self, feature, sub_feat_idx) self.device._centurion_sub_features.add(feature) - # Always store in sub_inverse for sub-device enumerate/display self.sub_inverse[sub_feat_idx] = feature + # Record version/flags so downstream settings can version-gate their + # payload format. get_feature_version(feature) reads self.version[feature]. + self.version[feature] = feat_version + self.flags[feature] = feat_type if logger.isEnabledFor(logging.DEBUG): - logger.debug("Centurion sub-device feature: %s at sub-index %d", feature, sub_feat_idx) + logger.debug( + "Centurion sub-device feature: %s at sub-index %d, version=%d, flags=0x%02X", + feature, + sub_feat_idx, + feat_version, + feat_type, + ) sub_feat_idx += 1 self._sub_feature_count = sub_feat_idx + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Centurion sub-device: discovered %d features total", sub_feat_idx) def get_feature(self, index: int) -> SupportedFeature | None: feature = self.inverse.get(index) if feature is not None: return feature + # Sub-device index; bridge unwrap offsets by 0x100 (see listener). + if index >= 0x100: + return self.sub_inverse.get(index - 0x100) elif self._check(): feature = self.inverse.get(index) if feature is not None: @@ -334,6 +369,12 @@ class FeaturesArray(dict): index = super().get(feature) if index is not None: return index + # Centurion devices enumerate all features upfront in _check_centurion(). + # If the feature isn't in the dict after _check(), it genuinely doesn't + # exist — skip the raw ROOT.GetFeature query that the dongle rejects + # with LOGITECH_ERROR and that creates cycling log spam during settings init. + if getattr(self.device, "centurion", False): + return None try: response = self.device.request(0x0000, struct.pack("!H", feature)) except exceptions.FeatureCallError: @@ -2365,6 +2406,18 @@ 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 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 +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_advanced_eq_slots # 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 diff --git a/lib/logitech_receiver/hidpp20_constants.py b/lib/logitech_receiver/hidpp20_constants.py index be0082c1..064c175c 100644 --- a/lib/logitech_receiver/hidpp20_constants.py +++ b/lib/logitech_receiver/hidpp20_constants.py @@ -214,6 +214,10 @@ class SupportedFeature(IntEnum): HEADSET_RGB_HOSTMODE = 0x0620 HEADSET_RGB_ONBOARD_EFFECTS = 0x0621 HEADSET_RGB_SIGNATURE_EFFECTS = 0x0622 + # 0x0623 is present on G522 sub-device but its function set is unmapped; + # add probe coverage in rgb_effects_probe so the next bring-up captures + # whatever read functions respond. + HEADSET_RGB_0623 = 0x0623 HEADSET_DO_NOT_DISTURB = 0x0631 CENTURION_ONBOARD_PROFILES = 0x0634 HEADSET_RGB_STREAMING = 0x0635 diff --git a/lib/logitech_receiver/listener.py b/lib/logitech_receiver/listener.py index 4137afd4..1ca4a778 100644 --- a/lib/logitech_receiver/listener.py +++ b/lib/logitech_receiver/listener.py @@ -15,6 +15,7 @@ ## with this program; if not, write to the Free Software Foundation, Inc., ## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +import dataclasses import logging import queue import threading @@ -52,9 +53,11 @@ class _ThreadedHandle: else: # if logger.isEnabledFor(logging.DEBUG): # logger.debug("%r opened new handle %d", self, handle) - # If original handle was centurion, register new per-thread handle too - if any(h in base._centurion_handles for h in self._handles): - base._centurion_handles.add(handle) + # If original handle was centurion, copy state to new per-thread handle + for h in self._handles: + if h in base._centurion_handles: + base._centurion_handles[handle] = dataclasses.replace(base._centurion_handles[h]) + break self._local.handle = handle self._handles.append(handle) return handle diff --git a/lib/logitech_receiver/logivoice.py b/lib/logitech_receiver/logivoice.py new file mode 100644 index 00000000..618eea4e --- /dev/null +++ b/lib/logitech_receiver/logivoice.py @@ -0,0 +1,300 @@ +## Copyright (C) 2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/ +## +## This program is free software; you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation; either version 2 of the License, or +## (at your option) any later version. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License along +## with this program; if not, write to the Free Software Foundation, Inc., +## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +"""LogiVoice (0x0900 + 0x0901-0x0907) read helpers. + +Each LogiVoice processing module exposes the same 5-function API: + + fn 0 SetState + fn 1 GetState -> u8 state (boolean) + fn 2 SetParameters + fn 3 GetParameters -> module-specific payload (see PARAMETERS_FIELDS) + fn 4 GetInfo -> per-field [min, max] bounds (see parse_info) + +All multi-byte integers on the wire are big-endian. Parameters layouts are +module-specific; PARAMETERS_FIELDS encodes per-field offset / width / +signedness / range / label metadata. The first field is at offset 0 — there +is no leading "state" byte (the state toggle is on fn 0/1 only). + +Writes are NOT implemented yet. State toggles via fn 0x00/0x10 are +shipping as boolean settings; per-field Parameters writes need a live +round-trip verification before they're safe to expose. +""" + +from __future__ import annotations + +import logging +import struct + +from typing import Iterable + +from .hidpp20_constants import SupportedFeature + +logger = logging.getLogger(__name__) + +# Wire function IDs (standard across all LogiVoice modules). +FN_SET_STATE = 0x00 +FN_GET_STATE = 0x10 +FN_SET_PARAMETERS = 0x20 +FN_GET_PARAMETERS = 0x30 +FN_GET_INFO = 0x40 + +# Human-readable names for the modules Solaar may see on a LogiVoice device. +MODULE_NAMES = { + SupportedFeature.LOGIVOICE: "LogiVoice", + SupportedFeature.LOGIVOICE_NOISE_REDUCTION: "Noise Reduction", + SupportedFeature.LOGIVOICE_NOISE_GATE: "Noise Gate", + SupportedFeature.LOGIVOICE_COMPRESSOR: "Compressor", + SupportedFeature.LOGIVOICE_DE_ESSER: "De-esser", + SupportedFeature.LOGIVOICE_DE_POPPER: "De-popper", + SupportedFeature.LOGIVOICE_LIMITER: "Limiter", + SupportedFeature.LOGIVOICE_HIGH_PASS_FILTER: "High Pass Filter", +} + +# Short slugs used in Solaar setting IDs (`logivoice--`). +MODULE_SLUGS = { + SupportedFeature.LOGIVOICE_NOISE_REDUCTION: "nr", + SupportedFeature.LOGIVOICE_NOISE_GATE: "ng", + SupportedFeature.LOGIVOICE_COMPRESSOR: "comp", + SupportedFeature.LOGIVOICE_DE_ESSER: "deesser", + SupportedFeature.LOGIVOICE_DE_POPPER: "depopper", + SupportedFeature.LOGIVOICE_LIMITER: "limiter", + SupportedFeature.LOGIVOICE_HIGH_PASS_FILTER: "hpf", +} + + +class Field: + """Metadata for one decoded Parameters field. + + offset: byte offset within the GetParameters payload. + byte_count: width (1 or 2 for fields we currently decode). + signed: whether to interpret as signed int. + min_value/max_value: range for the Solaar slider validator. For opaque + fields, use the full representable range (0..255 or 0..65535). + label: human-readable name for UI. + opaque: True if the field's wire encoding isn't pinned down — label + shows raw units and the caller should treat as round-trip. + """ + + def __init__(self, name, offset, byte_count, signed, min_value, max_value, label, opaque=False): + self.name = name + self.offset = offset + self.byte_count = byte_count + self.signed = signed + self.min_value = min_value + self.max_value = max_value + self.label = label + self.opaque = opaque + + +# Per-module field layout for GetParameters / SetParameters payload. Each +# module's struct is the union of named fields below; there is no separate +# "state" byte at offset 0 — that toggle is only on fn 0x00/0x10. Field +# encodings (signedness, byte order, units) and value ranges come from the +# device's GetInfo response (see parse_info) and are confirmed against +# captured bring-up bytes; ranges hardcoded here are the bounds the device +# reports and the values it ships as factory defaults. +# +# `opaque=True` is reserved for fields whose unit scale isn't pinned down +# (currently width_q on De-esser / De-popper — the host-side scale constant +# is loaded at runtime and not statically resolvable). Treat opaque values +# as monotonic raw integers until a live probe anchors the units. +PARAMETERS_FIELDS: dict[SupportedFeature, list[Field]] = { + SupportedFeature.LOGIVOICE_NOISE_REDUCTION: [ + Field("sensitivity", 0, 1, False, 0, 40, "Sensitivity"), + Field("release", 1, 2, False, 1, 1000, "Release (ms)"), + Field("bias", 3, 1, False, 0, 5, "Bias"), + Field("attenuation", 4, 1, True, -20, 0, "Attenuation (dB)"), + ], + SupportedFeature.LOGIVOICE_NOISE_GATE: [ + Field("threshold", 0, 1, True, -60, -35, "Threshold (dB)"), + Field("attenuation", 1, 1, True, -50, -3, "Attenuation (dB)"), + Field("attack", 2, 2, False, 1, 200, "Attack (ms)"), + Field("hold", 4, 2, False, 1, 1000, "Hold (ms)"), + Field("release", 6, 2, False, 1, 1000, "Release (ms)"), + ], + SupportedFeature.LOGIVOICE_COMPRESSOR: [ + Field("threshold", 0, 1, True, -40, 0, "Threshold (dB)"), + Field("attack", 1, 2, False, 1, 200, "Attack (ms)"), + Field("release", 3, 2, False, 50, 1000, "Release (ms)"), + Field("post_gain", 5, 1, True, -12, 12, "Post Gain (dB)"), + Field("pre_gain", 6, 1, True, -12, 12, "Pre Gain (dB)"), + # Ratio reports min=1 max=20 from GetInfo; whether the device interprets + # it as a literal X:1 ratio or a curve-table index is unconfirmed. + Field("ratio", 7, 1, False, 1, 20, "Ratio"), + ], + SupportedFeature.LOGIVOICE_DE_ESSER: [ + Field("threshold", 0, 1, True, -50, 0, "Threshold (dB)"), + Field("frequency", 1, 2, False, 1000, 10000, "Frequency (Hz)"), + # width_q is a Q-format quantization with a device-loaded scale we + # don't know; range/default come straight from GetInfo. + Field("width_q", 3, 1, False, 2, 120, "Width/Q", opaque=True), + Field("attack", 4, 2, False, 1, 200, "Attack (ms)"), + Field("release", 6, 2, False, 20, 1000, "Release (ms)"), + Field("attenuation", 8, 1, True, -40, 0, "Attenuation (dB)"), + ], + SupportedFeature.LOGIVOICE_DE_POPPER: [ + Field("threshold", 0, 1, True, -50, 0, "Threshold (dB)"), + Field("frequency", 1, 2, False, 60, 500, "Frequency (Hz)"), + Field("width_q", 3, 1, False, 2, 120, "Width/Q", opaque=True), + Field("attack", 4, 2, False, 1, 200, "Attack (ms)"), + Field("release", 6, 2, False, 20, 1000, "Release (ms)"), + Field("attenuation", 8, 1, True, -40, 0, "Attenuation (dB)"), + ], + SupportedFeature.LOGIVOICE_LIMITER: [ + Field("boost", 0, 1, True, -128, 127, "Boost (dB)"), + Field("attack", 1, 2, False, 1, 65535, "Attack (ms)"), + Field("release", 3, 2, False, 1, 65535, "Release (ms)"), + ], + SupportedFeature.LOGIVOICE_HIGH_PASS_FILTER: [ + Field("frequency", 0, 2, False, 60, 300, "Cutoff (Hz)"), + ], +} + + +def expected_payload_length(feature: SupportedFeature) -> int: + fields = PARAMETERS_FIELDS.get(feature) + if not fields: + return 0 + return max(f.offset + f.byte_count for f in fields) + + +def get_state(device, feature: SupportedFeature): + """Read the module's on/off state via fn 1. Returns int 0-255 or None.""" + result = device.feature_request(feature, FN_GET_STATE) + if result is None or len(result) < 1: + return None + return result[0] + + +def get_parameters(device, feature: SupportedFeature): + """Read the module's Parameters struct via fn 3. Returns raw bytes or None.""" + result = device.feature_request(feature, FN_GET_PARAMETERS) + if result is None: + return None + return bytes(result) + + +def get_info(device, feature: SupportedFeature): + """Read module capability info via fn 4. Returns raw bytes or None. + + Decoded per-field bounds are available via parse_info(). + """ + result = device.feature_request(feature, FN_GET_INFO) + if result is None: + return None + return bytes(result) + + +def _decode_field(chunk: bytes, byte_count: int, signed: bool) -> int: + """Decode `byte_count` bytes from `chunk` as an integer per the field's wire + encoding. Multi-byte values are big-endian (matches Parameters).""" + if byte_count == 1: + return struct.unpack("b" if signed else "B", chunk[:1])[0] + if byte_count == 2: + return struct.unpack(">h" if signed else ">H", chunk[:2])[0] + return int.from_bytes(chunk[:byte_count], "big", signed=signed) + + +def parse_info(feature: SupportedFeature, payload: bytes) -> dict: + """Decode a GetInfo response into per-field {min, max} bounds. + + Layout: for each field in PARAMETERS_FIELDS in order, the payload carries + [min_value, max_value] back-to-back using the field's wire encoding (so + a u16 field contributes 4 bytes — 2 for min, 2 for max). Trailing bytes + in the response are pad/zero. + + Returns a dict mapping field name to {"min": int, "max": int}. Fields + that don't fit in the payload are omitted. + """ + fields = PARAMETERS_FIELDS.get(feature) + if not fields or not payload: + return {} + out = {} + offset = 0 + for f in fields: + end = offset + 2 * f.byte_count + if end > len(payload): + break + min_val = _decode_field(payload[offset : offset + f.byte_count], f.byte_count, f.signed) + max_val = _decode_field(payload[offset + f.byte_count : end], f.byte_count, f.signed) + out[f.name] = {"min": min_val, "max": max_val} + offset = end + return out + + +def parse_parameters(feature: SupportedFeature, payload: bytes) -> dict: + """Decode Parameters bytes into a dict per the per-module field table. + + Returns {} on unknown feature or short payload — caller still has the raw + hex via get_parameters() for corpus logging. + """ + fields = PARAMETERS_FIELDS.get(feature) + if not fields or payload is None: + return {} + parsed = {} + for f in fields: + end = f.offset + f.byte_count + if end > len(payload): + continue + chunk = payload[f.offset : end] + if f.byte_count == 1: + val = struct.unpack("b" if f.signed else "B", chunk)[0] + elif f.byte_count == 2: + val = struct.unpack(">h" if f.signed else ">H", chunk)[0] + else: + val = int.from_bytes(chunk, "big", signed=f.signed) + parsed[f.name] = val + return parsed + + +def probe_module(device, feature: SupportedFeature) -> None: + """One-shot corpus probe. Logs state + raw parameters + parsed + raw info + + decoded info bounds.""" + name = MODULE_NAMES.get(feature, f"0x{int(feature):04X}") + state = get_state(device, feature) + params = get_parameters(device, feature) + info = get_info(device, feature) + logger.debug( + "LogiVoice %s [0x%04X]: state=%s parameters=%s info=%s", + name, + int(feature), + state, + params.hex() if params else None, + info.hex() if info else None, + ) + parsed = parse_parameters(feature, params) if params else {} + if parsed: + logger.debug("LogiVoice %s parsed: %s", name, parsed) + bounds = parse_info(feature, info) if info else {} + if bounds: + logger.debug("LogiVoice %s info bounds: %s", name, bounds) + + +def probe_all_modules(device, features: Iterable[SupportedFeature]) -> None: + """Probe every LogiVoice module present on the device. + + Call once at device-bring-up so the -dd corpus has a full snapshot. + Caller passes whichever subset of LogiVoice features are actually + discovered (usually derived from device.features). + """ + for feature in features: + if feature not in PARAMETERS_FIELDS and feature != SupportedFeature.LOGIVOICE: + continue + try: + probe_module(device, feature) + except Exception as e: + logger.debug("LogiVoice probe_module(%s) raised %s", feature, e) diff --git a/lib/logitech_receiver/notifications.py b/lib/logitech_receiver/notifications.py index d2323a26..933d2477 100644 --- a/lib/logitech_receiver/notifications.py +++ b/lib/logitech_receiver/notifications.py @@ -436,6 +436,47 @@ def _process_feature_notification(device: Device, notification: HIDPPNotificatio if logger.isEnabledFor(logging.DEBUG): logger.debug("%s: RGB_EFFECTS notification addr=%02x: %s", device, notification.address, notification) + elif feature == SupportedFeature.HEADSET_ADVANCED_PARA_EQ: + # G522 emits change events with the same payload shape as the + # corresponding setter request: + # fn 0 — band change (3-byte header [dir, slot, pad] + bands) + # fn 2 — friendly-name change (header + nameLen + name) + # fn 3 — UUID change (header + 16-byte UUID) + # Low nibble of `address` is the swid the firmware echoes back — + # match on the function index only. + fn = notification.address >> 4 + if fn == 0: + info = getattr(device, "_advanced_eq_info", None) + payload = notification.data[3:] if notification.data else b"" + if info and len(payload) >= 5: + bands = hidpp20.parse_v2_bands(b"\x00" + payload, info) + if bands and device.setting_callback: + band_map = {i: int(round(g)) for i, (_t, _f, g) in enumerate(bands)} + device.setting_callback(device, settings_templates.HeadsetAdvancedEQ, [band_map]) + elif logger.isEnabledFor(logging.DEBUG): + logger.debug( + "%s: HEADSET_ADVANCED_PARA_EQ band-change event with no parseable payload %s", device, notification + ) + elif fn in (2, 3) and logger.isEnabledFor(logging.DEBUG): + logger.debug("%s: HEADSET_ADVANCED_PARA_EQ fn=%d change event %s", device, fn, notification) + elif logger.isEnabledFor(logging.DEBUG): + logger.debug("%s: unknown HEADSET_ADVANCED_PARA_EQ %s", device, notification) + + elif feature == SupportedFeature.HEADSET_MIC_MUTE: + # G522 emits state-change events on two function indices, both carrying + # the new state in data[0] (0 = unmuted, 1 = muted): + # fn 0 — physical mute switch press + # fn 1 — echo following a host-driven SetState (fn 2) write + # Low nibble of `address` is the swid the firmware echoes back, which + # varies with the request — match on the function index only. + fn = notification.address >> 4 + if fn in (0, 1) and notification.data: + muted = bool(notification.data[0]) + if device.setting_callback: + device.setting_callback(device, settings_templates.HeadsetMicMute, [muted]) + elif logger.isEnabledFor(logging.DEBUG): + logger.debug("%s: unknown HEADSET_MIC_MUTE %s", device, notification) + diversion.process_notification(device, notification, feature) return True diff --git a/lib/logitech_receiver/onboard_eq.py b/lib/logitech_receiver/onboard_eq.py index 107b11f6..a082ad6a 100644 --- a/lib/logitech_receiver/onboard_eq.py +++ b/lib/logitech_receiver/onboard_eq.py @@ -27,11 +27,9 @@ import struct from .hidpp20_constants import SupportedFeature -# 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. +# Opaque bytes observed between band params and coefficient header. First +# byte matches band_count; bytes 2-3 look like LE16 coeff blob size. Keep +# verbatim until a device counter-example forces a re-derivation. _EQ_MYSTERY_BYTES = b"\x05\x5a\xe3\x00" @@ -85,7 +83,7 @@ def _build_coeff_section(bands, sample_rate, section_type=1): 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) + _HEADROOM = 1.19 # 19% headroom margin before quantization num_bands = len(bands) all_words = [num_bands] # first uint16 = num_bands diff --git a/lib/logitech_receiver/rgb_effects_probe.py b/lib/logitech_receiver/rgb_effects_probe.py new file mode 100644 index 00000000..c3be6a1c --- /dev/null +++ b/lib/logitech_receiver/rgb_effects_probe.py @@ -0,0 +1,242 @@ +"""Read-only corpus probe for the headset RGB feature triplet: + + - HEADSET_RGB_ONBOARD_EFFECTS (0x0621) + - HEADSET_RGB_SIGNATURE_EFFECTS (0x0622) + - HEADSET_RGB_0623 (0x0623, function set unmapped) + +Logs raw response bytes and lengths at INFO so field testers without +``-dd`` can still capture the data. All calls are strictly read-side — +no setters are invoked. If a feature isn't present the probe +short-circuits cleanly. + +Pcap analysis of G HUB's color-set traffic confirmed that on 0x0621, +``setRGBClusterEffect`` (fn 0x30) takes a 10-byte payload +``[cluster, effect_id_BE_u16, R, G, B, ...]`` where ``effect_id=0x0000`` +means "Static (with RGB)" — this is also the slot-0 entry in the +fn 0x10 ``getRGBClusterInfo`` reply, which we decode structurally so +the test corpus shows effect-id semantics in plaintext. +""" + +from __future__ import annotations + +import logging + +from . import exceptions +from .hidpp20_constants import SupportedFeature + +logger = logging.getLogger(__name__) + + +def _hex_or_none(data) -> str | None: + return data.hex() if data else None + + +def _format_feature(feat) -> str: + """Render a feature for the log: 0x{id:04X}{:NAME} when known, else raw. + + Unknown features are stored as the string "unknown:HHHH" by the feature + discovery code, so handle that shape explicitly — int(feat) on those + raises ValueError. Wrap the rest in a broad except so a future unhandled + feature shape can't kill the whole table dump. + """ + if feat is None: + return "?" + if isinstance(feat, str): + if feat.startswith("unknown:") and len(feat) > 8: + return f"0x{feat[8:].upper()}" + return feat + try: + return f"0x{int(feat):04X}:{feat.name}" + except (AttributeError, TypeError, ValueError): + try: + return f"0x{int(feat):04X}" + except (TypeError, ValueError): + return repr(feat) + + +def _log_feature_table(device) -> None: + if not device.features: + return + try: + # Parent features live in FeaturesArray.inverse, indexed by their + # parent feature-set position. On Centurion devices these are the + # ones the dongle itself exposes (typically 5-6 entries). + parent = [] + for idx in range(len(device.features)): + parent.append(f"{idx}:{_format_feature(device.features[idx])}") + logger.debug("RGB probe: parent features for %s: %s", device, ", ".join(parent)) + + # Centurion sub-device features live in FeaturesArray.sub_inverse, + # keyed by sub-device feature index. These are where the actual + # headset features (0x0620/0x0621/0x0622, LogiVoice, EQ, mic mute, + # …) live — without dumping them the log shows only the dongle's + # parent features and gives the wrong impression that the device + # has nothing else. + sub_inverse = getattr(device.features, "sub_inverse", None) + if sub_inverse: + sub = [f"{idx}:{_format_feature(feat)}" for idx, feat in sorted(sub_inverse.items())] + logger.debug("RGB probe: sub-device features for %s: %s", device, ", ".join(sub)) + except Exception as e: + logger.debug("RGB probe: feature-table dump failed: %s", e) + + +def _call(device, feature: SupportedFeature, fn: int, *params): + """Wrap feature_request with uniform INFO logging. + + Returns the raw bytes on success, None on transport/no-feature, and + doesn't raise — FeatureCallError is caught and logged as an error code. + """ + label = f"0x{int(feature):04X}.fn{fn:02X}" + if params: + label += "(" + ",".join(f"{b:02X}" for b in params) + ")" + try: + resp = device.feature_request(feature, fn, *params) + except exceptions.FeatureCallError as e: + logger.debug("RGB probe: %s err=0x%02X", label, getattr(e, "error", 0) & 0xFF) + return None + except Exception as e: + logger.debug("RGB probe: %s raised %r", label, e) + return None + if resp is None: + logger.debug("RGB probe: %s no reply (feature unsupported or transport failure)", label) + return None + logger.debug("RGB probe: %s resp=%s len=%d", label, _hex_or_none(resp), len(resp)) + return resp + + +# Names for known effect_ids on the headset RGB cluster. Confirmed via +# pcap analysis of G HUB color-set traffic: setRGBClusterEffect with +# effect_id=0x0000 + RGB writes a static color, so 0x0000 is "Static" +# rather than the "Off / Disabled" we'd guessed from cluster ordering. +# Other ids haven't been observed on the wire yet — names are placeholder +# until further pcap traffic confirms them. +_EFFECT_ID_NAMES = { + 0x0000: "Static", + 0x0001: "Effect 0x0001", + 0x0006: "Effect 0x0006", + 0x0007: "Effect 0x0007", + 0x000F: "Effect 0x000F", + 0x007F: "Effect 0x007F", +} + + +def _decode_cluster_info(resp) -> str | None: + """Decode a 0x0621 fn 0x10 getRGBClusterInfo reply into a readable + summary. Best-effort — returns None on unexpected length/shape. + + Observed shape on G522: 4-byte records (effect_id LE u16, slot_idx + LE u16). The effect_id at slot 0 is 0x0000 = "Static" (with RGB), + confirmed by pcap of G HUB color-set traffic. Records continue + until trailing-zero padding. + + Note: most HID++ multi-byte fields are BE, but this particular + response uses LE — confirmed against captured factory-default bytes + on G522 where the values 0x0001 / 0x000F / 0x007F appear at byte 0 + of each record with byte 1 = 0x00 (consistent with LE u16). + """ + if not resp or len(resp) < 4: + return None + effects = [] + seen_static = False + for i in range(0, len(resp) - 3, 4): + eid = resp[i] | (resp[i + 1] << 8) + slot = resp[i + 2] | (resp[i + 3] << 8) + # Skip purely-zero padding once we've seen the (effect=0, slot=0) entry. + if eid == 0 and slot == 0: + if seen_static: + continue + seen_static = True + name = _EFFECT_ID_NAMES.get(eid, f"0x{eid:04X}") + effects.append(f"slot={slot}:{name}") + return ", ".join(effects) if effects else None + + +def probe_onboard_effects(device) -> None: + """Probe 0x0621 RGBOnboardEffects read-side functions.""" + feature = SupportedFeature.HEADSET_RGB_ONBOARD_EFFECTS + if not device.features or feature not in device.features: + return + logger.debug("RGB probe: 0x0621 HEADSET_RGB_ONBOARD_EFFECTS present on %s", device) + + # fn 0x00 getInfo — empty payload + _call(device, feature, 0x00) + + # fn 0x10 getRGBClusterInfo — iterate cluster indexes 0..7, stop on error. + for cluster_idx in range(8): + resp = _call(device, feature, 0x10, cluster_idx) + if resp is None: + break + decoded = _decode_cluster_info(resp) + if decoded: + logger.debug("RGB probe: 0x0621.fn10(%02X) decoded: %s", cluster_idx, decoded) + + # fn 0x20 getRGBClusterEffect — current state per cluster. + for cluster_idx in range(8): + resp = _call(device, feature, 0x20, cluster_idx) + if resp is None: + break + + # fn 0x40 getRGBCustomEffectName — single call, documented. + _call(device, feature, 0x40) + + +def probe_unknown_0623(device) -> None: + """Probe 0x0623 (purpose unmapped) — present on G522 sub-device. + + Function set unknown. Try a small window of low function indexes to + capture whatever responds. Strictly read-side; we don't know what + arguments the functions take so we just call each with no payload + and let the device 0x0A any function that needs args. + """ + feature = SupportedFeature.HEADSET_RGB_0623 + if not device.features or feature not in device.features: + return + logger.debug("RGB probe: 0x0623 HEADSET_RGB_0623 present on %s", device) + # Functions 0..7 covers the typical "info / get* / get*" range; if + # anything responds we'll have first bytes to triangulate against. + for fn_idx in range(8): + _call(device, feature, fn_idx << 4) + + +def probe_signature_effects(device) -> None: + """Probe 0x0622 RGBSignatureEffects read-side functions.""" + feature = SupportedFeature.HEADSET_RGB_SIGNATURE_EFFECTS + if not device.features or feature not in device.features: + return + logger.debug("RGB probe: 0x0622 HEADSET_RGB_SIGNATURE_EFFECTS present on %s", device) + + # fn 0x00 getSignatureEffectsInfo. + _call(device, feature, 0x00) + + # fn 0x10 getSignatureEffectParams — iterate effectId 0..2 (Startup/Shutdown/Passive). + # effectId is u16 BE. + for eid in range(3): + _call(device, feature, 0x10, (eid >> 8) & 0xFF, eid & 0xFF) + + # fn 0x30 getSignatureEffectState — same effectId range. + for eid in range(3): + _call(device, feature, 0x30, (eid >> 8) & 0xFF, eid & 0xFF) + + +def probe(device) -> None: + """Run both read-only RGB-effects probes once per device. + + Gated via ``_rgb_effects_probed`` so re-entry on reconnect / setting + rebuild doesn't spam the log with duplicate corpus dumps. + """ + if getattr(device, "_rgb_effects_probed", False): + return + device._rgb_effects_probed = True + _log_feature_table(device) + try: + probe_onboard_effects(device) + except Exception as e: + logger.debug("RGB probe: onboard-effects probe raised %r", e) + try: + probe_signature_effects(device) + except Exception as e: + logger.debug("RGB probe: signature-effects probe raised %r", e) + try: + probe_unknown_0623(device) + except Exception as e: + logger.debug("RGB probe: 0x0623 probe raised %r", e) diff --git a/lib/logitech_receiver/settings.py b/lib/logitech_receiver/settings.py index 39c8c872..0f32f72b 100644 --- a/lib/logitech_receiver/settings.py +++ b/lib/logitech_receiver/settings.py @@ -181,8 +181,16 @@ class Setting: logger.debug("%s: prepare write(%s) => %r", self.name, value, data_bytes) reply = self._rw.write(self._device, data_bytes) - if not reply: - # tell whomever is calling that the write failed + # HID++ 2.0 "set" operations often return an empty ACK (b""). + # Treating empty bytes as failure (`not reply`) would misreport + # successful writes as errors to the GUI. Only report failure + # when the transport actually returned None (error or timeout). + if reply is None: + logger.info( + "%s: write on %s returned no reply (transport error/timeout)", + self.name, + self._device, + ) return None return value diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index 312c8e48..3c6f5c46 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -37,9 +37,12 @@ from . import desktop_notifications from . import device_quirks from . import diversion from . import exceptions +from . import headset_rgb from . import hidpp10_constants from . import hidpp20 from . import hidpp20_constants +from . import logivoice +from . import rgb_effects_probe from . import rgb_power from . import settings from . import settings_new @@ -1633,6 +1636,17 @@ class HeadsetEcoMode(settings.Setting): feature = _F.HEADSET_BATTERY_SAVER validator_class = settings_validator.BooleanValidator + @classmethod + def build(cls, device): + # G522 firmware rejects no-op writes with device-specific NACK 0x0B. + # BooleanValidator.prepare_write already skips writes that match the + # current value when needs_current_value=True; default-mask (0xFF) + # BooleanValidators get needs_current_value=False, so flip it here. + rw = settings.FeatureRW(cls.feature) + validator = settings_validator.BooleanValidator() + validator.needs_current_value = True + return cls(device, rw, validator) + class HeadsetDoNotDisturb(settings.Setting): name = "headset-do-not-disturb" @@ -1648,6 +1662,18 @@ class HeadsetMicMute(settings.Setting): description = _("Mute the microphone.") feature = _F.HEADSET_MIC_MUTE validator_class = settings_validator.BooleanValidator + # HEADSET_MIC_MUTE (0x0601) doesn't follow the typical fn 0 GetState / + # fn 1 SetState pattern that BooleanValidator defaults to. Function + # layout (confirmed via G HUB pcap on G522): + # fn 0 — physical-mute-switch state-change events from the device + # fn 1 — state-change events emitted as the device's echo of a + # host-driven SetState; also serves as the host-callable + # GetState read + # fn 2 — host-callable SetState (single byte: 0=unmuted, 1=muted) + # The standard fn 0/1 write path returns 0x0A UNSUPPORTED. State-change + # events from both fn 0 and fn 1 are handled by _process_feature_notification + # so the toggle reflects physical mute presses too. + rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} class HeadsetMicSNR(settings.Setting): @@ -1708,10 +1734,51 @@ class HeadsetMicGain(settings.Setting): feature = _F.HEADSET_MIC_GAIN rw_options = {"read_fnid": 0x10, "write_fnid": 0x20} validator_class = settings_validator.RangeValidator + # Fallback range covers int8; build() overrides with device-reported bounds + # from GetInfo (fn 0) so SetMicGain doesn't get device-specific + # out-of-range NACK (error 0x0B) on devices that use a small signed range + # (e.g. G522 reports a narrow window like -12..+12). min_value = -128 max_value = 127 validator_options = {"byte_count": 1, "signed": True} + @classmethod + def build(cls, device): + # GetInfo (function 0) returns [min_gain (int8), max_gain (int8)]. + # Query once at build time so the slider range reflects the device's + # actual supported range rather than a generic int8 window. + try: + info = device.feature_request(cls.feature, 0x00) + except Exception as e: + logger.debug("HeadsetMicGain: GetInfo raised %s, using fallback int8 range", e) + info = None + if info and len(info) >= 2: + min_gain = struct.unpack("b", bytes([info[0]]))[0] + max_gain = struct.unpack("b", bytes([info[1]]))[0] + if max_gain <= min_gain: # sanity — fall back to class defaults + logger.debug( + "HeadsetMicGain: GetInfo returned nonsense range [%d, %d] (hex=%s), using fallback int8 range", + min_gain, + max_gain, + info.hex(), + ) + min_gain, max_gain = cls.min_value, cls.max_value + elif logger.isEnabledFor(logging.DEBUG): + logger.debug( + "HeadsetMicGain: device reports gain range [%d, %d]", + min_gain, + max_gain, + ) + else: + logger.debug( + "HeadsetMicGain: GetInfo returned %s, using fallback int8 range", + info.hex() if info else info, + ) + min_gain, max_gain = cls.min_value, cls.max_value + rw = settings.FeatureRW(cls.feature, **cls.rw_options) + validator = settings_validator.RangeValidator(min_value=min_gain, max_value=max_gain, byte_count=1, signed=True) + return cls(device, rw, validator) + class HeadsetMixBalance(settings.Setting): name = "headset-mix-balance" @@ -1724,17 +1791,71 @@ class HeadsetMixBalance(settings.Setting): validator_options = {"byte_count": 1} +class _AutoSleepRangeValidator(settings_validator.RangeValidator): + """Single-slot read-modify-write validator for HID++ 0x0108 AutoSleep. + + 0x0108 is not a single timer: V3 has two uint8 bytes, V4+ has three. Each + byte is an independent timer slot. Solaar exposes only the user-facing slot + today and preserves the others via RMW; writing zero into the other slots + causes the firmware to reject the request. + + Wire byte layout per feature version: + V<3: [timer] + V3: [reserved, timer] — preserve byte[0] + V4+: [timer_a, timer_b, timer_c] — preserve byte[1], byte[2] + """ + + def __init__(self, byte_count, **kwargs): + super().__init__(byte_count=byte_count, **kwargs) + # V3 sources the user-controllable timer from byte[1] per LGHUB. + self._slot = 1 if byte_count == 2 else 0 + + def validate_read(self, reply_bytes): + if len(reply_bytes) <= self._slot: + raise AssertionError( + f"{self.__class__.__name__}: read returned {len(reply_bytes)} bytes, expected ≥ {self._slot + 1}" + ) + return reply_bytes[self._slot] + + def prepare_write(self, new_value, current_value=None): + if new_value < self.min_value or new_value > self.max_value: + raise ValueError(f"invalid choice {new_value!r}") + if current_value is None: + payload = bytearray(self._byte_count) + else: + payload = bytearray(current_value[: self._byte_count]) + if len(payload) < self._byte_count: + payload.extend(b"\x00" * (self._byte_count - len(payload))) + if payload[self._slot] == new_value: + return None + payload[self._slot] = new_value + return bytes(payload) + + class HeadsetAutoSleep(settings.Setting): name = "headset-auto-sleep" label = _("Auto Sleep Timeout") description = _("Idle time in minutes before the headset enters sleep mode (0 = disabled).") feature = _F.CENTURION_AUTO_SLEEP rw_options = {"read_fnid": 0x00, "write_fnid": 0x10} - validator_class = settings_validator.RangeValidator + validator_class = _AutoSleepRangeValidator min_value = 0 - max_value = 255 + max_value = 255 # uint8 slot validator_options = {"byte_count": 1} + @classmethod + def build(cls, device): + version = device.features.get_feature_version(cls.feature) or 0 + if version >= 4: + byte_count = 3 + elif version >= 3: + byte_count = 2 + else: + byte_count = 1 + rw = settings.FeatureRW(cls.feature, **cls.rw_options) + validator = _AutoSleepRangeValidator(min_value=0, max_value=cls.max_value, byte_count=byte_count) + return cls(device, rw, validator) + class HeadsetOnboardEQ(settings.RangeFieldSetting): name = "headset-onboard-eq" @@ -1751,10 +1872,19 @@ class HeadsetOnboardEQ(settings.RangeFieldSetting): def build(cls, setting_class, device): info = hidpp20.get_onboard_eq_info(device) if not info: + logger.debug("HeadsetOnboardEQ.build: getEQInfo failed, no panel will be built") return None _has_hw_eq, num_bands = info bands = hidpp20.get_onboard_eq_params(device, slot=0x00) - if not bands or len(bands) != num_bands: + if not bands: + logger.debug("HeadsetOnboardEQ.build: getEQParameters returned no bands, no panel will be built") + return None + if len(bands) != num_bands: + logger.debug( + "HeadsetOnboardEQ.build: band count mismatch — EQInfo=%d getEQParameters=%d; skipping", + num_bands, + len(bands), + ) return None keys = common.NamedInts() for i, (freq, _gain, _q) in enumerate(bands): @@ -1762,6 +1892,7 @@ class HeadsetOnboardEQ(settings.RangeFieldSetting): v = cls(keys, min_value=-12, max_value=12, count=num_bands, byte_count=1) v._band_freqs = [freq for freq, _g, _q in bands] v._band_qs = [q for _f, _g, q in bands] + logger.debug("HeadsetOnboardEQ.build: panel built with %d band(s)", num_bands) return v def validate_read(self, reply_bytes): @@ -1810,6 +1941,728 @@ class HeadsetOnboardEQ(settings.RangeFieldSetting): return result +class HeadsetAdvancedEQ(settings.RangeFieldSetting): + """Per-band gain editor for the headset's active AdvancedParaEQ (0x020D) slot. + + 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") + 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): + """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 + + @classmethod + def build(cls, setting_class, device): + info = hidpp20.get_advanced_eq_info(device) + if not info: + logger.debug("HeadsetAdvancedEQ.build: getEQInfos failed, no panel will be built") + return None + device._advanced_eq_info = info + version = info["version"] + gain_min = info["gain_min_db"] + gain_max = info["gain_max_db"] + step_db = info["step_db"] + + active_slot = hidpp20.get_advanced_eq_active_slot(device, direction=0) or 0 + bands = hidpp20.get_advanced_eq_params(device, direction=0, slot=active_slot) + if not bands: + logger.debug("HeadsetAdvancedEQ.build: getCustomEQ returned no bands, no panel will be built") + return None + band_count = len(bands) + expected = info.get("band_count") + if expected is not None and expected != band_count: + logger.debug( + "HeadsetAdvancedEQ.build: V%d band count mismatch — EQInfos=%d getCustomEQ=%d; trusting getCustomEQ", + version, + expected, + band_count, + ) + + keys = common.NamedInts() + for i, (filter_type, freq_hz, _gain_db) in enumerate(bands): + if filter_type == hidpp20.FILTER_TYPE_HP: + keys[i] = "HP " + str(freq_hz) + _("Hz") + else: + keys[i] = str(freq_hz) + _("Hz") + v = cls( + keys, + min_value=int(round(gain_min)), + max_value=int(round(gain_max)), + count=band_count, + byte_count=1, + ) + v._version = version + v._step_db = step_db + v._gain_min = gain_min + v._gain_max = gain_max + v._gain_steps = info.get("gain_steps", 241) + v._band_types = [band[0] for band in bands] + v._band_freqs = [band[1] for band in bands] + v._active_slot = active_slot + logger.debug( + "HeadsetAdvancedEQ.build: panel built V%d with %d band(s), slot=%d, range=[%d,%d], step_db=%.4f", + version, + band_count, + active_slot, + gain_min, + gain_max, + step_db, + ) + # One-shot per-slot probe — logs band data for each slot the + # firmware actually honors and caches the working-slot list on + # `device._advanced_eq_working_slots`. Cheap if HeadsetActiveEQPreset + # already populated the cache (it usually has at this point); + # otherwise this is the first-time probe. + if version >= 2: + try: + hidpp20.probe_advanced_eq_slots(device, direction=0, info=info) + except Exception as e: + logger.debug("HeadsetAdvancedEQ.build: preset corpus probe failed: %s", e) + return v + + def validate_read(self, reply_bytes): + if reply_bytes is None: + return {} + version = getattr(self, "_version", 0) + if version >= 2: + info = { + "gain_min_db": getattr(self, "_gain_min", -6), + "gain_max_db": getattr(self, "_gain_max", 6), + "gain_steps": getattr(self, "_gain_steps", 241), + "step_db": getattr(self, "_step_db", 0.05), + } + bands = hidpp20.parse_v2_bands(reply_bytes, info) + if bands is None: + return {} + result = {} + for i, (filter_type, freq_hz, gain_db) in enumerate(bands): + if i >= self.count: + break + result[i] = int(round(gain_db)) + if hasattr(self, "_band_types") and i < len(self._band_types): + self._band_types[i] = filter_type + if hasattr(self, "_band_freqs") and i < len(self._band_freqs): + self._band_freqs[i] = freq_hz + return result + # V0/V1: 3-byte stride. + result = {} + offset = 0 + i = 0 + while offset + 3 <= len(reply_bytes) and i < self.count: + freq = struct.unpack(">H", reply_bytes[offset : offset + 2])[0] + if freq == 0: + break + gain = struct.unpack("b", bytes([reply_bytes[offset + 2]]))[0] + result[i] = gain + if hasattr(self, "_band_freqs") and i < len(self._band_freqs): + self._band_freqs[i] = freq + offset += 3 + i += 1 + return result + + def prepare_write(self, new_values): + """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): + # 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): + """Choose which AdvancedParaEQ slot drives live audio. + + Activation works for any slot — read-only factory presets and + user-custom slots alike. The "(factory)" tag in the slot label + distinguishes the read-only ones; that distinction matters for + band-editing (not supported yet), not for activation today. + """ + + name = "headset-eq-active-preset" + label = _("EQ Preset") + description = _("Switch the active EQ preset. Factory presets are read-only.") + feature = _F.HEADSET_ADVANCED_PARA_EQ + rw_options = {"read_fnid": 0x30, "write_fnid": 0x40, "prefix": b"\x00"} + validator_class = settings_validator.ChoicesValidator + + @classmethod + def build(cls, device): + info = getattr(device, "_advanced_eq_info", None) or hidpp20.get_advanced_eq_info(device) + if not info: + return None + ro_count = info.get("onboard_ro_preset_count", 0) or 0 + # Probe each advertised slot — getEQInfos may report capacity that + # the firmware doesn't actually back (G522 advertises 16 slots but + # only honors slot 0). Only include slots that responded with band + # data; the result is cached on device._advanced_eq_working_slots + # so HeadsetAdvancedEQ.build can reuse it without re-probing. + working = hidpp20.probe_advanced_eq_slots(device, direction=0, info=info) + if len(working) <= 1: + # One option (or zero) is meaningless as a selector — there's + # nothing for the user to choose between. The active EQ is + # whatever slot 0 has, no preset switching is available. + return None + choices = common.NamedInts() + for slot, slot_name, _bands in working: + if not slot_name: + slot_name = _("Slot") + " " + str(slot) + if slot < ro_count: + slot_name = slot_name + " " + _("(factory)") + choices[slot] = slot_name + rw = settings.FeatureRW(cls.feature, **cls.rw_options) + validator = settings_validator.ChoicesValidator(choices=choices) + return cls(device, rw, validator) + + def write(self, value, save=True): + result = super().write(value, save) + if result is not None: + # After setActiveEQ, repopulate the AdvancedParaEQ band-display + # cache so the panel reflects the newly-active slot. Force a + # fresh read so _value is a real dict — leaving it as None + # would let a UI band-click hit `_value[item]` on None and + # crash (config_panel.py:589 'NoneType' is not subscriptable). + # The visible widget redraw still waits for a manual refresh / + # panel reopen — auto-redraw would need UI-side plumbing. + eq_panel = _headset_setting_by_name(self._device, HeadsetAdvancedEQ.name) + if eq_panel is not None: + try: + eq_panel._value = None + eq_panel.read(cached=False) + except Exception as e: + logger.debug("HeadsetActiveEQPreset: failed to refresh EQ panel: %s", e) + return result + + +_NO_CHANGE_COLOR = int(special_keys.COLORSPLUS["No change"]) + + +def _headset_setting_by_name(device, name): + for s in getattr(device, "settings", None) or []: + if getattr(s, "name", None) == name: + return s + return None + + +def _headset_primary_color(device, default=0xFFFFFF): + """Resolve the currently-saved Primary color, or `default` if absent.""" + s = _headset_setting_by_name(device, HeadsetLEDsPrimary.name) + if s is None: + return default + value = getattr(s, "_value", None) + color = getattr(value, "color", None) if value is not None else None + return int(color) if color is not None else default + + +def _headset_per_zone_overrides(device): + """Return `{zone_id: color_int}` for zones with explicit (non-'No change') + colors set via the Per-zone Lighting setting, or `None` if the setting + isn't built/present.""" + s = _headset_setting_by_name(device, HeadsetPerZoneLighting.name) + if s is None: + return None + value = getattr(s, "_value", None) + if not isinstance(value, dict): + return None + overrides = {} + for zone, color in value.items(): + try: + color_int = int(color) + except (TypeError, ValueError): + continue + if color_int != _NO_CHANGE_COLOR: + overrides[int(zone)] = color_int + return overrides + + +class _HeadsetStaticEffectOption: + """Minimal stand-in for `hidpp20.LEDEffectInfo`. + + `HeteroValidator` only inspects `.ID` and `.index` on its `options` + list; we don't need the full device-query machinery here because the + headset wire protocol is handled by `headset_rgb.write_zone_map`. + """ + + ID = 0x01 # matches hidpp20.LEDEffects[0x01] = Static + index = 0x01 + + +class HeadsetLEDControl(settings.Setting): + """Switch headset LED control between device and Solaar. + + Mirrors the `LEDControl` / `RGBControl` pattern used for keyboards and + mice. When set to Solaar, the `LEDs Primary` and `Per-zone Lighting` + settings drive the LEDs; when set to Device, firmware-driven onboard + and signature effects resume. + """ + + name = "headset_led_control" + label = _("LED Control") + description = _("Switch control of LED zones between device and Solaar") + feature = _F.HEADSET_RGB_HOSTMODE + rw_options = {"read_fnid": 0x70, "write_fnid": 0x80} + choices_universe = common.NamedInts(Device=0, Solaar=1) + validator_class = settings_validator.ChoicesValidator + validator_options = {"choices": choices_universe} + + @classmethod + def build(cls, device): + # One-shot read-only probe of 0x0621 / 0x0622 — logs the data the RE + # pass needs to pin down RGB onboard/signature effect structures. + # Skip cleanly if neither feature is exposed. + try: + rgb_effects_probe.probe(device) + except Exception as e: + logger.debug("RGB effects probe raised %r", e) + return super().build(device) + + def write(self, value, save=True): + # After switching to Solaar control, the firmware drops whatever + # colors we'd programmed — so reassert the saved Primary + per-zone + # overrides immediately. Otherwise the LEDs stay on whatever + # device-driven effect was last shown until the user edits a color. + result = super().write(value, save) + if result is not None and int(value) == 1 and self._device.online: + primary = _headset_primary_color(self._device) + zones = headset_rgb.discover_zones(self._device) + if zones: + zone_map = {int(z): primary for z in zones} + zone_map.update(_headset_per_zone_overrides(self._device) or {}) + headset_rgb.write_zone_map(self._device, zone_map) + return result + + +class HeadsetLEDsPrimary(settings.Setting): + """Primary headset LED color, rendered as a GTK color picker. + + Mirrors the `LEDZoneSetting` / `RGBEffectSetting` shape: a + `HeteroValidator` with a single "Static" effect whose only visible + field is the color. Write applies the chosen color across all zones + discovered at build time, then re-applies any per-zone overrides on + top so they aren't clobbered. + + Read support is deliberately disabled — the feature exposes no "get + current color" function, so we rely on the persister. + """ + + name = "headset_leds_primary" + label = _("LEDs") + " " + _("Primary") + description = _( + "Set the primary color applied to every headset LED zone.\n" "LED Control needs to be set to Solaar to be effective." + ) + feature = _F.HEADSET_RGB_HOSTMODE + persist = True + rw_options = {"read_fnid": None, "write_fnid": None} + + # HeteroKeyControl renders exactly these fields; ID is hidden + # (`label=None`) but kept so setup_visibles can key off it. + color_field = {"name": hidpp20.LEDParam.color, "kind": settings.Kind.COLOR, "label": _("Color")} + possible_fields = [ + { + "name": "ID", + "kind": settings.Kind.CHOICE, + "label": None, + "choices": [common.NamedInt(0x01, _("Static"))], + }, + color_field, + ] + # HeteroKeyControl.setup_visibles looks up fields_map[effect_id][1] to + # decide which fields to show — we only expose the color. + fields_map = {0x01: [common.NamedInt(0x01, _("Static")), {hidpp20.LEDParam.color: 0}]} + + @classmethod + def build(cls, device): + zones = headset_rgb.discover_zones(device) + if not zones: + return None + rw = settings.FeatureRW(cls.feature) + validator = settings_validator.HeteroValidator( + data_class=hidpp20.LEDEffectSetting, + options=[_HeadsetStaticEffectOption()], + readable=False, + ) + return cls(device, rw, validator) + + def read(self, cached=True): + # Feature 0x0620 doesn't expose a "current primary color" read — + # pull from the persister via _pre_read, fall back to white so + # the picker opens on a sane starting color. + self._pre_read(cached) + if self._value is not None: + return self._value + self._value = hidpp20.LEDEffectSetting(ID=common.NamedInt(0x01, _("Static")), color=0xFFFFFF) + return self._value + + def write(self, value, save=True): + color = getattr(value, "color", None) + if color is None: + return None + device = self._device + if not device.online: + return None + zones = headset_rgb.discover_zones(device) + if not zones: + return None + primary = int(color) + zone_map = {int(z): primary for z in zones} + # Re-apply any non-"No change" per-zone overrides on top of the + # fresh Primary baseline so the user's explicit zone choices stick + # when they change the bulk color. + overrides = _headset_per_zone_overrides(device) or {} + zone_map.update(overrides) + if headset_rgb.write_zone_map(device, zone_map): + self.update(value, save) + return value + return None + + +class HeadsetPerZoneLighting(settings.Settings): + """Per-zone LED color overrides. + + Mirrors `PerKeyLighting` — keys are firmware zone IDs, values are + 24-bit RGB ints with the `-1` sentinel meaning "inherit the current + `LEDs Primary` color." Surfaces in the UI via the per-key painter. + """ + + name = "headset_per_zone_lighting" + label = _("Per-zone Lighting") + description = _( + "Override individual zone colors. 'No change' inherits the LEDs Primary color.\n" + "LED Control needs to be set to Solaar to be effective." + ) + feature = _F.HEADSET_RGB_HOSTMODE + persist = True + editor_class = "solaar.ui.perkey.control:PerKeyControl" + + class rw_class(settings.FeatureRWMap): + pass + + class validator_class(settings_validator.MapRangeValidator): + _COLOR_RANGE = settings_validator.Range(min=0, max=0xFFFFFF, byte_count=3) + + @classmethod + def build(cls, setting_class, device): + zones = headset_rgb.discover_zones(device) + if not zones: + return None + choices_map = {common.NamedInt(int(z), _("Zone") + " " + str(int(z))): cls._COLOR_RANGE for z in zones} + return cls(choices_map) if choices_map else None + + def read(self, cached=True): + self._pre_read(cached) + if cached and self._value is not None: + return self._value + # Device doesn't expose current per-zone state; default every + # zone to "No change" so the primary color shows through. + reply_map = {int(key): _NO_CHANGE_COLOR for key in self._validator.choices} + self._value = reply_map + return reply_map + + def _resolve_zone_map(self, map_, primary): + """Substitute 'No change' entries with the primary color.""" + resolved = {} + for key, value in map_.items(): + try: + v = int(value) + except (TypeError, ValueError): + continue + resolved[int(key)] = primary if v == _NO_CHANGE_COLOR else v + return resolved + + def write(self, map_, save=True): + device = self._device + if not device.online: + return None + self.update(map_, save) + primary = _headset_primary_color(device) + zone_map = self._resolve_zone_map(map_, primary) + if not zone_map: + return None + if headset_rgb.write_zone_map(device, zone_map): + return map_ + return None + + def write_key_value(self, key, value, save=True): + result = super().write_key_value(int(key), value, save) + device = self._device + if not device.online: + return result + try: + v = int(value) + except (TypeError, ValueError): + return result + effective = _headset_primary_color(device) if v == _NO_CHANGE_COLOR else v + headset_rgb.write_zone_map(device, {int(key): int(effective)}) + return result + + +# ---------------------------------------------------------------------------- +# LogiVoice (0x0900 + 0x0901..0x0907) — read-only presentation pass. +# +# Per module we auto-generate two settings: +# 1. A flat State toggle — reads GetState (fn 1), renders as a boolean. +# Top-level so users see a direct on/off indicator at a glance. +# 2. A collapsible "Parameters" panel — one MULTIPLE_RANGE-kind setting +# that reads GetParameters (fn 3) once and distributes the bytes to +# per-field sliders. The existing MultipleRangeControl widget is +# collapsible by default, so the field-level clutter stays folded. +# +# Writes are disabled — the Parameters struct carries fields whose wire +# encodings are still ambiguous (see logivoice.py) and a SetParameters +# write must bundle all fields at once. A write pass can be added once +# each field's encoding is confirmed live. +# ---------------------------------------------------------------------------- + + +class _LogiVoiceStateSetting(settings.Setting): + """Per-module State toggle. Reads GetState (fn 1) and writes SetState (fn 0). + + State wire format is unambiguous (one byte: 0 = off, 1 = on), so this is + the one piece of the LogiVoice surface we enable for writes. The per-module + Parameters struct stays read-only until each field's encoding is confirmed. + """ + + rw_options = {"read_fnid": logivoice.FN_GET_STATE, "write_fnid": logivoice.FN_SET_STATE} + validator_class = settings_validator.BooleanValidator + + @classmethod + def build(cls, device): + # Corpus probe runs here (once per module) so -dd users get a full + # snapshot of state + raw Parameters + raw Info for future decoding. + try: + logivoice.probe_module(device, cls.feature) + except Exception as e: + logger.debug("LogiVoice probe_module(%s) raised %s", cls.feature, e) + return super().build(device) + + +class _LogiVoiceModuleItem: + """Top-level MULTIPLE_RANGE item representing one LogiVoice module. + + One `item` per setting — the module itself. `__int__` returns the feature + id so the Setting's reply dict is keyed predictably. + """ + + def __init__(self, feature: hidpp20_constants.SupportedFeature): + self._feature = feature + self.id = logivoice.MODULE_SLUGS.get(feature, f"0x{int(feature):04X}") + self.index = 0 + + def __int__(self): + return int(self._feature) + + def __str__(self): + return logivoice.MODULE_NAMES.get(self._feature, f"0x{int(self._feature):04X}") + + +class _LogiVoiceFieldSubItem: + """MULTIPLE_RANGE sub-item wrapping one decoded Parameters field. + + MultipleRangeControl reads minimum/maximum/length/widget/str(). We pick + SpinButton for wide ranges (0..65535) where a 64k-step slider is useless, + and Scale for small ranges (e.g. signed int8 thresholds). + """ + + def __init__(self, field: logivoice.Field): + self._field = field + self.id = field.name + self.minimum = field.min_value + self.maximum = field.max_value + self.length = field.byte_count + self.widget = "SpinButton" if (field.max_value - field.min_value) > 512 else "Scale" + + def __int__(self): + return hash(self.id) & 0xFFFFFF + + def __str__(self): + return self._field.label + (" (raw)" if self._field.opaque else "") + + +class _LogiVoiceParametersValidator(settings_validator.MultipleRangeValidator): + """Reads the whole GetParameters struct once and distributes bytes to fields. + + MULTIPLE_RANGE's default read loop fires prepare_read_item once per top- + level item; we have exactly one item (the module), so this issues a single + GetParameters call. validate_read_item parses the shared reply into a + {field_name: value} dict. Writes are blocked. + """ + + def __init__(self, feature: hidpp20_constants.SupportedFeature): + fields = logivoice.PARAMETERS_FIELDS.get(feature, []) + self._fields = list(fields) + item = _LogiVoiceModuleItem(feature) + sub_items = {item: [_LogiVoiceFieldSubItem(f) for f in fields]} + super().__init__(items=[item], sub_items=sub_items) + + def prepare_read_item(self, item): + return b"" # GetParameters takes no wire arguments + + def validate_read(self, reply_bytes): + # Setting.read() calls validate_read with the raw GetParameters reply. + # MultipleRangeValidator only defines validate_read_item, so wrap that + # call — we have a single item (the module) so one call suffices. + item = self.items[0] + return {int(item): self.validate_read_item(reply_bytes, item)} + + def validate_read_item(self, reply_bytes, item): + parsed = {} + # Key by str(sub_item) so MultipleRangeControl.set_value can look up + # values via v[str(sub_item)] — the UI uses the label as the dict key. + for sub in self.sub_items[item]: + f = sub._field + end = f.offset + f.byte_count + if end > len(reply_bytes): + continue + chunk = reply_bytes[f.offset : end] + if f.byte_count == 1: + v = struct.unpack("b" if f.signed else "B", chunk)[0] + elif f.byte_count == 2: + v = struct.unpack(">h" if f.signed else ">H", chunk)[0] + else: + v = int.from_bytes(chunk, "big", signed=f.signed) + parsed[str(sub)] = v + return parsed + + def prepare_write_item(self, item, value): + return None + + def prepare_write(self, value): + return None + + +class _LogiVoiceParametersSetting(settings.Setting): + """Collapsible read-only display of one module's GetParameters struct.""" + + rw_options = {"read_fnid": logivoice.FN_GET_PARAMETERS} + persist = False + kind = settings.Kind.MULTIPLE_RANGE + + @classmethod + def build(cls, device): + if not logivoice.PARAMETERS_FIELDS.get(cls.feature): + return None + rw = settings.FeatureRW(cls.feature, **cls.rw_options) + validator = _LogiVoiceParametersValidator(cls.feature) + return cls(device, rw, validator) + + def write(self, map, save=True): + return None + + +def _logivoice_make_state_class(feature: hidpp20_constants.SupportedFeature): + slug = logivoice.MODULE_SLUGS.get(feature) + if not slug: + return None + module_name = logivoice.MODULE_NAMES.get(feature, f"0x{int(feature):04X}") + attrs = { + "name": f"logivoice-{slug}-state", + "label": f"LogiVoice {module_name}", + "description": f"Enable the headset {module_name} processing block.", + "feature": feature, + } + return type(f"LogiVoice_{slug}_State", (_LogiVoiceStateSetting,), attrs) + + +def _logivoice_make_parameters_class(feature: hidpp20_constants.SupportedFeature): + slug = logivoice.MODULE_SLUGS.get(feature) + if not slug or not logivoice.PARAMETERS_FIELDS.get(feature): + return None + module_name = logivoice.MODULE_NAMES.get(feature, f"0x{int(feature):04X}") + attrs = { + "name": f"logivoice-{slug}-parameters", + "label": f"LogiVoice {module_name}: Parameters (read-only)", + "description": ( + f"Decoded {module_name} GetParameters fields. " + "Opaque raw values shown where the wire encoding isn't confirmed yet." + ), + "feature": feature, + } + return type(f"LogiVoice_{slug}_Parameters", (_LogiVoiceParametersSetting,), attrs) + + +_LOGIVOICE_SETTINGS: list[type] = [] +for _feature in logivoice.PARAMETERS_FIELDS: + _state_cls = _logivoice_make_state_class(_feature) + if _state_cls is not None: + _LOGIVOICE_SETTINGS.append(_state_cls) + _params_cls = _logivoice_make_parameters_class(_feature) + if _params_cls is not None: + _LOGIVOICE_SETTINGS.append(_params_cls) + + class BrightnessControl(settings.Setting): name = "brightness_control" label = _("Brightness Control") @@ -3178,6 +4031,12 @@ SETTINGS: list[settings.Setting] = [ HeadsetMixBalance, HeadsetAutoSleep, HeadsetOnboardEQ, + HeadsetActiveEQPreset, + HeadsetAdvancedEQ, + HeadsetLEDControl, + HeadsetLEDsPrimary, + HeadsetPerZoneLighting, + *_LOGIVOICE_SETTINGS, ] @@ -3273,8 +4132,22 @@ def check_feature(device, settings_class: SettingsProtocol) -> None | bool | Set if settings_class.feature not in device.features: return if settings_class.min_version > device.features.get_feature_version(settings_class.feature): + logger.debug( + "check_feature %s [%s]: min_version=%d > device feature version=%d; skipping", + settings_class.name, + settings_class.feature, + settings_class.min_version, + device.features.get_feature_version(settings_class.feature) or 0, + ) return if device.features.get_hidden(settings_class.feature): + flags = device.features.flags.get(settings_class.feature, 0) + logger.debug( + "check_feature %s [%s]: feature has INTERNAL flag set (flags=0x%02X); skipping", + settings_class.name, + settings_class.feature, + flags, + ) return try: detected = settings_class.build(device) @@ -3313,34 +4186,49 @@ def check_feature_settings(device, already_known) -> bool: known_present = sclass.name in device.persister else: known_present = False - if not any(s.name == sclass.name for s in already_known) and (known_present or sclass.name not in absent): - try: - setting = check_feature(device, sclass) - except Exception as err: - # on an internal HID++ error, assume offline and stop further checking - if ( - isinstance(err, exceptions.FeatureCallError) - and err.error == hidpp20_constants.ErrorCode.LOGITECH_ERROR - ): - logger.warning(f"HID++ internal error checking feature {sclass.name}: make device not present") - device.online = False - device.present = False - return False - else: - logger.warning(f"ignore feature {sclass.name} because of error {err}") + already = any(s.name == sclass.name for s in already_known) + if already: + continue + if not known_present and sclass.name in absent: + # Silent-skip cache from an earlier run's failed build(). If the + # feature is actually present on this device now, the cache is + # stale (e.g. from a prior build that returned None for a + # feature that currently works) — drop it and retry the probe. + if sclass.feature in device.features: + logger.debug( + "check_feature_settings: retrying %s — cached in _absent but feature %s is present now", + sclass.name, + sclass.feature, + ) + absent.remove(sclass.name) + if device.persister: + device.persister["_absent"] = absent + else: + continue + try: + setting = check_feature(device, sclass) + except Exception as err: + # on an internal HID++ error, assume offline and stop further checking + if isinstance(err, exceptions.FeatureCallError) and err.error == hidpp20_constants.ErrorCode.LOGITECH_ERROR: + logger.warning(f"HID++ internal error checking feature {sclass.name}: make device not present") + device.online = False + device.present = False + return False + else: + logger.warning(f"ignore feature {sclass.name} because of error {err}") - if isinstance(setting, list): - for s in setting: - already_known.append(s) - if sclass.name in new_absent: - new_absent.remove(sclass.name) - elif setting: - already_known.append(setting) - if sclass.name in new_absent: - new_absent.remove(sclass.name) - elif setting is None: - if sclass.name not in new_absent and sclass.name not in absent and sclass.name not in device.persister: - new_absent.append(sclass.name) + if isinstance(setting, list): + for s in setting: + already_known.append(s) + if sclass.name in new_absent: + new_absent.remove(sclass.name) + elif setting: + already_known.append(setting) + if sclass.name in new_absent: + new_absent.remove(sclass.name) + elif setting is None: + if sclass.name not in new_absent and sclass.name not in absent and sclass.name not in device.persister: + new_absent.append(sclass.name) if device.persister and new_absent: absent.extend(new_absent) device.persister["_absent"] = absent diff --git a/lib/solaar/listener.py b/lib/solaar/listener.py index e8d5b0d2..f09a9f18 100644 --- a/lib/solaar/listener.py +++ b/lib/solaar/listener.py @@ -152,6 +152,12 @@ class SolaarListener(listener.EventsListener): from logitech_receiver.device import CenturionReceiver if isinstance(self.receiver, CenturionReceiver): + if self.receiver._pending: + ihandle = int(self.receiver.handle) + state = base._centurion_handles.get(ihandle) + if state and state.device_addr is not None: + self.receiver._complete_deferred_init() + self._status_changed(self.receiver) self._handle_centurion_notification(n) return # a receiver notification diff --git a/lib/solaar/ui/perkey/layouts/__init__.py b/lib/solaar/ui/perkey/layouts/__init__.py index b2bde7e2..128b3e39 100644 --- a/lib/solaar/ui/perkey/layouts/__init__.py +++ b/lib/solaar/ui/perkey/layouts/__init__.py @@ -26,6 +26,7 @@ from __future__ import annotations from collections.abc import Callable from ..layout import Layout +from . import headset_g522 from . import keyboard_ansi from . import keyboard_iso_azerty from . import keyboard_iso_qwerty @@ -140,3 +141,5 @@ for _family, (_full, _tkl) in _FAMILY_LAYOUTS.items(): register_layout(0x8081, _keyboard_matcher(_family, full_size=False), _tkl) register_layout(0x8081, _name_contains("G502 X"), mouse_g502x.LAYOUT) +# HEADSET_RGB_HOSTMODE = 0x0620 +register_layout(0x0620, _name_contains("G522"), headset_g522.LAYOUT) diff --git a/lib/solaar/ui/perkey/layouts/headset_g522.py b/lib/solaar/ui/perkey/layouts/headset_g522.py new file mode 100644 index 00000000..de971bdf --- /dev/null +++ b/lib/solaar/ui/perkey/layouts/headset_g522.py @@ -0,0 +1,53 @@ +## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/ +## +## This program is free software; you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation; either version 2 of the License, or +## (at your option) any later version. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License along +## with this program; if not, write to the Free Software Foundation, Inc., +## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +"""LED layout for the G522 LIGHTSPEED headset. + +Eight LEDs in two 2×2 grids — one per earcup, viewed from outside: + + Left earcup Right earcup + ┌─────┬─────┐ ┌─────┬─────┐ + │ 8 │ 7 │ │ 6 │ 5 │ + ├─────┼─────┤ ├─────┼─────┤ + │ 4 │ 3 │ │ 2 │ 1 │ + └─────┴─────┘ └─────┴─────┘ +""" + +from __future__ import annotations + +from ..layout import Cell +from ..layout import Layout + +_CELLS: tuple[Cell, ...] = ( + # Left earcup (cols 0-1, outer view): top-left=8, top-right=7, bottom-left=4, bottom-right=3 + Cell(zone_id=8, row=0, col=0, group="main", label="8"), + Cell(zone_id=7, row=0, col=1, group="main", label="7"), + Cell(zone_id=4, row=1, col=0, group="main", label="4"), + Cell(zone_id=3, row=1, col=1, group="main", label="3"), + # Right earcup (cols 3-4, outer view): top-left=6, top-right=5, bottom-left=2, bottom-right=1 + Cell(zone_id=6, row=0, col=3, group="main", label="6"), + Cell(zone_id=5, row=0, col=4, group="main", label="5"), + Cell(zone_id=2, row=1, col=3, group="main", label="2"), + Cell(zone_id=1, row=1, col=4, group="main", label="1"), +) + + +LAYOUT: Layout = Layout( + cells=_CELLS, + rows=2, + cols=5, + description="Logitech G522 LIGHTSPEED headset (8 LEDs, 4 per earcup)", +) diff --git a/tests/logitech_receiver/test_base.py b/tests/logitech_receiver/test_base.py index 35ed9ba1..8d3355c9 100644 --- a/tests/logitech_receiver/test_base.py +++ b/tests/logitech_receiver/test_base.py @@ -8,7 +8,10 @@ import pytest from logitech_receiver import base from logitech_receiver import exceptions +from logitech_receiver.base import CENTURION_ADDRESSED_REPORT_ID +from logitech_receiver.base import CENTURION_REPORT_ID from logitech_receiver.base import HIDPP_SHORT_MESSAGE_ID +from logitech_receiver.base import CenturionHandleState from logitech_receiver.common import LOGITECH_VENDOR_ID from logitech_receiver.common import BusID from logitech_receiver.hidpp10_constants import ErrorCode as Hidpp10Error @@ -197,3 +200,217 @@ def test_ping_errors(simulated_error: Hidpp10Error, expected_result): else: result = base.ping(handle=handle, devnumber=device_number) assert result == expected_result + + +# --- Centurion transport tests --- + + +class TestCenturionFrameHeader: + """Test _centurion_frame_header builds correct headers for both variants.""" + + def test_0x51_header(self): + state = CenturionHandleState(report_id=CENTURION_REPORT_ID) + header = base._centurion_frame_header(state, cpl_length=5, flags=0x00) + assert header == bytes([0x51, 5, 0x00]) + + def test_0x51_header_with_flags(self): + state = CenturionHandleState(report_id=CENTURION_REPORT_ID) + header = base._centurion_frame_header(state, cpl_length=10, flags=0x03) + assert header == bytes([0x51, 10, 0x03]) + + def test_0x50_header_unknown_addr(self): + state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID, device_addr=None) + header = base._centurion_frame_header(state, cpl_length=5, flags=0x00) + # device_addr defaults to 0x00 when unknown + assert header == bytes([0x50, 0x00, 5, 0x00]) + + def test_0x50_header_known_addr(self): + state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID, device_addr=0x23) + header = base._centurion_frame_header(state, cpl_length=5, flags=0x00) + assert header == bytes([0x50, 0x23, 5, 0x00]) + + def test_0x50_header_with_flags(self): + state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID, device_addr=0x23) + header = base._centurion_frame_header(state, cpl_length=10, flags=0x07) + assert header == bytes([0x50, 0x23, 10, 0x07]) + + +class TestUnwrapCenturionFrame: + """Test _unwrap_centurion_frame for both 0x51 and 0x50 variants.""" + + HANDLE = 99 + + def setup_method(self): + """Ensure no leftover centurion state between tests.""" + base._centurion_handles.pop(self.HANDLE, None) + + def teardown_method(self): + base._centurion_handles.pop(self.HANDLE, None) + + def test_unwrap_0x51_frame(self): + """0x51 frame with feat_idx=0x02, func_sw=0x1A, 2 data bytes.""" + # cpl_length = 1(flags) + 1(feat_idx) + 1(func_sw) + 2(data) = 5 + raw = bytes([0x51, 5, 0x00, 0x02, 0x1A, 0xAA, 0xBB]) + b"\x00" * 57 + result = base._unwrap_centurion_frame(raw, self.HANDLE, self.HANDLE) + # Should reconstruct as [0x11, 0xFF, feat_idx, func_sw, data..., pad to 20] + assert result[0] == 0x11 + assert result[1] == 0xFF + assert result[2] == 0x02 # feat_idx + assert result[3] == 0x1A # func_sw + assert result[4] == 0xAA + assert result[5] == 0xBB + assert len(result) == 20 # padded to standard long + + def test_unwrap_0x50_frame(self): + """0x50 frame with device_addr=0x23, same payload as above.""" + # Frame: [0x50, device_addr, cpl_length, flags, feat_idx, func_sw, data...] + raw = bytes([0x50, 0x23, 5, 0x00, 0x02, 0x1A, 0xAA, 0xBB]) + b"\x00" * 56 + base._centurion_handles[self.HANDLE] = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID) + result = base._unwrap_centurion_frame(raw, self.HANDLE, self.HANDLE) + assert result[0] == 0x11 + assert result[1] == 0xFF + assert result[2] == 0x02 # feat_idx + assert result[3] == 0x1A # func_sw + assert result[4] == 0xAA + assert result[5] == 0xBB + assert len(result) == 20 + + def test_0x50_learns_device_addr(self): + """First RX on a 0x50 handle should learn the device address.""" + base._centurion_handles[self.HANDLE] = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID) + assert base._centurion_handles[self.HANDLE].device_addr is None + + raw = bytes([0x50, 0x23, 3, 0x00, 0x02, 0x1A]) + b"\x00" * 58 + base._unwrap_centurion_frame(raw, self.HANDLE, self.HANDLE) + + assert base._centurion_handles[self.HANDLE].device_addr == 0x23 + + def test_0x50_does_not_overwrite_addr(self): + """Once learned, device address should not be overwritten.""" + base._centurion_handles[self.HANDLE] = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID, device_addr=0x23) + raw = bytes([0x50, 0xFF, 3, 0x00, 0x02, 0x1A]) + b"\x00" * 58 + base._unwrap_centurion_frame(raw, self.HANDLE, self.HANDLE) + + # Should keep the original address, not overwrite with 0xFF + assert base._centurion_handles[self.HANDLE].device_addr == 0x23 + + def test_non_centurion_frame_passthrough(self): + """Non-centurion report IDs should be returned unchanged.""" + raw = bytes([0x11, 0x01, 0x02, 0x1A]) + b"\x00" * 16 + result = base._unwrap_centurion_frame(raw, self.HANDLE, self.HANDLE) + assert result == raw + + def test_unwrap_0x51_large_payload(self): + """0x51 frame with payload large enough to need 63-byte padding.""" + # cpl_length covers all 61 payload bytes + flags = 62 + payload = bytes(range(61)) + raw = bytes([0x51, 62, 0x00]) + payload + result = base._unwrap_centurion_frame(raw, self.HANDLE, self.HANDLE) + assert len(result) == 63 # padded to centurion extended + assert result[0] == 0x11 + assert result[1] == 0xFF + assert result[2:63] == payload + + +class TestProbeCenturionDeviceAddr: + """Test probe_centurion_device_addr: brute-force write for all 256 addrs, then read.""" + + HANDLE = 101 + + def setup_method(self): + base._centurion_handles.pop(self.HANDLE, None) + + def teardown_method(self): + base._centurion_handles.pop(self.HANDLE, None) + + def test_learns_addr_on_first_hit(self): + """Probe finds addr=0x23 on candidate #36 (0-indexed 0x23=35) and stops.""" + state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID) + reply = bytes([0x50, 0x23, 0x03, 0x00]) + b"\x00" * 60 + + def read_side_effect(_handle, _size, _timeout): + # Return a response only after the write with addr=0x23 + if mock_write.call_count == 0x24: # 0x23 is the 36th write (1-indexed) + return reply + return None + + with ( + mock.patch.object(base.hidapi, "write") as mock_write, + mock.patch.object(base.hidapi, "read", side_effect=read_side_effect), + ): + result = base.probe_centurion_device_addr(self.HANDLE, state) + assert result is True + assert state.device_addr == 0x23 + # Short-circuit: stopped at candidate 0x23 (36 writes), not all 256 + assert mock_write.call_count == 0x24 + + def test_skips_non_matching_read_until_match(self): + """Non-0x50 frames in the read are ignored; next candidate's read succeeds.""" + state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID) + noise = b"\x11\xff" + b"\x00" * 62 + match = bytes([0x50, 0x42, 0x03, 0x00]) + b"\x00" * 60 + # Reads cycle: noise, noise, match — so addr is found on 3rd candidate + with ( + mock.patch.object(base.hidapi, "write"), + mock.patch.object(base.hidapi, "read", side_effect=[noise, noise, match]), + ): + result = base.probe_centurion_device_addr(self.HANDLE, state) + assert result is True + assert state.device_addr == 0x42 + + def test_returns_false_when_no_response(self): + state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID) + with ( + mock.patch.object(base.hidapi, "write"), + mock.patch.object(base.hidapi, "read", return_value=None), + ): + result = base.probe_centurion_device_addr(self.HANDLE, state) + assert result is False + assert state.device_addr is None + + def test_noop_for_0x51_variant(self): + state = CenturionHandleState(report_id=CENTURION_REPORT_ID) + with ( + mock.patch.object(base.hidapi, "write") as mock_write, + mock.patch.object(base.hidapi, "read") as mock_read, + ): + result = base.probe_centurion_device_addr(self.HANDLE, state) + assert result is False + assert state.device_addr is None + mock_write.assert_not_called() + mock_read.assert_not_called() + + def test_noop_when_addr_already_known(self): + state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID, device_addr=0x23) + with ( + mock.patch.object(base.hidapi, "write") as mock_write, + mock.patch.object(base.hidapi, "read") as mock_read, + ): + result = base.probe_centurion_device_addr(self.HANDLE, state) + assert result is False + assert state.device_addr == 0x23 + mock_write.assert_not_called() + mock_read.assert_not_called() + + def test_aborts_on_repeated_write_failure(self): + state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID) + with ( + mock.patch.object(base.hidapi, "write", side_effect=OSError("no device")), + mock.patch.object(base.hidapi, "read") as mock_read, + ): + result = base.probe_centurion_device_addr(self.HANDLE, state) + assert result is False + assert state.device_addr is None + mock_read.assert_not_called() + + def test_write_frames_have_sequential_addrs(self): + """Verify each write uses a different device_addr from 0x00 to 0xFF.""" + state = CenturionHandleState(report_id=CENTURION_ADDRESSED_REPORT_ID) + with ( + mock.patch.object(base.hidapi, "write") as mock_write, + mock.patch.object(base.hidapi, "read", return_value=None), # no response → scans all 256 + ): + base.probe_centurion_device_addr(self.HANDLE, state) + assert mock_write.call_count == 256 + addrs_sent = [mock_write.call_args_list[i][0][1][1] for i in range(256)] + assert addrs_sent == list(range(256)) diff --git a/tests/logitech_receiver/test_device.py b/tests/logitech_receiver/test_device.py index 9836d27e..c22f439e 100644 --- a/tests/logitech_receiver/test_device.py +++ b/tests/logitech_receiver/test_device.py @@ -17,6 +17,7 @@ from dataclasses import dataclass from functools import partial from typing import Optional +from unittest import mock import pytest @@ -61,6 +62,7 @@ class DeviceInfoStub: bus_id: int = 0x0003 # USB serial: str = "aa:aa:aa;aa" centurion: bool = False + centurion_report_id: int | None = None di_bad_handle = DeviceInfoStub(None, product_id="CCCC") @@ -100,20 +102,46 @@ def test_create_centurion_device(): """Test that a centurion device gets hidpp_long forced to True and centurion flag set.""" from logitech_receiver import base - low_level_mock = LowLevelInterfaceFake(fake_hidpp.r_empty) - test_device = device.create_device(low_level_mock, di_0AF7) + with mock.patch.object(base, "probe_centurion_device_addr", return_value=False): + low_level_mock = LowLevelInterfaceFake(fake_hidpp.r_empty) + test_device = device.create_device(low_level_mock, di_0AF7) assert test_device is not None assert test_device.centurion is True assert test_device.hidpp_long is True assert int(test_device.handle) in base._centurion_handles + state = base._centurion_handles[int(test_device.handle)] + assert state.report_id == base.CENTURION_REPORT_ID # 0x51 default # kind is seeded at construction, so the headset icon shows even offline test_device.online = False assert test_device.kind == "headset" # Clean up - base._centurion_handles.discard(int(test_device.handle)) + base._centurion_handles.pop(int(test_device.handle), None) + + +di_0B18 = DeviceInfoStub("11", product_id="0B18", centurion=True, centurion_report_id=0x50) + + +def test_create_centurion_0x50_device(): + """Test that a 0x50 centurion device gets the correct report ID registered.""" + from logitech_receiver import base + + with mock.patch.object(base, "probe_centurion_device_addr", return_value=False): + low_level_mock = LowLevelInterfaceFake(fake_hidpp.r_empty) + test_device = device.create_device(low_level_mock, di_0B18) + + assert test_device is not None + assert test_device.centurion is True + assert test_device.hidpp_long is True + assert int(test_device.handle) in base._centurion_handles + state = base._centurion_handles[int(test_device.handle)] + assert state.report_id == base.CENTURION_ADDRESSED_REPORT_ID # 0x50 + assert state.device_addr is None # not yet learned + + # Clean up + base._centurion_handles.pop(int(test_device.handle), None) @pytest.mark.parametrize( diff --git a/tests/logitech_receiver/test_hidpp20_complex.py b/tests/logitech_receiver/test_hidpp20_complex.py index 1e754f37..03ce788d 100644 --- a/tests/logitech_receiver/test_hidpp20_complex.py +++ b/tests/logitech_receiver/test_hidpp20_complex.py @@ -956,29 +956,18 @@ def test_centurion_sub_device_feature_discovery(): dev = fake_hidpp.Device("CENT_SUB", True, 2.6, fake_hidpp.r_centurion_headset, centurion=True) # Set up bridge responses for sub-device discovery: # 1. CenturionRoot.GetFeature(0x0001) -> FeatureSet at sub-index 1 - # 2. CenturionFeatureSet.GetFeatureId(index=0) -> bulk feature list + # 2. CenturionFeatureSet.GetCount (func 0) -> total feature count + # 3. CenturionFeatureSet.GetFeatureId (func 0x10) per-index -> one feature each dev._bridge_responses = { # CenturionRoot(idx=0).GetFeature(func=0) with feature_id=0x0001 -> sub_fs_index=1 (0x00, 0x00, "0001"): bytes([0x01, 0x00, 0x00]), - # CenturionFeatureSet(idx=1).GetFeatureId(func=0x10, start=0) -> 3 features - # Response: [count, (feat_hi, feat_lo, type, flags) × count] - (0x01, 0x10, "00"): bytes( - [ - 0x03, # 3 features - 0x06, - 0x04, - 0x00, - 0x00, # HEADSET_AUDIO_SIDETONE (0x0604) at sub-idx 0 - 0x06, - 0x01, - 0x00, - 0x00, # HEADSET_MIC_MUTE (0x0601) at sub-idx 1 - 0x06, - 0x11, - 0x00, - 0x00, # HEADSET_MIC_GAIN (0x0611) at sub-idx 2 - ] - ), + # CenturionFeatureSet(idx=1).GetCount (func=0) -> 3 features + (0x01, 0x00, ""): bytes([0x03, 0x00, 0x00]), + # CenturionFeatureSet(idx=1).GetFeatureId (func=0x10, index=N) -> one feature per response. + # Response format: [remaining, feat_hi, feat_lo, type, flags] + (0x01, 0x10, "00"): bytes([0x02, 0x06, 0x04, 0x00, 0x00]), # HEADSET_AUDIO_SIDETONE at sub-idx 0 + (0x01, 0x10, "01"): bytes([0x01, 0x06, 0x01, 0x00, 0x00]), # HEADSET_MIC_MUTE at sub-idx 1 + (0x01, 0x10, "02"): bytes([0x00, 0x06, 0x11, 0x00, 0x00]), # HEADSET_MIC_GAIN at sub-idx 2 } featuresarray = hidpp20.FeaturesArray(dev) dev.features = featuresarray