592 lines
22 KiB
Python
592 lines
22 KiB
Python
## Copyright (C) 2012-2013 Daniel Pavel
|
|
## Copyright (C) 2014-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.
|
|
|
|
"""Centurion device protocol — receiver class, factory, device info/firmware/battery queries.
|
|
|
|
CenturionReceiver is a lightweight receiver-like container for Centurion
|
|
(PRO X 2 LIGHTSPEED and similar) dongles. Protocol functions query device
|
|
info, firmware, serial, name, and battery via Centurion-specific HID++ features.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import errno
|
|
import logging
|
|
import struct
|
|
|
|
from typing import Callable
|
|
|
|
from solaar import configuration
|
|
|
|
from . import base
|
|
from . import exceptions
|
|
from . import hidpp10
|
|
from . import hidpp10_constants
|
|
from .centurion_constants import CenturionCoreFeature
|
|
from .common import Alert
|
|
from .common import Battery
|
|
from .common import BatteryStatus
|
|
from .common import FirmwareKind
|
|
from .common import _read_usb_product_string
|
|
from .hidpp20_constants import SupportedFeature
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# --- Centurion protocol functions (standalone, operate on any device-like object) ---
|
|
|
|
|
|
def get_firmware_centurion(device):
|
|
"""Reads firmware info from a Centurion device via DeviceInfo (0x0100) function 1."""
|
|
from . import common
|
|
|
|
fw = []
|
|
seen = set() # track response signatures to detect duplicates
|
|
for index in range(0, 8): # try up to 8 entities
|
|
try:
|
|
report = device.feature_request(SupportedFeature.CENTURION_DEVICE_INFO, 0x10, index)
|
|
except exceptions.FeatureCallError:
|
|
break
|
|
if not report or len(report) < 5:
|
|
break
|
|
# Dedup: parent device returns the same response for every entity index
|
|
sig = bytes(report[: 5 + report[4]])
|
|
if sig in seen:
|
|
break
|
|
seen.add(sig)
|
|
fw_type = report[0]
|
|
version = struct.unpack("!H", report[2:4])[0]
|
|
name_len = report[4]
|
|
name = report[5 : 5 + name_len].decode("ascii", errors="replace").rstrip("\x00") if name_len else ""
|
|
version_str = f"{version >> 8}.{version & 0xFF:02d}"
|
|
kind = FirmwareKind(fw_type) if fw_type <= 3 else FirmwareKind.Other
|
|
fw.append(common.FirmwareInfo(kind, name, version_str, None))
|
|
return tuple(fw) if fw else None
|
|
|
|
|
|
def get_serial_centurion(device):
|
|
"""Reads the serial number from a Centurion device via DeviceInfo (0x0100) function 2."""
|
|
try:
|
|
report = device.feature_request(SupportedFeature.CENTURION_DEVICE_INFO, 0x20)
|
|
except exceptions.FeatureCallError:
|
|
return None
|
|
if not report or len(report) < 2:
|
|
return None
|
|
str_len = report[0]
|
|
return report[1 : 1 + str_len].decode("ascii", errors="replace").rstrip("\x00")
|
|
|
|
|
|
def get_hardware_info_centurion(device):
|
|
"""Reads hardware info from a Centurion device via DeviceInfo (0x0100) function 0.
|
|
|
|
Returns (modelId, hardwareRevision, productId) or None.
|
|
"""
|
|
try:
|
|
report = device.feature_request(SupportedFeature.CENTURION_DEVICE_INFO)
|
|
except exceptions.FeatureCallError:
|
|
return None
|
|
if not report or len(report) < 4:
|
|
return None
|
|
model_id = report[0]
|
|
hw_revision = report[1]
|
|
product_id = struct.unpack("!H", report[2:4])[0]
|
|
return model_id, hw_revision, product_id
|
|
|
|
|
|
def _centurion_sub_device_info_request(device, function=0x00, *params):
|
|
"""Send a DeviceInfo (0x0100) request to the sub-device via bridge."""
|
|
sub_indices = getattr(device, "_centurion_sub_indices", {})
|
|
sub_idx = sub_indices.get(SupportedFeature.CENTURION_DEVICE_INFO)
|
|
if sub_idx is None:
|
|
return None
|
|
return device.centurion_bridge_request(sub_idx, function, *params)
|
|
|
|
|
|
def get_firmware_centurion_sub(device):
|
|
"""Reads firmware info from the Centurion sub-device (headset) via bridge."""
|
|
from . import common
|
|
|
|
fw = []
|
|
seen = set()
|
|
for index in range(0, 8):
|
|
report = _centurion_sub_device_info_request(device, 0x10, index)
|
|
if not report or len(report) < 5:
|
|
break
|
|
sig = bytes(report[: 5 + report[4]])
|
|
if sig in seen:
|
|
break
|
|
seen.add(sig)
|
|
fw_type = report[0]
|
|
version = struct.unpack("!H", report[2:4])[0]
|
|
name_len = report[4]
|
|
name = report[5 : 5 + name_len].decode("ascii", errors="replace").rstrip("\x00") if name_len else ""
|
|
version_str = f"{version >> 8}.{version & 0xFF:02d}"
|
|
kind = FirmwareKind(fw_type) if fw_type <= 3 else FirmwareKind.Other
|
|
fw.append(common.FirmwareInfo(kind, name, version_str, None))
|
|
return tuple(fw) if fw else None
|
|
|
|
|
|
def get_serial_centurion_sub(device):
|
|
"""Reads the serial number from the Centurion sub-device (headset) via bridge."""
|
|
report = _centurion_sub_device_info_request(device, 0x20)
|
|
if not report or len(report) < 2:
|
|
return None
|
|
str_len = report[0]
|
|
return report[1 : 1 + str_len].decode("ascii", errors="replace").rstrip("\x00")
|
|
|
|
|
|
def get_hardware_info_centurion_sub(device):
|
|
"""Reads hardware info from the Centurion sub-device (headset) via bridge.
|
|
|
|
Returns (modelId, hardwareRevision, productId) or None.
|
|
"""
|
|
report = _centurion_sub_device_info_request(device)
|
|
if not report or len(report) < 4:
|
|
return None
|
|
model_id = report[0]
|
|
hw_revision = report[1]
|
|
product_id = struct.unpack("!H", report[2:4])[0]
|
|
return model_id, hw_revision, product_id
|
|
|
|
|
|
def get_name_centurion(device):
|
|
"""Reads a Centurion device's name via DeviceName (0x0101).
|
|
|
|
Tries two response formats:
|
|
1. Inline: function 0 returns [name_len, name_bytes...] (like serial)
|
|
2. Chunked: function 0 returns [name_len], function 1 returns [name_bytes...] (like standard DeviceName)
|
|
"""
|
|
try:
|
|
reply = device.feature_request(SupportedFeature.CENTURION_DEVICE_NAME)
|
|
except exceptions.FeatureCallError:
|
|
return None
|
|
if not reply:
|
|
return None
|
|
name_length = reply[0]
|
|
if name_length == 0:
|
|
return None
|
|
# If the full name is inline (length + name bytes in one response)
|
|
if len(reply) >= 1 + name_length:
|
|
return reply[1 : 1 + name_length].decode("utf-8", errors="replace").rstrip("\x00")
|
|
# Otherwise, fetch name in chunks via function 1 (like standard DEVICE_NAME)
|
|
name = b""
|
|
while len(name) < name_length:
|
|
try:
|
|
fragment = device.feature_request(SupportedFeature.CENTURION_DEVICE_NAME, 0x10, len(name))
|
|
except exceptions.FeatureCallError:
|
|
break
|
|
if fragment:
|
|
name += fragment[: name_length - len(name)]
|
|
else:
|
|
break
|
|
return name.decode("utf-8", errors="replace").rstrip("\x00") if name else None
|
|
|
|
|
|
def get_battery_centurion(device):
|
|
"""Query battery via CENTURION_BATTERY_SOC."""
|
|
try:
|
|
report = device.feature_request(SupportedFeature.CENTURION_BATTERY_SOC)
|
|
if report is not None:
|
|
return decipher_battery_centurion(report)
|
|
except exceptions.FeatureCallError:
|
|
if SupportedFeature.CENTURION_BATTERY_SOC in device.features:
|
|
return SupportedFeature.CENTURION_BATTERY_SOC
|
|
return None
|
|
|
|
|
|
def decipher_battery_centurion(report) -> tuple[SupportedFeature, Battery]:
|
|
"""Decipher CENTURION_BATTERY_SOC (0x0104) response.
|
|
|
|
Response format (3 bytes):
|
|
Byte 0: Battery Percentage (0-100)
|
|
Byte 1: Battery Percentage (duplicate)
|
|
Byte 2: Charging Status (0=discharging, 1=charging, 2=charging via USB, 3=charge complete)
|
|
"""
|
|
if len(report) < 1:
|
|
return SupportedFeature.CENTURION_BATTERY_SOC, Battery(None, None, BatteryStatus.DISCHARGING, None)
|
|
soc = report[0]
|
|
logger.debug("centurion battery SOC raw: %s", report[:8].hex())
|
|
charging_status = report[2] if len(report) >= 3 else 0
|
|
if charging_status in (1, 2):
|
|
status = BatteryStatus.RECHARGING
|
|
elif charging_status == 3:
|
|
status = BatteryStatus.FULL
|
|
else:
|
|
status = BatteryStatus.DISCHARGING
|
|
return SupportedFeature.CENTURION_BATTERY_SOC, Battery(soc, None, status, None)
|
|
|
|
|
|
# --- CenturionReceiver class ---
|
|
|
|
|
|
class CenturionReceiver:
|
|
"""A lightweight receiver-like container for Centurion (PRO X 2 LIGHTSPEED) dongles.
|
|
|
|
Provides the Receiver interface to the UI so the dongle appears as a parent
|
|
with the headset as an indented child device. NOT a subclass of Receiver —
|
|
Receiver's __init__ does HID++ 1.0 register reads and pairing setup that
|
|
don't apply to Centurion.
|
|
|
|
All centurion communication (bridge, features, settings, battery) lives in
|
|
the child Device; this class is just a UI container + handle owner.
|
|
"""
|
|
|
|
read_register: Callable = hidpp10.read_register
|
|
write_register: Callable = hidpp10.write_register
|
|
number = 0xFF
|
|
kind = None
|
|
isDevice = False
|
|
may_unpair = False
|
|
re_pairs = False
|
|
max_devices = 1
|
|
|
|
def __init__(self, low_level, handle, device_info, setting_callback=None):
|
|
assert handle
|
|
self.low_level = low_level
|
|
self.handle = handle
|
|
self.path = device_info.path
|
|
self.product_id = device_info.product_id
|
|
self.setting_callback = setting_callback
|
|
self.status_callback = None
|
|
self.notification_flags = None
|
|
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
|
|
self.serial = None
|
|
self._usb_name = getattr(device_info, "product", None)
|
|
if not self._usb_name and self.path:
|
|
self._usb_name = _read_usb_product_string(self.path)
|
|
# User-facing name: "Centurion" is Logitech's internal codename for this
|
|
# headset-dongle transport, kept in code/logs but not shown to users.
|
|
self.name = "Lightspeed Headset Receiver"
|
|
|
|
# Dummy pairing object — lock_open stays False
|
|
from .receiver import Pairing
|
|
|
|
self.pairing = Pairing()
|
|
|
|
# Discover dongle features independently
|
|
self._discover_dongle_features()
|
|
|
|
# Read serial from dongle's CENTURION_DEVICE_INFO if available
|
|
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
|
|
|
|
def enable_connection_notifications(self, enable=True):
|
|
return False
|
|
|
|
def remaining_pairings(self, cache=True):
|
|
return None
|
|
|
|
def device_codename(self, n):
|
|
return self._usb_name
|
|
|
|
def request(self, request_id, *params, no_reply=False):
|
|
"""Send an HID++ request directly to the dongle (not through bridge)."""
|
|
if self.handle:
|
|
return self.low_level.request(
|
|
self.handle, 0xFF, request_id, *params, no_reply=no_reply, long_message=True, protocol=2.0
|
|
)
|
|
|
|
def feature_request(self, feature, function=0x00, *params, no_reply=False):
|
|
"""Send a feature request to the dongle using discovered feature indices."""
|
|
if self._dongle_features is None:
|
|
self._discover_dongle_features()
|
|
feature_int = int(feature)
|
|
for _feat, feat_id, index in self._dongle_features or []:
|
|
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=feature)
|
|
|
|
def _discover_dongle_features(self):
|
|
"""Independently discover features on the dongle hardware."""
|
|
self._dongle_features = []
|
|
try:
|
|
# Query ROOT for FEATURE_SET index
|
|
response = self.request(0x0000, 0x00, 0x01)
|
|
if response is None or response[0] == 0:
|
|
return
|
|
fs_index = response[0]
|
|
# Get feature count
|
|
count_resp = self.request(fs_index << 8)
|
|
if count_resp is None:
|
|
return
|
|
feature_count = count_resp[0]
|
|
# Enumerate features via CenturionFeatureSet (func 1 = 0x10, per-index query)
|
|
for idx in range(feature_count):
|
|
resp = self.request((fs_index << 8) | 0x10, idx)
|
|
if resp is None or len(resp) < 3:
|
|
continue
|
|
feat_id = struct.unpack("!H", resp[1:3])[0]
|
|
try:
|
|
feature = SupportedFeature(feat_id)
|
|
except ValueError:
|
|
feature = f"unknown:{feat_id:04X}"
|
|
self._dongle_features.append((feature, feat_id, idx))
|
|
if logger.isEnabledFor(logging.DEBUG):
|
|
logger.debug("Centurion dongle features: %s", self._dongle_features)
|
|
except Exception:
|
|
logger.debug("Centurion dongle feature discovery failed", exc_info=True)
|
|
|
|
@property
|
|
def dongle_features(self):
|
|
"""Return list of (feature, feat_id, index) tuples for dongle features."""
|
|
if self._dongle_features is None:
|
|
self._discover_dongle_features()
|
|
return self._dongle_features
|
|
|
|
def count(self):
|
|
return len([d for d in self._devices.values() if d is not None])
|
|
|
|
@property
|
|
def firmware(self):
|
|
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)
|
|
|
|
# Create child Device with receiver=self, number=1
|
|
pairing_info = {
|
|
"wpid": self.product_id,
|
|
"kind": hidpp10_constants.DEVICE_KIND.headset, # every Centurion-transport device so far is a headset
|
|
"serial": None,
|
|
"polling": None,
|
|
"power_switch": None,
|
|
}
|
|
dev = Device(
|
|
self.low_level,
|
|
self,
|
|
1,
|
|
None,
|
|
pairing_info=pairing_info,
|
|
setting_callback=self.setting_callback,
|
|
)
|
|
# Set centurion attributes on the child
|
|
dev.centurion = True
|
|
dev.product_id = self.product_id
|
|
dev.hidpp_long = True
|
|
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 == CenturionCoreFeature.CENT_PP_BRIDGE:
|
|
dev._centurion_bridge_index = idx
|
|
break
|
|
|
|
self._devices[1] = dev
|
|
configuration.attach_to(dev)
|
|
dev.status_callback = self.status_callback
|
|
|
|
# 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)
|
|
|
|
def changed(self, alert=Alert.NOTIFICATION, reason=None):
|
|
if self.status_callback is not None:
|
|
self.status_callback(self, alert=alert, reason=reason)
|
|
|
|
def status_string(self):
|
|
count = self.count()
|
|
if count == 0:
|
|
return "No devices."
|
|
return f"{count} device connected."
|
|
|
|
def close(self):
|
|
handle, self.handle = self.handle, None
|
|
for _n, d in self._devices.items():
|
|
if d:
|
|
d.close()
|
|
self._devices.clear()
|
|
for cleanup in self.cleanups:
|
|
cleanup(self)
|
|
return handle and self.low_level.close(handle)
|
|
|
|
def __del__(self):
|
|
self.close()
|
|
|
|
def __iter__(self):
|
|
for dev in self._devices.values():
|
|
if dev is not None:
|
|
yield dev
|
|
|
|
def __getitem__(self, key):
|
|
dev = self._devices.get(key)
|
|
if dev is not None:
|
|
return dev
|
|
raise IndexError(key)
|
|
|
|
def __len__(self):
|
|
return len([d for d in self._devices.values() if d is not None])
|
|
|
|
def __contains__(self, dev):
|
|
if isinstance(dev, int):
|
|
return self._devices.get(dev) is not None
|
|
return self.__contains__(dev.number)
|
|
|
|
def __bool__(self):
|
|
return self.handle is not None
|
|
|
|
__nonzero__ = __bool__
|
|
|
|
def __eq__(self, other):
|
|
return other is not None and self.kind == other.kind and self.path == other.path
|
|
|
|
def __ne__(self, other):
|
|
return other is None or self.kind != other.kind or self.path != other.path
|
|
|
|
def __hash__(self):
|
|
return self.path.__hash__()
|
|
|
|
def __str__(self):
|
|
return "<%s(%s,%s%s)>" % (
|
|
self.name.replace(" ", "") if self.name else "CenturionReceiver",
|
|
self.path,
|
|
"" if isinstance(self.handle, int) else "T",
|
|
self.handle,
|
|
)
|
|
|
|
__repr__ = __str__
|
|
|
|
|
|
def create_centurion_receiver(low_level, device_info, setting_callback=None):
|
|
"""Opens a Centurion dongle and wraps it as a receiver-like container.
|
|
|
|
Creates a CenturionReceiver, discovers its features, then checks if
|
|
CentPPBridge (0x0003) is among them. If not, this is a direct-connected
|
|
device — wired headset, or a Bluetooth-paired Centurion headset where
|
|
there is no separate dongle. Close and return None so the caller can
|
|
fall back to create_device().
|
|
|
|
:returns: A CenturionReceiver, or None.
|
|
"""
|
|
try:
|
|
handle = low_level.open_path(device_info.path)
|
|
if 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 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:
|
|
raise e
|
|
except Exception as e:
|
|
logger.exception("open %s", device_info)
|
|
raise e
|