Commit Graph

152 Commits

Author SHA1 Message Date
Ken Sanislo 4c709ecb73 tests: mock the libnotify backend so the suite raises no real notifications
The desktop_notifications tests called the real init/alert/show, which
reach the libnotify daemon — every test run popped real GNOME
notifications titled "MockDevice" and "unknown".

Add an autouse conftest fixture that swaps Notify for a mock in both
desktop_notifications modules (solaar.ui and logitech_receiver). The
tests still exercise the real init/alert/show code paths, but
Notification.show() never reaches the daemon. The fixture returns the
mock; the notification tests now assert show() was actually dispatched
against it, so they verify the path instead of just "didn't crash".
2026-05-21 10:15:12 -04:00
Ken Sanislo eefa37d83f tests: isolate solaar.configuration from the real config file
Tests build FakeDevices named "TestDevice". Any test that touches
device.settings or device.persister without mocking
configuration.persister (e.g. test_device_complex, test_device_battery)
calls the real configuration.persister(), which loads and rewrites
~/.config/solaar/config.yaml — the TestDevice has no stable identity
so it never matches an existing entry, and a fresh blank TestDevice
entry is appended on every test run.

Add an autouse conftest fixture that points configuration's yaml/json
paths at a per-test tmp_path and clears the cached _config. No test
can reach the real config now, and each test starts from an empty
config instead of inheriting entries from earlier tests.
2026-05-21 10:15:12 -04:00
Ken Sanislo cce8808995 headset RGB: regression tests for _HeadsetOnboardEffect color seeding
Pin the behavior fixed in 7f6df37a: explicit black (0) is honored,
genuinely absent fields fall back to _DEFAULTS, and a black Static
frame round-trips through from_bytes instead of being rewritten white.
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 a5d12f9039 device: use full live name for direct-USB codename
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.
2026-05-19 15:38:09 -04:00
Ken Sanislo badce9bb07
centurion: Headset icon kind at construction (#3226)
* 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.
2026-05-19 15:36:29 -04:00
Ken Sanislo e77746818e centurion: show the receiver as "Lightspeed Headset Receiver" in the UI
"Centurion" is Logitech's internal codename for the headset-dongle
transport — useful as a developer/log identifier but meaningless to a
user, who just sees "Centurion Receiver" in the device list with no
clue it's their wireless headset dongle.

Rename only the user-facing receiver name. The protocol label, module,
class, function names, and log messages keep "Centurion" — the codename
stays everywhere it helps developers, and is hidden only where it faced
the user.
2026-05-19 15:34:57 -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 3e88c73645
base: fix sw_id at 0x0B instead of rotating 0x2..0xF (#3218)
Solaar's old rotating sw_id (cycling 0x2..0xF on every request) eats
HID++ replies addressed to other userspace clients sharing the same
device, because reply matching is feature + function + sw_id only and
Solaar eventually claims every value in the range. Cooperative use
with OpenRGB, LGSTrayEx, etc. is impossible by construction.

Pick one value and hold it. Other tools can pick a different one and
filter Solaar's traffic out of their reply stream cleanly.

  0x07  OpenRGB
  0x0A  LGSTrayEx
  0x0D  Logitech G HUB (host-side)
  0x0F  Logitech firmware (sub-device self-enumeration on wired)

0x0B is unallocated among the above and keeps the high bit set so
replies stay trivially distinguishable from notifications (sw_id=0).

Audit of why nothing breaks:

- Reply matcher in request() still works — Solaar's request loop is
  synchronous per device, so (feat, func, fixed_sw_id) is enough to
  identify the in-flight request's reply. The rotation never bought
  uniqueness across processes; it only avoided self-collision across
  successive synchronous requests, which doesn't exist as a problem.

- Ping reply identification uses a separate random mark byte
  (getrandbits(8) appended to the request data, checked at byte 4 of
  the reply). That randomization is unchanged.

- Stale-reply protection comes from _read_input_buffer draining the
  device handle before every new write. A delayed reply from a prior
  timed-out request gets routed to the notification hook, not
  mistaken for the current request's reply — independent of whether
  sw_id rotates.

- The "separate results and notifications" claim in the old docstring
  was misleading: true notifications carry sw_id=0 per the HID++ spec.
  What actually keeps replies distinguishable is the high bit being
  set, which 0x0B preserves.

- Centurion bridge in device.py uses the same sw_id-as-correlation-
  token pattern with the same synchronous-per-device flow; fixed sw_id
  works identically.
2026-05-14 15:08:56 -04:00
Ken Sanislo 1d1a0915f1 rgb_power.perkey_has_paint: gate on IGNORE only, not on != True
The locked-but-applied sensitivity state (False) means the user can't
change the setting from the UI — not that the value should be ignored.
Per-key paint written under sensitivity=False is still on the device's
LEDs and still the dominant layer; gating it out caused the idle dim
ramp to push zone Static over a still-visible per-key buffer, making
the per-key layer vanish instead of fade.
2026-05-13 19:03:44 -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 09cb2dddb9
ui: Show offline status for receiver-paired device batteries (#3217)
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.
2026-05-13 17:32:03 -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 d159081893
device: Fix operator precedence bug and end-of-configuration timing in device.changed() (#3173)
* 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).
2026-05-13 11:30:26 -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 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
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
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
Peter F. Patel-Schneider c87730f1eb tests: remove test that doesn't work in older Pythons 2025-12-24 08:11:04 -05:00
Peter F. Patel-Schneider 8298db0891 receiver: fix crash when turning notification flags into strings 2025-12-11 15:23:06 -05:00
Peter F. Patel-Schneider 4c63bdb6ee
show better pairing errors (#3063)
* Fix: Show pairing error str

Fixes #2827

* device: also show bolt pairing errors

---------

Co-authored-by: MattHag <16444067+MattHag@users.noreply.github.com>
2025-12-10 11:18:50 -05:00
MattHag 441d608ca0
test_pair_window: Simplify tests by cleaning up receiver mock (#2899)
Remove unnecessary parameter for these tests.
2025-12-10 10:50:07 -05:00
Peter F. Patel-Schneider 2e549371ef device: fix typing issue with notification flags 2025-12-10 07:31:48 -05:00
Peter F. Patel-Schneider a866de47fb udev: correctly re-raise access exception 2025-10-17 19:41:23 -04:00
Peter F. Patel-Schneider f942dbec41 device: add special keys from Logitech 2025-10-16 20:57:15 -04:00
Peter F. Patel-Schneider 694513832d
device: report symbolic names for pairing errors (#2886)
* device: report symbolic names for pairing errors

* testing: fix testing of notifications
2025-05-31 08:12:42 -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 198067519d settings: better support RGB Effects - not readable 2025-03-03 14:11:09 -05:00
Peter F. Patel-Schneider 9b5e416755 receiver: Handle unknown power switch locations again
Ensure functionality via unit test.
2025-02-22 15:29:35 -05:00
Peter F. Patel-Schneider 7d4f787344 ui: guard against typeerror when setting the value of a control box 2025-02-04 10:22:28 -05:00
Peter F. Patel-Schneider 5a03433f86 tests: fix ui desktop notifications test 2025-01-02 15:04:41 -05:00
MattHag bd00cc97ad
Estimate accurate battery level for some rechargable devices (#2745)
* battery: Extract battery level estimation into function

Test battery level estimation with sharp edges based on predefined
steps. Rename variable for clarity and add type hints.

Related #2744

* battery: Interpolate battery level for some rechargeable devices in percent

Estimate remaining battery based on measured battery voltage. Use linear
interpolation to achieve a smooth line instead of 10 percent jumps.

Fixes #2744
2025-01-02 10:58:07 -05:00
Peter F. Patel-Schneider 3192fa1a34 testing: upgrade desktop notifications tests to take notifications availability into account 2025-01-02 10:47:53 -05:00
MattHag ab52c4a7c0 Introduce error types
Related #2273
2025-01-02 08:29:32 -05:00
MattHag 8894463f64 notification: Refactor process_device_notification
Simplify code and unify interfaces and type hints.

Related #2273
2025-01-02 08:05:02 -05:00
MattHag 15aaba2802 notification: Refactor process_receiver_notification
Remove repeated code pattern with generalized implementation. Aim
towards easy extension and code readability.

Related #2273
2025-01-02 08:05:02 -05:00
MattHag 810cda917a Refactor notifications
Add type hints and reasonable variable names.

Related #2711
2025-01-01 13:48:14 -05:00
MattHag 207be464a5 Test notifications
Fixes #2711
2025-01-01 13:48:14 -05:00
MattHag f28a923d15 receiver: Test extraction of serial and max. devices
Related #2273
2025-01-01 12:52:33 -05:00
MattHag 1a3f4dab36 Speedup lookup of known receivers
Refactor get_receiver_info. Replacing data structure of known receivers
to avoid for loop, when an efficient dictionary lookup is possible.

Related #2273
2025-01-01 11:33:07 -05:00
MattHag 3186d880fc base: Refactor device filtering
Related #2273
2025-01-01 11:20:28 -05:00
MattHag 5d86c74df4 base: Turn filter_products_of_interest into a public function
Related #2273
2025-01-01 11:20:28 -05:00
MattHag 5cf7cbfd5d base: Improve tests of known receivers
Related #2273
2025-01-01 11:20:28 -05:00
MattHag 72c9dfc50c Remove NamedInts: Convert NotificationFlag to flag
Related #2273
2025-01-01 10:46:04 -05:00
MattHag 571cdb5f2d Prepare refactoring of NotificationFlag
Ensure behavior stays the same.

Related #2273
2025-01-01 10:46:04 -05:00
MattHag 5f5c7cdcce Fixes on top of refactoring 2025-01-01 10:46:04 -05:00
MattHag ad3916e1b8 Fix KeyFlag conversion 2025-01-01 10:46:04 -05:00