Commit Graph

2958 Commits

Author SHA1 Message Date
Ken Sanislo 41db76bc81 AdvancedParaEQ: add V2 wire-format support; keep V0/V1
G522's 0x020D V2 uses a 5-byte band stride
[freq_hi, freq_lo, gain_i8, q_hi, q_lo] and a 13-byte getEQInfos
(gain bounds, gain_steps, format, xy, preset counts). Frequency and
Q are opaque u16 round-trip values — the u16->Hz and u16->Q mappings
need a LGHUB pcap to pin down (documented in
~/ghub/HEADSET_ADVANCED_PARA_EQ_WIRE_PROTOCOL.md).

get_advanced_eq_info now returns a dict with a `version` discriminator
and the union of V0/V1 and V2 fields; step_db is derived from the
gain_min/gain_max/gain_steps triple on V2 (0.05 dB/LSB on G522).

get_advanced_eq_params version-switches: V2 uses parse_v2_bands which
probes header length {5, 2, 0} until the tail is a clean multiple of
5, strips trailing all-zero terminator entries. V0/V1 falls through
to the legacy 3-byte stride so older devices still work.

HeadsetAdvancedEQ.build() no longer requires band_count from
getEQInfos (V2 doesn't advertise it); derives from getCustomEQ length
per the wire-protocol doc's recommendation. V2 band labels use
"Band N" since u16->Hz isn't confirmed. Read-only still — writes
stay gated until pcap confirms the encodings.
2026-05-10 15:39:17 -07:00
Ken Sanislo 1bcee309d3 Fall back to FrameEnd 0x01; log _absent cache skips
FrameEnd 0x02 (persistent) returns LOGITECH_INTERNAL (0x05) on the G522
even after a successful SetHostModeState + SetRgbZonesSingleValue
sequence. Persistent commit probably requires onboard profile state
we haven't mapped. Use 0x01 (transient) so the LEDs at least refresh
visually; sort out persistence once we have a wireshark capture of the
LGHUB commit sequence.

check_feature_settings now logs at INFO when it skips a setting cached
in the persister's _absent list despite the feature being present on
the device. Without this, a previous run's failed build() silently
suppresses the setting forever — users see no panel and no diagnostic.
2026-05-10 15:39:17 -07:00
Ken Sanislo c45449b212 Add diagnostic logging for EQ setting build failures
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo c8a52ecd3c Reject bridge responses for wrong sub-device function
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo e09f0de107 HeadsetRGBColor: persist colors via FrameEnd 0x02, 0x01 for off
Further RE finding refines FrameEnd byte 0 semantics:
  0x02 = persistent commit — saves to onboard NVS as baseline, survives
         the firmware's host-mode self-release window (color sticks)
  0x01 = transient commit — for live preview/animation frames, doesn't
         wear out NVS; requires keepalive or continuous frames
  0x00 = silently discarded by firmware (the old bug)

For solaar's "pick a color and walk away" model, users expect the
color to persist. Use 0x02 when writing a real color. When writing
black (off), use 0x01 — matches LGHUB's turn_off_lighting so we don't
save an all-black baseline to the device's NVS.

This should make colors actually stay visible after the firmware
auto-releases host mode, which was the root cause of the "color
doesn't appear" symptom on the G522.
2026-05-10 15:39:17 -07:00
Ken Sanislo 309ec2baf1 HeadsetRGBColor: FrameEnd byte 0 must be 0x01, not 0x00
RE of lghub_agent.arm64 (see HEADSET_RGB_HOSTMODE_WIRE_PROTOCOL.md)
corrected the canonical protocol doc: FrameEnd byte 0 is a frame_type
tag where 0x01 = transient commit and 0x02 = persistent/final flush.
The firmware silently discards frames sent with byte 0 = 0x00 — the
HID++ ACK succeeds but the staged color writes are never committed.

This is why SetRgbZonesSingleValue appeared to succeed but LEDs never
changed color on the tester's G522.

The canonical doc's "For basic usage, all parameter bytes can be set
to 0x00" is wrong. Change FrameEnd payload from `\x00\x00\x00\x00` to
`\x01\x00\x00\x00`.

Also worth noting (not fixed here): firmware auto-releases the
host-mode claim if no Set+FrameEnd traffic arrives within a few
seconds. HeadsetRGBColor already re-issues SetHostModeState(1) on
every color change (the LGHUB "on-demand" model), so that covers the
common case. HeadsetRGBHostMode toggle alone will still appear to
"not stick" in GetHostModeState after the firmware release window —
that's the firmware's behavior, not a bug.
2026-05-10 15:39:17 -07:00
Ken Sanislo f1c8f22fbc Log GetHostModeState before/after write for RGB diagnostic
solaar show has been reporting headset-rgb-hostmode as False even
when SetHostModeState(1) just succeeded without error. Could be:
  (a) the write doesn't actually stick on the device, OR
  (b) our GetHostModeState response decode is wrong, OR
  (c) the device uses a different function for "read" than we expect.

Log the raw response bytes returned by function 7 (GetHostModeState)
immediately before and after the SetHostModeState write so the next
test log will show:
- what byte the device returns for "off"
- what byte the device returns for "on"
- whether the byte changes across a write

Diagnostic only — temporary; remove once we've isolated the cause.
2026-05-10 15:39:17 -07:00
Ken Sanislo a56d02a6fb HeadsetEcoMode: skip same-value writes to avoid G522 NACK 0x0B
RE of lghub_agent.arm64 revealed that LGHUB's service-layer handler
for 0x0618 HeadsetBatterySaverMode
(on_headset_battery_saver_set_handler @ 0x100c21790) compares the
requested new state against its cached current state and ONLY invokes
the devio SetEcoModeState write on a genuine transition. Wire format
is confirmed 0/1 (canonical doc correct); the G522 firmware rejects
no-op writes with device-specific NACK 0x0B.

BooleanValidator's prepare_write already contains the "skip if same as
current" branch — it just fires only when current_value is supplied,
which requires needs_current_value=True. For default-mask (0xFF)
validators, needs_current_value defaults to False so Setting.write
skips the pre-read and prepare_write gets current_value=None.

HeadsetEcoMode now builds its validator explicitly and forces
needs_current_value=True so Setting.write reads first, compares in
prepare_write, and skips redundant writes — matching LGHUB exactly.

Same fix may apply to other Centurion boolean features whose firmware
rejects no-op writes, but leaving those unchanged until observed.
2026-05-10 15:39:17 -07:00
Ken Sanislo 10b1d8baff Add diagnostic logs for HeadsetMicGain fallback + bridge TX payloads
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 299dc4daab HeadsetMicGain: query device-reported gain bounds from GetInfo
Per newly-documented HEADSET_MIC_WIRE_PROTOCOL.md, feature 0x0611
returns device-specific NACK 0x0B when SetMicGain is written with a
value outside the device's supported range. LGHUB calls GetInfo
(function 0) once at startup to cache (min_gain, max_gain) as two
signed int8 bytes, then rescales subsequent writes into that range.

Solaar was using int8's full range (-128..127) as validator bounds
and accepting any UI value — which goes out-of-range on devices with
narrow ranges like the G522 (current gain is 8, suggesting small
range). Every gain change attempt NACK'd with 0x0B.

Fix: add HeadsetMicGain.build() that queries GetInfo at probe time,
parses the two-byte [min, max] response as signed int8, and hands
those to the RangeValidator. Falls back to the int8 default range
if GetInfo is unavailable or returns nonsense. Logs the reported
range at INFO so testers can verify in the log.

Does NOT address mic-mute NACK 0x0A — per the same doc that's the
hardware mic-flip boom position locking software mute on the G522,
and there's nothing software can do about it.
2026-05-10 15:39:17 -07:00
Ken Sanislo fa205436f1 Record feature versions in Centurion Phase A (parent/direct) too
The previous commit fixed version recording for bridge-routed sub-device
features (Phase B). This applies the same fix to Phase A, which is used
for parent features on a wireless dongle AND for the whole feature set
on direct-USB Centurion devices like the wired G522 (PID 0x0B19).

Without this, a wired Centurion headset would enumerate all features
with version=0 and version-gated settings (sidetone 3-byte format,
auto-sleep 2/3-byte timer) would send the wrong payload and get
OUT_OF_RANGE rejections — same symptom the wireless G522 had before.
2026-05-10 15:39:17 -07:00
Ken Sanislo ef439d87fc Record Centurion sub-device feature versions + version-gate AutoSleep
The Centurion sub-device discovery ignored the type/version bytes in
the per-index getFeatureId response and defaulted every feature's
version to 0. That made version-gated settings (sidetone, auto-sleep)
send V0 payload formats on features that may actually be V2/V3/V4 —
which the G522 rejects with OUT_OF_RANGE (error 0x03).

Evidence from tester's log (version acfd02ab):
  bridge sub-device error: orig_feat_idx=13 orig_func=0x1B error=0x03
  bridge sub-device error: orig_feat_idx=20 orig_func=0x1F error=0x03

feat_idx 13 is HeadsetAudioSidetone (0x0604); 20 is CenturionAutoSleep
(0x0108). Both have version-gated payload formats per the protocol doc.

Fixes:
1. In _discover_sub_device_features, extract response[3] (type) and
   response[4] (version) and store them in self.version / self.flags,
   so get_feature_version() returns the real version for downstream
   callers.

2. Add HeadsetAutoSleep.build() that picks byte_count and max_value
   based on the reported version (V<3=1 byte, V=3=2 bytes, V>=4=3).
   HeadsetSidetone.build() already had version gating — it just wasn't
   getting the real version before.

Does NOT address the mic-mute (error 0x0A) and mic-gain (error 0x0B)
rejections — those are non-standard error codes likely meaning the G522
either gates those behind the physical mic button (mic-mute) or rejects
writes for other reasons (mic-gain). Needs separate investigation once
the simpler version fix lands.
2026-05-10 15:39:17 -07:00
Ken Sanislo a11d402c96 Log bridge sub-device error responses at INFO, not DEBUG
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 143c8093f2 Don't misreport empty-ACK writes as failed (Setting.write)
HID++ 2.0 'set' operations (SetSidetone, SetAutoSleep, SetMicGain etc.)
frequently respond with an empty ACK — just the echoed request_id with
no data bytes after. device.feature_request() strips the request_id
echo and returns the remaining payload, so our reply variable is b""
for these successful writes.

The old check `if not reply:` evaluates b"" as falsy, so we returned
None from Setting.write, which the GUI interpreted as "Read/write
operation failed" — even though the device actually succeeded.

Change the check to `if reply is None:` so empty bytes (success) pass
through and only genuine transport failures (None return from base.request)
are flagged as errors. Also add an INFO log on the real failure path so
the user can tell from the log whether a write reached the device.

This likely fixes the G522 sidetone / mic-gain / auto-sleep "Read/write
operation failed" reports — all of those features return empty ACKs for
set operations.
2026-05-10 15:39:17 -07:00
Ken Sanislo 6e84325d67 HeadsetRGBColor: reorder host mode before zone query, parse tight response
Field testing on G522 showed two problems:

1. GetRGBZoneInfo returned all zeros (count=0, all-zero body) when
   queried BEFORE SetHostModeState(1). Protocol doc's recommended
   order is: claim host mode first, then enumerate zones. Our code
   was querying zones first.

2. The response format the G522 returns does NOT match the doc's
   layout. Doc says [count, 3 reserved, 1 reserved, zone_ids...] but
   the G522 seems to pack them tight as [count, zone_ids...] — the
   SetRgbZonesSingleValue response "0801020304050607080000" decodes
   cleanly as count=8, zones=[1..8] under the tight format.

Fixes:
- Call SetHostModeState(1) before GetRGBZoneInfo.
- Try tight format first, fall back to doc format. Only cache a
  parsed result if it makes sense (non-zero zone IDs, count matches).
- Don't cache ambiguous results so subsequent writes retry.

This also incidentally suggests the G522 has 8 RGB zones (not the 2
left/right earcups we were guessing) — the red set we sent in the
previous test probably did set zones 0x01 and 0x02 correctly, but
those zones are only a fraction of the total lighting so the color
change wasn't visible.
2026-05-10 15:39:17 -07:00
Ken Sanislo bde3c3bc86 Add read-only HeadsetAdvancedEQ (0x020D) display
The G522 exposes AdvancedParaEQ (0x020D), a different EQ feature than
the PRO X 2's OnboardEQ (0x0636). Key differences:

- 3-byte-per-band wire format ([freq_hi, freq_lo, gain]) vs 0x0636's
  4-byte-per-band ([freq_hi, freq_lo, gain, Q])
- Device handles biquad coefficient computation — no host-side DSP math
- Has explicit direction (playback/capture), multiple preset slots with
  getActiveEQ/setActiveEQ for switching, preset friendly names

Adds new advanced_para_eq.py module with getInfos, getActiveEQ, and
getCustomEQ helpers (function 0, 3, 1 respectively). Re-exported from
hidpp20.py following the onboard_eq.py pattern.

HeadsetAdvancedEQ setting displays the currently-active playback EQ
using the same RangeFieldSetting + PackedRangeValidator UI pattern as
HeadsetOnboardEQ, so the graphic EQ widget looks the same.

**Writes are intentionally disabled for now** — prepare_write returns
None and write() logs "read-only mode" without sending anything. This
lets us verify the wire format matches the protocol doc against real
hardware before risking a write that could misconfigure the DSP. Once
read output is confirmed sensible, we'll wire up setCustomEQ (function
2) to enable writes.
2026-05-10 15:39:17 -07:00
Ken Sanislo b90bbf8af3 HeadsetRGBColor: use shared special_keys.COLORS palette
Drop the ad-hoc 10-color preset list and feed special_keys.COLORS
(the Xorg rgb.txt palette solaar already uses for DPI button color
mapping) directly as the ChoicesValidator choices_universe. Users now
pick from the same named colors other parts of the app expose, and we
stop inventing a one-off naming scheme.

"Off" goes away as a color option — to disable host control, toggle
the separate headset-rgb-hostmode setting instead. Picking `black`
writes (0,0,0) which effectively turns the LEDs off while host mode
stays claimed.

This still isn't the "ideal" solaar pattern (Kind.COLOR with
Gtk.ColorButton via HeteroValidator), but it reuses existing
infrastructure — ChoicesValidator + NamedInts — instead of creating
new patterns just for this setting.
2026-05-10 15:39:17 -07:00
Ken Sanislo 059874c210 Add INFO/WARNING logging to HeadsetRGBColor for field diagnostics
If the RGB write doesn't work as expected on a tester's G522, we now
get enough trace info at INFO level to see exactly what happened:

- Color chosen + RGB values + target zone IDs (hex)
- Each of the 3 feature requests logged with payload hex and response
  hex: SetHostModeState(1), SetRgbZonesSingleValue, FrameEnd
- Zone discovery: logs raw GetRGBZoneInfo response when it succeeds,
  WARNING when response shape is unexpected and we fall back to
  [0x01, 0x02]
- WARNING on exceptions with color name context

Existing ERROR logs in base.py already surface device-returned error
responses (OUT_OF_RANGE, UNSUPPORTED etc.) for the individual feature
calls, so a stack of ERROR + our INFO context tells us which of the
three steps failed.
2026-05-10 15:39:17 -07:00
Ken Sanislo ad3d761118 Add basic headset RGB support (HeadsetRGBHostMode + HeadsetRGBColor)
First pass at controlling the G522's RGB LEDs via feature 0x0620
HEADSET_RGB_HOSTMODE. Exposes two settings:

1. headset-rgb-hostmode — boolean toggle for SetHostModeState
   (function 8). Turning this on claims LED control; off returns
   control to firmware effects.

2. headset-rgb-color — preset color chooser (Off, Red, Green, Blue,
   White, Yellow, Cyan, Magenta, Orange, Purple). On write it enables
   host mode, queries zone IDs via GetRGBZoneInfo (function 1), calls
   SetRgbZonesSingleValue (function 5) with the chosen RGB, then
   commits via FrameEnd (function 6). "Off" issues SetHostModeState(0)
   only.

Protocol reference: ~/ghub/LOGITECH_HIDPP2_PROTOCOL.md functions on
0x0620. We skip the SetSWControl on 0x0600 step because the G522
doesn't expose 0x0600. If the device rejects host-mode writes without
that prerequisite we'll add it back.

Falls back to zones 0x01/0x02 (typical left/right earcup) if
GetRGBZoneInfo returns an unexpected format.
2026-05-10 15:39:17 -07:00
Ken Sanislo cfc69431d1 Use per-index queries for Centurion sub-device feature enumeration
The previous "bulk" query (CenturionFeatureSet.GetFeatureId with
start_index, per-batch response) was limited by the 64-byte Centurion
frame size to ~13 features per response, and the device apparently
does not fragment MessageEvent responses — so start_index=14 returned
empty and we missed the remaining features.

Evidence: wired G522 exposes 30 features directly but wireless via
bridge only ever reports 13. Same sub-device, different access path,
same underlying feature set.

Switch to per-index queries matching how _discover_dongle_features
already works on the parent:
  1. CenturionFeatureSet.GetCount (func 0) -> total feature count
  2. CenturionFeatureSet.GetFeatureId (func 1) per index -> one feature
     per response

Slower (N round-trips instead of 1) but handles any feature count
without fragmentation. Expected to reveal RGB, advanced EQ, sidetone,
etc. on the wireless G522.

Updated test fixture with per-index response format.
2026-05-10 15:39:17 -07:00
Ken Sanislo f469e86b9c Guard Centurion feature lookups against HID++ 2.0 enum collisions
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo e7ee34f132 Loop Centurion sub-device feature discovery across batches
CenturionFeatureSet bulk query only returns as many features as fit in
a single ~60-byte Centurion frame (~13-14 features per response). The
single-call implementation silently truncated devices with more
features, which may explain missing RGB/audio features on the G522.

Loop the query with increasing start_index, stopping on empty batch,
short batch, or 16-batch safety bound (224 feature max).

Also log the final discovered count at INFO level so we can see it in
field test logs.
2026-05-10 15:39:17 -07:00
Ken Sanislo fc91057f80 Force FeaturesArray for Centurion devices regardless of reported protocol
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 7851acb850 Tighten Centurion probe and silence FeaturesArray query spam
Three cleanup fixes after field testing confirmed the brute-force probe
finds device_addr=0x23 on the G522 (no special addresses at 0x00/0xFF):

1. Short-circuit the probe on first hit instead of sweeping all 256. The
   diagnostic full-sweep served its purpose — only 0x23 responds.
2. Drop per-candidate read timeout from 20ms to 5ms. USB round-trip is
   <1ms, so 5ms is 5x margin. Worst case: 1.3s. Typical: 180ms.
3. Short-circuit FeaturesArray.__getitem__ for Centurion devices. All
   parent + sub-device features are enumerated upfront by
   _check_centurion(). If a feature isn't in the dict after _check(),
   it genuinely doesn't exist — skip the raw ROOT.GetFeature query that
   the dongle rejects with LOGITECH_ERROR. Eliminates cycling
   {0002}..{000F} error log spam during settings init and speeds up
   check_feature_settings() by ~225 round trips per device.
2026-05-10 15:39:17 -07:00
Ken Sanislo 0eb592d6e1 Full-sweep probe: log all responding device_addr candidates
For initial field testing, sweep all 256 candidates instead of stopping
on first hit. Logs every address that responds (sent_addr, response_addr,
first 8 bytes) at INFO level so we can discover if 0x00, 0xFF, or other
addresses have special behavior.

The first responding address is still used as the device_addr. Revert to
short-circuit once we've confirmed there are no special addresses worth
trying first.
2026-05-10 15:39:17 -07:00
Ken Sanislo 556c83d0a4 Probe device_addr one at a time instead of blasting all 256
Write one candidate, read briefly (20ms), move to next. Stops on first
hit. Avoids overwhelming the dongle's RX buffer with 256 back-to-back
frames where the response could get lost.

Worst case (addr=0xFF): 256 × 20ms ≈ 5s.
Typical G522 (addr=0x23): 36 × 20ms ≈ 0.7s.
2026-05-10 15:39:17 -07:00
Ken Sanislo 6f3e6fccd3 Brute-force probe all 256 device_addr candidates on 0x50 handles
The previous probe sent a single all-zeros frame and waited for the
dongle to respond — but the dongle silently drops frames with the
wrong device_addr, producing no response.

Now send a valid ROOT.GetProtocolVersion request for every possible
device_addr (0x00–0xFF). The dongle ignores the 255 wrong addresses
and responds only to the correct one. The response carries the real
address at byte[1]. 256 writes complete in under 100ms on USB; the
read phase (3 x 500ms) catches the single response.

This discovers the address during synchronous init, before the
listener starts, eliminating the need for deferred init when the
headset is already powered on.
2026-05-10 15:39:17 -07:00
Ken Sanislo 5fddfb67ea Fix FeatureNotSupported crash on pending CenturionReceiver
When a pending (deferred-init) CenturionReceiver has no discovered
features, accessing receiver.firmware triggers get_firmware_centurion
which calls feature_request. With an empty feature list, feature_request
raises FeatureNotSupported — but passes a positional arg to KwException
which only accepts **kwargs, producing a TypeError.

Two fixes:
- Guard firmware property with `not self._pending` so it skips fetch
  when features haven't been discovered yet
- Fix FeatureNotSupported raise to use keyword argument
2026-05-10 15:39:17 -07:00
Ken Sanislo e91cbff361 Fix G522 0x50 Centurion init failure and protocol version crash
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo c27e4c1317 Add debug logging around Centurion device_addr probe
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo f73fe2d295 Probe Centurion 0x50 device address on handle open
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo e5f7da6bf5 Add G522 LIGHTSPEED headset support (Centurion 0x50 transport variant)
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.
2026-05-10 15:39:16 -07:00
Ken Sanislo 1952e9ce98 Add regional keyboard layouts (ISO_QWERTY, QWERTZ, AZERTY, JIS) and fix copyright
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.
2026-05-10 17:52:55 -04:00
Ken Sanislo d8422d78d1 Add per-key RGB color painter and replace MAP_CHOICE color validator
Replace the per-key dropdown UI (MapChoiceControl) with a Cairo-rendered
keyboard canvas where users can paint colors directly onto keys.

Editor (lib/solaar/ui/perkey/):
  - Cairo DrawingArea renders cells from a Layout dataclass; bound cells
    take their painted color, unset cells show a diagonal hash whose base
    color matches the device's rgb_zone_* setting.
  - Tools: brush, drag-rectangle, flood-fill (4-adjacent, Paint-style),
    and a directional gradient (line A->B projected across the matrix
    with cells past the endpoints clamped to the endpoint colors).
  - GradientSwatch is the single source of truth for the gradient's two
    colors; the canvas reads from it on each gradient stroke.
  - Palette: GTK ColorButton plus an unset toggle that paints the
    "no change" sentinel (-1).
  - PerKeyEditorDialog auto-sizes from the canvas's size_request, so a
    104-key keyboard opens wide and a 8-LED mouse opens compact.
  - Editor consumes only a narrow PerKeyColorSink protocol; never imports
    from lib/logitech_receiver, preserving the FE/BE seam.
  - Per-device palette state (active + previous color) persists via the
    existing persister under a _palette: prefixed key.

Layouts:
  - ANSI 104-key full-size and TKL keyboard layouts.
  - G502 X family mouse layout (zones 1-8 -> labels A-H).
  - Generic registry: register_layout(feature, matcher, layout). A
    _name_contains() helper builds case-insensitive substring matchers
    against device codename / name. Unknown devices fall back to a flat
    strip of all reported zones.

Validator (open value space):
  - New Range dataclass and MapRangeValidator extending Validator
    directly (kind=MAP_CHOICE for dispatch compatibility). Replaces the
    ChoicesMapValidator on PerKeyLighting -- the named-color universe
    (COLORSPLUS) was rejecting any picker color outside its ~20 entries.
    Other MAP_CHOICE settings are untouched.

Integration:
  - Setting base gains an editor_class string attribute. config_panel's
    _create_sbox resolves it via importlib before the kind dispatch, so
    PerKeyLighting routes to the new editor without a new Kind value.
  - CLI gains a hex/dec parser for open-value MAP_CHOICE settings:
      solaar config <dev> per-key-lighting A 0xFF00FF
  - Diversion rule editor skips Range-valued MAP_CHOICE settings'
    value-selector instead of crashing on the open value space.
  - pycairo declared in install_requires; transitively present on most
    systems but now explicit for pip-from-source installs.

Tests in test_setting_templates.py updated for the new validator.
2026-05-10 17:52:55 -04:00
Jakob Wenzel 4f3583ae10
device: Fix another crash when reading notification flags (#3206) 2026-05-10 09:58:35 -04:00
Ken Sanislo ec05c112f0 settings_templates: fix 0x1B0C wire encoding for Pro X 2 Superstrike
The AnalogButtons feature packs each tunable in bits 7..2 of its byte
(wire = logical << 2); byte 2 bit 0 is a firmware-managed sensitivity
flag, the rest of the low bits are reserved-zero. Solaar 1.1.19 sent
the slider value verbatim, so any logical 1/2/3 produced wire bytes
0x01/0x02/0x03 — non-zero reserved bits and below the logical minimum,
hence INVALID_ARGUMENT (issue #3202). Only multiples of 4 happened to
land on a valid wire byte.

Decode bytes 1/2/3 of getConfig and caps[2..4] of getCapabilities by
right-shifting 2; left-shift the user value by 2 on setConfig and OR
back the prior sensitivity bit on rapid-trigger writes. Defaults
fallbacks updated to logical mid-points and the validator maxima now
reflect the real ranges (actuation 1..10, rapid trigger 1..5, haptics
0..5).

Persisted values from 1.1.19 were raw wire bytes (e.g. 40), which now
exceed the new max and would fail apply()'s prepare_write. A new
_AnalogButtonSetting subclass migrates such values in _pre_read by
dividing by 4 when the result lands inside the new valid range, and
rewrites the persister so the migration is one-shot.
2026-05-10 09:55:08 -04:00
Niko Savola 3733713d68 Fix duplicate class field typo in HID parser data 2026-05-05 14:43:03 -04:00
Nick ff9324d346 Update sv.po 2026-04-22 00:45:08 -04:00
Nick b309cfd771 Update sv.po 2026-04-22 00:45:08 -04:00
Nick cd93f7e113 Update Swedish translation 2026-04-22 00:45:08 -04:00
Si13n7 33952d98fc Add support for battery-0N0 icon naming scheme 2026-04-20 21:52:00 -04:00
Si13n7 e199fb868d Use battery-level-N icons with semantic and generic fallback 2026-04-20 21:52:00 -04:00
Peter F. Patel-Schneider d25da9bdbb docs: document haptic capability 2026-04-18 16:31:17 -04:00
Peter F. Patel-Schneider b952d710fe docs: mention Centurion protocol in documentation 2026-04-18 16:31:17 -04:00
Ken Sanislo ba0b45df22
device: Support per-slot unpair on Lightspeed receivers (CLI + GUI) (#3183)
* Add CLI --slot unpair for Lightspeed receivers

Adds Receiver.force_unpair_slot(), a low-level method that writes the
RECEIVER_PAIRING unpair register (action 0x03) for a given slot regardless
of cache state, bypassing may_unpair and re_pairs gates. Intended for
clearing stale pairings on Lightspeed receivers where Solaar cannot read
the slot's pairing info or the device is no longer reachable on RF.

Extends the `solaar unpair` CLI with three new flags:
  --receiver <name>   select which receiver to target
  --slot <N>          target a specific slot number directly
  --dry-run           print what would happen without issuing the write

The --slot path is gated to Lightspeed receivers only (by receiver_kind)
so Unifying/Bolt/Nano behavior is unchanged. It populates the cache first
and prints the current slot contents so the user can confirm what is
about to be cleared, but does not refuse based on active/offline state —
the explicit --slot N is treated as sufficient intent.

Verified end-to-end on a C547 dual-slot Lightspeed receiver: stale slot
cleared, RECEIVER_INFO sub-registers 0x21/0x31 went to None, connection
count register dropped from 2 to 1, running solaar daemon picked up the
change in real time via the existing DJ pairing notification hook.

Covered by 5 unit tests against a mocked Receiver: empty slot, stale
sentinel, active device invalidation, register write failure, closed
handle.

* Enable GUI unpair for Lightspeed receivers

Flip _lightspeed_receiver() to may_unpair: True so the GUI unpair button
becomes sensitive for Lightspeed-paired devices, and route the GUI unpair
action through _unpair_device(n, force=True) so the unpair register write
actually fires instead of short-circuiting to cache invalidation.

The previous GUI path called `del receiver[n]`, which dispatches to
Receiver.__delitem__ → _unpair_device(n, force=False). On receivers with
re_pairs=True (Lightspeed, Nano) that hits the cache-invalidation branch
at receiver.py:391 and never writes the unpair register — a "fake unpair"
that would have left the slot bound on the hardware even after the button
was enabled.

With force=True, the GUI now issues RECEIVER_PAIRING action 0x03 for the
selected slot, matching the CLI unpair path (cli/unpair.py:39) which has
always used force=True. Lightspeed and Unifying unpair behavior are now
symmetric: the button is enabled, the confirmation dialog is shown, and
the register write is performed.

The pair/add flow is untouched: it still uses set_lock(device=0) which
lets the receiver firmware pick an empty slot, re_pairs remains True so
the listener's silent-replace branch continues to handle re-pair into an
occupied devnumber. Verified on dual-slot C547 hardware that pairing into
an empty slot preserves the occupant of the other slot.

Stale pairings where Solaar can't enumerate the slot (no cached device
row to right-click) still require the --slot CLI from the preceding
commit — that path is orthogonal to this GUI enablement.

* Apply suggestion from @pfps

Lightspeed receivers don't appear to re-pair.

---------

Co-authored-by: Peter F. Patel-Schneider <pfpschneider@gmail.com>
2026-04-17 09:34:58 -04:00
Alessio85 8b023115d2
device: Handle composite IntFlag.name on Python < 3.11 (#3187)
* Handle composite IntFlag.name on Python < 3.11

On Python < 3.11, IntFlag.name returns None for composite flags
(e.g. NotificationFlag(0x000900) = SOFTWARE_PRESENT|WIRELESS).

The previous fix (#3184) returns an empty list in this case, losing
the flag names. This commit decomposes composite flags manually by
iterating over enum members, producing correct results on all
supported Python versions (>= 3.8 as declared in setup.py).

Add regression tests covering composite flags and None input.

Tested on:
- Python 3.9.2 (Debian 11): 56/56 passed
- Python 3.11.15 (pyenv): 56/56 passed

Refs #3184

* Fix line length style violation (E501)

Move comment above the parametrize entry to stay under 127 chars.

---------

Co-authored-by: avercelli <avercelli@vulog.com>
2026-04-16 11:38:25 -04:00
Peter F. Patel-Schneider 0a6421ef82 docs: mention Centurion protocol in documentation 2026-04-14 12:31:33 -04:00
Ken Sanislo 25865994cb
device: Treat empty hidraw read as device removal (EOF) (#3174)
* Treat empty hidraw read as device removal (EOF)

When select() reports a hidraw fd as readable but os.read() returns
empty bytes, that's EOF per POSIX — the device has been removed.
Previously this was silently treated as no data, causing the listener
to loop indefinitely on a gone device instead of cleaning up.

* Fix test_ping_errors missing mocks for _read_input_buffer and write

The test was not mocking _read_input_buffer or write, so ping() would
call into real hidapi.read() with a fake handle (fd 1). The empty-read
EOF detection added in the previous commit made this consistently fail
by raising OSError → NoReceiver before reaching the mocked _read path.

Add the same mocks used by the adjacent test_request_errors.

---------

Co-authored-by: Peter F. Patel-Schneider <pfpschneider@gmail.com>
2026-04-14 11:56:01 -04:00
Peter F. Patel-Schneider 9c80b64b49 device: fix interface for K845 2026-04-14 11:45:17 -04:00
Ken Sanislo 12aabf029b
centurion: support PRO X 2 LIGHTSPEED headphones Centurion features (#3150)
* 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>
2026-04-14 11:43:23 -04:00