Centurion feature discovery now logs each parent + sub-device feature at
INFO with name/index/version/flags. `check_feature` logs INFO when it
skips a setting for min_version or the INTERNAL flag. HeadsetAdvancedEQ
and HeadsetOnboardEQ `build()` paths log at every failure branch. The
three AdvancedParaEQ helpers log raw response bytes.
On the G522, `HEADSET_ADVANCED_PARA_EQ` (0x020D) is present in the
feature set but no settings panel appears and current logging gives us
no way to tell which step silently returns None.
Field testing revealed cross-contamination between function calls on
the same sub-device feature. A late-arriving MessageEvent for
GetRGBZoneInfo (function 1) was being accepted as the response to a
subsequent GetHostModeState (function 7) on the same feature (0x0620),
because _is_bridge_response_for only matched on sub_feat_idx.
Evidence from tester log:
HeadsetRGBHostMode.write: before=b'\x08\x01\x02\x03\x04\x05\x06\x07\x08...'
The "before" read of GetHostModeState returned what is clearly a
GetRGBZoneInfo response (count=8, zones=[1..8]) queued from earlier.
The device echoes our exact sub-device function byte (function<<4 | sw_id)
in the response. Plumb that expected value from centurion_bridge_request
through _is_bridge_response_for and reject any response whose
sub_func_sw doesn't match. Also validate orig_func_sw for error responses.
Also removes the zone_id==0 filter in HeadsetRGBColor._zone_ids — with
proper response matching the device should now consistently report real
zone IDs (G522 has 8 zones at IDs 1-8 per the delayed response capture),
and filtering is no longer needed.
If the HeadsetMicGain GetInfo probe goes wrong, we were silently
falling back to the int8 default range with no log. Now we log each
fallback path distinctly so field diagnostics can tell:
- Exception during GetInfo (transport error)
- GetInfo returned non-None but too short (truncated response)
- GetInfo returned nonsense range (max <= min, probably wrong feature
format)
Separately, centurion_bridge_request now logs outgoing sub-messages at
INFO level showing sub_idx, function, sw_id, and payload hex. This
pairs with the existing "bridge sub-device error" INFO log so when a
NACK fires we can see both the rejected value and the device's error
code in one place. Verbose during normal operation — dial back to
DEBUG once the G522 writes are confirmed working.
The feature_request path for Centurion sub-device features routes
through centurion_bridge_request, which returned None silently when
the sub-device replied with sub_feat_idx=0xFF (error marker). The
existing log was at DEBUG, invisible for users whose -dd doesn't
turn on DEBUG-level output.
The write-returned-no-reply INFO we added recently caught mic-gain /
sidetone / auto-sleep writes failing with no visible log about WHY.
Bumping this log to INFO surfaces the original feat_idx, function,
and error code so we can distinguish transport timeout vs device
rejection, and debug what exactly the G522 dislikes about our writes.
No behavior change — same return value (None), just more visible log.
IntEnum members with the same int value hash equal and compare equal,
so a dict lookup for SupportedFeature.DEVICE_NAME (0x0005) finds an
entry stored as CenturionCoreFeature.MULTI_HOST_CONTROL (same 0x0005).
The index is right for the Centurion feature but wrong for the HID++
2.0 feature the caller intended.
Concrete impact: `solaar show` on wired G522 called get_kind()
-> feature_request(DEVICE_NAME, 0x20), which resolved to
MULTI_HOST_CONTROL.function_2 via the collision, and the device
returned OUT_OF_RANGE -> FeatureCallError crashed `solaar show`.
Fix: in Device.feature_request, after resolving the index, compare the
type of the stored inverse entry against the type of the requested
feature. Mismatched types mean the device actually has the Centurion
variant, not the HID++ 2.0 feature — return None instead of issuing a
mis-targeted request.
Device.__init__ creates self.features as an empty dict ({}) when the
reported _protocol is <2.0, reserving FeaturesArray for HID++ 2.0.
The protocol floor in the protocol property only helps code paths that
access the property (e.g. feature_request's `if self.protocol >= 2.0`).
But __init__ reads the raw self._protocol attribute, so a wired G522
(reports 1.1) ends up with self.features = {}.
When solaar show later triggers `self.features._check()` inside
feature_request, the dict has no _check method → AttributeError →
crashes `solaar show` for the wired G522.
Fix: use FeaturesArray unconditionally when the device is Centurion.
The protocol version reported by these dongles is cosmetic — all
Centurion devices speak HID++ 2.0 features.
Three interrelated fixes for G522 LIGHTSPEED headset support:
1. Deferred init for silent 0x50 dongles: When the probe fails and
feature discovery returns nothing (dongle silently drops all frames
with device_addr=0x00), return a "pending" CenturionReceiver instead
of falling through to the broken create_device direct-device path.
The listener thread starts reading; when the first unsolicited frame
arrives, _unwrap_centurion_frame learns device_addr, the notification
handler detects the pending state, and re-runs feature discovery with
the correct address — finding the bridge and creating the child device.
2. Centurion protocol version floor: The G522 dongle reports protocol
1.1 (major=1, minor=1), which routes all protocol < 2.0 gates into
HID++ 1.0 code paths (battery register reads, etc.) that crash with
INVALID_SUB_ID_COMMAND. Centurion devices always use HID++ 2.0
features, so the protocol property now returns 2.0 as a floor for
any device with centurion=True.
3. CI segfault fix: Mock probe_centurion_device_addr in the two
Centurion device tests so fake handles never reach the real hidapi
C extension on macOS.
Temporary diagnostics for field-testing the probe fix. Each step of
the probe loop logs attempt number, bytes received, and the first 4
bytes of the RX frame so a `solaar -dd` trace shows exactly what the
device returned (or didn't).
Also adds a WARNING log in Device.battery() when it falls through to
the HID++ 1.0 path on a Centurion device. If that warning ever fires
in the wild, it means the probe silently failed and the next step will
be the register read that returns INVALID_SUB_ID_COMMAND — a direct
breadcrumb from cause to crash.
Revert this commit once we've confirmed the probe works reliably on
reporter hardware.
The 0x50 variant requires a device-specific address byte at frame[1]
on every TX frame. Until now we left state.device_addr=None and sent
0x00 as a placeholder, relying on the device to either be permissive
enough to respond, or to send an unsolicited frame early enough for
_unwrap_centurion_frame to learn the address passively.
Strict firmware silently drops device_addr=0x00 requests, which breaks
dongle feature discovery before it can start: _discover_dongle_features
times out, has_bridge is False, create_centurion_receiver falls through
to create_device, Device.__init__ leaves _protocol=None, and a later
read_battery() dispatches HID++ 1.0 read_register(BATTERY_CHARGE) that
the dongle rejects with INVALID_SUB_ID_COMMAND.
Port strain08's fix from LGSTrayEx (commits 1439b27a + c6d21972): right
after registering a 0x50 handle, write a 64-byte all-zero frame with
just the report ID set. That elicits an error/unsolicited response
whose byte[1] is the real device address. Read up to 3 x 500ms until
a matching frame arrives, then store the address on the handle state
so subsequent TX frames carry it correctly.
On timeout the probe logs a warning and leaves device_addr=None, so
behavior falls back to the current 0x00-placeholder path (no regression
for devices where the probe isn't needed). The passive learn-on-first-RX
in _unwrap_centurion_frame is preserved as a second line of defense.
The G522 uses a Centurion protocol variant with report ID 0x50 that adds
a device address byte at frame position [1], shifting all CPL fields by
+1 compared to the PRO X 2's 0x51 variant. This commit adds transport-
layer support for both variants while consolidating per-handle centurion
state into a single CenturionHandleState dataclass.
Key changes:
- Consolidate _centurion_handles (set) and _centurion_protocol_versions
(dict) into a single dict[int, CenturionHandleState] keyed by handle
- Add _unwrap_centurion_frame() helper that auto-detects 0x50 vs 0x51
from raw frame bytes and learns the device address on first RX
- Add _centurion_frame_header() to build the correct TX header per variant
- Detect both report IDs in udev report descriptor parsing
- Adjust bridge fragment chunk sizes for 0x50's extra header byte
- Propagate full CenturionHandleState when opening per-thread handles
Wire format verified against G522 diagnostic logs from LGSTrayEx#15.
Read HID++ feature 0x4540 KeyboardLayout to detect the device's country
code, then route the per-key painter to a matching regional layout.
Changes:
- lib/logitech_receiver/hidpp20.py: new get_keyboard_layout() returning the
HID Usage Table country code from feature 0x4540's first response byte.
- lib/logitech_receiver/device.py: lazy device.keyboard_layout property,
guarded by feature presence so devices without 0x4540 don't pay a query
cost on access.
- lib/solaar/ui/perkey/control.py: thread the country code into the editor
hint dict.
- lib/solaar/ui/perkey/layouts/_keyboard_base.py (new): factor out the
function row, nav cluster, and numpad block as shared building blocks.
Two main-block variants (ANSI with row 2 col 13 backslash, ISO without)
cover all five regions. build_layout() applies per-zone label overrides
on top of either main block.
- lib/solaar/ui/perkey/layouts/keyboard_ansi.py: refactored to use the
builder; same LAYOUT_FULL/LAYOUT_TKL exports.
- lib/solaar/ui/perkey/layouts/keyboard_iso_qwerty.py (new): UK English
ISO. Same shape as DE/FR/JIS but no label overrides.
- lib/solaar/ui/perkey/layouts/keyboard_iso_qwertz.py (new): DE/Swiss --
Y/Z swap, Ü/Ö/Ä/ß placement.
- lib/solaar/ui/perkey/layouts/keyboard_iso_azerty.py (new): FR -- A↔Q,
W↔Z, French digit-row symbols, M repositioning.
- lib/solaar/ui/perkey/layouts/keyboard_jis.py (new): JP -- @ / [ / :
bracket-row relabels.
- lib/solaar/ui/perkey/layouts/__init__.py: country-code-aware matchers,
five families × two sizes (full/TKL). Defaults to ANSI when 0x4540 is
unsupported or returns an unknown code.
POUND, ISO_BACKSLASH, and the L-shape Enter top half (zone 46) are
intentionally omitted from the ISO layouts -- same coverage as OpenRGB.
ABNT2 (Brazilian) deferred until a confirmed Logitech BR RGB device shows
up; adding it later is one new layout file plus a country-code entry.
Also fix copyright headers on all new lib/solaar/ui/perkey/ files: the
files were created in 2026, not 2024 as the headers said.
* Add Centurion transport and PRO X 2 LIGHTSPEED headset support
Adds support for the Logitech PRO X 2 LIGHTSPEED Gaming Headset (PID 0x0AF7)
which uses the Centurion transport protocol (report ID 0x51 on USB usage page
0xFFA0) instead of standard HID++ report IDs.
Changes:
- HID enumeration: detect Centurion devices via report descriptor parsing
(usage page 0xFFA0, report ID 0x51, 63-byte frames)
- Centurion transport: wrap/unwrap HID++ 2.0 frames in Centurion framing
for write, read, and ping operations
- Feature discovery: enumerate features individually on Centurion devices
(different response format: [remaining_count, feat_hi, feat_lo])
- Device descriptor for PRO X 2 LIGHTSPEED Gaming Headset
- New feature enum entries for Centurion-era headset features (0x06xx)
- CenturionRawRW class for write-only headset settings controlled via
raw Centurion commands reverse-engineered from HeadsetControl
- HeadsetSidetone setting (0-100 range, persisted locally)
Known limitations:
- Only sidetone control is implemented; other features need RE work
- Settings are write-only (no read-back from device)
- Headset features (0x06xx) not discoverable via IRoot; registered manually
* Remove static PRO X 2 descriptor; fully probe Centurion devices at runtime
Replace the hardcoded descriptor entry with dynamic discovery of all device
properties via the Centurion protocol. The headset name, kind, serial,
firmware, and battery are now probed at runtime — matching how the device
actually presents itself rather than relying on static data.
Key changes:
- Discover sub-device features via CentPPBridge and route requests through
the bridge automatically
- Infer device kind from feature IDs (0x06xx = headset) for both wireless
and direct USB connections
- Read device name from USB product string with protocol probe fallback
- Parse bridge error responses (sub_feat_idx=0xFF) instead of timing out
- Handle unknown HID++ error codes gracefully in base.py
- Fix firmware deduplication for Centurion parent devices
- Prefer sub-device serial/firmware over parent (non-printable) values
- Add Centurion-aware display in solaar show with parent/sub-device sections
- Support both wireless (0AF7 dongle) and direct USB (0AF8) connections
* Display Centurion dongle as receiver with headset as child device
- Add CenturionReceiver class that provides the Receiver UI interface so
the dongle appears as a parent with the headset indented underneath,
matching how Lightspeed/Unifying receivers display
- Independently probe dongle features via feature_request() on the
CenturionReceiver, separate from headset features via bridge
- Fix bridge notification dispatch: remove incorrect sub_cpl=0xFF filter
that was silently dropping all battery and other notifications
- Fix battery status decoding: charging status is at byte 2 (not byte 1)
of the CENTURION_BATTERY_SOC response
- Detect wired vs wireless by checking for CentPPBridge in discovered
features; wired headsets fall back to standalone Device
- Name the dongle "Centurion Receiver" to distinguish from the headset
- Filter unprintable dongle serial (control characters 0x14-0x1F)
- Update CLI show output with proper receiver/child hierarchy and spacing
* Fix headset setting validators and code formatting
- Add signed int8 support to RangeValidator for HeadsetMicGain (0x0611)
- Make HeadsetSidetone version-aware: v1 uses 2-byte skip, v2+ uses
3-byte skip with 0xFF separator per protocol spec
- Fix ruff formatting in device.py, listener.py, udev_impl.py
- Update CenturionReceiver test for renamed receiver
* Use ConnectionStateChangedEvent for headset online/offline detection
Replace ad-hoc heuristics with proper bridge event function dispatch:
- Function 0 (ConnectionStateChangedEvent): parse sub-device list length
to determine connect (len>0) vs disconnect (len=0)
- Function 1 (MessageEvent): fallback online detection if headset sends
a message while marked offline (handles cold-start power-on)
Remove CPL sub_id>=0x80 fallback in listener that misidentified HID++
error replies as disconnect events. Skip HID++ 1.0
set_configuration_pending_flags for CenturionReceiver (not supported).
Also adds OnboardEQ (0x0636) support, bridge multi-fragment sends,
bridge-based headset ping probe, and CLI offline display.
* Update PRO X 2 LIGHTSPEED device doc with current solaar show output
* Fix Centurion protocol version display (1.16 not 2.6)
The HID++ ping math (major + minor/10.0) produced a bogus "2.6" for
Centurion devices whose ProtocolCapabilities returns major=1, minor=0x10.
Store the raw (major, minor) bytes from the ping response and display
them correctly as "Centurion 1.16" in both CLI and GUI.
* Add OnboardEQ (0x0636) support for Centurion headsets
Implement host-computed biquad EQ coefficient generation and multi-fragment
bridge writes for the PRO X 2 LIGHTSPEED headset's 5-band parametric EQ.
The coefficient algorithm uses standard Audio EQ Cookbook peaking EQ formulas
with a simplified rescale normalization (max_b0 × 1.19 headroom). This is our
own implementation — not an exact replica of LGHUB's ~350-line per-band cascade
normalization — but it produces functionally correct results. The DSP
compensates via the rescale factor, and the EQ changes are audible and working
on real hardware.
Wire format verified against 38 LGHUB pcap writes:
- 4-byte LE section headers, LE uint16 coefficient words
- Mixed Q1.31/Q2.30 fixed-point with 24-bit precision
- Only b-coefficients divided by rescale; a-coefficients unchanged
- Two sections: 48kHz playback + 16kHz mic
- No trailing padding, no extra words between sections
Changes:
- base.py: Add flags parameter to write_centurion_cpl() for multi-fragment CPL
- device.py: Rewrite multi-fragment bridge send — proper CPL fragmentation with
fragment 0 carrying bridge prefix/hdr and continuations carrying raw sub_msg,
all fragments sent back-to-back without intermediate ACKs
- hidpp20.py: Replace placeholder coefficient code with full biquad math,
mixed Q-format quantization, rescale normalization, and dual-section output
- settings_templates.py: Persist EQ to slot 0x80 after writing to slot 0x00
so settings survive power cycle
- tests: Update expected SetEQParameters payloads for new coefficient format
* Extract Centurion protocol into separate modules
Move CenturionReceiver class, factory function, and Centurion protocol
queries (firmware, serial, hardware info, battery, name) from device.py
and hidpp20.py into new centurion.py module. Move OnboardEQ biquad math
and payload builders from hidpp20.py into new onboard_eq.py module.
Move _read_usb_product_string() to common.py to avoid circular imports.
Re-exports preserve backward compatibility for all existing callers.
* Add vertical graphic EQ slider widget for headset equalizer
Replace horizontal slider rows with a traditional graphic EQ layout
using vertical sliders side-by-side, with dB value display and
frequency labels per band.
* Fix device online state clobbered by debug ping in _status_changed
The INFO-level logging guard in _status_changed() called device.ping()
before logging, purely to show accurate online status. But ping() has
side effects — it sets device.online based on the result. When a
ConnectionStateChangedEvent correctly marked a device online, the
subsequent _status_changed() callback would re-ping. If the device
wasn't ready yet (e.g. Centurion headset still booting), the ping
timed out and set online back to False, requiring 2-3 power cycles
to sync state.
Remove the unnecessary ping — the log message already reads
device.online which reflects the state set by the event handler.
* Sort feature constants by ID and add PROFILE_MANAGEMENT
Move RPM_INDICATOR/RPM_LED_PATTERN (0x807A-B) before PER_KEY_LIGHTING
(0x8080-81), sort five Centurion-era headset entries into their correct
positions by feature ID, and add missing PROFILE_MANAGEMENT = 0x8101.
* 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.
* Fix ruff I001 import sorting in centurion.py and hidpp20.py
* Add 9 missing centurion/headset feature names
Add feature constants split out from the HID++ 2.0 names PR (#3153):
CENTURION_LED_BRIGHTNESS (0x0110), CENTURION_EU_POWER_MODE (0x0115),
CENTURION_DEVICE_BOOL_STATE (0x0116), HEADSET_ADVANCED_PARA_EQ (0x020D),
HEADSET_MIC_TEST (0x020E), HEADSET_EQ_STYLES (0x0213),
BT_HOST_INFO (0x0305), LIGHTSPEED_PAIRING (0x0309),
BT_GAMING_MODE (0x030A).
* Extract _record_ping_protocol helper so all ping paths capture Centurion version
The raw Centurion (major, minor) pickup was only in the Centurion-child
dongle branch of Device.ping(). Wired Centurion variants (e.g. PRO X 2
LIGHTSPEED 046d:0AF8) go through the generic fallback branch and never
recorded the raw version, so they displayed "Centurion 2.6" instead of
"Centurion 1.16".
Extract the protocol + centurion version recording into a helper and
call it from both branches.
---------
Co-authored-by: Peter F. Patel-Schneider <pfpschneider@gmail.com>
The Flag enum was applied the value method twice. remove the value
method call from the set_flag_bits in device.py. There is no such value
call in receiver.py set_flag_bits in the same commit so I believe this
was a mistake.
With this fix the LX7 mouse is properly enumerated over a Logitech
C-BT44 Receiver (seen as EX100, compatible 27MHz FastRF protocol)
Close#2850.
Fixes: 72c9dfc5 Remove NamedInts: Convert NotificationFlag to flag
* Refactor: test_named_ints_flag_names
Shorten test and clarify behavior using binary numbers.
* Introduce plain flag_names function
This replicates the NamedInts functionality as plain function.
* Refactor FeatureFlag to use IntFlag
Replace NamedInts implementation with IntFlag enum and plain flag_names
function.
Related #2273
* Refactor FirmwareKind to use IntEnum
- Move general FirmwareKind to common module.
- Replace NamedInts implementation with IntEnum.
- Harden related HIDPP 1.0 get_firmware test.
Related #2273
* Refactor CID_GROUP, CID_GROUP_BIT to use IntEnum
Related #2273
Warnings found by automatic code inspection and partially tackled
- Drop distuitls inf favour of setuptools
- Replace deprecated pyudev.Device.from_device_number
- Remove unnecessary brackets
- Avoid access to private variables etc.
- Shadows built-in name
- Line length >120 characters
- Not a module level variable
- Simplify clause
and more
Avoid direct access to hidapi and use the base module as low-level API
instead. This change replaces the remaining calls to find_paired_node
and find_paired_node_wpid by exposing them via base module.
* Refactor HID Register definitions
Use enums for distinct type hints, easy discovery of registers.
Make constants uppercase and benefit from enum auto-completion.
Related #2273
* Improve type hints: Registers