The HEADSET_SIGNATURE_EFFECTS_ALLOWED allowlist was keyed on "33", a
model byte no real G522 reports. DeviceInfo (0x0100) func 0 returns
0x32 for the G522 (0x44 for the G325) — confirmed against every saved
diagnostic log, including our own development unit. The 0x33/0x45
values came from the protocol doc, which had both transcribed
off-by-one (a shifted read of a G HUB USB capture).
Effect: the SOLAAR_EXPERIMENTAL masking suppressed the headset
signature-effect settings on every G522, not just unvalidated models.
Re-key to "32" and correct the modelId comments.
NVconfig-saved colors (0x8071 RGBEffects boot effects, 0x0622 HeadsetRGB
signature effects) persist to device storage, so an unvalidated control
can durably misconfigure a device. Gate them behind a per-model
allowlist: every field is hidden and every slot suppressed unless the
exact model is known-good. SOLAAR_EXPERIMENTAL=true bypasses the masking
for testers. Non-persistent effect parameters (zone effects, LED
directions) keep their default-allow blocklist — unchanged.
device_quirks.py is rewritten around two per-feature allowlists with
their own accessors, replacing the flat blocklist QUIRKS dict.
Centurion device identification: _get_ids_centurion now derives modelId
from the firmware-stable model_id byte (G522 0x33, G325 0x45) instead of
productId, which is shared across the headset family and varies by
firmware (G522 0x0508 -> 0x0509) — it could never key a quirk reliably.
Approved models: G502 X PLUS and G515 TKL for the 0x8071 boot effects,
G522 for the 0x0622 signature effects (startup primary only, shutdown
both colors, no speed, passive slot suppressed pending RE).
When a receiver-paired mouse/keyboard is plugged in directly over USB,
it has no receiver to supply a codename. If it also lacks the
DEVICE_FRIENDLY_NAME feature (0x0007), the codename property fell back
to name.split(" ")[0] — truncating a good name like "G502 X PLUS" to
"G502" at the first space.
Use the full live device name instead, only dropping a leading
"Logitech" word. The live name is always accurate to the current
connection — unlike a persisted copy, which can go stale for devices
that report mode-variant names (e.g. a headset's "- Wireless Mode" vs
"- Wired Mode"). This matches what the centurion branch already does.
* device: seed Centurion device kind=headset at construction
Centurion-transport devices have no static descriptor and are not
receiver-paired, so their kind was only resolved by an online feature
scan. A headset powered off when Solaar started showed no kind, hence
no headphone icon in the tray/window — unlike receiver-paired mice and
keyboards, whose kind comes from the receiver's persistent pairing
registers.
Every Centurion-transport device seen so far is a headset, and the
centurion flag is known at construction time. Seed _kind=headset there
so the icon is correct offline and on first run. Drop the now-dead
online _infer_kind_centurion() feature scan and its Centurion branch
in the kind property.
* centurion: seed child headset kind via pairing_info
The construction-time kind=headset seeding only covered the direct
create_device path. G522-style devices reach a CenturionReceiver,
whose notify_devices() builds the headset as a child Device with
device_info=None and sets dev.centurion=True only after __init__ —
so the __init__ guard never fired and the child kept kind=None.
Set kind=headset in the pairing_info dict the receiver already passes
into Device.__init__, which covers the bridge path. Both paths now
seed the kind offline.
Software-managed LED persistence and power management for devices that
expose RGBEffects (0x8071) — primarily G515 LIGHTSPEED TKL, but the same
infrastructure works on any 0x8071 device that supports SW takeover.
Core mechanism: RGBControl toggle drives a Set SWControl(mode=3, flags)
handshake. While SW control is held, the host owns the LED pipeline: zone
effects, per-key paint, idle/sleep transitions, and the NvConfig boot/exit
animations. On release, the firmware resumes its onboard profile.
Major pieces:
- rgb_power.py — new module hosting the software RGB power manager:
ACTIVE / DIMMING / IDLE / SLEEPING state machine driven by firmware
onUserActivity events, smooth 5-second dim ramp (zone or per-key), idle
Static color snap, software sleep timer, wake handler that re-pushes
saved state. Includes the cleanup hook that runs on device close (and,
optionally, fires the cap 0x0040 shutdown trigger).
- RGBControl (settings_templates) — switch-style render via BooleanValidator
(true_value=3 / false_value=0) plus a full _claim_sw_control /
_release_sw_control pair: profile-management mode, SetSWControl, per-key
flag reset, manager start, cleanup registration, and a post-claim
repaint pass so the device immediately reflects Solaar's saved zone +
per-key state.
- RGBEffectSetting — zone-effect Setting subclass for 0x8071. Handles
per-key/zone coexistence: per-key paint dominates only when zone is
Static and the user has explicitly opted in via the lock icon; under
animations or before opt-in, the zone wire push is the visible layer.
- RGBIdleEffect / RGBIdleTimeout / RGBSleepTimeout — Solaar-managed idle
behavior. Choice list: "No change" → Dim → Static (snap to color) →
device-specific animations. Static idle substitutes the idle color for
unset per-key cells via effective_zone_base_color's state-aware lookup.
- RgbStartupAnimation / RgbShutdownAnimation — toggle-and-color rows for
RGBEffects NvConfig caps 0x0001 and 0x0040, exposed only on devices
that answer the probe. Shutdown trigger fires SetRgbPowerMode(0) at
cleanup time so the firmware plays the configured animation on exit.
- PerKeyLighting — per-key painter improvements: explicit-opt-in
dominance over zone effects, BUSY retry, FrameEnd suppression on
per-cell failure, single-shot prep sequence (SetEffectByIndex into the
out-of-range slot) on mice with firmware effect cards.
- device_quirks.py — small per-model quirks table keyed by device.modelId
(stable across USB/BLE/wireless). Currently used to mark RGBEffects
NvConfig color slots as inert on devices where the firmware accepts
but ignores the bytes (G502 X PLUS startup colors).
- config_panel.py — HeteroKeyControl gains a TOGGLE field kind that
renders as a Gtk.Switch (used by the boot-effect rows). Visual gate
greys out RGB settings whose prerequisites aren't met: rgb_zone_* /
rgb_idle_* / rgb_sleep_timeout require LED Control = Solaar; per-key
additionally requires every zone effect to be Static. Visual-only —
doesn't touch the persister's user lock-icon state.
- tests/test_rgb_power.py — coverage for the power manager state
machine, dim ramp, idle effects, wake path, and per-key/zone
coexistence.
Closespwr-Solaar/Solaar#3149.
When a receiver-paired device goes offline, update its battery status
to OFFLINE so the tray and UI show "Battery: 58% (offline)" instead of
the stale "Battery: 58% (discharging)". The last known percentage is
preserved as useful context.
Only applies to devices paired through a receiver (Unifying, Bolt,
Lightspeed) which maintain a persistent connection while available.
Bluetooth devices that disconnect during idle keep their prior status
since going offline is normal power-saving behavior for them.
Add BatteryStatus.OFFLINE (0xFF) as a Solaar-internal value (not a
HID++ protocol value). Fresh battery data from read_battery() replaces
it when the device reconnects.
* Fix WIRELESS_DEVICE_STATUS reconfiguration: push settings and ack with proper cookie
When a device sends WIRELESS_DEVICE_STATUS with config_needed=1, the host
should re-push settings and acknowledge via ConfigChange SetComplete. The
existing code relied on the push flag in changed(), but operator precedence
caused push=True to be ignored for devices that have the WIRELESS_DEVICE_STATUS
feature — the exact devices that send this notification.
Handle the reconfiguration entirely in the notification handler: explicitly
push settings and ack, rather than trying to overload the changed() condition.
Also replace hardcoded cookie 0x11 with proper GetCookie/SetComplete protocol:
read the device's current configuration cookie and echo it back, per the
ConfigChange (0x0020) specification.
* WIRELESS_DEVICE_STATUS reconfig: gate apply on ConfigChange cookie
Add a cookie-comparison gate so devices that emit multiple reconfig
notifications (PRO X 2 sends two on power-on) don't get their settings
re-applied for every one. The gate also benefits any path that calls
apply_settings_if_needed — devices that retain config through
power-save now skip the redundant apply.
New flow:
- Device.apply_settings_if_needed() reads ConfigChange (0x0020) cookie
via GetCookie. If the live value matches the cookie we stored after
the last successful sync (persister key `_config_cookie`), the
device hasn't drifted and we skip apply_all_settings entirely. On a
real apply, SetComplete echoes the cookie back and we persist it.
Devices without CONFIG_CHANGE bypass the gate (always apply).
- device.changed(active=True) calls apply_settings_if_needed in place
of the old apply_all_settings + signal_configuration_complete pair.
- WIRELESS_DEVICE_STATUS reconfig handler drops the redundant explicit
apply + ack (changed() already handles it on transition to active)
and instead calls apply_settings_if_needed to cover the case of
follow-up reconfig notifications on an already-active device — the
cookie gate makes the second/third call essentially free.
Protocol cleanup:
- set_configuration_complete no longer auto-increments the cookie
before echoing. The device owns the cookie and bumps it when its
config drifts; the host's job is to confirm which value it synced
with, not to advance the value. Host-side increment introduced a
needless race with device-side bumps.
- signal_configuration_complete gains an optional cookie= arg so the
caller can pass a value it already read (saves a redundant GetCookie
round-trip from inside the gated apply path).
Tests:
- get_configuration_cookie returns the two-byte cookie from fn 0.
- set_configuration_complete echoes a provided cookie unchanged.
- set_configuration_complete with cookie=None reads the live cookie
and echoes it (no increment).
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