Commit Graph

265 Commits

Author SHA1 Message Date
Ken Sanislo 275ad64be1 HeadsetSignatureEffects: add 0x0622 firmware signature-effect slots
RGB headsets (e.g. G522) expose HEADSET_RGB_SIGNATURE_EFFECTS (0x0622)
— three firmware-played lighting slots: startup, shutdown, passive.
Each carries an on/off enable, a primary and secondary color, and a
speed.

Add a per-slot setting modeled on the keyboard boot-animation settings
(_RgbBootEffectSetting): a HETERO setting with the enable byte as a
Gtk.Switch plus two color pickers and a speed slider. The rw_class
bridges the firmware's split functions — get/setSignatureEffectParams
(colors + speed) and get/setSignatureEffectState (enable).

Slots are discovered by probing getSignatureEffectState per candidate
(0/1/2), so a device exposing only some slots gets only those
settings. getSignatureEffectsInfo (fn 0) is logged once at debug for a
later move to info-based discovery once its byte layout is confirmed.

Like the boot animations these run autonomously on the device
firmware, so they are not gated on host LED control.
2026-05-21 10:13:00 -04:00
Ken Sanislo 5b704c5bd7 HeadsetSidetone: scale UI percent to the device's gain-step grid
The 0x0604 wire "level" is a gain-step index 0..N-1, not a 0-100
percent. G HUB reads the step count N from getSidetoneLevelSettings
(function 2) and scales: level = (N-1)*pct/100. Solaar wrote the raw
percent, which only matches when N == 101 — so V2 headsets reporting a
smaller N (G522: N=10) got out-of-range step writes the firmware
silently ACK'd without applying.

Read func 2 at build time, take reply byte 2 as N (default 101 when
the call is absent — V1 — or the reply is unusable). The validator now
maps the 0-100 UI percent to/from the wire step index and clamps
writes to N-1. V1 devices (e.g. PRO X 2) keep N=101, which makes the
scaling an exact identity — their wire traffic is unchanged. The raw
func-2 reply is still logged at debug level for a future G HUB
setpoint correlation.
2026-05-21 10:13:00 -04:00
Ken Sanislo c9db25a302 HeadsetSidetone: log getSidetoneLevelSettings raw reply under -dd
Binary RE of LGHUB established that the 0x0604 wire 'level' is a
gain-step index, not a 0-100 percent: GHUB scales it through a step
count N sourced from getSidetoneLevelSettings (function 2). Solaar
writes a raw percent, which is only correct when N == 101 — so V2
headsets reporting a smaller N (G522/G325) get out-of-range step
writes the firmware silently clamps.

Function 2's byte layout couldn't be recovered from the binary (it
decodes in an inlined lambda). Probe it at build time and log the raw
reply so the next debug log captures where N sits — no behavior
change, gated on DEBUG so it only runs when diagnostics are on.
2026-05-21 10:13:00 -04:00
Ken Sanislo 0baeb87294 HeadsetAdvancedEQ: validate persister against live read on apply
Setting.apply uses cached=True so the persister is treated as the
source of truth and the device's live state never wins. That model is
correct for most settings, but it created a destructive bug for the
0x020D AdvancedParaEQ:

1. The V2 wire parser went through several iterations during G522
   bring-up (commits bde3c3bc, 41db76bc, 59e3dcb7) with different
   strides and gain encodings before settling at 7c73c888. Each
   intermediate produced different decoded values from the same wire
   response; whatever a user's Solaar was on when they last apply'd
   got stored to the persister.

2. PerKeyLighting-style `prepare_write` silently fills missing band
   keys with 0 dB and clamps out-of-range gain values to the
   [gain_min, gain_max] rail. A partial/stale persister dict
   therefore encodes as a complete wire payload — looking valid to
   the device.

3. Writes were disabled in the early V2 builds (until be047fd9 on
   May 10). Once writes shipped, the next apply read the stale
   persister, prepare_write filled+clamped it, and setCustomEQ
   slot 0 overwrote whatever the user had configured.

Observed on a G325 LIGHTSPEED user log: persister carried
`{0: -6, 1: 1094}` from an older build; apply pushed
`-6 +6 0 0 0 0 0 0 0 0` to slot 0 (band 1 clamped from 1094 to
the +6 dB rail, bands 2-9 zero-filled), wiping the user's
hand-tuned EQ.

Fix: override apply for HeadsetAdvancedEQ. Validate the persister
value against the current validator's count + gain range. If it's
well-formed, push it normally (preserves Solaar's "user config is
authoritative" model). If it's malformed (wrong key count, missing
indices, out-of-range gain), do a live device read and reseed both
_value and the persister from the device — without writing the
corrupt persister back. If both are invalid, log a warning and skip
this setting only; apply_all_settings continues with the rest.
2026-05-21 10:13:00 -04:00
Ken Sanislo f4345b9ce0 LogiVoice: hide read-only parameter panels from the UI
Each LogiVoice module exposes both a state toggle and a read-only
parameters panel. The toggles are reliable, but the parameters panels
only partially decode the GetParameters response — fields not yet
identified surface as opaque hex blobs, which adds UI noise without
giving the user anything actionable (no write path either).

Stop registering the parameters classes in _LOGIVOICE_SETTINGS. The
state toggles still register and work as before. Bring the parameter
panels back when the wire encoding is fully reverse-engineered and a
write path lands.
2026-05-21 10:13:00 -04:00
Ken Sanislo ac7add6297 G522 LIGHTSPEED headphones support
End-to-end support for the G522 LIGHTSPEED gaming headset (and the
infrastructure that comes with it for related Centurion-bridged headsets).

Hardware/protocol layer:

- centurion.py — CenturionReceiver: lightweight receiver-like wrapper
  for Centurion-bridged dongles (PRO X 2 LIGHTSPEED already, G522 added
  here). Handles deferred init for 0x50 devices that don't respond to
  the initial probe, sub-device feature discovery via bridge, online
  detection via ping.

- base.py — Centurion handle state, device_addr probe (256-candidate
  scan), 0x50/0x51 frame routing, bridge TX/RX framing.

- hidpp20.py — Centurion sub-device feature query and bridge request
  routing. CENTURION_DEVICE_INFO / CENTURION_BATTERY_SOC / CENTURION_*
  query helpers in centurion.py for parent + sub paths.

- device.py — Centurion device creation path, USB product-string
  fallback for naming, bridge sub-device error handling.

- notifications.py — HEADSET_ADVANCED_PARA_EQ band-change events,
  HEADSET_MIC_MUTE state-change events; both route to the relevant
  setting via setting_callback.

Headset settings:

- HeadsetEcoMode, HeadsetDoNotDisturb, HeadsetMicMute, HeadsetMicSNR,
  HeadsetAINR / HeadsetAINRLevel, HeadsetSidetone, HeadsetMicGain,
  HeadsetMixBalance, HeadsetAutoSleep — bool / range / choice settings
  for the headset-specific feature pages.

- HeadsetOnboardEQ + HeadsetActiveEQPreset — onboard EQ slot picker
  with active-preset tracking via the EQ change event subscription.

- HeadsetAdvancedEQ — multi-band parametric EQ (advanced_para_eq.py
  module handles getEQInfos / getCustomEQ / band-change subscription;
  setting builds the per-band sliders).

- HeadsetLEDControl + HeadsetLedsPrimary + HeadsetPerZoneLighting —
  RGB feature set (0x0620 HEADSET_RGB_HOSTMODE) with shared zone-write
  helper (headset_rgb.py) and the G522 layout for the per-key painter
  (perkey/layouts/headset_g522.py).

- LogiVoice family (12 settings: NR / NG / Compressor / De-esser /
  Depopper / Limiter / HPF, each with state + parameters) — voice
  processing pipeline. logivoice.py module handles probe + parsing.

Probes:

- rgb_effects_probe.py — runtime probe for headset RGB feature
  variants (0x0621 onboard-effects, 0x0622 signature-effects, 0x0623).

Tests:

- test_base.py / test_device.py / test_hidpp20_complex.py — Centurion
  framing, device-creation path, sub-device feature parsing.

- test_setting_templates.py — fixtures for the new headset settings.

Closes pwr-Solaar/Solaar#3181.
2026-05-21 10:13:00 -04:00
Ken Sanislo 4d1f9dc6c1 rgb_control: honor the off state — don't auto-claim, init, or shutdown LEDs
PerKeyLighting.write was force-claiming SW control via rgb_control.write(True)
through _ensure_sw_control whenever a saved per-key map was applied. On
startup with rgb_control persisted as False, the apply path would re-enable
LED Control and overwrite the persister with True — making "off" impossible
to keep across restarts.

The fix treats rgb_control as the single gate for LED activity: when it's
off, Solaar performs no SW claim, no per-key/zone wire writes, no SW release
on apply (we never claimed), no profile-management restoration, and no
shutdown-animation trigger on exit. This lets another tool (OpenRGB etc.)
drive the LEDs without Solaar fighting it.

Settings that don't actively change current lighting are still allowed:
NV-config writes for startup/shutdown animations, brightness control (its
own feature, no color push), and persister updates for per-key/zone state
so colors survive an off→on toggle.

UI: _apply_rgb_gates already greys per-key/zone/idle/sleep rows when
rgb_control is off. Fix a race where the async-read completion callback in
_update_setting_item would re-set sensitivity from the user lock-icon flag
alone and undo the grey-out if rgb_control's read happened to complete
first. Extract _gate_blocks as the single source of truth and AND-combine
it into _update_setting_item's set_sensitive call.
2026-05-15 10:45:27 -04:00
Ken Sanislo 3df2a30f30 Add RGB lighting persistence and software LED power management for G515
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.

Closes pwr-Solaar/Solaar#3149.
2026-05-13 19:03:44 -04:00
Ken Sanislo fc62ef00ad
LEDControl / RGBControl: render as Gtk.Switch instead of a 2-option combo (#3215)
LED Control on both 0x8070 (COLOR_LED_EFFECTS) and 0x8071 (RGB_EFFECTS)
features is a ChoicesValidator with exactly two values, Device=0 and
Solaar=1 — semantically a binary toggle dressed up as a dropdown. Render
both as a Gtk.Switch (Solaar=on, Device=off) by swapping ChoicesValidator
for BooleanValidator with true_value=1 / false_value=0.

BooleanValidator's kind is TOGGLE, which the existing config_panel
dispatcher already maps to ToggleControl (Gtk.Switch). No UI plumbing
changes required.

Add a small _pre_read override to migrate persister entries from the old
ChoicesValidator (stored as int 0/1) to bool, so the switch widget gets
a value it can pass to Gtk.Switch.set_state().

Tooltip cleanup to match the new styling:
- "Switch control of LED zones between device and Solaar" → "Allow Solaar
  to control LED zones." on both control rows.
- "LED Control needs to be set to Solaar to be effective." → "LED Control
  needs to be enabled." on every dependent setting's tooltip.

Setting names (led_control / rgb_control) and persister keys are
unchanged; only the YAML value type migrates from int to bool on the next
read.
2026-05-13 17:30:02 -04:00
Ken Sanislo ad96dd395b PerKeyLighting: drop misleading live-read output in solaar show
The 0x8081 PerKeyLighting v2 protocol has no GetIndividualRgbZones
function — there's no way to ask the device what colors are currently
on the per-key buffer over HID++. PerKeyLighting.read() papered over
this gap by fabricating an all-"No change" sentinel map whenever the
caller asked for a live (uncached) read, which produced misleading
solaar show output like:

    Per-key Lighting (saved): {1:0xc01c28, 2:0xc52d26, ...}
    Per-key Lighting        : {1:No change, 2:No change, ...}

— even with the keyboard clearly running the saved colors.

Two-part fix:

1. PerKeyLighting.read() now returns self._value if populated (from
   prior write or persister) regardless of `cached`. Only fabricates
   the all-"No change" map when there's truly no state — fresh device,
   no prior write, nothing persisted. This is the right starting state
   in that case because per-key writes are additive over a buffer of
   unknown content.

   Other callers benefit too: solaar config calls read(cached=False) on
   per-key settings to display current values, and prior to this
   change it would get fabricated sentinels back instead of the
   in-memory map.

2. Add a `live_readable = True` class attribute to Setting (default
   preserves existing behavior). Override to False on PerKeyLighting.
   solaar show gates its live-read print on this flag, so non-readable
   settings show only the (saved) line — which is the authoritative
   record of what was last written to a setting whose live state can't
   be read back.

After both fixes solaar show prints just:

    Per-key Lighting (saved): {1:0xc01c28, 2:0xc52d26, ...}

— honest about what we know.

690 tests pass; pre-commit clean.
2026-05-12 12:51:36 -04:00
Ken Sanislo 9b627410b6 common: render RGB color values as 0xrrggbb in config and solaar show
24-bit RGB values stored in PerKeyLighting per-key maps and in
LEDEffectSetting `.color` fields currently dump as decimal integers in
both the YAML config file and `solaar show` output:

    per-key-lighting: {1: -1, 2: 16733440, 3: 16755200, ...}
    LEDs Keys: {1:-1, 2:16733440, 3:16755200, ...}

Hex is the canonical RGB representation. Render colors as `0xrrggbb`
everywhere: solaar show output, repr, and YAML config dumps. Both new
values and legacy values from pre-existing YAML configs migrate
transparently.

Implementation: a `ColorInt(int)` subclass in `common.py`.

  - `str(c)` / `repr(c)` → `'0xrrggbb'` for 0..0xFFFFFF; falls back to
    decimal for out-of-range values so sentinels like
    `COLORSPLUS["No change"] = -1` still display naturally.
  - Constructor accepts ints AND hex strings (`'0xrrggbb'` or
    `'#rrggbb'`) so pre-existing configs that wrote decimal continue
    to load.
  - YAML representer emits a hex int literal (`tag:yaml.org,2002:int`
    with style `'0xrrggbb'`). YAML 1.1 parses hex int literals back as
    plain ints with no custom loader registration — values round-trip
    cleanly without a custom YAML tag.

Wiring:

  - `Range` gains a `value_type=int` field; `MapRangeValidator.validate_read`
    wraps results through `rng.value_type(...)`. PerKeyLighting's
    `_COLOR_RANGE` sets `value_type=ColorInt`; other Range users keep
    the int default with no behavior change.
  - PerKeyLighting overrides `update()` and `update_key_value()` to wrap
    raw ints in `ColorInt` at write time. `type(v) is int` (exact
    match, not isinstance) deliberately excludes NamedInt sentinels and
    avoids re-wrapping existing ColorInts.
  - `LEDEffectSetting.__init__` wraps the `color` param in `ColorInt`
    with the same guard, so zone-effect color round-trips as hex
    through `yaml.dump(setting)` / `val_to_string`.
  - `MapRangeValidator.to_string` re-wraps raw ints loaded from YAML
    (which `yaml.safe_load` returns as plain Python ints regardless of
    the choice's `value_type`) through `rng.value_type` before
    formatting. Without this, `solaar show` would render legacy saved
    values as decimal even after the rest of the pipeline is hex-aware.

Coverage: 12 new unit tests across `test_common.py` and
`test_settings_validator.py`:

  - ColorInt str/repr, equality with plain int, hex-string constructor
    (0x / 0X / # prefixes), out-of-range fallback to decimal, YAML
    dump format, plain-int round-trip on load, dict-value formatting.
  - MapRangeValidator.to_string: plain-int re-wrap via value_type,
    pass-through for already-wrapped ColorInts, NamedInt sentinel
    preservation, and no behavior change for int-typed Ranges.

Existing test fixtures updated: _PERKEY_COLOR_RANGE now carries
value_type=ColorInt to match runtime _COLOR_RANGE.

702 tests pass; pre-commit clean.
2026-05-12 12:51:36 -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
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
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
Caio Quirino da Silva 5478224cfa
device: Add PRO X 2 Superstrike mouse support with HITS tuning settings (#3132)
* feat: PRO X 2 Superstrike support with click haptics, actuation point and rapid trigger config support

* feat: PRO X 2 Superstrike docs

* docs: document PRO X 2 Superstrike features, device entry, and capabilities

* fix: code review points

# Conflicts:
#	lib/logitech_receiver/hidpp20_constants.py

* Fix the write_key_value for dpi_extended

I was playing with the branch from the MR and I wanted to fix the cli stuff, it now properly sets when I use:

solaar config 1 dpi_extended X 400

Should be enough

Signed-off-by: Shane Fagan <mail@shanefagan.com>

* Fix ruff style check

---------

Signed-off-by: Shane Fagan <mail@shanefagan.com>
Co-authored-by: Shane Fagan <mail@shanefagan.com>
2026-04-12 09:53:47 -04:00
Peter F. Patel-Schneider 55a67c142c device: remove incorrect descriptor for WPID 4004 2026-03-13 16:21:51 -04:00
Niko Savola 30d4d0f65d
Update Finnish localization (#3154)
* Update Finnish translation template

* Add missing Finnish translations and polish

* Fix typo feeback → feedback

* Update translators list and solaar.pot
2026-03-05 07:53:51 -05:00
Peter F. Patel-Schneider 705279097f cli: allow to change LED settings 2025-12-21 18:03:53 -05:00
Peter F. Patel-Schneider 3686920e85 settings: add onboard profiles warning to sensitivity tooltip 2025-11-25 09:26:45 -05:00
Peter F. Patel-Schneider 29bf463509 settings: prevent lock failure when showing debug messages 2025-11-15 18:21:18 -05:00
danicc097 817c90e561
replace color picker (#3028)
* replace color picker
2025-11-15 11:05:59 -05:00
Peter F. Patel-Schneider 0fd262424e settings: add setting for HAPTIC feature 2025-11-12 14:50:46 -05:00
Peter F. Patel-Schneider 97b6b958c8 settings: expand new settings type 2025-11-12 14:33:34 -05:00
Peter F. Patel-Schneider f739331dc2 settings: add new settings type for structure-backed setting 2025-11-12 14:33:34 -05:00
Peter F. Patel-Schneider cff0110f81 settings: add scroll ratchet force setting 2025-11-04 03:25:12 +09:00
Peter F. Patel-Schneider 2cb5fa4b97 settings: ignore hidden features 2025-10-27 15:27:13 -04:00
Peter F. Patel-Schneider abea1c4341 device: add present flag, unset when internal error occurs, set when notification appears 2025-04-22 08:45:55 -04:00
Peter F. Patel-Schneider 217b9360e6 device: pause setting up features when error occurs; use ADC message to signal connection and disconnection 2025-04-22 08:45:55 -04:00
Peter F. Patel-Schneider 3fcc75f892 settings: use correct LOD values for extended adjustable dpi 2025-03-25 10:52:56 -04:00
Peter F. Patel-Schneider 198067519d settings: better support RGB Effects - not readable 2025-03-03 14:11:09 -05:00
DomHeadroom 90ab457ebe Rewrote string concatenation/format with f strings 2025-01-29 08:40:14 -05:00
MattHag ad3916e1b8 Fix KeyFlag conversion 2025-01-01 10:46:04 -05:00
MattHag c34fd3c2b0 Remove NamedInts: Convert LedRampChoice to flag
Related #2273
2025-01-01 10:46:04 -05:00
MattHag 0bf7a78553 Add type hints
Related #2273
2025-01-01 10:46:04 -05:00
MattHag 5a9725ee17 Add type hints
Related #2273
2025-01-01 10:46:04 -05:00
MattHag 0d7fc46a81 settings: Add docstrings and type hint
Related #2273
2025-01-01 10:46:04 -05:00
MattHag dfb4ccc93f type hints: Introduce settings protocol
Related #2273
2025-01-01 10:46:04 -05:00
MattHag 03de6fb276 Split up huge settings module
- Move validators into their own module.
- Convert Kind to IntEnum

Related #2273
2025-01-01 10:46:04 -05:00
Max Ammann ff6f7a8e22
settings: Add ratchet setting for smart shift enhanced devices (#2681)
* Add ratchet setting for smart shift enhanced devices

* Update lib/logitech_receiver/settings_templates.py

---------

Co-authored-by: Peter F. Patel-Schneider <pfpschneider@gmail.com>
2024-11-16 16:06:02 -05:00
MattHag 862cef1f77 hidpp20_constants: Refactor Gesture into enum
Replace Gesture NamedInts with enum.

Related #2273
2024-11-03 14:41:07 -05:00
MattHag c90146df31
Refactor: hidpp20 to use enum (#2647)
* Remove duplicated Param definition

Use constants from hidpp20 constants

Related #2273

* hidpp20/Param: Refactor to use IntEnum

Related #2273

* hidpp20_constants: Refactor to use IntEnum

Related #2273
2024-11-02 08:33:58 -04:00
MattHag 0cd9c0c9b5 Refactor: Introduce Feature enum
Convert Feature NamedInts to SupportedFeature integer enum.

Related #2273
2024-10-14 07:28:09 -04:00
Peter F. Patel-Schneider a36973916c settings: check all bits for extended report rate 2024-10-13 20:46:21 -04:00
MattHag c9e781e752 settings_template: Introduce State enum 2024-10-11 07:42:38 -04:00
MattHag 2aa462532a settings_template: Prepare removal of desktop_notifications dependency
Related #2273
2024-10-08 14:35:16 -04:00
MattHag 0f8ab61ddf Rename lr/notify module to desktop_notifications
Related #2273
2024-10-08 14:35:16 -04:00
Peter F. Patel-Schneider 3aa064b40f settings: finish change to new constants 2024-06-13 10:01:50 -04:00
MattHag be83dac209
hid: Convert definition of HID registers to enum
* 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
2024-06-02 10:34:00 -04:00
MattHag 5a63e44d58 Remove empty comment lines
Remove hashtags solely used for structuring.

Related #2273
2024-06-02 09:54:21 -04:00
MattHag 815dce07be Unify imports in logitech package
Related #2273
2024-05-23 16:42:18 -04:00