From fc874710cd6fb96a22c2d44f9abcf7d141f7ab6e Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Tue, 10 Mar 2026 21:10:29 -0700 Subject: [PATCH] Add CenturionCoreFeature enum for colliding feature IDs Centurion transport reuses HID++ 2.0 feature IDs 0x0000, 0x0001, 0x0003, 0x0005, 0x0007 with different meanings. Since SupportedFeature (IntEnum) requires unique values, create a separate CenturionCoreFeature enum and resolve_feature() helper for transport-aware lookup. Also replace the +0x100 offset hack in FeaturesArray.inverse with a dedicated sub_inverse dict for sub-device feature indexing. --- lib/logitech_receiver/centurion.py | 5 +-- lib/logitech_receiver/centurion_constants.py | 38 ++++++++++++++++++++ lib/logitech_receiver/hidpp20.py | 17 ++++----- lib/logitech_receiver/settings.py | 5 +-- lib/solaar/cli/show.py | 5 ++- 5 files changed, 55 insertions(+), 15 deletions(-) create mode 100644 lib/logitech_receiver/centurion_constants.py diff --git a/lib/logitech_receiver/centurion.py b/lib/logitech_receiver/centurion.py index 81d29e42..0fa8a1ee 100644 --- a/lib/logitech_receiver/centurion.py +++ b/lib/logitech_receiver/centurion.py @@ -39,6 +39,7 @@ from .common import Alert from .common import Battery from .common import BatteryStatus from .common import FirmwareKind +from .centurion_constants import CenturionCoreFeature from .common import _read_usb_product_string from .hidpp20_constants import SupportedFeature @@ -395,7 +396,7 @@ class CenturionReceiver: dev._centurion_usb_name = self._usb_name # Pre-set bridge index from dongle features so ping can probe the headset for _feat, feat_id, idx in self._dongle_features or []: - if feat_id == 0x0003: # CentPPBridge + if feat_id == CenturionCoreFeature.CENT_PP_BRIDGE: dev._centurion_bridge_index = idx break @@ -493,7 +494,7 @@ def create_centurion_receiver(low_level, device_info, setting_callback=None): base._centurion_handles.add(int(handle)) cr = CenturionReceiver(low_level, handle, device_info, setting_callback) # Check if any discovered feature is CentPPBridge (0x0003) - has_bridge = any(feat_id == 0x0003 for _, feat_id, _ in (cr.dongle_features or [])) + 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)) diff --git a/lib/logitech_receiver/centurion_constants.py b/lib/logitech_receiver/centurion_constants.py new file mode 100644 index 00000000..6e0e3585 --- /dev/null +++ b/lib/logitech_receiver/centurion_constants.py @@ -0,0 +1,38 @@ +"""Centurion transport-specific constants. + +Feature IDs that collide with HID++ 2.0 core features live here +so they can coexist with SupportedFeature (which requires unique values). +""" + +from __future__ import annotations + +from enum import IntEnum + +from .hidpp20_constants import SupportedFeature + + +class CenturionCoreFeature(IntEnum): + """Centurion transport-specific features that collide with HID++ 2.0 core IDs.""" + + CENTURION_ROOT = 0x0000 + CENTURION_FEATURE_SET = 0x0001 + CENT_PP_BRIDGE = 0x0003 + MULTI_HOST_CONTROL = 0x0005 + KEEP_ALIVE = 0x0007 + + def __str__(self): + return self.name.replace("_", " ") + + +def resolve_feature(feat_id: int, centurion: bool = False): + """Resolve a feature ID to the appropriate enum, checking centurion-specific + features first when on the centurion transport.""" + if centurion: + try: + return CenturionCoreFeature(feat_id) + except ValueError: + pass + try: + return SupportedFeature(feat_id) + except ValueError: + return None diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index c9b2e638..b54f4ec6 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -52,6 +52,8 @@ from .hidpp20_constants import ErrorCode from .hidpp20_constants import FeatureFlag from .hidpp20_constants import GestureId from .hidpp20_constants import ParamId +from .centurion_constants import CenturionCoreFeature +from .centurion_constants import resolve_feature from .hidpp20_constants import SupportedFeature logger = logging.getLogger(__name__) @@ -140,6 +142,7 @@ class FeaturesArray(dict): self.supported = True # Actually don't know whether it is supported yet self.device = device self.inverse = {} + self.sub_inverse = {} self.version = {} self.flags = {} self.count = 0 @@ -195,14 +198,12 @@ class FeaturesArray(dict): continue # Centurion FeatureSet response: [remaining_count, feat_hi, feat_lo, type, flags] feat_id = struct.unpack("!H", response[1:3])[0] - try: - feature = SupportedFeature(feat_id) - except ValueError: + feature = resolve_feature(feat_id, centurion=True) + if feature is None: feature = f"unknown:{feat_id:04X}" self[feature] = index self.inverse[index] = feature - # Feature 0x0003 on Centurion = CentPPBridge (not FirmwareInfo) - if feat_id == 0x0003: + if feature is CenturionCoreFeature.CENT_PP_BRIDGE: bridge_index = index if bridge_index is not None: @@ -257,8 +258,8 @@ class FeaturesArray(dict): if dict.get(self, feature) is None: dict.__setitem__(self, feature, sub_feat_idx) self.device._centurion_sub_features.add(feature) - # Always store in offset inverse for sub-device enumerate/display - self.inverse[sub_feat_idx + 0x100] = feature + # Always store in sub_inverse for sub-device enumerate/display + self.sub_inverse[sub_feat_idx] = feature if logger.isEnabledFor(logging.DEBUG): logger.debug("Centurion sub-device feature: %s at sub-index %d", feature, sub_feat_idx) sub_feat_idx += 1 @@ -300,7 +301,7 @@ class FeaturesArray(dict): # Also yield sub-device features for Centurion devices sub_count = getattr(self, "_sub_feature_count", 0) for sub_idx in range(sub_count): - feature = self.inverse.get(sub_idx + 0x100) + feature = self.sub_inverse.get(sub_idx) if feature is not None: yield feature, sub_idx diff --git a/lib/logitech_receiver/settings.py b/lib/logitech_receiver/settings.py index 543994ce..a27d7b06 100644 --- a/lib/logitech_receiver/settings.py +++ b/lib/logitech_receiver/settings.py @@ -27,6 +27,7 @@ from solaar.i18n import _ from . import common from . import hidpp20_constants from . import settings_validator +from .centurion_constants import CenturionCoreFeature from .common import NamedInt logger = logging.getLogger(__name__) @@ -628,7 +629,7 @@ class FeatureRW: read_prefix=b"", no_reply=False, ): - assert isinstance(feature, hidpp20_constants.SupportedFeature) + assert isinstance(feature, (hidpp20_constants.SupportedFeature, CenturionCoreFeature)) self.feature = feature self.read_fnid = read_fnid self.write_fnid = write_fnid @@ -665,7 +666,7 @@ class FeatureRWMap(FeatureRW): key_byte_count=default_key_byte_count, no_reply=False, ): - assert isinstance(feature, hidpp20_constants.SupportedFeature) + assert isinstance(feature, (hidpp20_constants.SupportedFeature, CenturionCoreFeature)) self.feature = feature self.read_fnid = read_fnid self.write_fnid = write_fnid diff --git a/lib/solaar/cli/show.py b/lib/solaar/cli/show.py index f0214fd3..d1a1f84c 100644 --- a/lib/solaar/cli/show.py +++ b/lib/solaar/cli/show.py @@ -232,8 +232,7 @@ def _print_device(dev, num=None): else: feature_bytes = feature.to_bytes(2, byteorder="little") feature_int = int.from_bytes(feature_bytes, byteorder="little") - # On Centurion, parent feature 0x0003 is CentPPBridge, not DEVICE_FW_VERSION - display_name = "CENTPP BRIDGE" if is_centurion and not in_sub_device and feature_int == 0x0003 else feature + display_name = feature if is_centurion_child and in_sub_device: # Use cached version — skip slow bridge ROOT queries version = dev.features.get_feature_version(feature_int) or 0 @@ -339,7 +338,7 @@ def _print_device(dev, num=None): if hw_info: model_id, hw_rev, product_id = hw_info print(f" Hardware: model {model_id}" f" rev {hw_rev} product {product_id:04X}") - elif feature == SupportedFeature.DEVICE_FW_VERSION and not (is_centurion and not in_sub_device): + elif isinstance(feature, SupportedFeature) and feature == SupportedFeature.DEVICE_FW_VERSION: for fw in _hidpp20.get_firmware(dev): extras = strhex(fw.extras) if fw.extras else "" print(f" Firmware: {fw.kind} {fw.name} {fw.version} {extras}")