diff --git a/lib/logitech_receiver/centurion.py b/lib/logitech_receiver/centurion.py index 356310a9..e1b4b788 100644 --- a/lib/logitech_receiver/centurion.py +++ b/lib/logitech_receiver/centurion.py @@ -265,6 +265,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 @@ -364,11 +365,63 @@ class CenturionReceiver: 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.info( + "CenturionReceiver %s: completing deferred init (device_addr=0x%02X)", + self.path, + learned_addr or 0, + ) + + self._dongle_features = None + self._discover_dongle_features() + logger.info( + "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) @@ -407,6 +460,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.info( + "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) @@ -498,13 +558,26 @@ def create_centurion_receiver(low_level, device_info, setting_callback=None): 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.pop(int(handle), None) - 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.info( + "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/device.py b/lib/logitech_receiver/device.py index c1a8bdef..bd696412 100644 --- a/lib/logitech_receiver/device.py +++ b/lib/logitech_receiver/device.py @@ -243,7 +243,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): diff --git a/lib/solaar/listener.py b/lib/solaar/listener.py index 4aee6700..039e0701 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/tests/logitech_receiver/test_device.py b/tests/logitech_receiver/test_device.py index 63e632bc..fc7b27fa 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 @@ -101,8 +102,9 @@ 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 @@ -122,8 +124,9 @@ def test_create_centurion_0x50_device(): """Test that a 0x50 centurion device gets the correct report ID registered.""" from logitech_receiver import base - low_level_mock = LowLevelInterfaceFake(fake_hidpp.r_empty) - test_device = device.create_device(low_level_mock, di_0B18) + 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