Commit Graph

962 Commits

Author SHA1 Message Date
Ken Sanislo 4a7edd75ce headset RGB: LED Control as a claim switch + keyboard-style restructure
Rework headset RGB lighting so it mirrors the keyboard/mouse model
instead of its own ad-hoc shape.

LED Control (0x0620 HostMode) becomes a boolean toggle: whether Solaar
holds the headset's live-coloring claim. Off releases the LEDs so
another app (e.g. OpenRGB) can drive them; on lets Solaar drive.

0x0620 per-zone painting and the 0x0621 onboard effect are both live
LED control, so both are gated on the claim — UI rows grey out and
wire writes are skipped (value still persisted) when the claim isn't
held, mirroring the keyboard's RGBEffectSetting under rgb_control.

0x0621 HeadsetOnboardEffect is now the primary lighting setting, the
headset analog of keyboard 0x8071 zone effects. Its build reads the
cluster's supported-effect set so the picker offers only those; effect
id 0 is labelled "Static" to match every other Solaar device. The
redundant HeadsetLEDsPrimary (0x0620 single-colour host push) is
removed — that job is exactly the 0x0621 Static effect.

HeadsetPerZoneLighting is the per-key-style overlay: gated on the
claim AND the onboard effect being Static, since per-zone painting
overlays a Static cluster effect (the analog of keyboard per-key
needing rgb_control + zone Static).

The 0x0622 signature effects (startup/shutdown/passive colours) are
the only stored settings here and stay ungated — editable whether or
not Solaar holds the claim.

On re-claim HeadsetLEDControl.write reasserts the dominant layer:
per-zone painting when the onboard effect is Static, else the 0x0621
effect itself.
2026-05-21 10:13:00 -04:00
Ken Sanislo d800fc4c54 GraphicEQControl: only re-render the written slider on write completion
The partial-dict hardening in d632febf made set_value call
control.set_value() for every band unconditionally. A per-slider
write returns a single-key result dict, so completing one write
re-rendered all 10 sliders from setting._value. Any slider the user
had dragged but whose 0.5s debounce hadn't fired yet got reverted to
its old value — and the subsequent debounce then read that reverted
value and dropped the user's change.

Restore the original behavior: only set sliders present in the result
dict; for the rest, read stored.get() for the tooltip but leave the
widget alone. Keeps the KeyError-safe .get() from d632febf.
2026-05-21 10:13:00 -04:00
Ken Sanislo 5853cf5a8e GraphicEQControl: tolerate a partial _value dict
_write and set_value both did a bare _value[int(item)] subscript while
the slider grid has validator.count entries. A persister value with
fewer keys than bands (stale data from an older EQ parser) raised
KeyError on render and on every slider drag for a missing band, so
slider changes never reached the persister.

Use .get() with a 0 dB fallback for unset bands, and guard against
_value not being a dict at all.
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 ba32ee6ea0 perkey/canvas: allow rect/gradient anchors in grid gaps
_cell_at now returns a phantom unbound BoundCell when the click lands
in a matrix-grid gap. Endpoint tools (rect/gradient) accept these as
anchors; brush/bucket still require a bound cell. No painting happens
on phantoms — they only place the corner/endpoint.
2026-05-14 12:26:33 -04:00
Ken Sanislo e4d543732b config_panel: don't show failed-write alert for unreadable settings
Write-only HeteroKey settings (e.g. 0x8071 zone effects) legitimately
return None on first read. Gate null_okay on validator.readable so
genuine read failures on readable settings still surface; drop the
unconditional alert in HeteroKeyControl.set_value(None).
2026-05-14 12:25:27 -04:00
Ken Sanislo 8760789930 listener: share bluez-watch wiring across Centurion-direct and standard device paths
The bluez-dbus connect watcher (used to surface BT disconnect / reconnect
events to the UI without restarting Solaar) was only installed in the
non-Centurion device path of _start(). The Centurion-direct fallback —
used for wired headsets and, prospectively, for BT-paired Centurion
headsets where there's no LIGHTSPEED dongle — skipped it.

Factor the post-create_device wiring (configuration.attach_to + bluez
watch installation) into a shared _post_attach_device() helper that
both paths call. No behaviour change for wired headsets (they aren't
Bluetooth, so the watch installation is a no-op for them). For
BT-paired headsets that come through the centurion-direct fallback,
this makes reconnect events propagate the same way as for any other
BT-paired Logitech HID++ device.

Also broadens the docstring on create_centurion_receiver to mention
BT-paired Centurion headsets as a valid "direct device" case alongside
wired headsets.

First commit on the centurion-bluetooth branch — see
~/.claude/plans/can-we-make-a-graceful-dongarra.md for the full plan.
Hardware verification still required to confirm Path A (existing
hidraw pipeline works for BT Centurion) before any further changes.
2026-05-13 19:13:47 -04:00
Ken Sanislo 53e379fd24 about: add Ken Sanislo to Additional Programming credits 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 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 fc19860f76 perkey: label G502 X LEDs by zone id, not letter
OpenRGB labels each LED with a letter (A..H) for display; we inherited
that convention when porting the G502 X grid. For LEDs without physical
keycaps, letters add a mental translation step ("A is zone 1, B is zone
2...") with no visual benefit. Switch to bare zone numbers so the painter
cell labels match what shows up in logs, the persister, and rule
arguments.
2026-05-12 12:51:36 -04:00
Ken Sanislo 3dc121e8b3 PerKey gradient swatch: align gradient endpoints to visible corners
Cairo linear-gradient endpoints were placed at the rounded rect's
geometric corners (3,3) and (21,21), but the *visible* corner pixel
of a rounded rect with radius 2 is the outermost point of the arc,
inset along the diagonal by r * (1 - 1/sqrt(2)) ≈ 0.586 units.

That meant t=0 and t=1 of the gradient landed in the cut-off corner
regions, and the rendered corners sampled at t≈0.033 / 0.967 — about
3.3% in from each endpoint, ~8 RGB units short of the true previous /
active colors (visible on saturated pairs like pure red → pure blue:
the "pure red" corner rendered as ~rgb(194, 10, 0)).

Shift the gradient endpoints inward by the arc inset so t=0 maps to
the visible TL corner pixel and t=1 to the visible BR pixel. The
gradient now spans the visually rendered area exactly; saturated
endpoints render flush.
2026-05-12 12:51:36 -04:00
Ken Sanislo a8e006c948 PerKey gradient swatch: Tabler "square" outline around the gradient
The gradient swatch on the gradient-tool button drew a flat
rectangular fill with a 1px translucent-black border. Visually it
sat as an odd-one-out next to the rounded-square Tabler outline
icons on the rest of the toolbar — a sharp-cornered patch flanked
by rounded-corner icons.

Render the gradient inside the same path Tabler "square" uses
(rounded rect from (3,3) to (21,21), corner radius 2, stroke 2 in
a 24x24 viewBox; cairo scale into the swatch's pixel size) and
stroke the outline in the GTK theme's foreground color. The
swatch now reads as one of the icon family — same outline style,
same line weight, same theme-following color — with the gradient
filling the inside.

Connect style-updated to queue_draw so the outline color tracks
runtime theme switches alongside the icon buttons.
2026-05-12 12:51:36 -04:00
Ken Sanislo b594c7292b PerKey dialog: one window per device, keyed by firmware unit-id
The editor was a process-wide singleton: opening it on a different
device replaced the content in the existing window. A user with two
perkey-capable devices (e.g. a G915 keyboard and a G502 mouse) had to
context-switch between them, losing any in-progress edits on the
device they switched away from.

Replace the singleton with a `_dialogs` dict in dialog.py keyed by a
stable per-device identifier. control.py builds the key from
`device.unitId` first — read from the device firmware via the
DeviceInformation feature, the same regardless of whether the device
is currently on a receiver or plugged directly via USB — so the same
physical device on different transports shares one dialog instead of
opening two windows. Falls back to `serial`, `hid_serial`, `codename`,
and finally `id(sink)` for the pathological case where nothing else
identifies the device.

If the dialog is already open for a given device and `present()` is
called with the same sink instance, the window is just raised — no
flicker, in-progress interaction state preserved. A different sink
under the same key (transport change for the same physical device)
rebuilds the window content under the existing dialog slot, so the
window position is preserved across transport switches.

Closing a window via the WM tears down only that dialog and pops it
from the registry; other open editors stay up. As a side cleanup the
unused `inset` ScrolledWindow shadow already added in editor.py and
the per-device sizing logic in dialog.py remain in place.
2026-05-12 12:51:36 -04:00
Ken Sanislo 6c99d2f9d1 PerKey dialog: size window from measured natural size
The dialog used hardcoded offsets to compute its target size from the
canvas's size_request:

    target_w = canvas_w + 32  # 8 wrapper border * 2 + ~16 scrollbar slack
    target_h = canvas_h + 80  # 8 wrapper border * 2 + 50 toolbar + slack

Two problems with that:

1. The "+32" only covered the canvas's width plus borders, not the
   toolbar's width. Small layouts (e.g. an 8-LED mouse: canvas ~172px)
   produced a window narrower than the toolbar wanted (~261px with
   the icon buttons + palette + color picker + unset toggle), causing
   toolbar overflow / clipping.

2. The "+80" assumed a fixed toolbar height and scrollbar slack —
   wrong on themes with chunkier buttons or different scrollbar
   metrics, and brittle to any future toolbar additions.

Replace with `wrapper.get_preferred_size()`. GTK already aggregates
the canvas's size_request through ScrolledWindow + the editor VBox +
the wrapper's border into a natural size that accounts for every
contribution, including the toolbar's width. Drop the now-unused
`canvas_size()` shim from PerKeyEditor.
2026-05-12 12:51:36 -04:00
Ken Sanislo dfa1cb7ca5 PerKey icons: read theme fg from style-updated, not Settings notify
Previously the editor and palette listened to Gtk.Settings
notify::gtk-theme-name (and notify::gtk-application-prefer-dark-theme)
to re-render their themed icons on theme switch. Two problems:

1. The initial icon load happened during widget construction, before
   the buttons were attached to the toolbar — so the style context
   resolved to a default (often white) foreground rather than the
   actual theme text color. Icons showed up white until the first
   theme-change event.

2. Settings notify fires *before* GTK's CSS engine re-resolves styles
   for the new theme. Reading the style context's foreground from
   that handler returned the previous theme's color, so toggling
   light <-> dark left both states settling on the same shade.

Move both responsibilities into a new attach_themed_icon helper in
_icons.py: it does the initial load, connects to the *button's own*
style-updated signal, and rebuilds the icon on each emission. That
signal fires *after* CSS resolution (both on first realize and on
runtime theme switches), so the foreground we read is always the
current one.

A per-button color-key guard skips the rebuild when the resolved
foreground hasn't changed, so unrelated style-updated emissions
(hover, focus, active) don't trigger needless re-renders.

The handler is connected to the button itself, so GTK cleans it up
when the button is destroyed; both editor.py and palette.py drop
their bespoke Gtk.Settings handler bookkeeping.
2026-05-12 12:51:36 -04:00
Ken Sanislo c3382b0ba6 PerKey canvas: symmetric hash stripes for unset cells
The "no change" hash overlay used a single black-or-white stripe
color picked by the base luminance, so the perceived average of an
unset cell was uniformly biased toward black or white instead of
sitting on the actual base color. That made dark base cells look
darker than they really are on the keyboard, and light ones lighter.

Draw two interleaved stripe sets at base ± offset (per channel),
spaced by half-period so the dark and light stripes alternate
evenly across the cell. Equal coverage of the two stripe colors
keeps the perceived average at base.

When a channel is too close to 0 or 1 to fit the full offset
(±0.22), halve the offset on the constrained side. The cell's
average then drifts at the limits but stays centered on base
everywhere else — verified visually across mid-tones, primaries,
and near-black/white bases.

The "no zone base color known" path keeps the previous neutral-grey
look unchanged; the average-preservation property only applies when
there is a base color to preserve.
2026-05-12 12:51:36 -04:00
Ken Sanislo 661a186429 PerKey palette: replace hashed unset swatch with palette-off icon
The "Unset" toggle button next to the color picker used a custom
HashSwatch drawing area that mirrored the canvas's diagonal-hash
pattern for unset cells. The pattern was visually noisy at button
size and tied the button's appearance to the current zone base
color (the swatch had to be told the base color via
set_zone_base_color so it could redraw).

Use the Tabler "palette-off" symbolic icon instead. It reads as
"clear / no paint" at a glance, is independent of the zone base
color, and matches the icon style of the tool buttons on the other
end of the toolbar.

Extract the icon loader (themed_icon_image, ensure_icon_path) from
editor.py into a new private module _icons.py so palette.py can
reuse it; the new palette tracks its own Gtk.Settings notify signals
to re-render the icon on theme switches, and disconnects them via a
new Palette.shutdown() called from the editor's shutdown.

Adds MIT-licensed Tabler palette-off icon under share/solaar/icons/,
see THIRD_PARTY.md.
2026-05-12 12:51:36 -04:00
Ken Sanislo 1210d32b93 PerKeyEditor: rebuild tool icons on GTK theme change
Tool icon pixbufs are baked at construction time using the button's
current style context foreground color. Without this, switching the
desktop theme (e.g. light <-> dark) at runtime leaves the editor's
icons stuck in the previous color while the rest of the UI updates.

Subscribe to Gtk.Settings notify::gtk-theme-name and
notify::gtk-application-prefer-dark-theme; on either, replace each
themed button's child Gtk.Image with a freshly-recolored one.
Disconnect both signals from PerKeyEditor.shutdown so the editor
doesn't leak handlers across openings.
2026-05-12 12:51:36 -04:00
Ken Sanislo cf9a88bcaf PerKeyEditor: replace tool button labels with icons
The Brush / Rect / Fill tool buttons in the per-key color editor now
show icons instead of text labels, with the original labels retained
as tooltips and accessible names.

Icons are recolored at load time to match the active GTK theme's text
foreground (light grey on dark themes, dark on light), by reading the
button's style context and substituting currentColor in the SVG before
loading the pixbuf. GTK's stock symbolic loader is bypassed because it
only recolors fill palette stand-ins and ignores stroke="currentColor",
which is what the source SVGs use.

Adds MIT-licensed Tabler icons under share/solaar/icons/, see
THIRD_PARTY.md.
2026-05-12 12:51:36 -04:00
Ken Sanislo 671c07d861
cli: enumerate LED effects under RGB_EFFECTS / COLOR_LED_EFFECTS in solaar show (#3213)
Print each zone's effect slot (index, wire ID, name from LEDEffects
table, decoded capabilities bits, default period, and the param
keys the host would expose as widgets) for any device with 0x8070
or 0x8071. Unknown wire IDs print as 'Unknown(0xXX)' rather than
being skipped, so probing surfaces effects the table doesn't yet
model.

Capabilities are decoded against the documented bits (color, fade,
period, direction, fw-handled) with any remaining bits printed as
a residual hex value, since LGHUB sets additional bits on newer
effects whose semantics aren't yet known.
2026-05-12 09:54:30 -04: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
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
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
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 51532252df ui: better handling of missing devices 2026-03-13 13:41:59 -04:00
NaviMen 75aadc706c Add Ukrainian credit to the about model
​Hi! I have contributed to the Ukrainian translation and would like to be added to the translators list in the "About" section.
2026-02-28 09:50:44 -05:00
Peter F. Patel-Schneider 42e0e391b5 config: tolerate devices with no unitId 2026-02-05 10:50:49 -05:00
Peter F. Patel-Schneider a79bb24da5 cli: correctly handle timeout in Bolt discovery 2026-01-18 14:21:56 -05:00
Peter F. Patel-Schneider 97311bed5f ui: handle missing receiver_path more gracefully 2026-01-08 12:38:14 -05:00
Peter F. Patel-Schneider 0110bbff31 cli: be defensive when showing features in solaar show 2026-01-08 12:36:42 -05:00
Peter F. Patel-Schneider 4bda869542 release 1.1.19 2026-01-08 12:32:44 -05:00
Peter F. Patel-Schneider fc68521731 release 1.1.19rc1 2025-12-29 09:47:07 -05:00
Peter F. Patel-Schneider 76346cd5aa docs: update help messages for CLI commands 2025-12-21 18:03:53 -05:00
Peter F. Patel-Schneider 705279097f cli: allow to change LED settings 2025-12-21 18:03:53 -05:00
Gabriel Ebner 1dd1ace327
cli: Fix crash when showing notification flags. (#3070) 2025-12-12 04:54:10 -05:00
Peter F. Patel-Schneider a87ae59a93 release 1.1.18 2025-12-11 15:28:01 -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 2e549371ef device: fix typing issue with notification flags 2025-12-10 07:31:48 -05:00
Peter F. Patel-Schneider 24fe69924b release 1.1.17 2025-12-09 14:30:04 -05:00
Peter F. Patel-Schneider f9ce65fd18 release 1.1.17rc3 2025-12-01 13:19:51 -05:00
Peter F. Patel-Schneider b3ea338f86 release 1.1.17rc2 2025-12-01 13:15:26 -05:00
Peter F. Patel-Schneider dbc97d96d5 cli: better error messages for solaar profile 2025-11-19 13:50:54 -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