Compare commits

...

135 Commits

Author SHA1 Message Date
Ken Sanislo 189a50e926 perkey layouts: add POUND and ISO_BACKSLASH cells to MAIN_ISO
ISO keyboards have two physical keys that ANSI does not — POUND (#) at
row 3 col 12 between the right-of-quote position and Enter, and
ISO_BACKSLASH (<) at row 4 col 1 between LShift and Z. The firmware
reports them as zones 47 and 97 on G915 ISO models, but MAIN_ISO only
*subtracted* the ANSI backslash at row 2 col 13 without ever adding
those two cells back. They fell through to the unmapped pool and got
dropped by the EXTRAS_ALLOWLIST phantom-zone filter, so PerKey lighting
silently left them undrawable (issue #3239 — German G915).

Add both cells to MAIN_ISO with the UK QWERTY labels (# and \\) as the
default, and override them in the regional layouts: # / < on QWERTZ DE,
* / < on AZERTY FR. UK QWERTY inherits the defaults.

ANSI is unaffected — MAIN_ANSI still omits 47/97 so they keep getting
filtered as phantoms on ANSI keyboards like the G515.
2026-06-01 19:09:11 -04:00
Juan Jose del Rio Holgado f68230b83d cli/config: wrap argv in list for Gio.Application.run (PyGObject 3.56)
The remote-config path passes a yaml.dump(...) string to
Gio.Application.run(), whose argv parameter is Optional[list[str]].
Pre-3.56 PyGObject tolerated a bare str; the marshaller refactor in
the 3.55 dev series (MR !487) tightened this, and 3.56 now raises
TypeError: Unable to marshal str as an array.

Wrap the YAML string in a 1-element list. The receiver in
solaar.ui._command_line already does yaml.safe_load("".join(args)) on
the argv, so a 1-element argv reconstructs the original YAML cleanly
under both old and new PyGObject.
2026-05-28 08:19:06 -04:00
Matthaiks 7647cea478
po: Update Polish translation (#3236) 2026-05-24 17:48:53 -04:00
Peter F. Patel-Schneider be45e15552 release 1.1.20rc2 2026-05-24 10:14:45 -04:00
Peter F. Patel-Schneider 02fa95e622 release 1.1.20rc1 2026-05-24 10:14:45 -04:00
Rongrong 88e7791b32 po: Update zh_CN.po
Uniform terminologies:
- report => 报文
- notification => 通告
- divert/diversion => 重定义
- key => 按键
- button => 按钮

Fix typos and confusing expressions.

Signed-off-by: Rongrong <i@rong.moe>
2026-05-23 15:21:39 -04:00
Marián Kyral 87017d1d73
po: Update cs.po (#3232) 2026-05-22 14:47:50 -04:00
Peter F. Patel-Schneider 03b55ed1e2 Revert "po: Update list of translators in credits (#3211)"
This reverts commit ec53ad9f97.
2026-05-22 11:19:25 -04:00
Niko Savola ec53ad9f97
po: Update list of translators in credits (#3211) 2026-05-22 11:11:09 -04:00
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 7c7666466a headset RGB: honor explicit black (0) onboard-effect colors
_HeadsetOnboardEffect.__init__ seeded per-effect defaults for any
field that was falsy, so a Static color1 of 0x000000 (black) was
treated as unset and overwritten with the white default — setting the
onboard color to black turned the LEDs white instead of off.

Switch the constructor to None-sentinel defaults: a field is seeded
from _DEFAULTS only when genuinely absent (None), so an explicit 0 is
honored. The UI's get_value() always passes explicit values, and a
fresh effect-pick seeds its RANGE widgets UI-side via _apply_id_defaults,
so animated effects still get sane defaults.

Reported by @rouderz on PR #3181.
2026-05-21 10:13:00 -04:00
Ken Sanislo 5f6def437e headset RGB: fix G522 allowlist key — model byte is 0x32, not 0x33
The HEADSET_SIGNATURE_EFFECTS_ALLOWED allowlist was keyed on "33", a
model byte no real G522 reports. DeviceInfo (0x0100) func 0 returns
0x32 for the G522 (0x44 for the G325) — confirmed against every saved
diagnostic log, including our own development unit. The 0x33/0x45
values came from the protocol doc, which had both transcribed
off-by-one (a shifted read of a G HUB USB capture).

Effect: the SOLAAR_EXPERIMENTAL masking suppressed the headset
signature-effect settings on every G522, not just unvalidated models.

Re-key to "32" and correct the modelId comments.
2026-05-21 10:13:00 -04:00
Ken Sanislo 653f7aea18 headset RGB: re-overlay per-zone paint after onboard cluster writes
Writing the 0x0621 onboard cluster effect re-fills every LED uniformly,
which the headset firmware treats as dropping the host per-zone buffer.
HeadsetLEDControl.write already re-asserted the per-zone layer on
re-claim, but HeadsetOnboardEffect.write did not — so changing the LEDs
Primary color clobbered individually-painted zones with the flat base
color and never restored them.

Extract the re-assert logic into _headset_reassert_zone_layer (repaint
every zone to LEDs Primary, then overlay the explicit per-zone
overrides) and call it from both write paths. The helper is a no-op
unless the onboard effect is Static, since a non-Static animation owns
the LEDs and masks per-zone anyway.
2026-05-21 10:13:00 -04:00
Ken Sanislo e557c30ab2 headset/RGB: default-deny allowlist for NVconfig-saved color settings
NVconfig-saved colors (0x8071 RGBEffects boot effects, 0x0622 HeadsetRGB
signature effects) persist to device storage, so an unvalidated control
can durably misconfigure a device. Gate them behind a per-model
allowlist: every field is hidden and every slot suppressed unless the
exact model is known-good. SOLAAR_EXPERIMENTAL=true bypasses the masking
for testers. Non-persistent effect parameters (zone effects, LED
directions) keep their default-allow blocklist — unchanged.

device_quirks.py is rewritten around two per-feature allowlists with
their own accessors, replacing the flat blocklist QUIRKS dict.

Centurion device identification: _get_ids_centurion now derives modelId
from the firmware-stable model_id byte (G522 0x33, G325 0x45) instead of
productId, which is shared across the headset family and varies by
firmware (G522 0x0508 -> 0x0509) — it could never key a quirk reliably.

Approved models: G502 X PLUS and G515 TKL for the 0x8071 boot effects,
G522 for the 0x0622 signature effects (startup primary only, shutdown
both colors, no speed, passive slot suppressed pending RE).
2026-05-21 10:13:00 -04:00
Ken Sanislo d37573122f headset/RGB effects: document device default colors in tooltips
The signature-effect and RGB boot-animation settings have no reset
affordance, so a user who changes them has no in-app way back to the
factory state. Add the verified device defaults to each setting's
tooltip in #RRGGBB form, matching the color picker.

Signature effects (0x0622), confirmed from a G522 capture:
Primary #00B8FC, Secondary #FF00AB; Speed 100 startup/shutdown, 75
passive. RGB boot animations (0x8071 NvConfig 0x0001/0x0040):
Primary #FF0081, Secondary #80AAFF.
2026-05-21 10:13:00 -04:00
Ken Sanislo 5aae38f929 headset 0x0621: fix onboard effect param encoding from binary decode
Breathing rendered all LEDs off because wire byte 3 (CE[6]) was
hardcoded 0 — it is the intensity field (Breathing.Params field 3,
value/100). Encode it, expose the intensity slider, and seed a non-zero
default so picking the effect does not send an off frame.

DualColor wire byte 6 is intensity, not "speed" — the firmware-lighting
decode shows no speed/period field for DualColor. Rename throughout.

Custom (effect 5) is a stored card-reference effect, not a parametric
one; it cannot be set via setRGBClusterEffect. Drop it from the picker.

Also seed sane per-effect defaults (the picker rebuilds the effect from
zeroed widgets, so an unseeded pick sends an all-zero frame the firmware
rejects) and clamp the period slider to 1000-20000 ms, matching the
keyboard/mouse RGB effects.
2026-05-21 10:13:00 -04:00
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 936991e0b4 HeadsetOnboardEffect: add 0x0621 onboard RGB effect control
RGB headsets (e.g. G522) expose HEADSET_RGB_ONBOARD_EFFECTS (0x0621) —
a firmware-played RGB effect on the headset's primary lighting cluster,
chosen from six effect types: Fixed, Color Cycle, Color Wave,
Breathing, Dual Color, Custom.

Add an effect-switching HETERO setting modeled on the keyboard zone
effects: a six-way effect picker plus the per-effect parameter widgets
(colours, intensity, period, saturation, direction), with fields_map
showing only the fields the selected effect uses. The rw_class reads
getRGBClusterEffect and writes setRGBClusterEffect; the data class
encodes each effect's distinct parameter layout.

build() reads getRGBClusterInfo to learn the cluster's supported-effect
set and offers only those in the picker; an unparseable reply falls
back to offering all six (the firmware rejects any it doesn't support).
intensity is a 0-100 percent; saturation is a raw 0-255 byte, matching
the keyboard RGB effects. Like the signature/boot effects this runs
autonomously on the firmware and is not gated on host LED control.

Cluster 0 only — no multi-cluster headset has been seen and the
getInfo multi-cluster descriptor stride is unconfirmed.
2026-05-21 10:13:00 -04:00
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 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 7593781367 Drop 0x0623 probe — feature is unmapped and not in G HUB
G HUB doesn't touch 0x0623 either, so blind low-fn probing isn't
giving us anything to triangulate. Remove HEADSET_RGB_0623 from the
SupportedFeature enum and the probe call.
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 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
Marián Kyral 9780e730d8 cs.po update 2026-05-15 16:23:07 -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 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 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 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 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 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 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 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
Daniel Frost 1cb656b2e3
po: updating a few German strings (#3212) 2026-05-12 09:49:29 -04:00
Peter F. Patel-Schneider 22a457da9a settings: fix incorrect logic in range acceptable method 2026-05-12 08:59:25 -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
Ken Sanislo ec05c112f0 settings_templates: fix 0x1B0C wire encoding for Pro X 2 Superstrike
The AnalogButtons feature packs each tunable in bits 7..2 of its byte
(wire = logical << 2); byte 2 bit 0 is a firmware-managed sensitivity
flag, the rest of the low bits are reserved-zero. Solaar 1.1.19 sent
the slider value verbatim, so any logical 1/2/3 produced wire bytes
0x01/0x02/0x03 — non-zero reserved bits and below the logical minimum,
hence INVALID_ARGUMENT (issue #3202). Only multiples of 4 happened to
land on a valid wire byte.

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

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

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

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

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

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

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

* Enable GUI unpair for Lightspeed receivers

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

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

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

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

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

* Apply suggestion from @pfps

Lightspeed receivers don't appear to re-pair.

---------

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

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

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

Add regression tests covering composite flags and None input.

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

Refs #3184

* Fix line length style violation (E501)

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

---------

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

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

* Fix test_ping_errors missing mocks for _read_input_buffer and write

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

Add the same mocks used by the adjacent test_request_errors.

---------

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

Adds support for the Logitech PRO X 2 LIGHTSPEED Gaming Headset (PID 0x0AF7)
which uses the Centurion transport protocol (report ID 0x51 on USB usage page
0xFFA0) instead of standard HID++ report IDs.

Changes:
- HID enumeration: detect Centurion devices via report descriptor parsing
  (usage page 0xFFA0, report ID 0x51, 63-byte frames)
- Centurion transport: wrap/unwrap HID++ 2.0 frames in Centurion framing
  for write, read, and ping operations
- Feature discovery: enumerate features individually on Centurion devices
  (different response format: [remaining_count, feat_hi, feat_lo])
- Device descriptor for PRO X 2 LIGHTSPEED Gaming Headset
- New feature enum entries for Centurion-era headset features (0x06xx)
- CenturionRawRW class for write-only headset settings controlled via
  raw Centurion commands reverse-engineered from HeadsetControl
- HeadsetSidetone setting (0-100 range, persisted locally)

Known limitations:
- Only sidetone control is implemented; other features need RE work
- Settings are write-only (no read-back from device)
- Headset features (0x06xx) not discoverable via IRoot; registered manually

* Remove static PRO X 2 descriptor; fully probe Centurion devices at runtime

Replace the hardcoded descriptor entry with dynamic discovery of all device
properties via the Centurion protocol. The headset name, kind, serial,
firmware, and battery are now probed at runtime — matching how the device
actually presents itself rather than relying on static data.

Key changes:
- Discover sub-device features via CentPPBridge and route requests through
  the bridge automatically
- Infer device kind from feature IDs (0x06xx = headset) for both wireless
  and direct USB connections
- Read device name from USB product string with protocol probe fallback
- Parse bridge error responses (sub_feat_idx=0xFF) instead of timing out
- Handle unknown HID++ error codes gracefully in base.py
- Fix firmware deduplication for Centurion parent devices
- Prefer sub-device serial/firmware over parent (non-printable) values
- Add Centurion-aware display in solaar show with parent/sub-device sections
- Support both wireless (0AF7 dongle) and direct USB (0AF8) connections

* Display Centurion dongle as receiver with headset as child device

- Add CenturionReceiver class that provides the Receiver UI interface so
  the dongle appears as a parent with the headset indented underneath,
  matching how Lightspeed/Unifying receivers display
- Independently probe dongle features via feature_request() on the
  CenturionReceiver, separate from headset features via bridge
- Fix bridge notification dispatch: remove incorrect sub_cpl=0xFF filter
  that was silently dropping all battery and other notifications
- Fix battery status decoding: charging status is at byte 2 (not byte 1)
  of the CENTURION_BATTERY_SOC response
- Detect wired vs wireless by checking for CentPPBridge in discovered
  features; wired headsets fall back to standalone Device
- Name the dongle "Centurion Receiver" to distinguish from the headset
- Filter unprintable dongle serial (control characters 0x14-0x1F)
- Update CLI show output with proper receiver/child hierarchy and spacing

* Fix headset setting validators and code formatting

- Add signed int8 support to RangeValidator for HeadsetMicGain (0x0611)
- Make HeadsetSidetone version-aware: v1 uses 2-byte skip, v2+ uses
  3-byte skip with 0xFF separator per protocol spec
- Fix ruff formatting in device.py, listener.py, udev_impl.py
- Update CenturionReceiver test for renamed receiver

* Use ConnectionStateChangedEvent for headset online/offline detection

Replace ad-hoc heuristics with proper bridge event function dispatch:
- Function 0 (ConnectionStateChangedEvent): parse sub-device list length
  to determine connect (len>0) vs disconnect (len=0)
- Function 1 (MessageEvent): fallback online detection if headset sends
  a message while marked offline (handles cold-start power-on)

Remove CPL sub_id>=0x80 fallback in listener that misidentified HID++
error replies as disconnect events. Skip HID++ 1.0
set_configuration_pending_flags for CenturionReceiver (not supported).

Also adds OnboardEQ (0x0636) support, bridge multi-fragment sends,
bridge-based headset ping probe, and CLI offline display.

* Update PRO X 2 LIGHTSPEED device doc with current solaar show output

* Fix Centurion protocol version display (1.16 not 2.6)

The HID++ ping math (major + minor/10.0) produced a bogus "2.6" for
Centurion devices whose ProtocolCapabilities returns major=1, minor=0x10.
Store the raw (major, minor) bytes from the ping response and display
them correctly as "Centurion 1.16" in both CLI and GUI.

* Add OnboardEQ (0x0636) support for Centurion headsets

Implement host-computed biquad EQ coefficient generation and multi-fragment
bridge writes for the PRO X 2 LIGHTSPEED headset's 5-band parametric EQ.

The coefficient algorithm uses standard Audio EQ Cookbook peaking EQ formulas
with a simplified rescale normalization (max_b0 × 1.19 headroom). This is our
own implementation — not an exact replica of LGHUB's ~350-line per-band cascade
normalization — but it produces functionally correct results. The DSP
compensates via the rescale factor, and the EQ changes are audible and working
on real hardware.

Wire format verified against 38 LGHUB pcap writes:
- 4-byte LE section headers, LE uint16 coefficient words
- Mixed Q1.31/Q2.30 fixed-point with 24-bit precision
- Only b-coefficients divided by rescale; a-coefficients unchanged
- Two sections: 48kHz playback + 16kHz mic
- No trailing padding, no extra words between sections

Changes:
- base.py: Add flags parameter to write_centurion_cpl() for multi-fragment CPL
- device.py: Rewrite multi-fragment bridge send — proper CPL fragmentation with
  fragment 0 carrying bridge prefix/hdr and continuations carrying raw sub_msg,
  all fragments sent back-to-back without intermediate ACKs
- hidpp20.py: Replace placeholder coefficient code with full biquad math,
  mixed Q-format quantization, rescale normalization, and dual-section output
- settings_templates.py: Persist EQ to slot 0x80 after writing to slot 0x00
  so settings survive power cycle
- tests: Update expected SetEQParameters payloads for new coefficient format

* Extract Centurion protocol into separate modules

Move CenturionReceiver class, factory function, and Centurion protocol
queries (firmware, serial, hardware info, battery, name) from device.py
and hidpp20.py into new centurion.py module. Move OnboardEQ biquad math
and payload builders from hidpp20.py into new onboard_eq.py module.
Move _read_usb_product_string() to common.py to avoid circular imports.

Re-exports preserve backward compatibility for all existing callers.

* Add vertical graphic EQ slider widget for headset equalizer

Replace horizontal slider rows with a traditional graphic EQ layout
using vertical sliders side-by-side, with dB value display and
frequency labels per band.

* Fix device online state clobbered by debug ping in _status_changed

The INFO-level logging guard in _status_changed() called device.ping()
before logging, purely to show accurate online status. But ping() has
side effects — it sets device.online based on the result. When a
ConnectionStateChangedEvent correctly marked a device online, the
subsequent _status_changed() callback would re-ping. If the device
wasn't ready yet (e.g. Centurion headset still booting), the ping
timed out and set online back to False, requiring 2-3 power cycles
to sync state.

Remove the unnecessary ping — the log message already reads
device.online which reflects the state set by the event handler.

* Sort feature constants by ID and add PROFILE_MANAGEMENT

Move RPM_INDICATOR/RPM_LED_PATTERN (0x807A-B) before PER_KEY_LIGHTING
(0x8080-81), sort five Centurion-era headset entries into their correct
positions by feature ID, and add missing PROFILE_MANAGEMENT = 0x8101.

* Add CenturionCoreFeature enum for colliding feature IDs

Centurion transport reuses HID++ 2.0 feature IDs 0x0000, 0x0001,
0x0003, 0x0005, 0x0007 with different meanings. Since SupportedFeature
(IntEnum) requires unique values, create a separate CenturionCoreFeature
enum and resolve_feature() helper for transport-aware lookup.

Also replace the +0x100 offset hack in FeaturesArray.inverse with a
dedicated sub_inverse dict for sub-device feature indexing.

* Fix ruff I001 import sorting in centurion.py and hidpp20.py

* Add 9 missing centurion/headset feature names

Add feature constants split out from the HID++ 2.0 names PR (#3153):
CENTURION_LED_BRIGHTNESS (0x0110), CENTURION_EU_POWER_MODE (0x0115),
CENTURION_DEVICE_BOOL_STATE (0x0116), HEADSET_ADVANCED_PARA_EQ (0x020D),
HEADSET_MIC_TEST (0x020E), HEADSET_EQ_STYLES (0x0213),
BT_HOST_INFO (0x0305), LIGHTSPEED_PAIRING (0x0309),
BT_GAMING_MODE (0x030A).

* Extract _record_ping_protocol helper so all ping paths capture Centurion version

The raw Centurion (major, minor) pickup was only in the Centurion-child
dongle branch of Device.ping(). Wired Centurion variants (e.g. PRO X 2
LIGHTSPEED 046d:0AF8) go through the generic fallback branch and never
recorded the raw version, so they displayed "Centurion 2.6" instead of
"Centurion 1.16".

Extract the protocol + centurion version recording into a helper and
call it from both branches.

---------

Co-authored-by: Peter F. Patel-Schneider <pfpschneider@gmail.com>
2026-04-14 11:43:23 -04:00
Alessio85 7d571d855f
device: Fix crash in NotificationFlag.flag_names when flags is None (#3185)
flag_names() crashes with AttributeError when NotificationFlag returns
a value whose .name attribute is None. This occurs with certain receiver
firmware versions (Nano C52F, Unifying C52B, Bolt C548).

Return an empty list instead, consistent with the List[str] return type.

Fixes #3184

Co-authored-by: avercelli <avercelli@vulog.com>
2026-04-13 12:46:43 -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
Nikolay Yordanov 99a403c554
add Bulgarian translation
* Bulgarian PO files

Български превод на приложението

* Delete bg.mo

* Delete bg.po

* Delete bg.po~

* Bulgarian Translation

Български превод на приложението.

* Update i18n.md

* Delete po/bg.mo

* Delete po/bg.po~
2026-04-07 08:36:02 -04:00
Ken Sanislo b9e0cf8235
hidpp: Add names for HID++ 2.0 features and sort by ID (#3153)
Add 30 documented HID++ 2.0 feature names from LGHUB source analysis:
keyboard/mouse (PROPERTY_ACCESS, BLE_PRO_PRE_PAIRING, FULL_KEY_CUSTOMIZATION,
CONTROL_LIST, SWITCH_SWAPABILITY, DEVICE_MODE, ENABLE_HIDDEN_FEATURES,
KEYBOARD_DISABLE_CONTROLS, LOGI_MODIFIERS), racing peripherals
(RPM_INDICATOR, RPM_LED_PATTERN, LEGACY/AXIS_RESPONSE_CURVE, BANDED_AXIS,
COMBINED_PEDALS, BUNNY_HOPPING, PROFILE_MANAGEMENT, DUAL_CLUTCH,
WHEEL_CENTER_POSITION, DISPLAY_GAME_DATA, CENTER_SPRING, AXIS_MAPPING,
GLOBAL_DAMPING, BRAKE_FORCE, PEDAL_STATUS, TORQUE_LIMIT,
CONFIGURATION_PROFILES, OPERATING_RANGE, TRUE_FORCE, FFB_FILTER).

Sort RPM_INDICATOR/RPM_LED_PATTERN (0x807A-B) before PER_KEY_LIGHTING
(0x8080-81) to maintain ID ordering.
2026-03-20 09:07:11 -04:00
Peter F. Patel-Schneider a22ae124d9 device: don't use Logitech for codename 2026-03-19 11:27:26 -04:00
Peter F. Patel-Schneider 230eaf242c docs: add several device descriptions 2026-03-19 11:27:26 -04:00
Peter F. Patel-Schneider ee25bc76c7 device: put lock around getting device name 2026-03-19 11:27:26 -04:00
Peter F. Patel-Schneider dc9affe6fb hidpp: fix bug when showing device notification flags 2026-03-19 11:27:26 -04:00
Peter F. Patel-Schneider 7520c9cc28 hidpp20: be defensive about no device features 2026-03-13 16:21:51 -04:00
Peter F. Patel-Schneider 94e94c1254 hidpp: add feature x1b04 flag sent by M510 4004 2026-03-13 16:21:51 -04:00
Peter F. Patel-Schneider 55a67c142c device: remove incorrect descriptor for WPID 4004 2026-03-13 16:21:51 -04:00
Peter F. Patel-Schneider 51532252df ui: better handling of missing devices 2026-03-13 13:41:59 -04:00
Grant Scott Turner 940aae1be1
Improve RHEL installation guide and add automated install example (#3162)
* Improve RHEL installation guide and add automated install example

* docs: fix RHEL guide formatting and pre-commit style
2026-03-10 07:43:25 -04:00
Peter F. Patel-Schneider 4525704793 docs: document change to use uinput only 2026-03-08 20:58:43 -04:00
Peter F. Patel-Schneider f17021e2f0 rules: remove use of XTest and use uinput in all cases 2026-03-08 20:58:43 -04:00
gnotree ✟🕊︎ 9344466949
Add installation guide for Solaar on RHEL 10 (#3158)
* Add installation guide for Solaar on RHEL 10

Document steps to install and run Solaar on RHEL 10, including environment setup and troubleshooting.

* Add interactive RHEL installer script
2026-03-08 20:54:31 -04:00
Br1an67 8cea17fc46 Use console_scripts entry point for pipx compatibility
Replace scripts=glob("bin/*") with entry_points console_scripts
to make solaar installable via pipx. pipx requires packages to
define console_scripts entry points to detect command-line apps.
2026-03-07 07:17:54 -05: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
Din Tort 310b3af76f
Skip Logitech webcams to prevent them from locking up during HID++ checks on Macs
* Skip Logitech webcams (PID 0x0800 to 0x09FF) to prevent them from locking up during hidpp checks #3145

* Skip Logitech webcams (PID 0x0800 to 0x09FF) to prevent them from locking up during hidpp checks #3145 - format comment

* Skip Logitech webcams (PID 0x0800 to 0x09FF) to prevent them from locking up during hidpp checks #3145 - format hex

* Skip Logitech webcams from hidpp checks #3145 - local constant for LOGITECH_VENDOR_ID as per code review
2026-02-28 10:50:36 -05: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 d919bcbb30 device: downgrade ping no such device to informational log entry 2026-02-26 08:46:22 -05:00
Peter F. Patel-Schneider 97dd9467b5 device: add names for G500 mouse 2026-02-26 07:49:18 -05:00
Peter F. Patel-Schneider cbb3106993 device: recover from guessing the wrong number for direct-connected HID++ 1.0 devices 2026-02-26 07:49:18 -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 40dcaadec7 docs: add information on G733 Gaming Headest 2026-02-05 10:41:07 -05:00
Peter F. Patel-Schneider 1e756f6438 po: fix format characters in sk translation 2026-02-05 10:39:43 -05:00
Peter F. Patel-Schneider a79bb24da5 cli: correctly handle timeout in Bolt discovery 2026-01-18 14:21:56 -05:00
aasami 7dbb51b05b Update sk.po
Almost complete Slovak translation.
2026-01-18 14:20:18 -05:00
Mário Victor Ribeiro Silva d82233b69c feat: update pt_BR translations 2026-01-08 22:40:59 -05:00
daviddavid 2f3b3c1964 Update French translation (for release 1.1.19)
- by David Geiger <david.david@mageialinux-online.org>
2026-01-08 14:40:36 -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 6926047020 device: handle inaccessiable devices when determining protocol 2026-01-08 12:37:31 -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
Ekaterine Papava ce1adc7b03 po: Add Georgian translation 2026-01-07 22:50:17 -05:00
Peter F. Patel-Schneider fc68521731 release 1.1.19rc1 2025-12-29 09:47:07 -05: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 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
Peter F. Patel-Schneider 36377fdd5a doc: instructions on running solaar show in 1.1.18 2025-12-21 18:01:53 -05:00
Peter F. Patel-Schneider a427c66dc1 tools: add python3-devel to install-dnf in Makefile 2025-12-20 12:31:17 -05:00
Peter F. Patel-Schneider f0c64f5fb3 tools: improve flags for hidconsole 2025-12-19 10:55:50 -05:00
Peter F. Patel-Schneider a0e19282ec tools: hidconsole can send an HID command non-interactively 2025-12-19 09:38:17 -05:00
Peter F. Patel-Schneider e999b12246 receiver: add info about new lightspeed receiver 2025-12-17 15:52:17 -05:00
Peter F. Patel-Schneider d3216ea57a device: remove debugging statement 2025-12-17 15:44:14 -05:00
Nick 0b6a5fa108 Update Swedish translation
Fix one translation
2025-12-17 11:47:25 -05:00
Peter F. Patel-Schneider ff23601183 device: fix bug when showing details about direct-connected device 2025-12-16 15:23:06 -05:00
Daniel Nylander aaabb5d811 Updated Swedish translation 2025-12-13 07:53:04 -05:00
Peter Dave Hello ccf1ac5b6d po: Update zh_TW Traditional Chinese locale 2025-12-13 07:52:20 -05:00
Peter F. Patel-Schneider 46da00e214 release: drop testing of Python before 3.13 2025-12-12 04:55:14 -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 93e90f4894 docs: update pairing documentation 2025-12-10 14:03:44 -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 4d2a42d541 doc: better formating of release notes 2025-12-09 15:41:02 -05:00
128 changed files with 29086 additions and 8148 deletions

View File

@ -23,6 +23,8 @@ assignees: ''
- Distribution:
- Kernel version (ex. `uname -srmo`):
- Output of `solaar show`:
<!-- To run `solaar show` in 1.1.18 you have to clone Solaar from this repository
and `run bin/solaar show` from the download directory. -->
<details>

View File

@ -8,7 +8,7 @@ jobs:
strategy:
matrix:
python-version: [3.8, 3.13]
python-version: [3.13]
fail-fast: false
steps:
@ -54,7 +54,7 @@ jobs:
strategy:
matrix:
python-version: [3.8, 3.13]
python-version: [3.13]
fail-fast: false
steps:

View File

@ -1,3 +1,93 @@
# 1.1.20rc2
* Mock libnotify to not perform notifications when doing tests
* Isolate testing from real configuration file
* Update handling of headset RGB controls
* Update equalizer processing
* Hide read-only paramaters from the UI
* Better support for G522 Lightspeed headset
* Use mostly full names for direct-USB codenames
* Use correct icon for CENTURION devices
* rgb_control: honor the off state — don't auto-claim, init, or shutdown LEDs
* base: fix sw_id at 0x0B instead of rotating 0x2..0xF (#3218)
* perkey/canvas: allow rect/gradient anchors in grid gaps
* config_panel: don't show failed-write alert for unreadable settings
* listener: share bluez-watch wiring across Centurion-direct and standard device paths
* rgb_power.perkey_has_paint: gate on IGNORE only, not on != True
* about: add Ken Sanislo to Additional Programming credits
* Add RGB lighting persistence and software LED power management for G515
* ui: Show offline status for receiver-paired device batteries (#3217)
* LEDControl / RGBControl: render as Gtk.Switch instead of a 2-option combo (#3215)
* device: Fix operator precedence bug and end-of-configuration timing in device.changed() (#3173)
* PerKeyLighting: drop misleading live-read output in solaar show
* perkey: label G502 X LEDs by zone id, not letter
* PerKey gradient swatch: align gradient endpoints to visible corners
* PerKey gradient swatch: Tabler "square" outline around the gradient
* PerKey dialog: one window per device, keyed by firmware unit-id
* PerKey dialog: size window from measured natural size
* PerKey icons: read theme fg from style-updated, not Settings notify
* PerKey canvas: symmetric hash stripes for unset cells
* PerKey palette: replace hashed unset swatch with palette-off icon
* PerKeyEditor: rebuild tool icons on GTK theme change
* PerKeyEditor: replace tool button labels with icons
* common: render RGB color values as 0xrrggbb in config and solaar show
* Better display of LED effects for some devices in solaar show.
* Fix bug affecting using solaar config to change range-based settings.
* Add regional keyboard layouts
* Use per-key RGB color painter
* Fix bug in notification flag handling
* Fix bug in HID parser
* Update Swedish, German, Polish, Chinese translations
* Use battery-level-N icons when available
* Document haptic capabilities
* Support per-slot unpair on Lightspeed receivers
* Fix bugs related to integer flags in older versions of Python
* Add mention of Centurion protocol support
* Treat empty hidraw read as device removal (EOF) (#3174)
* fix interface for K845
* support PRO X 2 LIGHTSPEED headphones Centurion features (#3150)
* Fix crash in NotificationFlag.flag_names when flags is None (#3185)
* Add PRO X 2 Superstrike mouse support with HITS tuning settings (#3132)
* Add names for some HID++ 2.0 features and sort by ID (#3153)
* Don't use Logitech for codename
* Put lock around getting device name
* Fix bug when showing device notification flags
* Be defensive about no device features
* Add feature x1b04 flag sent by M510 4004
* Remove incorrect descriptor for WPID 4004
* Better handling of missing devices
* Improve RHEL installation guide and add automated install example (#3162)
* Remove use of XTest and use uinput in all cases
* Add installation guide for Solaar on RHEL 10 (#3158)
* Use console_scripts entry point for pipx compatibility
* Skip Logitech webcams to prevent them from locking up during HID++ checks on Macs
* Downgrade ping no such device to informational log entry
* Recover from guessing the wrong number for direct-connected HID++ 1.0 devices
* Tolerate devices with no unitId
* Correctly handle timeout in Bolt discovery
* Update or add fr, pt_BR, sk, Ukrainian, Finnish, Bulgarian translations
* Handle missing receiver_path more gracefully
* Handle inaccessiable devices when determining protocol
* Be defensive when showing features in solaar show
# 1.1.19
* New Georgian translation
* Remove test that doesn't work in older Pythons
* Update help messages for CLI commands
* Allow solaar config to change LED settings
* Add python3-devel to install-dnf in Makefile
* hidconsole can send an HID command non-interactively
* Add info about new lightspeed receiver
* Update Swedish and zh_TW translation
* Fix bug when showing details about direct-connected device
* Drop testing of Python before 3.13
* Fix crash in solaar show when showing notification flags. (#3070)
# 1.1.18
* Fix crash when showing notification flags
# 1.1.17
* Add dark icons

View File

@ -25,8 +25,8 @@ install_apt_python3.13:
sudo apt install libdbus-1-dev libglib2.0-dev libgtk-3-dev libgirepository-2.0-dev gobject-introspection
install_dnf:
@echo "Installing Solaar dependencies via dn"
sudo dnf install gtk3 python3-gobject python3-dbus python3-pyudev python3-psutil python3-xlib python3-yaml
@echo "Installing Solaar dependencies via dnf"
sudo dnf install gtk3 python3-devel python3-gobject python3-dbus python3-pyudev python3-psutil python3-xlib python3-yaml
install_brew:
@echo "Installing Solaar dependencies via brew"

View File

@ -11,7 +11,7 @@ Release routine:
- Add release changes to `CHANGELOG.md`
- Add release information to `share/solaar/io.github.pwr_solaar.solaar.metainfo.xml`
- Create a commit that starts with `release VERSION`
- Push commit to Solaar repository
- Push commit to Solaar repository and merge it
- Invoke `./release.sh`
- Git tags are signed so you must have GPG set up
- You are required to have a github token with `public_repo` access

175
RHEL.md Normal file
View File

@ -0,0 +1,175 @@
# Solaar installation guide for RHEL, Rocky, AlmaLinux, and CentOS Stream
This guide covers manual installation and an automated install example for
RHEL-family systems using `dnf`.
## Supported distributions
- Red Hat Enterprise Linux (RHEL)
- Rocky Linux
- AlmaLinux
- Oracle Linux
- CentOS Stream
The commands assume a minimal CLI system with `sudo` access.
## 1) Install dependencies
```bash
sudo dnf makecache --refresh
sudo dnf install -y \
git \
gtk3 \
python3 \
python3-devel \
python3-dbus \
python3-gobject \
python3-pip \
python3-psutil \
python3-pyudev \
python3-setuptools \
python3-xlib \
python3-yaml
```
Optional troubleshooting helpers:
```bash
sudo dnf install -y \
evemu \
libinput \
usbutils
```
## 2) Clone Solaar
```bash
git clone https://github.com/pwr-Solaar/Solaar.git
cd Solaar
```
## 3) Install Solaar
Install for the current user:
```bash
python3 -m pip install --user .
```
Or install system-wide:
```bash
sudo python3 -m pip install .
```
## 4) Install udev rules
Install the recommended `uinput` rule:
```bash
sudo make install_udev_uinput
```
Verify rule installation:
```bash
ls -l /etc/udev/rules.d/42-logitech-unify-permissions.rules
```
Rollback udev rule installation:
```bash
sudo make uninstall_udev
```
## 5) Run Solaar
```bash
solaar
```
or:
```bash
python3 -m solaar
```
## 6) Automated install options
Use the guided installer in this repository:
```bash
./tools/install-rhel.sh
```
Minimal non-interactive example script:
```bash
cat > install-rhel-solaar.sh <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
if [[ "${EUID}" -eq 0 ]]; then
echo "Run as a regular user with sudo access, not as root."
exit 1
fi
sudo dnf makecache --refresh
sudo dnf install -y \
git \
gtk3 \
python3 \
python3-devel \
python3-dbus \
python3-gobject \
python3-pip \
python3-psutil \
python3-pyudev \
python3-setuptools \
python3-xlib \
python3-yaml
if [[ ! -d Solaar/.git ]]; then
git clone https://github.com/pwr-Solaar/Solaar.git
fi
cd Solaar
python3 -m pip install --user .
sudo make install_udev_uinput
~/.local/bin/solaar --version
SCRIPT
chmod +x install-rhel-solaar.sh
./install-rhel-solaar.sh
```
## 7) Verification
```bash
command -v solaar
solaar --version
python3 -m pip show solaar
```
If installed with `--user`, ensure `~/.local/bin` is on your `PATH`:
```bash
echo "$PATH" | tr ':' '\n' | grep -Fx "$HOME/.local/bin" >/dev/null || \
echo 'Add ~/.local/bin to PATH'
```
## 8) Troubleshooting
Receiver not detected:
```bash
lsusb | grep -Ei 'logitech|046d'
sudo udevadm trigger
```
Check access to hidraw devices:
```bash
ls -l /dev/hidraw*
getfacl /dev/hidraw* 2>/dev/null | sed -n '1,80p'
```

View File

@ -1,12 +1,22 @@
# Notes on Major Changes in Releases
## Since 1.1.16
## Version 1.1.20
* Solaar has much better support for the LEDs on some newer devices, such as the G515 Lightspeed TKL.
* Solaar now supports the Centurion protocol, a variation of the HIDP++ protocol that is used by several headsets.
* Solaar now uses uinput for all simulated input, even in X11. As a result there is no need for a separate udev rule for Wayland, and it may be removed in future.
## Version 1.1.18
* Solaar is only guaranteed to work in Python 3.13 or later.
## Version 1.1.17
* Several new features have been added related to the MX Master 4
** The scroll ratchet force can be adjusted
** The force required to click the button under your thumb can be adjusted
** The haptic force can be adjusted
** Haptic feeback can be triggered by commands like `solaar config 'mx master 4' haptic-play 'HAPPY ALER'`
* The scroll ratchet force can be adjusted
* The force required to click the button under your thumb can be adjusted
* The haptic force can be adjusted
* Haptic feeback can be triggered by commands like `solaar config 'mx master 4' haptic-play 'HAPPY ALER'`
## Version 1.1.16

View File

@ -0,0 +1,374 @@
# Logitech PRO X 2 Superstrike - Solaar CLI Reference
This document describes all available settings for the Logitech PRO X 2 Superstrike mouse via the Solaar CLI.
## Device Identification
| Property | Value |
|----------|-------|
| Name | PRO X 2 Superstrike |
| WPID | 40BD |
| Protocol | HID++ 4.2 |
| Kind | mouse |
## General CLI Syntax
```bash
# List all settings for device
solaar config <device>
# Read a specific setting
solaar config <device> <setting-name>
# Write a specific setting
solaar config <device> <setting-name> <value>
```
The `<device>` can be:
- Device number (e.g., `1`)
- Device name (e.g., `"PRO X 2 Superstrike"`)
- Serial number (e.g., `A1C55DB2`)
---
## Available Settings
### 1. Onboard Profiles
Controls whether the device uses its onboard profile or host-controlled settings.
| Property | Value |
|----------|-------|
| Setting Name | `onboard_profiles` |
| Type | Choice |
| Possible Values | `Disabled`, `Profile 1` |
**Commands:**
```bash
# Read current value
solaar config 1 onboard_profiles
# Set to disabled (allows host control of DPI, report rate, etc.)
solaar config 1 onboard_profiles Disabled
# Set to Profile 1 (use onboard profile)
solaar config 1 onboard_profiles "Profile 1"
```
**Note:** Many settings require `onboard_profiles` to be set to `Disabled` to be effective.
---
### 2. Report Rate
Controls the frequency of device movement reports.
| Property | Value |
|----------|-------|
| Setting Name | `report_rate_extended` |
| Type | Choice |
| Possible Values | `8ms`, `4ms`, `2ms`, `1ms`, `500us`, `250us`, `125us` |
**Commands:**
```bash
# Read current value
solaar config 1 report_rate_extended
# Set to 1ms (1000Hz)
solaar config 1 report_rate_extended 1ms
# Set to 500us (2000Hz)
solaar config 1 report_rate_extended 500us
# Set to 125us (8000Hz)
solaar config 1 report_rate_extended 125us
```
**Polling Rate Reference:**
| Value | Polling Rate |
|-------|--------------|
| `8ms` | 125 Hz |
| `4ms` | 250 Hz |
| `2ms` | 500 Hz |
| `1ms` | 1000 Hz |
| `500us` | 2000 Hz |
| `250us` | 4000 Hz |
| `125us` | 8000 Hz |
---
### 3. Sensitivity (DPI)
Controls mouse movement sensitivity.
| Property | Value |
|----------|-------|
| Setting Name | `dpi_extended` |
| Type | Complex (X, Y, LOD) |
| DPI Range | 100 - 32000 |
| LOD Values | `LOW`, `HIGH` |
**Commands:**
```bash
# Read current value
solaar config 1 dpi_extended
# Set DPI (format: {X:<value>, Y:<value>, LOD:<value>})
solaar config 1 dpi_extended "{X:800, Y:800, LOD:HIGH}"
# Set to 1600 DPI
solaar config 1 dpi_extended "{X:1600, Y:1600, LOD:HIGH}"
# Set different X and Y sensitivity
solaar config 1 dpi_extended "{X:800, Y:1600, LOD:LOW}"
```
---
## HITS Tuning Settings (Hall-Effect Inductive Trigger Switch)
These settings control the advanced click behavior of the PRO X 2 Superstrike's hall-effect switches.
### 4. Actuation Point
Controls how deep the button must be pressed to register a click.
| Property | Value |
|----------|-------|
| Setting Name (Left) | `superstrike-tuning_actuation-0` |
| Setting Name (Right) | `superstrike-tuning_actuation-1` |
| Type | Range |
| Range | 1 - 10 |
| Default | 5 |
**Value Interpretation:**
- `1` = Shallowest (hair trigger, minimal press)
- `10` = Deepest (full press required)
**Commands:**
```bash
# Read left button actuation
solaar config 1 superstrike-tuning_actuation-0
# Read right button actuation
solaar config 1 superstrike-tuning_actuation-1
# Set left button to shallow actuation (hair trigger)
solaar config 1 superstrike-tuning_actuation-0 1
# Set left button to deep actuation
solaar config 1 superstrike-tuning_actuation-0 10
# Set right button to medium actuation
solaar config 1 superstrike-tuning_actuation-1 5
```
---
### 5. Rapid Trigger Level
Controls the rapid trigger sensitivity, which allows the button to re-actuate quickly after partial release.
| Property | Value |
|----------|-------|
| Setting Name (Left) | `superstrike-tuning_rapid-trigger-level-0` |
| Setting Name (Right) | `superstrike-tuning_rapid-trigger-level-1` |
| Type | Range |
| Range | 1 - 5 |
| Default | 3 |
**Value Interpretation:**
- `1` = Fastest (most sensitive, smallest movement to re-trigger)
- `5` = Slowest (least sensitive, larger movement needed)
**Note:** Rapid trigger cannot be disabled on this device. The minimum level is 1.
**Commands:**
```bash
# Read left button rapid trigger level
solaar config 1 superstrike-tuning_rapid-trigger-level-0
# Read right button rapid trigger level
solaar config 1 superstrike-tuning_rapid-trigger-level-1
# Set left button to fastest rapid trigger
solaar config 1 superstrike-tuning_rapid-trigger-level-0 1
# Set left button to slowest rapid trigger
solaar config 1 superstrike-tuning_rapid-trigger-level-0 5
# Set right button to medium rapid trigger
solaar config 1 superstrike-tuning_rapid-trigger-level-1 3
```
---
### 6. Click Haptics
Controls the intensity of the haptic feedback when clicking.
| Property | Value |
|----------|-------|
| Setting Name (Left) | `superstrike-tuning_haptics-0` |
| Setting Name (Right) | `superstrike-tuning_haptics-1` |
| Type | Range |
| Range | 0 - 5 |
| Default | 3 |
**Value Interpretation:**
- `0` = Off (no haptic feedback)
- `1` = Minimal
- `2` = Light
- `3` = Medium
- `4` = Strong
- `5` = Strongest (maximum haptic feedback)
**Commands:**
```bash
# Read left button haptics level
solaar config 1 superstrike-tuning_haptics-0
# Read right button haptics level
solaar config 1 superstrike-tuning_haptics-1
# Disable haptics on left button
solaar config 1 superstrike-tuning_haptics-0 0
# Set left button to maximum haptics
solaar config 1 superstrike-tuning_haptics-0 5
# Set right button to medium haptics
solaar config 1 superstrike-tuning_haptics-1 3
```
---
## Complete Settings Summary
| Setting | CLI Name | Type | Range/Values | Button-Specific |
|---------|----------|------|--------------|-----------------|
| Onboard Profiles | `onboard_profiles` | Choice | `Disabled`, `Profile 1` | No |
| Report Rate | `report_rate_extended` | Choice | `8ms` to `125us` | No |
| Sensitivity | `dpi_extended` | Complex | 100-32000 DPI | No |
| Actuation Point | `superstrike-tuning_actuation-{0,1}` | Range | 1-10 | Yes |
| Rapid Trigger | `superstrike-tuning_rapid-trigger-level-{0,1}` | Range | 1-5 | Yes |
| Click Haptics | `superstrike-tuning_haptics-{0,1}` | Range | 0-5 | Yes |
---
## Batch Configuration Examples
### Gaming Profile (Fast Response)
```bash
#!/bin/bash
# Gaming profile: fast actuation, sensitive rapid trigger, medium haptics
solaar config 1 onboard_profiles Disabled
solaar config 1 report_rate_extended 125us
solaar config 1 dpi_extended "{X:800, Y:800, LOD:HIGH}"
# Left button - hair trigger
solaar config 1 superstrike-tuning_actuation-0 1
solaar config 1 superstrike-tuning_rapid-trigger-level-0 1
solaar config 1 superstrike-tuning_haptics-0 3
# Right button - hair trigger
solaar config 1 superstrike-tuning_actuation-1 1
solaar config 1 superstrike-tuning_rapid-trigger-level-1 1
solaar config 1 superstrike-tuning_haptics-1 3
```
### Productivity Profile (Comfortable)
```bash
#!/bin/bash
# Productivity profile: deeper actuation, slower rapid trigger, strong haptics
solaar config 1 onboard_profiles Disabled
solaar config 1 report_rate_extended 1ms
solaar config 1 dpi_extended "{X:1600, Y:1600, LOD:HIGH}"
# Left button - comfortable click
solaar config 1 superstrike-tuning_actuation-0 7
solaar config 1 superstrike-tuning_rapid-trigger-level-0 4
solaar config 1 superstrike-tuning_haptics-0 5
# Right button - comfortable click
solaar config 1 superstrike-tuning_actuation-1 7
solaar config 1 superstrike-tuning_rapid-trigger-level-1 4
solaar config 1 superstrike-tuning_haptics-1 5
```
### Silent Profile (No Haptics)
```bash
#!/bin/bash
# Silent profile: no haptic feedback
solaar config 1 superstrike-tuning_haptics-0 0
solaar config 1 superstrike-tuning_haptics-1 0
```
---
## Programmatic Usage
### Reading All Settings (JSON-like parsing)
```bash
# Get all settings as output
solaar config 1 2>/dev/null | grep "^[a-z]" | while read line; do
setting=$(echo "$line" | cut -d'=' -f1 | tr -d ' ')
value=$(echo "$line" | cut -d'=' -f2 | tr -d ' ')
echo "{\"setting\": \"$setting\", \"value\": \"$value\"}"
done
```
### Reading a Single Setting Value
```bash
# Extract just the value
solaar config 1 superstrike-tuning_actuation-0 2>/dev/null | grep "^superstrike" | cut -d'=' -f2 | tr -d ' '
```
### Error Handling
```bash
# Check if command succeeded
if solaar config 1 superstrike-tuning_actuation-0 5 2>/dev/null; then
echo "Setting applied successfully"
else
echo "Failed to apply setting"
fi
```
---
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | Error (device not found, invalid setting, invalid value) |
---
## Notes
1. **Device Discovery**: Use `solaar show` to list all connected devices and their indices.
2. **Persistence**: Settings are saved to `~/.config/solaar/config.yaml` and automatically reapplied when the device reconnects.
3. **Onboard Profiles**: When `onboard_profiles` is set to `Profile 1`, some settings (DPI, report rate) are controlled by the device's onboard memory and cannot be changed via Solaar.
4. **HITS Settings**: The actuation, rapid trigger, and haptics settings are stored in the device and persist across reconnections, regardless of the onboard profile setting.
5. **Button Index**: `0` = Left button, `1` = Right button.

View File

@ -21,9 +21,9 @@ Not all such devices supported in Solaar as information needs to be added to Sol
for each device type that directly connects.
## HID++
## HID++ and Centurion
The devices that Solaar handles use Logitech's HID++ protocol.
The devices that Solaar handles use Logitech's HID++ and Centurion protocols.
HID++ is a Logitech-proprietary protocol that extends the standard HID
protocol for interfacing with receivers, keyboards, mice, and so on. It allows
@ -43,6 +43,8 @@ Contrariwise, two different devices may appear different physically but
actually look the same to software. (For example, some M185 mice look the
same to software as some M310 mice.)
Centurion is closely related to HID++ and is used by some Logitech headsets.
The software identity of a receiver can be determined by its USB product ID
(reported by Solaar and also viewable in Linux using `lsusb`). The software
identity of a device that connects to a receiver can be determined by
@ -56,12 +58,12 @@ Bluetooth product ID.
Solaar is able to pair and unpair devices with
receivers as supported by the device and receiver.
For Unifying receivers, pairing adds a new paired device, but
For Unifying and Bolt receivers, pairing adds a new paired device, but
only if there is an open slot on the receiver. So these receivers need to
be able to unpair devices that they have been paired with or else they will
not have any open slots for pairing. Some other receivers, like the
Nano receiver with USB ID `046d:c534`, can only pair with particular kinds of
devices and pairing a new device replaces whatever device of that kind was
not have any open slots for pairing. Some Nano and Lightspeed receivers, like the
Nano receiver with USB ID `046d:c534`, can only pair with one keyboard and one mouse
and pairing a new device replaces whatever device of that kind was
previously paired to the receiver. These receivers cannot unpair. Further,
some receivers can pair an unlimited number of times but others can only
pair a limited number of times.
@ -69,20 +71,22 @@ pair a limited number of times.
Bolt receivers add an authentication phase to pairing,
where the user has type a passcode or click some buttons to authenticate the device.
Only some connections between receivers and devices are possible. In should
Only some connections between receivers and devices are possible. It should
be possible to connect any device with a Unifying logo on it to any receiver
with a Unifying logo on it. Receivers without the Unifying logo probably
can connect only to the kind of devices they were bought with and devices
without the Unifying logo can probably only connect to the kind of receiver
that they were bought with.
with a Unifying logo on it and any device with a Bolt logo on it to any receiver
with a Bolt logo on it.
Many receivers without the Unifying or Bolt logo
can connect only to the model of devices they were bought with and many devices
without the Unifying or Bolt logo can only connect to a receiver
that matches the one they were bought with.
## Device Settings
Solaar can display quite a few changeable settings of receivers and devices.
For a list of HID++ features and their support see [the features page](features.md).
For a list of features and their support see [the features page](features.md).
Solaar does not do much beyond using the HID++ protocol to change the
Solaar does not do much beyond using the protocols to change the
behavior of receivers and devices via changing their settings.
In particular, Solaar cannot change how
the operating system turns the keycodes that a keyboard produces into
@ -112,7 +116,7 @@ Solaar keeps track of settings independently on each computer.
As a result if a device is switched between different computers
Solaar may apply different settings for it on the different computers.
Querying a device for its current state can require quite a few HID++
Querying a device for its current state can require quite a few
interactions. These interactions can temporarily slow down the device, so
Solaar tries to internally cache information about devices while it is
running. If the device
@ -186,6 +190,52 @@ Solaar uses the standard US keyboard layout. This currently only matters for th
This is an experimental feature and may be modified or even eliminated.
### HITS Tuning (Hall-Effect Inductive Trigger Switch)
Some gaming mice (such as the PRO X 2 Superstrike) feature hall-effect magnetic switches on their primary buttons instead of traditional mechanical switches. These switches expose tunable parameters via the `SUPERSTRIKE_TUNING` HID++ feature (`0x1B0C`).
Solaar supports three per-button settings for each primary button (left = 0, right = 1):
- **Actuation Point** (`superstrike-tuning_actuation-{0,1}`): How deep the button must be pressed to register a click. Range 110, where 1 is the shallowest (hair trigger) and 10 is the deepest (full press). Default is 5.
- **Rapid Trigger Level** (`superstrike-tuning_rapid-trigger-level-{0,1}`): Sensitivity of rapid re-actuation after partial release. Range 15, where 1 is the most sensitive and 5 is the least. This cannot be fully disabled.
- **Click Haptics** (`superstrike-tuning_haptics-{0,1}`): Intensity of haptic feedback on click. Range 05, where 0 disables haptics and 5 is maximum intensity.
These settings are written directly to the device and persist across reconnections regardless of the onboard profile state.
### Extended DPI
Some gaming mice (such as the PRO X 2 Superstrike) support the `EXTENDED_ADJUSTABLE_DPI` feature (`0x2202`) which allows independent X and Y axis DPI configuration as well as lift-off distance (LOD) control. This is exposed via the `dpi_extended` setting:
```bash
solaar config <device> dpi_extended "{X:1600, Y:1600, LOD:HIGH}"
```
LOD values are `LOW` and `HIGH`. DPI range depends on the device sensor (up to 32000 DPI on the PRO X 2 Superstrike).
### Haptic Feedback
Some devices, such as the MX Master 4 have haptic feeback.
The Solaar CLI can be used to 'play' wave forms, for example
```
solaar config 'mx master 4' haptic-play 'HAPPY ALERT'
```
Solaar rules can also do this using their `Set` action.
### Extended Report Rate
Some gaming mice (such as the PRO X 2 Superstrike) support the `EXTENDED_ADJUSTABLE_REPORT_RATE` feature (`0x8061`) which enables sub-millisecond polling rates beyond the standard 1 ms (1000 Hz). This is exposed via the `report_rate_extended` setting:
| Value | Polling Rate |
|---------|-------------|
| `8ms` | 125 Hz |
| `4ms` | 250 Hz |
| `2ms` | 500 Hz |
| `1ms` | 1000 Hz |
| `500us` | 2000 Hz |
| `250us` | 4000 Hz |
| `125us` | 8000 Hz |
### Onboard Profiles
Some mice store one or more profiles onboard. An onboard profile controls certain aspects of the behavior of the mouse, including the rate at which the mouse reports movement, the resolution of the the movement reports, what the mouse buttons do, LED effects, and maybe more. Solaar has a setting that switches between profiles or disables all profiles.

View File

@ -5,7 +5,7 @@ layout: page
# Supported receivers and devices
Solaar only supports Logitech receivers and devices that use the Logitech proprietary HID++ protocol.
Solaar only supports Logitech receivers and devices that use the Logitech proprietary HID++ and Centurion protocols.
Solaar supports most Logitech Nano, Unifying, and Bolt receivers.
Solaar supports some Lightspeed receivers.
@ -14,7 +14,7 @@ See the receiver table below for the list of currently supported receivers.
Solaar supports all Logitech devices (keyboards, mice, trackballs, touchpads, and headsets)
that can connect to supported receivers.
Solaar supports all Logitech devices that can connect via a USB cable or via Bluetooth,
as long as the device uses the HID++ protocol.
as long as the device uses the HID++ or Centurion protocol.
The best way to determine whether Solaar supports a device is to run Solaar while the device is connected.
If the device is supported, it will show up in the Solaar main window.
@ -211,6 +211,7 @@ so what is important for support is the USB WPID or Bluetooth model ID.
|------------------------------|------|-------|
| G604 Wireless Gaming Mouse | 4085 | 4.2 |
| PRO X Superlight Wireless | 4093 | 4.2 |
| PRO X 2 Superstrike | 40BD | 4.2 |
### Trackballs (Unifying)

View File

@ -0,0 +1,101 @@
solaar version 1.1.19-25-g7520c9cc
1: G502 X PLUS
Device path : /dev/hidraw8
WPID : 4099
Codename : G502 X PLUS
Kind : mouse
Protocol : HID++ 4.2
Report Rate : 1ms
Serial number: C6884511
Model ID: 4099C0950000
Unit ID: C6884511
1: BL1 42.00.B0016
0: MPM 27.00.B0016
3:
3:
3:
3:
3:
3:
3:
3:
3:
3:
The power switch is located on the unknown.
Supports 32 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: DEVICE FW VERSION {0003} V4
Firmware: 1 BL1 42.00.B0016 AB0BFBB13A33
Firmware: 0 MPM 27.00.B0016 4099FBB13A33
Firmware: 3
Firmware: 3
Firmware: 3
Firmware: 3
Firmware: 3
Firmware: 3
Firmware: 3
Firmware: 3
Firmware: 3
Firmware: 3
Unit ID: C6884511 Model ID: 4099C0950000 Transport IDs: {'wpid': '4099', 'usbid': 'C095'}
3: DEVICE NAME {0005} V0
Name: G502 X PLUS
Kind: mouse
4: WIRELESS DEVICE STATUS {1D4B} V0
5: CONFIG CHANGE {0020} V0
Configuration: 11000000000000000000000000000000
6: UNIFIED BATTERY {1004} V3
Battery: 77%, BatteryStatus.DISCHARGING.
7: ADJUSTABLE DPI {2201} V2
Sensitivity (DPI) (saved): 800
Sensitivity (DPI) : 800
8: HIRES WHEEL {2121} V0
Multiplier: 8
Has invert: Normal wheel motion
Has ratchet switch: Normal wheel mode
Low resolution mode
HID notification
Scroll Wheel Direction (saved): False
Scroll Wheel Direction : False
Scroll Wheel Resolution (saved): False
Scroll Wheel Resolution : False
Scroll Wheel Diversion (saved): False
Scroll Wheel Diversion : False
9: RGB EFFECTS {8071} V2
LED Control (saved): Solaar
LED Control : Solaar
LEDs Primary (saved): !LEDEffectSetting {ID: 0, color: 10820909, intensity: 0, period: 100, ramp: 0, speed: 0}
LEDs Primary : !LEDEffectSetting {ID: 0, color: 10820909, intensity: 0, period: 100, ramp: 0, speed: 0}
10: PER KEY LIGHTING V2 {8081} V2
Per-key Lighting (saved): {A:No change, B:No change, C:No change, D:No change, E:No change, F:No change, G:No change, H:No change}
Per-key Lighting : {A:No change, B:No change, C:No change, D:No change, E:No change, F:No change, G:No change, H:No change}
11: ONBOARD PROFILES {8100} V0
Device Mode: On-Board
Onboard Profiles (saved): Profile 1
Onboard Profiles : Profile 1
12: MOUSE BUTTON SPY {8110} V0
13: REPORT RATE {8060} V0
Report Rate: 1ms
Report Rate (saved): 1ms
Report Rate : 1ms
14: FORCE PAIRING {1500} V0
15: DFU {00D0} V3
16: DEVICE RESET {1802} V0
17: unknown:1803 {0318} V0 internal, hidden, unknown:000010
18: CONFIG DEVICE PROPS {1806} V8
19: unknown:1811 {1118} V0 internal, hidden, unknown:000010
20: OOBSTATE {1805} V0
21: unknown:1830 {3018} V0 internal, hidden, unknown:000010
22: unknown:1875 {7518} V0 internal, hidden, unknown:000010
23: unknown:1861 {6118} V0 internal, hidden, unknown:000010
24: unknown:1890 {9018} V0 internal, hidden, unknown:000008
25: unknown:18A1 {A118} V0 internal, hidden, unknown:000010
26: unknown:1801 {0118} V0 internal, hidden, unknown:000010
27: unknown:1E00 {001E} V0 hidden
28: unknown:1E22 {221E} V0 internal, hidden, unknown:000010
29: unknown:1EB0 {B01E} V0 internal, hidden, unknown:000010
30: unknown:18B1 {B118} V0 internal, hidden, unknown:000010
31: unknown:18C0 {C018} V0 internal, hidden, unknown:000010
Battery: 77%, BatteryStatus.DISCHARGING.

View File

@ -0,0 +1,71 @@
solaar version 1.1.13+dfsg-1
1: G515 LS TKL
Device path : None
WPID : 40B4
Codename : G515 LS TKL
Kind : keyboard
Protocol : HID++ 4.2
Report Rate : 8ms
Serial number: 54FEF928
Model ID: B38940B4C355
Unit ID: 54FEF928
1: BL2 19.01.B0011
3:
0: MPK 25.01.B0011
3:
The power switch is located on the top right corner.
Supports 34 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: DEVICE FW VERSION {0003} V6
Firmware: Bootloader BL2 19.01.B0011 ABD580558692
Firmware: Other
Firmware: Firmware MPK 25.01.B0011 40B480558692
Firmware: Other
Unit ID: 54FEF928 Model ID: B38940B4C355 Transport IDs: {'btleid': 'B389', 'wpid': '40B4', 'usbid': 'C355'}
3: DEVICE NAME {0005} V3
Name: G515 LS TKL
Kind: keyboard
4: WIRELESS DEVICE STATUS {1D4B} V0
5: CONFIG CHANGE {0020} V0
Configuration: 11000000000000000000000000000000
6: DEVICE FRIENDLY NAME {0007} V0
Friendly Name: G515 LS TKL
7: unknown:0011 {0011} V0
8: UNIFIED BATTERY {1004} V5
Battery: 82%, discharging.
9: RGB EFFECTS {8071} V4
LED Control (saved): Solaar
LED Control : Solaar
LEDs Primary (saved): !LEDEffectSetting {ID: 1, color: 16776960, intensity: 26, period: 2167, ramp: 1, speed: 0}
LEDs Primary : HID++ error {'number': 1, 'request': 2537, 'error': 7, 'params': b'\x00'}
10: PER KEY LIGHTING V2 {8081} V0
Per-key Lighting (saved): {A:indian red, B:indian red, C:indian red, D:indian red, E:indian red, F:indian red, G:indian red, H:indian red, I:indian red, J:indian red, K:indian red, L:indian red, M:indian red, N:indian red, O:indian red, P:indian red, Q:indian red, R:indian red, S:indian red, T:indian red, U:indian red, V:indian red, W:indian red, X:indian red, Y:indian red, Z:indian red, 1:orange, 2:orange, 3:orange, 4:orange, 5:orange, 6:orange, 7:orange, 8:orange, 9:orange, 0:yellow, ENTER:green, ESC:green, BACKSPACE:red, TAB:yellow, SPACE:yellow, -:indian red, =:indian red, [:indian red, \:indian red, KEY 46:white, ~:indian red, ;:indian red, ':indian red, `:indian red, ,:indian red, .:indian red, /:indian red, CAPS LOCK:red, F1:indian red, F2:indian red, F3:indian red, F4:indian red, F5:indian red, F6:indian red, F7:indian red, F8:indian red, F9:indian red, F10:indian red, F11:indian red, F12:indian red, PRINT:red, SCROLL LOCK:orange, PASTE:indian red, INSERT:green, HOME:indian red, PAGE UP:yellow, DELETE:red, END:indian red, PAGE DOWN:yellow, RIGHT:indian red, LEFT:indian red, DOWN:indian red, UP:indian red, KEY 97:indian red, COMPOSE:white, POWER:white, KEY 100:indian red, KEY 101:red, KEY 102:red, KEY 103:red, LEFT CTRL:indian red, LEFT SHIFT:yellow, LEFT ALT:indian red, LEFT WINDOWS:blue, RIGHT CTRL:indian red, RIGHT SHIFT:yellow, RIGHT ALTGR:blue, RIGHT WINDOWS:indian red, KEY 254:white}
Per-key Lighting : {A:No change, B:No change, C:No change, D:No change, E:No change, F:No change, G:No change, H:No change, I:No change, J:No change, K:No change, L:No change, M:No change, N:No change, O:No change, P:No change, Q:No change, R:No change, S:No change, T:No change, U:No change, V:No change, W:No change, X:No change, Y:No change, Z:No change, 1:No change, 2:No change, 3:No change, 4:No change, 5:No change, 6:No change, 7:No change, 8:No change, 9:No change, 0:No change, ENTER:No change, ESC:No change, BACKSPACE:No change, TAB:No change, SPACE:No change, -:No change, =:No change, [:No change, \:No change, KEY 46:No change, ~:No change, ;:No change, ':No change, `:No change, ,:No change, .:No change, /:No change, CAPS LOCK:No change, F1:No change, F2:No change, F3:No change, F4:No change, F5:No change, F6:No change, F7:No change, F8:No change, F9:No change, F10:No change, F11:No change, F12:No change, PRINT:No change, SCROLL LOCK:No change, PASTE:No change, INSERT:No change, HOME:No change, PAGE UP:No change, DELETE:No change, END:No change, PAGE DOWN:No change, RIGHT:No change, LEFT:No change, DOWN:No change, UP:No change, KEY 97:No change, COMPOSE:No change, POWER:No change, KEY 100:No change, KEY 101:No change, KEY 102:No change, KEY 103:No change, LEFT CTRL:No change, LEFT SHIFT:No change, LEFT ALT:No change, LEFT WINDOWS:No change, RIGHT CTRL:No change, RIGHT SHIFT:No change, RIGHT ALTGR:No change, RIGHT WINDOWS:No change, KEY 254:No change}
11: unknown:1B10 {1B10} V0
12: unknown:4523 {4523} V1
13: KEYBOARD LAYOUT 2 {4540} V1
14: BRIGHTNESS CONTROL {8040} V0
Brightness Control (saved): 40
Brightness Control : 40
15: unknown:8101 {8101} V0
16: unknown:1B05 {1B05} V0
17: unknown:8051 {8051} V0
18: DFU {00D0} V3
19: DEVICE RESET {1802} V0 internal, hidden, unknown:000010
20: unknown:1803 {1803} V1 internal, hidden, unknown:000010
21: unknown:1807 {1807} V3 internal, hidden, unknown:000010
22: unknown:1817 {1817} V0 internal, hidden, unknown:000010
23: OOBSTATE {1805} V0 internal, hidden
24: unknown:1830 {1830} V0 internal, hidden, unknown:000010
25: unknown:1890 {1890} V9 internal, hidden, unknown:000008
26: unknown:1891 {1891} V9 internal, hidden, unknown:000008
27: unknown:1E00 {1E00} V0 hidden
28: unknown:1E02 {1E02} V0 internal, hidden
29: unknown:1602 {1602} V0
30: unknown:1EB0 {1EB0} V0 internal, hidden, unknown:000010
31: unknown:1861 {1861} V1 internal, hidden, unknown:000010
32: unknown:18B0 {18B0} V1 internal, hidden, unknown:000010
33: unknown:1801 {1801} V0 internal, hidden, unknown:000010
Battery: 82%, discharging.

View File

@ -1,4 +1,4 @@
Solaar version 1.1.3
solaar version 1.1.19-25-g7520c9cc
1: G613 Wireless Mechanical Gaming Keyboard
Device path : None
@ -6,69 +6,71 @@ Solaar version 1.1.3
Codename : G613
Kind : keyboard
Protocol : HID++ 4.2
Polling rate : 1 ms (1000Hz)
Serial number: 0DBC5FF6
Report Rate : 1ms
Serial number: 710EC3A3
Model ID: B34F40650000
Unit ID: 9AAB3225
Bootloader: BOT 46.00.B0006
Firmware: MPK 05.02.B0021
Other:
Unit ID: 2A923B25
1: BOT 46.00.B0006
0: MPK 05.02.B0021
3:
The power switch is located on the unknown.
Supports 32 HID++ 2.0 features:
0: ROOT {0000}
1: FEATURE SET {0001}
2: DEVICE FW VERSION {0003}
Firmware: Bootloader BOT 46.00.B0006 00006E86A7BD
Firmware: Firmware MPK 05.02.B0021 40656E86A7BD
Firmware: Other
Unit ID: 9AAB3225 Model ID: B34F40650000 Transport IDs: {'btleid': 'B34F', 'wpid': '4065'}
3: DEVICE NAME {0005}
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: DEVICE FW VERSION {0003} V2
Firmware: 1 BOT 46.00.B0006 00006E86A7BD
Firmware: 0 MPK 05.02.B0021 40656E86A7BD
Firmware: 3
Unit ID: 2A923B25 Model ID: B34F40650000 Transport IDs: {'btleid': 'B34F', 'wpid': '4065'}
3: DEVICE NAME {0005} V0
Name: G613 Wireless Mechanical Gaming Keyboard
Kind: keyboard
4: WIRELESS DEVICE STATUS {1D4B}
5: RESET {0020}
6: DEVICE FRIENDLY NAME {0007}
4: WIRELESS DEVICE STATUS {1D4B} V0
5: CONFIG CHANGE {0020} V0
Configuration: 11000000000000000000000000000000
6: DEVICE FRIENDLY NAME {0007} V0
Friendly Name: G613
7: BATTERY STATUS {1000}
Battery: 50%, discharging, next level 20%.
8: CHANGE HOST {1814}
Change Host : 1:video2
9: HOSTS INFO {1815}
Host 0 (paired): video2
Host 1 (paired): CB73CG2
10: REPROG CONTROLS V4 {1B04}
7: BATTERY STATUS {1000} V0
Battery: 80%, BatteryStatus.DISCHARGING, next level 50%.
8: CHANGE HOST {1814} V1
Change Host : 1:cosmo
9: HOSTS INFO {1815} V1
Host 0 (paired): cosmo
Host 1 (paired): Mi 11
10: REPROG CONTROLS V4 {1B04} V3
Key/Button Diversion (saved): {Host Switch Channel 1:Regular, Host Switch Channel 2:Regular}
Key/Button Diversion : {Host Switch Channel 1:Regular, Host Switch Channel 2:Regular}
11: REPORT HID USAGE {1BC0}
12: ENCRYPTION {4100}
13: KEYBOARD DISABLE BY USAGE {4522}
14: KEYBOARD LAYOUT 2 {4540}
15: GKEY {8010}
Divert G Keys (saved): True
Divert G Keys : False
16: REPORT RATE {8060}
Polling Rate (ms): 1
Polling Rate (ms) (saved): 1
Polling Rate (ms) : 1
17: DFUCONTROL SIGNED {00C2}
18: DEVICE RESET {1802} internal, hidden
19: unknown:1803 {1803} internal, hidden
20: CONFIG DEVICE PROPS {1806} internal, hidden
21: unknown:1813 {1813} internal, hidden
22: OOBSTATE {1805} internal, hidden
23: unknown:1830 {1830} internal, hidden
24: unknown:1890 {1890} internal, hidden
25: unknown:1891 {1891} internal, hidden
26: unknown:18A1 {18A1} internal, hidden
27: unknown:1DF3 {1DF3} internal, hidden
28: unknown:1E00 {1E00} hidden
29: unknown:1EB0 {1EB0} internal, hidden
30: unknown:1861 {1861} internal, hidden
31: unknown:18B1 {18B1} internal, hidden
11: REPORT HID USAGE {1BC0} V1
12: ENCRYPTION {4100} V0
13: KEYBOARD DISABLE BY USAGE {4522} V0
14: KEYBOARD LAYOUT 2 {4540} V0
15: GKEY {8010} V0
Divert G and M Keys (saved): False
Divert G and M Keys : False
16: REPORT RATE {8060} V0
Report Rate: 1ms
Report Rate (saved): 1ms
Report Rate : 1ms
17: DFUCONTROL SIGNED {00C2} V0
18: DEVICE RESET {1802} V0
19: unknown:1803 {0318} V0 internal, hidden
20: CONFIG DEVICE PROPS {1806} V3
21: unknown:1813 {1318} V0 internal, hidden
22: OOBSTATE {1805} V0
23: unknown:1830 {3018} V0 internal, hidden
24: unknown:1890 {9018} V0 internal, hidden
25: unknown:1891 {9118} V0 internal, hidden
26: unknown:18A1 {A118} V0 internal, hidden
27: unknown:1DF3 {F31D} V0 internal, hidden
28: unknown:1E00 {001E} V0 hidden
29: unknown:1EB0 {B01E} V0 internal, hidden
30: unknown:1861 {6118} V0 internal, hidden
31: unknown:18B1 {B118} V0 internal, hidden
Has 2 reprogrammable keys:
0: Host Switch Channel 1 , default: HostSwitch Channel 1 => HostSwitch Channel 1
divertable, persistently divertable, pos:1, group:0, group mask:empty
0: Host Switch Channel 1 , default: Hostswitch Channel 1 => Hostswitch Channel 1
persistently_divertable, divertable, pos:1, group:0, group mask:empty
reporting: default
1: Host Switch Channel 2 , default: HostSwitch Channel 2 => HostSwitch Channel 2
divertable, persistently divertable, pos:2, group:0, group mask:empty
1: Host Switch Channel 2 , default: Hostswitch Channel 2 => Hostswitch Channel 2
persistently_divertable, divertable, pos:2, group:0, group mask:empty
reporting: default
Battery: 50%, discharging, next level 20%.
Battery: 80%, BatteryStatus.DISCHARGING, next level 50%.

View File

@ -1,7 +1,7 @@
Solaar version 1.1.5
Solaar version 1.1.19
2: G733 Gaming Headset
Device path : /dev/hidraw2
G733 Gaming Headset
Device path : /dev/hidraw0
USB id : 046d:0AB5
Codename : G733 Headset
Kind : headset
@ -9,24 +9,34 @@ Solaar version 1.1.5
Serial number:
Model ID: 0AB500000000
Unit ID: FFFFFFFF
Firmware: U1 37.00.B0131
0: U1 37.00.B0131
Supports 9 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: DEVICE FW VERSION {0003} V2
Firmware: Firmware U1 37.00.B0131 0AB5
Firmware: 0 U1 37.00.B0131 0AB5
Unit ID: FFFFFFFF Model ID: 0AB500000000 Transport IDs: {'usbid': '0AB5'}
3: DEVICE NAME {0005} V0
Name: G733 Gaming Headset
Kind: None
4: COLOR LED EFFECTS {8070} V0
4: COLOR LED EFFECTS {8070} V3
Sterowanie diodami LED (saved): Device
Sterowanie diodami LED : Device
Diody LED None (saved): !LEDEffectSetting {ID: 0}
Diody LED None : !LEDEffectSetting {ID: 0}
Diody LED None (saved): !LEDEffectSetting {ID: 1, color: 131072, ramp: 0}
Diody LED None : !LEDEffectSetting {ID: 1, color: 66048, ramp: 0}
5: GKEY {8010} V0
Divert G Keys (saved): True
Divert G Keys : False
6: EQUALIZER {8310} V0
Przekieruj klawisze G i M (saved): False
Przekieruj klawisze G i M : False
6: EQUALIZER {8310} V1
Korektor (saved): {0: 5, 1: 4, 2: 3, 3: 5, 4: 5, 5: 5, 6: 4, 7: 3, 8: 4, 9: 5}
Korektor : {0: 5, 1: 4, 2: 3, 3: 5, 4: 5, 5: 5, 6: 4, 7: 3, 8: 4, 9: 5}
7: SIDETONE {8300} V0
Sidetone (saved): 65
Sidetone : 65
8: ADC MEASUREMENT {1F20} V0
Battery status unavailable.
Battery status unavailable.
Efekt lokalny (saved): 0
Efekt lokalny : 0
8: ADC MEASUREMENT {1F20} V4
Battery: 89% 4058mV , BatteryStatus.DISCHARGING.
Zarządzanie energią (saved): 30
Zarządzanie energią : 30
Battery: 89% 4058mV , BatteryStatus.DISCHARGING.

View File

@ -0,0 +1,41 @@
solaar version 1.1.19-25-g7520c9cc
Logitech G933 Gaming Wireless Headset
Device path : /dev/hidraw4
USB id : 046d:0A5B
Codename : Logitech
Kind : ?
Protocol : HID++ 4.2
Serial number:
Model ID: 000000000A5B
Unit ID: FFFFFFFF
0: U 98.03.B0027
Supports 9 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: DEVICE FW VERSION {0003} V2
Firmware: 0 U 98.03.B0027 0A5B
Unit ID: FFFFFFFF Model ID: 000000000A5B Transport IDs: {'btid': '0000', 'btleid': '0000'}
3: DEVICE NAME {0005} V0
Name: Logitech G933 Gaming Wireless Headset
Kind: None
4: COLOR LED EFFECTS {8070} V3
LED Control : HID++ error {'number': 255, 'request': 1147, 'error': 7, 'params': b''}
LEDs None (saved): !LEDEffectSetting {ID: 0}
LEDs None : !LEDEffectSetting {ID: 0}
LEDs None (saved): !LEDEffectSetting {ID: 0, color: 9519532, intensity: 0, period: 100, ramp: 0, speed: 0}
LEDs None : !LEDEffectSetting {ID: 1, color: 0, ramp: 0}
5: GKEY {8010} V0
Divert G and M Keys (saved): False
Divert G and M Keys : False
6: EQUALIZER {8310} V1
Equalizer (saved): {0: 8, 1: 8, 2: 4, 3: 2, 4: 1, 5: 4, 6: 7, 7: 10, 8: 5, 9: 11}
Equalizer : {0: 8, 1: 8, 2: 4, 3: 2, 4: 1, 5: 4, 6: 7, 7: 10, 8: 5, 9: 11}
7: SIDETONE {8300} V0
Sidetone (saved): 30
Sidetone : 30
8: ADC MEASUREMENT {1F20} V3
Battery: 100% 4183mV , BatteryStatus.RECHARGING.
Power Management (saved): 30
Power Management : 30
Battery: 100% 4183mV , BatteryStatus.RECHARGING.

View File

@ -0,0 +1,47 @@
solaar version 1.1.19-24-g693159ee
Centurion Receiver
Device path : /dev/hidraw4
USB id : 046d:0AF7
Protocol : Centurion
1 : 0.02
Has 1 device(s) out of a maximum of 1.
Supports 5 dongle features:
0: ROOT {0000}
1: FEATURE SET {0001}
2: CENTURION DEVICE INFO {0100}
Firmware: 1 0.02
Hardware: model 26 rev 255 product 0508
3: CENTPP BRIDGE {0003}
4: CENTURION GENERIC DFU {010A}
1: PRO X 2 LIGHTSPEED
Device path : /dev/hidraw4
USB id : 046d:0AF7
Codename : PRO X 2 LIGHTSPEED
Kind : headset
Protocol : Centurion 2.6
Serial number: <redacted>
Model ID: 0508
Unit ID: <redacted>
1: 0.02
Supports 10 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: CENTURION DEVICE NAME {0101} V0
3: CENTURION DEVICE INFO {0100} V0
Firmware: 1 0.02
Serial: <redacted>
Hardware: model 26 rev 255 product 0508
4: CENTURION BATTERY SOC {0104} V0
Battery: 97%, BatteryStatus.DISCHARGING.
5: CENTURION GENERIC DFU {010A} V0
6: CENTURION AUTO SLEEP {0108} V0
Auto Sleep Timeout: 10
7: HEADSET AUDIO SIDETONE {0604} V0
Headset Sidetone: 55
8: HEADSET MIC SNR {0602} V0
Mic SNR: True
9: HEADSET ONBOARD EQ {0636} V0
EQ: 80Hz:+0dB, 240Hz:+0dB, 750Hz:+0dB, 2200Hz:+0dB, 6600Hz:+0dB
Battery: 97%, BatteryStatus.DISCHARGING.

View File

@ -0,0 +1,100 @@
Solaar version 1.1.19
1: PRO X 2 Superstrike
Device path : None
WPID : 40BD
Codename : PRO X 2 Superstrike
Kind : mouse
Protocol : HID++ 4.2
Report Rate : 1ms
Serial number:
Model ID: 40BDC0A80000
Unit ID:
Bootloader: BL2 73.00.B0011
Firmware: MPM 42.00.B0011
The power switch is located on the base.
Supports 36 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: DEVICE FW VERSION {0003} V7
Firmware: Bootloader BL2 73.00.B0011
Firmware: Firmware MPM 42.00.B0011
Unit ID: Model ID: 40BDC0A80000 Transport IDs: {'wpid': '40BD', 'usbid': 'C0A8'}
3: DEVICE NAME {0005} V5
Name: PRO X2 SUPERSTRIKE
Kind: mouse
4: WIRELESS DEVICE STATUS {1D4B} V0
5: CONFIG CHANGE {0020} V0
6: UNIFIED BATTERY {1004} V5
7: XY STATS {2250} V1
8: WHEEL STATS {2251} V0
9: EXTENDED ADJUSTABLE DPI {2202} V0
Sensitivity (DPI): {X:800, Y:800, LOD:HIGH}
10: MODE STATUS {8090} V3
11: unknown:80E0 {80E0} V0
12: SUPERSTRIKE TUNING {1B0C} V0
Left Button Actuation Point: 5
Left Button Rapid Trigger Level: 3
Left Button Click Haptics: 3
Right Button Actuation Point: 5
Right Button Rapid Trigger Level: 3
Right Button Click Haptics: 3
13: EXTENDED ADJUSTABLE REPORT RATE {8061} V0
Report Rate: 1ms
14: ONBOARD PROFILES {8100} V0
Device Mode: On-Board
Onboard Profiles: Profile 1
15: MOUSE BUTTON SPY {8110} V0
16: FORCE PAIRING {1500} V0
17: unknown:1801 {1801} V0 internal, hidden
18: DEVICE RESET {1802} V0
19: unknown:1803 {1803} V0 internal, hidden
20: CONFIG DEVICE PROPS {1806} V8
21: unknown:1817 {1817} V0 internal, hidden
22: OOBSTATE {1805} V0
23: unknown:1830 {1830} V0 internal, hidden
24: unknown:1877 {1877} V0 internal, hidden
25: unknown:9403 {9403} V0 internal, hidden
26: unknown:1861 {1861} V0 internal, hidden
27: unknown:1890 {1890} V0 internal, hidden
28: unknown:18A1 {18A1} V0 internal, hidden
29: unknown:1E00 {1E00} V0 hidden
30: unknown:1E02 {1E02} V0 internal, hidden
31: unknown:1E22 {1E22} V0 internal, hidden
32: unknown:1E30 {1E30} V0 internal, hidden
33: unknown:1602 {1602} V0
34: unknown:1EB0 {1EB0} V0 internal, hidden
35: unknown:18B1 {18B1} V0 internal, hidden
Battery: discharging.
SUPERSTRIKE TUNING Feature (0x1B0C)
-----------------------------------
This feature controls the HITS (Hall-Effect Inductive Trigger Switch) settings
for the left and right mouse buttons.
Capabilities (function 0x00):
Byte 0: Flags
Byte 1: Button count (3, but only 0 and 1 are accessible)
Byte 2: Max actuation value (40)
Byte 3: Max rapid trigger value (20)
Byte 4: Max haptics value (20)
Read button settings (function 0x20, param: button_index):
Response: [button_index, actuation, rapid_trigger, haptics, ...]
- actuation: 4-40 (quantized to multiples of 4)
- rapid_trigger: 1-20 (cannot be set to 0)
- haptics: 0-20 (quantized to multiples of 4: 0, 4, 8, 12, 16, 20)
Write button settings (function 0x10):
Params: [button_index, actuation, rapid_trigger, haptics]
Solaar Settings:
- superstrike-tuning_actuation-{0,1}: Range 1-10 (maps to device 4-40)
- superstrike-tuning_rapid-trigger-level-{0,1}: Range 1-5 (maps to device 1-20)
- superstrike-tuning_haptics-{0,1}: Range 0-5 (maps to device 0-20)
Note: Feature 0x80E0 (unknown:80E0) appears to be a non-functional stub for haptics.
Haptics are actually controlled via byte 3 of the SUPERSTRIKE TUNING feature.
Note: Feature 0x9403 (unknown:9403) appears to be a hidden BHOP (bunny hop) feature
that is not accessible via HID++.

View File

@ -0,0 +1,79 @@
solaar version 1.1.19
1: Signature M550
Device path : None
WPID : B02B
Codename : Logi M550
Kind : mouse
Protocol : HID++ 4.5
Serial number: D074434E
Model ID: B02B00000000
Unit ID: D074434E
1: BL1 39.01.B0013
0: RBM 17.01.B0013
3:
The power switch is located on the (unknown).
Supports 30 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: DEVICE FW VERSION {0003} V4
Firmware: 1 BL1 39.01.B0013 B02BB0706FCD
Firmware: 0 RBM 17.01.B0013 B02BB0706FCD
Firmware: 3
Unit ID: D074434E Model ID: B02B00000000 Transport IDs: {'btleid': 'B02B'}
3: DEVICE NAME {0005} V0
Name: Signature M550
Kind: mouse
4: WIRELESS DEVICE STATUS {1D4B} V0
5: CONFIG CHANGE {0020} V0
Configuration: 11000000000000000000000000000000
6: DEVICE FRIENDLY NAME {0007} V0
Friendly Name: Logi M550
7: UNIFIED BATTERY {1004} V3
Battery: 35%, BatteryStatus.DISCHARGING.
8: REPROG CONTROLS V4 {1B04} V5
Key/Button Actions (saved): {Middle Button:Mouse Middle Button}
Key/Button Actions : {Middle Button:Mouse Middle Button}
Key/Button Diversion (saved): {Middle Button:Regular}
Key/Button Diversion : {Middle Button:Regular}
9: HOSTS INFO {1815} V2
Host 0 (paired): mst
10: XY STATS {2250} V1
11: LOWRES WHEEL {2130} V0
Wheel Reports: HID
Scroll Wheel Diversion (saved): False
Scroll Wheel Diversion : False
12: ADJUSTABLE DPI {2201} V2
Sensitivity (DPI) (saved): 1000
Sensitivity (DPI) : 1000
13: DFUCONTROL {00C3} V0
14: DEVICE RESET {1802} V0
15: unknown:1803 {0318} V0 internal, hidden, unknown:000010
16: CONFIG DEVICE PROPS {1806} V8
17: unknown:1816 {1618} V0 internal, hidden, unknown:000010
18: OOBSTATE {1805} V0
19: unknown:1830 {3018} V0 internal, hidden, unknown:000010
20: unknown:1891 {9118} V0 internal, hidden, unknown:000008
21: unknown:18A1 {A118} V0 internal, hidden, unknown:000010
22: unknown:1E00 {001E} V0 hidden
23: unknown:1E02 {021E} V0 internal, hidden
24: unknown:1E22 {221E} V0 internal, hidden, unknown:000010
25: unknown:1602 {0216} V0
26: unknown:1EB0 {B01E} V0 internal, hidden, unknown:000010
27: unknown:1861 {6118} V0 internal, hidden, unknown:000010
28: unknown:18B1 {B118} V0 internal, hidden, unknown:000010
29: unknown:920A {0A92} V0 internal, hidden, unknown:000010
Has 4 reprogrammable keys:
0: Left Button , default: Left Click => Left Click
analytics_key_events, mse, pos:0, group:1, group mask:empty
reporting: default
1: Right Button , default: Right Click => Right Click
analytics_key_events, mse, pos:0, group:1, group mask:empty
reporting: default
2: Middle Button , default: Mouse Middle Button => Mouse Middle Button
analytics_key_events, raw_xy, divertable, reprogrammable, mse, pos:0, group:2, group mask:g1,g2
reporting: default
3: Virtual Gesture Button , default: Virtual Gesture Button => Virtual Gesture Button
force_raw_xy, raw_xy, virtual, divertable, pos:0, group:3, group mask:empty
reporting: default
Battery: 35%, BatteryStatus.DISCHARGING.

View File

@ -33,6 +33,7 @@ Feature | ID | Status | Notes
`UNIFIED_BATTERY` | `0x1004` | Supported | `get_battery`, read only
`CHARGING_CONTROL` | `0x1010` | Unsupported |
`LED_CONTROL` | `0x1300` | Unsupported |
`FORCE_PAIRING` | `0x1500` | Unsupported |
`GENERIC_TEST` | `0x1800` | Unsupported |
`DEVICE_RESET` | `0x1802` | Unsupported |
`OOBSTATE` | `0x1805` | Unsupported |
@ -49,6 +50,7 @@ Feature | ID | Status | Notes
`REPROG_CONTROLS_V2_2` | `0x1B02` | Unsupported |
`REPROG_CONTROLS_V3` | `0x1B03` | Unsupported |
`REPROG_CONTROLS_V4` | `0x1B04` | Partial Support | `ReprogrammableKeys`, `DivertKeys`, `MouseGesture`, `get_keys`
`SUPERSTRIKE_TUNING` | `0x1B0C` | Supported | `SuperstrikeTuning` (actuation point, rapid trigger, click haptics)
`REPORT_HID_USAGE` | `0x1BC0` | Unsupported |
`PERSISTENT_REMAPPABLE_ACTION` | `0x1C00` | Supported | `PersistentRemappableAction`
`WIRELESS_DEVICE_STATUS` | `0x1D4B` | Read only | status reporting from device
@ -67,9 +69,12 @@ Feature | ID | Status | Notes
`THUMB_WHEEL` | `0x2150` | Supported | `ThumbMode`, `ThumbInvert`
`MOUSE_POINTER` | `0x2200` | Supported | `get_mouse_pointer_info`, read only
`ADJUSTABLE_DPI` | `0x2201` | Supported | `AdjustableDpi`, `DpiSliding`
`EXTENDED_ADJUSTABLE_DPI` | `0x2202` | Supported | `ExtendedAdjustableDpi` (X/Y DPI + lift-off distance)
`POINTER_SPEED` | `0x2205` | Supported | `PointerSpeed`, `SpeedChange`, `get_pointer_speed_info`
`ANGLE_SNAPPING` | `0x2230` | Unsupported |
`SURFACE_TUNING` | `0x2240` | Unsupported |
`XY_STATS` | `0x2250` | Unsupported |
`WHEEL_STATS` | `0x2251` | Unsupported |
`HYBRID_TRACKING` | `0x2400` | Unsupported |
`FN_INVERSION` | `0x40A0` | Supported | `FnSwap`
`NEW_FN_INVERSION` | `0x40A2` | Supported | `NewFnSwap`, `get_new_fn_inversion
@ -101,6 +106,7 @@ Feature | ID | Status | Notes
`MR` | `0x8030` | Supported | `MRKeyLED`
`BRIGHTNESS_CONTROL` | `0x8040` | Supported | `BrightnessControl`
`REPORT_RATE` | `0x8060` | Supported | `ReportRate`
`EXTENDED_ADJUSTABLE_REPORT_RATE` | `0x8061` | Supported | `report_rate_extended` (sub-millisecond polling up to 8000 Hz)
`COLOR_LED_EFFECTS` | `0x8070` | Supported | `LEDControl`, `LEDZoneSetting`
`RGB_EFFECTS` | `0X8071` | Supported | `RGBControl`, `RGBEffectSetting`
`PER_KEY_LIGHTING` | `0x8080` | Unsupported |

View File

@ -49,7 +49,7 @@ Some of the languages Solaar has been translated to are listed below. A full lis
- Danish: John Erling Blad
- Dutch: Heimen Stoffels
- Français: [Papoteur][papoteur], [David Geiger][david-geiger], [Damien Lallement][damsweb]
- Finnish: Tomi Leppänen
- Finnish: Tomi Leppänen, [Niko Savola][nikosavola]
- German: Daniel Frost
- Greek: Vangelis Skarmoutsos
- Indonesia: [Ferdina Kusumah][feku]
@ -68,6 +68,7 @@ Some of the languages Solaar has been translated to are listed below. A full lis
- Swedish: John Erling Blad, [Daniel Zippert][zipperten], Emelie Snecker, Jonatan Nyberg
- Turkish: Osman Karagöz
- Ukrainian: Олександр Афанасьєв
- Bulgarian Николай Йорданов
[Rongronggg9]: https://github.com/Rongronggg9
[papoteur]: https://github.com/papoteur
@ -83,3 +84,4 @@ Some of the languages Solaar has been translated to are listed below. A full lis
[jeblad]: https://github.com/jeblad
[feku]: https://github.com/FerdinaKusumah
[renatoka]: https://github.com/renatoka
[nikosavola]: https://github.com/nikosavola

View File

@ -15,10 +15,12 @@ using one of the methods described below.
Solaar runs as a regular user process, albeit with direct access to the Linux interface
that lets it directly communicate with the Logitech devices it manages using special
Logitech-proprietary (HID++) commands.
Logitech-proprietary (HID++ and Centurion) commands.
Each Logitech device implements a different subset of these commands.
Solaar is thus only able to make the changes that a particular device supports.
Note: Support for Centurion devices is new and should be considered experimental.
Solaar is not a device driver and does not process normal input from devices.
It is thus unable to fix problems that arise from incorrect handling of
mouse movements or keycodes by Linux drivers or other software.

View File

@ -12,8 +12,6 @@ First install pip, and then run
This will not install the Solaar udev rule, which you will need to install manually by copying
`~/.local/lib/udev/rules.d/42-logitech-unify-permissions.rules`
to `/etc/udev/rules.d` as root.
If you want Solaar rules to simulate input you will have to instead install Solaar's uinput udev rule
from the GitHub repository.
## Installing in macOS
@ -52,8 +50,6 @@ First, install the needed system packages by `make install_apt`
or `make install_dnf` or `make install_brew`.
These might not install all needed packages in older versions of your distribution.
Next, install the Solaar rule via `make install_udev`.
If you are using Wayland instead of X11 you may want to instead `make install_udev_uinput`
so that Solaar rules can simulate input in Wayland.
Finally, install Solaar via `make install_pip` or `make install_pipx`.
Parts of the installation process require sudo privileges so you may be asked for your password.
@ -129,8 +125,6 @@ For more information see [the rules page](https://pwr-solaar.github.io/Solaar/ru
You can install Solaar's udev rule manually by copying the file
`rules.d/42-logitech-unify-permissions.rules`
as root from the Solaar repository to `/etc/udev/rules.d`.
In Wayland you may want to instead copy
`rules.d-uinput/42-logitech-unify-permissions.rules`.
Let udev reload its rules by running `sudo udevadm control --reload-rules`.
# Solaar in other languages

View File

@ -8,12 +8,19 @@ layout: page
- Some internal structures in Solaar have been updated to use more standard Python language features.
This has caused some problems and introduced bugs are still being found.
- Onboard Profiles, when active, can prevent changes to other settings, such as Polling Rate, DPI, and various LED settings. Which settings are affected depends on the device. To make changes to affected settings, disable Onboard Profiles. If Onboard Profiles are later enabled the affected settings may change to the value in the profile.
- Some devices, such as the G515 Lightspeed TLK, have multiple ways of controlling their LEDs,
for example one way controls each LED individually and another controls multiple LEDs at once.
For these devices the settings for one way should be set to ignore.
Having multiple ways that are not set to ignore may result in unusual behavior.
- Bluez 5.73 does not remove Bluetooth devices when they disconnect.
Solaar 1.1.12 processes the DBus disconnection and connection messages from Bluez and does re-initialize devices when they reconnect.
The HID++ driver does not re-initialize devices, which causes problems with smooth scrolling.
Until the problem is resolved having Scroll Wheel Resolution set to true (and not ignored) may be helpful.
- Onboard Profiles, when active, can prevent changes to other settings, such as Polling Rate, DPI,
and various LED settings. Which settings are affected depends on the device. To make changes
to affected settings, disable Onboard Profiles. If Onboard Profiles are later enabled the affected
settings may change to the value in the profile.
- Solaar expects that it has exclusive control over settings that are not ignored.
Running other programs that modify these settings, such as logiops,
will likely result in unexpected device behavior.
- The Linux HID++ driver modifies the Scroll Wheel Resolution setting to
implement smooth scrolling. If Solaar changes this setting, scrolling
@ -22,15 +29,11 @@ layout: page
"Ignore this setting", which is the default for new devices.
The mouse has to be reset (e.g., by turning it off and on again) before this fix will take effect.
- Solaar expects that it has exclusive control over settings that are not ignored.
Running other programs that modify these settings, such as logiops,
will likely result in unexpected device behavior.
- The driver also sets the scrolling direction to its normal setting when implementing smooth scrolling.
- The Linux HID++ driver sets the scrolling direction to its normal setting when implementing smooth scrolling.
This can interfere with the Scroll Wheel Direction setting, requiring flipping this setting back and forth
to restore reversed scrolling.
- The driver sends messages to devices that do not conform with the Logitech HID++ specification
- The Linux HID++ driver sends messages to devices that do not conform with the Logitech HID++ specification
resulting in responses being sent back that look like other messages. For some devices this causes
Solaar to report incorrect battery levels.
@ -45,7 +48,7 @@ layout: page
in some system tray implementations. Changing to a different theme may help.
The `--battery-icons=symbolic` option can be used to force symbolic icons.
- Solaar will try to use uinput to simulate input from rules under Wayland or if Xtest is not available
- Solaar uses uinput to simulate input
but this needs write permission on /dev/uinput.
For more information see [the rules page](https://pwr-solaar.github.io/Solaar/rules).

View File

@ -15,9 +15,9 @@ although on GNOME desktop under Wayland, you can use those with the Solaar Gnome
You can install it from `https://extensions.gnome.org/extension/6162/solaar-extension`.
Under Wayland using keyboard groups may result in incorrect symbols being input for simulated input.
Under Wayland simulating inputs when modifier keys are pressed may result in incorrect symbols being sent.
Simulated input uses Xtest if available under X11 or uinput if the user has write access to /dev/uinput.
The easiest way to maintain write access to /dev/uinput is to use Solaar's alternative udev rule by downloading
`https://raw.githubusercontent.com/pwr-Solaar/Solaar/master/rules.d-uinput/42-logitech-unify-permissions.rules`
Simulated input uses uinput to simulate input so the user has to have write access to /dev/uinput.
The easiest way to maintain write access to /dev/uinput is to use Solaar's udev rule by downloading
`https://raw.githubusercontent.com/pwr-Solaar/Solaar/master/rules.d/42-logitech-unify-permissions.rules`
and copying it as root into the `/etc/udev/rules.d` directory.
You may have to reboot your system for the write permission to be set up.
Another way to get write access to /dev/uinput is to run `sudo setfacl -m u:${USER}:rw /dev/uinput`

View File

@ -46,7 +46,7 @@ class _DataMeta(type):
This metaclass also does some verification to prevent duplicated data.
"""
def __new__(mcs, name: str, bases: Tuple[Any], dic: Dict[str, Any]): # type: ignore # noqa: C901
def __new__(mcs, name: str, bases: Tuple[Any], dic: Dict[str, Any]): # type: ignore
dic["_single"] = {}
dic["_range"] = []
@ -215,7 +215,7 @@ class GenericDesktopControls(_Data):
Z = 0x32, "Z", UsageTypes.DV
RX = 0x33, "Rx", UsageTypes.DV
RY = 0x34, "Ry", UsageTypes.DV
RX = 0x35, "Rz", UsageTypes.DV
RZ = 0x35, "Rz", UsageTypes.DV
SLIDER = 0x36, "Slider", UsageTypes.DV
DIAL = 0x37, "Dial", UsageTypes.DV
WHEEL = 0x38, "Wheel", UsageTypes.DV

View File

@ -18,3 +18,5 @@ class DeviceInfo:
isDevice: bool
hidpp_short: str | None
hidpp_long: str | None
centurion: bool = False
centurion_report_id: int | None = None # 0x50 or 0x51 when centurion=True

View File

@ -38,6 +38,8 @@ from typing import Callable
from hidapi.common import DeviceInfo
LOGITECH_VENDOR_ID = 0x046D
if typing.TYPE_CHECKING:
import gi
@ -251,6 +253,12 @@ def _match(
logger.info(f"Skipping unlikely device {device['path']} ({bus_id}/{vid:04X}/{pid:04X})")
return None
# Skip Logitech webcams to prevent them from locking up during hidpp checks
# (product IDs range for webcams from docs/usb.ids.txt)
if vid == LOGITECH_VENDOR_ID and 0x0800 <= pid <= 0x09FF:
logger.info(f"Skipping Logitech webcam {device['path']} ({bus_id}/{vid:04X}/{pid:04X})")
return None
# Check for hidpp support
device["hidpp_short"] = False
device["hidpp_long"] = False

View File

@ -135,10 +135,11 @@ def _open(args):
if vid == LOGITECH_VENDOR_ID:
return {"vid": vid}
device = args.device
if args.hidpp and not device:
device = args.path
d = None
if not device:
for d in hidapi.enumerate(matchfn):
if d.driver == "logitech-djreceiver":
if (d.hidpp_short or d.hidpp_long) and (args.id is None or args.id.lower() == d.product_id.lower()):
device = d.path
break
if not device:
@ -146,13 +147,17 @@ def _open(args):
if not device:
sys.exit("!! Device path required.")
print(".. Opening device", device)
handle = hidapi.open_path(device)
if not handle:
sys.exit(f"!! Failed to open {device}, aborting.")
print(
".. Opened handle %r, vendor %r product %r serial %r."
% (handle, hidapi.get_manufacturer(handle), hidapi.get_product(handle), hidapi.get_serial(handle))
".. Opened device %r, vendor %r product %r serial %r."
% (
device,
hidapi.get_manufacturer(handle) or d.vendor_id if d else None,
hidapi.get_product(handle) or d.product_id if d else None,
hidapi.get_serial(handle),
)
)
if args.hidpp:
if hidapi.get_manufacturer(handle) is not None and hidapi.get_manufacturer(handle) != b"Logitech":
@ -170,12 +175,10 @@ def _parse_arguments():
arg_parser = argparse.ArgumentParser()
arg_parser.add_argument("--history", help="history file (default ~/.hidconsole-history)")
arg_parser.add_argument("--hidpp", action="store_true", help="ensure input data is a valid HID++ request")
arg_parser.add_argument(
"device",
nargs="?",
help="linux device to connect to (/dev/hidrawX); "
"may be omitted if --hidpp is given, in which case it looks for the first Logitech receiver",
)
arg_parser.add_argument("command", nargs="?", help="command to send (otherwise get commands from input)")
group = arg_parser.add_mutually_exclusive_group()
group.add_argument("-p", "--path", help="HID raw device to connect to (/dev/hidrawX); ")
group.add_argument("-i", "--id", help="product ID of device to connect to (XXXX)")
return arg_parser.parse_args()
@ -183,6 +186,17 @@ def main():
args = _parse_arguments()
handle = _open(args)
if args.command: # send a command
data = _validate_input(args.command, args.hidpp)
if data:
hidapi.write(handle, data)
reply = hidapi.read(handle, 128, 1)
if reply:
hexs = strhex(reply)
s = "[%s %s %s %s] %s" % (hexs[0:2], hexs[2:4], hexs[4:8], hexs[8:], repr(data))
print(s)
exit()
if interactive:
print(".. Press ^C/^D to exit, or type hex bytes to write to the device.")
@ -232,7 +246,6 @@ def main():
time.sleep(1)
finally:
print(f".. Closing handle {handle!r}")
hidapi.close(handle)
if interactive:
readline.write_history_file(args.history)

View File

@ -101,7 +101,8 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo
try: # if report descriptor does not indicate HID++ capabilities then this device is not of interest to Solaar
from hid_parser import ReportDescriptor
hidpp_short = hidpp_long = False
hidpp_short = hidpp_long = centurion = False
centurion_report_id = None
devfile = "/sys" + hid_device.properties.get("DEVPATH") + "/report_descriptor"
with fileopen(devfile, "rb") as fd:
with warnings.catch_warnings():
@ -111,11 +112,22 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo
# and _Usage(0xFF00, 0x0001) in rd.get_input_items(0x10)[0].usages # be more permissive
hidpp_long = 0x11 in rd.input_report_ids and 19 * 8 == int(rd.get_input_report_size(0x11))
# and _Usage(0xFF00, 0x0002) in rd.get_input_items(0x11)[0].usages # be more permissive
if not hidpp_short and not hidpp_long:
# Centurion transport: 63-byte reports on usage page 0xFFA0 (both input and output)
# 0x51 = PRO X 2 LIGHTSPEED variant, 0x50 = G522 LIGHTSPEED variant (with device address byte)
if 0x51 in rd.input_report_ids and 63 * 8 == int(rd.get_input_report_size(0x51)) and 0x51 in rd.output_report_ids:
centurion_report_id = 0x51
elif (
0x50 in rd.input_report_ids and 63 * 8 == int(rd.get_input_report_size(0x50)) and 0x50 in rd.output_report_ids
):
centurion_report_id = 0x50
centurion = centurion_report_id is not None
if not hidpp_short and not hidpp_long and not centurion:
return
except Exception as e: # if can't process report descriptor fall back to old scheme
hidpp_short = None
hidpp_long = None
centurion = False
centurion_report_id = None
logger.info(
"Report Descriptor not processed for DEVICE %s BID %s VID %s PID %s: %s",
device.device_node,
@ -125,7 +137,7 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo
e,
)
filtered_result = filter_func(int(bid, 16), int(vid, 16), int(pid, 16), hidpp_short, hidpp_long)
filtered_result = filter_func(int(bid, 16), int(vid, 16), int(pid, 16), hidpp_short or centurion, hidpp_long or centurion)
if not filtered_result:
return
interface_number = filtered_result.get("usb_interface")
@ -165,6 +177,8 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo
isDevice=isDevice,
hidpp_short=hidpp_short,
hidpp_long=hidpp_long,
centurion=centurion if centurion else False,
centurion_report_id=centurion_report_id,
)
return d_info
@ -403,6 +417,8 @@ def read(device_handle, bytes_count, timeout_ms=-1):
data = os.read(device_handle, bytes_count)
assert data is not None
assert isinstance(data, bytes), (repr(data), type(data))
if not data: # empty read when select() said readable means EOF (device removed)
raise OSError(errno.EIO, f"device disconnected on file descriptor {int(device_handle)}")
return data
else:
return b""

View File

@ -0,0 +1,456 @@
## Copyright (C) 2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""AdvancedParaEQ (0x020D) helpers.
The device handles biquad coefficient computation we transmit only
per-band filter-type + frequency + gain; the DSP does the rest.
V0/V1 wire format: 3-byte band stride [freq_hi, freq_lo, gain_i8],
gain is whole dB; getEQInfos returns 5 bytes [bandCount, dbRange,
caps, dbMin, dbMax].
V2 wire format: 1-byte header [direction_echo], then N × 5-byte band
stride [freq_hi, freq_lo, filter_type, gain_hi, gain_lo], with 0..3
trailer bytes (opaque, ignored). The parser consumes 5-byte chunks
until <5 bytes remain or a freq=0 sentinel is hit. Frequency u16 BE
in Hz; gain u16 BE in **offset-binary**: raw 0..(steps-1) maps
linearly to gain_min..gain_max (so on G522 with steps=241 /
gain=[-6..6], raw=120 = 0 dB). getEQInfos returns 13 bytes with gain
bounds + step count, format enum, XY-support flag, and onboard preset
counts.
(The protocol spec lists a 2-byte header [dir_echo, slot_echo] for
getCustomEQ, but G522 firmware via the centurion bridge omits the
slot_echo and emits a 1-byte header that matches getEQDefaults.
Verified against pcap traces of LGHUB G522 LIGHTSPEED traffic.)
"""
from __future__ import annotations
import logging
import struct
from .hidpp20_constants import SupportedFeature
logger = logging.getLogger(__name__)
DIRECTION_PLAYBACK = 0
DIRECTION_CAPTURE = 1
# V2 filter-type taxonomy (byte [+0] of each band). 0x16 is observed on
# G522 for every band of its factory custom slot at ISO third-octave
# centers, treated as peaking. Other filter kinds (LP, shelf, notch …)
# need a live probe per device firmware to enumerate.
FILTER_TYPE_HP = 0x00
FILTER_TYPE_PEAKING_G522 = 0x16
FILTER_TYPE_PEAKING = 0x78
FILTER_TYPE_NAMES = {
FILTER_TYPE_HP: "HP",
FILTER_TYPE_PEAKING_G522: "peaking",
FILTER_TYPE_PEAKING: "peaking",
}
def _get_version(device) -> int:
return device.features.get_feature_version(SupportedFeature.HEADSET_ADVANCED_PARA_EQ) or 0
def get_advanced_eq_info(device):
"""Query getEQInfos (function 0). Returns a dict or None.
Common fields:
version int feature version (0, 1, 2)
gain_min_db int signed whole-dB min
gain_max_db int signed whole-dB max
step_db float dB per raw LSB (1.0 on V0/V1)
V0/V1 only:
band_count int number of bands (from wire byte 0)
db_range int raw byte 1
capabilities int raw byte 2
V2 only:
gain_steps int discrete gain positions
format int 0=CLASSIC, 1=STYLES
supports_xy bool
onboard_ro_preset_count int factory preset slots
onboard_custom_preset_count int user-writable preset slots
"""
version = _get_version(device)
result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x00)
if result is None:
logger.debug("AdvancedParaEQ getEQInfos V%d: feature_request returned None", version)
return None
if version >= 2:
if len(result) < 13:
logger.debug("AdvancedParaEQ getEQInfos V2: short response len=%d %s", len(result), result.hex())
return None
gain_min = struct.unpack("b", bytes([result[2]]))[0]
gain_max = struct.unpack("b", bytes([result[3]]))[0]
gain_steps = struct.unpack(">H", result[4:6])[0]
fmt = result[6]
supports_xy = bool(result[7])
ro_presets = result[9]
custom_presets = result[10]
step_db = (gain_max - gain_min) / max(1, gain_steps - 1)
info = {
"version": 2,
"gain_min_db": gain_min,
"gain_max_db": gain_max,
"gain_steps": gain_steps,
"step_db": step_db,
"format": fmt,
"supports_xy": supports_xy,
"onboard_ro_preset_count": ro_presets,
"onboard_custom_preset_count": custom_presets,
}
logger.debug(
"AdvancedParaEQ getEQInfos V2: gain=[%d,%d] steps=%d step_db=%.4f format=%d xy=%s "
"presets_ro=%d presets_custom=%d",
gain_min,
gain_max,
gain_steps,
step_db,
fmt,
supports_xy,
ro_presets,
custom_presets,
)
device._advanced_eq_info = info
return info
# V0 / V1
if len(result) < 5:
logger.debug("AdvancedParaEQ getEQInfos V%d: short response len=%d %s", version, len(result), result.hex())
return None
band_count = result[0]
db_range = result[1]
caps = result[2]
gain_min = struct.unpack("b", bytes([result[3]]))[0]
gain_max = struct.unpack("b", bytes([result[4]]))[0]
info = {
"version": version,
"band_count": band_count,
"db_range": db_range,
"capabilities": caps,
"gain_min_db": gain_min,
"gain_max_db": gain_max,
"step_db": 1.0,
}
logger.debug(
"AdvancedParaEQ getEQInfos V%d: bands=%d dbRange=%d caps=0x%02X gain=[%d,%d]",
version,
band_count,
db_range,
caps,
gain_min,
gain_max,
)
device._advanced_eq_info = info
return info
def get_advanced_eq_active_slot(device, direction=DIRECTION_PLAYBACK):
"""Query getActiveEQ (function 3). Returns the active slot index, or None."""
result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x30, direction)
if result is None:
logger.debug("AdvancedParaEQ getActiveEQ(dir=%d): feature_request returned None", direction)
return None
if len(result) < 1:
logger.debug("AdvancedParaEQ getActiveEQ(dir=%d): empty response", direction)
return None
logger.debug("AdvancedParaEQ getActiveEQ(dir=%d): slot=%d", direction, result[0])
return result[0]
def parse_v2_bands(result: bytes, info: dict | None):
"""Parse a V2 getCustomEQ / getEQDefaults response.
Wire layout (see module docstring):
[direction_echo] (1-byte header)
N × [freq_hi, freq_lo, filter_type, gain_hi, gain_lo] (5 bytes)
[trailer ] (0..3 bytes, ignored)
Gain is offset-binary against `info`'s gain bounds:
gain_db = gain_min + (gain_max - gain_min) * raw / (steps - 1)
`info` is the dict returned by `get_advanced_eq_info`. If absent we
fall back to step_db=1.0 (and log via the caller, not here) which is
wrong but won't crash.
Returns list of (filter_type_byte, freq_hz, gain_db) tuples, or None
if the payload is too short to contain a header. Empty payload with
valid header returns []. Bands with freq=0 are treated as the
end-of-bands sentinel (matches V0/V1 behavior at lines below).
"""
if result is None or len(result) < 1:
return None
payload = result[1:] # skip [dir_echo]
band_size = 5
if info:
gain_min = info.get("gain_min_db", -6)
gain_max = info.get("gain_max_db", 6)
steps = info.get("gain_steps", 241)
else:
gain_min, gain_max, steps = 0, 0, 1 # produces gain_db=0 for any raw
bands = []
for i in range(len(payload) // band_size):
e = payload[i * band_size : (i + 1) * band_size]
freq_hz = (e[0] << 8) | e[1]
filter_type = e[2]
gain_raw = (e[3] << 8) | e[4]
if freq_hz == 0:
break # disabled band — end-of-bands sentinel
if steps > 1:
gain_db = gain_min + (gain_max - gain_min) * gain_raw / (steps - 1)
else:
gain_db = 0.0
bands.append((filter_type, freq_hz, float(gain_db)))
return bands
def _band_label(filter_type_byte: int, freq_hz: int) -> str:
kind = FILTER_TYPE_NAMES.get(filter_type_byte, f"type-0x{filter_type_byte:02X}")
if filter_type_byte == FILTER_TYPE_HP:
return f"HP {freq_hz} Hz"
return f"{freq_hz} Hz" if kind == "peaking" else f"{kind} {freq_hz} Hz"
def get_advanced_eq_defaults(device, direction=DIRECTION_PLAYBACK, slot=0):
"""Query getEQDefaults (function 5). Same per-band layout as getCustomEQ.
Returns list of (filter_type_byte, freq_hz, gain_db) tuples, or None.
V0/V1 callers receive (FILTER_TYPE_PEAKING, freq_hz, gain_db) for
compatibility with the V2 tuple shape.
"""
version = _get_version(device)
result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x50, direction, slot)
if result is None:
logger.debug(
"AdvancedParaEQ getEQDefaults V%d (dir=%d slot=%d): feature_request returned None",
version,
direction,
slot,
)
return None
if version >= 2:
info = getattr(device, "_advanced_eq_info", None)
bands = parse_v2_bands(result, info)
if bands is None:
logger.debug(
"AdvancedParaEQ getEQDefaults V2 (dir=%d slot=%d): payload too short raw=%s",
direction,
slot,
result.hex(),
)
return None
logger.debug(
"AdvancedParaEQ getEQDefaults V2 (dir=%d slot=%d): %d band(s) raw=%s %s",
direction,
slot,
len(bands),
result.hex(),
[_band_label(t, f) + f" {round(g, 2)}dB" for t, f, g in bands],
)
return bands
# V0/V1 legacy 3-byte stride.
bands = []
offset = 0
while offset + 3 <= len(result):
freq = struct.unpack(">H", result[offset : offset + 2])[0]
if freq == 0:
break
gain_db = struct.unpack("b", bytes([result[offset + 2]]))[0]
bands.append((FILTER_TYPE_PEAKING, freq, float(gain_db)))
offset += 3
logger.debug(
"AdvancedParaEQ getEQDefaults V%d (dir=%d slot=%d): %d band(s)",
version,
direction,
slot,
len(bands),
)
return bands
def get_advanced_eq_friendly_name(device, direction=DIRECTION_PLAYBACK, slot=0):
"""Query getEQFriendlyName (function 6). Returns the UTF-8 preset name or None."""
result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x60, direction, slot)
if result is None or len(result) < 1:
return None
name_len = result[0]
if 1 + name_len > len(result):
name_len = len(result) - 1
try:
name = bytes(result[1 : 1 + name_len]).decode("utf-8", errors="replace")
except Exception:
name = result[1 : 1 + name_len].hex()
return name
def probe_advanced_eq_slots(device, direction=DIRECTION_PLAYBACK, info=None):
"""Probe every advertised EQ slot via getCustomEQ and cache which respond.
Some firmware (G522) advertises N slots via getEQInfos but only honors a
subset for getCustomEQ / setActiveEQ the rest return 0x0B NOT_SUPPORTED.
This iterates 0..total-1 and records which slots actually have data.
Result is cached on `device._advanced_eq_working_slots` as a list of
`(slot_index, name, bands)` tuples. The HeadsetActiveEQPreset selector
builds its choices from this list; the HeadsetAdvancedEQ panel uses it
to skip dead slots in its diagnostic output.
Logs each working slot's bands at INFO and a summary line indicating
how many of the advertised slots are actually accessible.
"""
cached = getattr(device, "_advanced_eq_working_slots", None)
if cached is not None:
return cached
if info is None:
info = getattr(device, "_advanced_eq_info", None) or get_advanced_eq_info(device)
if not info:
return []
ro_count = info.get("onboard_ro_preset_count", 0) or 0
custom_count = info.get("onboard_custom_preset_count", 0) or 0
total = ro_count + custom_count
if total == 0:
return []
def probe(slot):
bands = get_advanced_eq_params(device, direction=direction, slot=slot)
if bands is None:
return None
name = get_advanced_eq_friendly_name(device, direction=direction, slot=slot)
kind = "factory" if slot < ro_count else "custom"
logger.debug(
"AdvancedParaEQ %s preset slot=%d (dir=%d) name=%r: %s",
kind,
slot,
direction,
name,
[f"{_band_label(t, f)} {round(g, 2)}dB" for t, f, g in bands],
)
return (slot, name, bands)
working = []
# Slot 0 is canonical. If it fails the device is unusable; bail.
entry = probe(0)
if entry is None:
device._advanced_eq_working_slots = working
return working
working.append(entry)
# Slot 1 acts as a "multi-slot capable?" canary. G522 firmware
# advertises 16 slots but only honors slot 0; LGHUB itself never
# touches slots > 0 on this device. When the canary fails, skip the
# remaining 14 NOT_SUPPORTED probes.
if total > 1:
entry = probe(1)
if entry is None:
logger.debug(
"AdvancedParaEQ: slot 1 returned NOT_SUPPORTED; " "firmware advertises %d slots but only honors slot 0",
total,
)
device._advanced_eq_working_slots = working
return working
working.append(entry)
for slot in range(2, total):
entry = probe(slot)
if entry is not None:
working.append(entry)
device._advanced_eq_working_slots = working
logger.debug(
"AdvancedParaEQ working slots on dir=%d: %d of %d advertised %s",
direction,
len(working),
total,
[w[0] for w in working],
)
return working
# Backward-compat alias kept until external callers are migrated.
probe_all_presets = probe_advanced_eq_slots
def get_advanced_eq_params(device, direction=DIRECTION_PLAYBACK, slot=0):
"""Query getCustomEQ (function 1). Returns list of (filter_type, freq_hz, gain_db) or None.
V0/V1: filter_type is always FILTER_TYPE_PEAKING (synthesized), freq is
raw Hz from wire, gain is whole dB.
V2: filter_type comes from the wire (0x00=HP, 0x78=peaking), freq is raw
Hz, gain is int16 × step_db.
step_db for V2 is cached on the device by get_advanced_eq_info.
"""
version = _get_version(device)
result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x10, direction, slot)
if result is None:
logger.debug(
"AdvancedParaEQ getCustomEQ V%d (dir=%d slot=%d): feature_request returned None",
version,
direction,
slot,
)
return None
if version >= 2:
info = getattr(device, "_advanced_eq_info", None)
if not info:
logger.warning("AdvancedParaEQ getCustomEQ V2: no cached getEQInfos — gain values will be wrong")
bands = parse_v2_bands(result, info)
if bands is None:
logger.debug(
"AdvancedParaEQ getCustomEQ V2 (dir=%d slot=%d): payload too short raw=%s",
direction,
slot,
result.hex(),
)
return None
step_db = info["step_db"] if info and "step_db" in info else 1.0
# Log raw=... too so we can compare wire shapes across firmware
# variants and across get-fns (getCustomEQ vs getEQDefaults).
logger.debug(
"AdvancedParaEQ getCustomEQ V2 (dir=%d slot=%d): %d band(s) step_db=%.4f raw=%s %s",
direction,
slot,
len(bands),
step_db,
result.hex(),
[f"{_band_label(t, f)} {round(g, 2)}dB" for t, f, g in bands],
)
return bands
# V0 / V1
bands = []
offset = 0
while offset + 3 <= len(result):
freq = struct.unpack(">H", result[offset : offset + 2])[0]
if freq == 0:
break
gain_db = struct.unpack("b", bytes([result[offset + 2]]))[0]
bands.append((FILTER_TYPE_PEAKING, freq, float(gain_db)))
offset += 3
logger.debug(
"AdvancedParaEQ getCustomEQ V%d (dir=%d slot=%d): parsed %d band(s) %s",
version,
direction,
slot,
len(bands),
bands,
)
return bands

View File

@ -96,6 +96,32 @@ HIDPP_SHORT_MESSAGE_ID = 0x10
HIDPP_LONG_MESSAGE_ID = 0x11
DJ_MESSAGE_ID = 0x20
# Centurion transport (used by PRO X 2 LIGHTSPEED headset and similar)
# Two variants exist, distinguished by report ID:
# 0x51 (PRO X 2): [0x51, cpl_length, flags, feat_idx, func_sw, params..., pad]
# 0x50 (G522): [0x50, device_addr, cpl_length, flags, feat_idx, func_sw, params..., pad]
# The 0x50 variant adds a device_addr byte at position [1], shifting all CPL fields by +1.
# cpl_length = number of bytes from flags to end of meaningful data (includes flags byte).
# The device_index byte from standard HID++ is NOT present in Centurion framing.
CENTURION_REPORT_ID = 0x51
CENTURION_ADDRESSED_REPORT_ID = 0x50 # addressed variant with device_addr byte at frame[1] (G522 etc.)
CENTURION_FRAME_SIZE = 64 # 1 byte report ID + 63 bytes payload
_CENTURION_MSG_SIZE = 63 # max reconstructed message size after unwrapping (2 + 61 payload bytes)
@dataclasses.dataclass
class CenturionHandleState:
"""Per-handle state for Centurion devices."""
report_id: int = CENTURION_REPORT_ID # 0x50 or 0x51
device_addr: int | None = None # learned from first RX (0x50 only)
protocol_version: tuple[int, int] | None = None # from ping response
# All centurion per-handle state in a single dict.
# Membership test (ihandle in _centurion_handles) gates centurion-specific code paths.
_centurion_handles: dict[int, CenturionHandleState] = {}
"""Default timeout on read (in seconds)."""
DEFAULT_TIMEOUT = 4
@ -287,6 +313,7 @@ def close(handle):
if handle:
try:
if isinstance(handle, int):
_centurion_handles.pop(handle, None)
hidapi.close(handle)
else:
handle.close()
@ -297,6 +324,115 @@ def close(handle):
return False
def _centurion_frame_header(state: CenturionHandleState, cpl_length: int, flags: int) -> bytes:
"""Build the fixed prefix of a centurion frame.
0x51: [0x51, cpl_length, flags] (3 bytes)
0x50: [0x50, device_addr, cpl_length, flags] (4 bytes)
"""
if state.report_id == CENTURION_ADDRESSED_REPORT_ID:
device_addr = state.device_addr if state.device_addr is not None else 0x00
return struct.pack("!BBBB", CENTURION_ADDRESSED_REPORT_ID, device_addr, cpl_length, flags)
return struct.pack("!BBB", CENTURION_REPORT_ID, cpl_length, flags)
_CENTURION_REPORT_IDS = (CENTURION_REPORT_ID, CENTURION_ADDRESSED_REPORT_ID)
# Per-candidate read timeout (ms) for the device_addr probe.
# USB round-trip is <1ms; 5ms gives 5x margin.
_CENTURION_PROBE_PER_ADDR_TIMEOUT_MS = 5
def probe_centurion_device_addr(handle, state: CenturionHandleState) -> bool:
"""Brute-force probe the device address byte for a 0x50-variant Centurion handle.
Sends a ROOT.GetProtocolVersion request for each candidate device_addr
(0x000xFF), reading briefly after each write. The dongle silently ignores
wrong addresses and responds only to the correct one. Stops on first hit.
Worst case (no response): 256 × 5ms = ~1.3s.
Typical G522 (addr=0x23): 36 × 5ms = ~180ms.
No-op for 0x51 (no device_addr byte) or when an address is already known.
Returns True if the address was learned.
"""
if state.report_id != CENTURION_ADDRESSED_REPORT_ID or state.device_addr is not None:
return False
ihandle = int(handle)
logger.debug("(%s) probing centurion device_addr: scanning 0x00-0xFF", handle)
# ROOT.GetProtocolVersion: feat_idx=0x00, func=0x10, 3 zero param bytes
payload = bytes([0x00, 0x10, 0x00, 0x00, 0x00])
cpl_length = len(payload) + 1 # +1 for flags byte
write_errors = 0
for addr in range(256):
frame = struct.pack("!BBBB", CENTURION_ADDRESSED_REPORT_ID, addr, cpl_length, 0x00) + payload
frame = frame + b"\x00" * (CENTURION_FRAME_SIZE - len(frame))
try:
hidapi.write(ihandle, frame)
except Exception:
write_errors += 1
if write_errors > 3:
logger.debug("(%s) centurion device_addr probe: too many write failures, aborting", handle)
return False
continue
try:
data = hidapi.read(ihandle, CENTURION_FRAME_SIZE, _CENTURION_PROBE_PER_ADDR_TIMEOUT_MS)
except Exception as reason:
logger.debug("(%s) centurion device_addr probe read failed at addr 0x%02X: %s", handle, addr, reason)
return False
if data and len(data) >= 2 and ord(data[:1]) == state.report_id:
state.device_addr = ord(data[1:2])
logger.debug(
"(%s) probed centurion device addr 0x%02X (after %d candidates)",
handle,
state.device_addr,
addr + 1,
)
return True
logger.debug("(%s) centurion device_addr probe: no response from any of 256 candidates", handle)
return False
def _unwrap_centurion_frame(data: bytes, ihandle: int, handle) -> bytes:
"""Unwrap a Centurion CPL frame (0x50 or 0x51) into a standard HID++ long message.
Auto-detects the variant from the raw report ID byte (self-describing),
matching how _read() handles 0x10 vs 0x11.
For 0x50, learns the device address from byte[1] on first receive.
"""
raw_report_id = ord(data[:1])
if raw_report_id == CENTURION_ADDRESSED_REPORT_ID:
# 0x50: [report_id, device_addr, cpl_length, flags, feat_idx, func_sw, data...]
device_addr = ord(data[1:2])
state = _centurion_handles.get(ihandle)
if state is not None and state.device_addr is None:
state.device_addr = device_addr
if logger.isEnabledFor(logging.DEBUG):
logger.debug("(%s) learned centurion device addr 0x%02X", handle, device_addr)
cpl_length = ord(data[2:3])
inner_payload = data[4 : 3 + cpl_length] # cpl_length - 1 bytes (skip flags)
elif raw_report_id == CENTURION_REPORT_ID:
# 0x51: [report_id, cpl_length, flags, feat_idx, func_sw, data...]
cpl_length = ord(data[1:2])
inner_payload = data[3 : 2 + cpl_length] # cpl_length - 1 bytes (skip flags)
else:
return data # not a centurion frame
data = bytes([HIDPP_LONG_MESSAGE_ID, 0xFF]) + inner_payload
# Pad to a valid message size: standard long (20) or Centurion extended (63)
if len(data) <= _LONG_MESSAGE_SIZE:
data = data + b"\x00" * (_LONG_MESSAGE_SIZE - len(data))
elif len(data) <= _CENTURION_MSG_SIZE:
data = data + b"\x00" * (_CENTURION_MSG_SIZE - len(data))
else:
data = data[:_CENTURION_MSG_SIZE]
return data
def write(handle, devnumber, data, long_message=False):
"""Writes some data to the receiver, addressed to a certain device.
@ -318,6 +454,17 @@ def write(handle, devnumber, data, long_message=False):
wdata = struct.pack("!BB18s", HIDPP_LONG_MESSAGE_ID, devnumber, data)
else:
wdata = struct.pack("!BB5s", HIDPP_SHORT_MESSAGE_ID, devnumber, data)
ihandle = int(handle)
if ihandle in _centurion_handles:
# Centurion CPL framing — strip device_index from HID++ and wrap in CPL header.
# cpl_length = len(meaningful_payload) + 1 (the +1 counts the flags byte).
state = _centurion_handles[ihandle]
payload = wdata[2:] # skip report_id and devnumber from standard frame
cpl_length = len(data) + 1 # data is the unpadded payload; +1 for flags byte
wdata = _centurion_frame_header(state, cpl_length, 0x00) + payload
wdata = wdata + b"\x00" * (CENTURION_FRAME_SIZE - len(wdata))
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"(%s) <= w[%02X %02X %s %s]",
@ -329,7 +476,37 @@ def write(handle, devnumber, data, long_message=False):
)
try:
hidapi.write(int(handle), wdata)
hidapi.write(ihandle, wdata)
except Exception as reason:
logger.error("write failed, assuming handle %r no longer available", handle)
close(handle)
raise exceptions.NoReceiver(reason=reason) from reason
def write_centurion_cpl(handle, layer3_payload, flags=0x00):
"""Send a Centurion CPL frame with the given Layer 3+ payload.
Builds the appropriate header for the handle's report ID variant:
0x51: [0x51, cpl_length, flags, layer3_payload..., pad to 64]
0x50: [0x50, device_addr, cpl_length, flags, layer3_payload..., pad to 64]
where cpl_length = len(layer3_payload) + 1 (the +1 counts the flags byte).
For multi-fragment sends, flags encodes fragment index and continuation:
flags = (fragment_index << 1) | (1 if more_fragments else 0)
Single-frame messages use flags=0x00 (default).
"""
ihandle = int(handle)
if ihandle not in _centurion_handles:
raise ValueError("write_centurion_cpl called on non-Centurion handle")
state = _centurion_handles[ihandle]
cpl_length = len(layer3_payload) + 1 # +1 for flags byte
header = _centurion_frame_header(state, cpl_length, flags)
wdata = header + layer3_payload
wdata = wdata + b"\x00" * (CENTURION_FRAME_SIZE - len(wdata))
if logger.isEnabledFor(logging.DEBUG):
logger.debug("(%s) <= centurion_cpl[%s]", handle, common.strhex(wdata[: len(header) + cpl_length - 1]))
try:
hidapi.write(ihandle, wdata)
except Exception as reason:
logger.error("write failed, assuming handle %r no longer available", handle)
close(handle)
@ -361,17 +538,17 @@ def _is_relevant_message(data: bytes) -> bool:
"""
assert isinstance(data, bytes), (repr(data), type(data))
# mapping from report_id to message length
# mapping from report_id to accepted message lengths
report_lengths = {
HIDPP_SHORT_MESSAGE_ID: SHORT_MESSAGE_SIZE,
HIDPP_LONG_MESSAGE_ID: _LONG_MESSAGE_SIZE,
DJ_MESSAGE_ID: _MEDIUM_MESSAGE_SIZE,
0x21: _MAX_READ_SIZE,
HIDPP_SHORT_MESSAGE_ID: (SHORT_MESSAGE_SIZE,),
HIDPP_LONG_MESSAGE_ID: (_LONG_MESSAGE_SIZE, _CENTURION_MSG_SIZE),
DJ_MESSAGE_ID: (_MEDIUM_MESSAGE_SIZE,),
0x21: (_MAX_READ_SIZE,),
}
report_id = ord(data[:1])
if report_id in report_lengths:
if report_lengths.get(report_id) == len(data):
if len(data) in report_lengths[report_id]:
return True
else:
logger.warning(f"unexpected message size: report_id {report_id:02X} message {common.strhex(data)}")
@ -387,15 +564,21 @@ def _read(handle, timeout) -> tuple[int, int, bytes]:
been physically removed from the machine, or the kernel driver has been
unloaded. The handle will be closed automatically.
"""
ihandle = int(handle)
is_centurion = ihandle in _centurion_handles
read_size = CENTURION_FRAME_SIZE if is_centurion else _MAX_READ_SIZE
try:
# convert timeout to milliseconds, the hidapi expects it
timeout = int(timeout * 1000)
data = hidapi.read(int(handle), _MAX_READ_SIZE, timeout)
data = hidapi.read(ihandle, read_size, timeout)
except Exception as reason:
logger.warning("read failed, assuming handle %r no longer available", handle)
close(handle)
raise exceptions.NoReceiver(reason=reason) from reason
if data and is_centurion and ord(data[:1]) in _CENTURION_REPORT_IDS:
data = _unwrap_centurion_frame(data, ihandle, handle)
if data and _is_relevant_message(data): # ignore messages that fail check
report_id = ord(data[:1])
devnumber = ord(data[1:2])
@ -564,13 +747,17 @@ def request(
if reply_data[:1] == b"\xff" and reply_data[1:3] == request_data[:2]:
# a HID++ 2.0 feature call returned with an error
error = ord(reply_data[3:4])
try:
error_name = Hidpp20ErrorCode(error)
except ValueError:
error_name = f"unknown:{error:02X}"
logger.error(
"(%s) device %d error on feature request {%04X}: %d = %s",
handle,
devnumber,
request_id,
error,
Hidpp20ErrorCode(error),
error_name,
)
raise exceptions.FeatureCallError(
number=devnumber,
@ -641,9 +828,15 @@ def ping(handle, devnumber, long_message: bool = False):
if reply:
report_id, reply_devnumber, reply_data = reply
if reply_devnumber == devnumber or reply_devnumber == devnumber ^ 0xFF: # BT device returning 0x00
if reply_data[:2] == request_data[:2] and reply_data[4:5] == request_data[-1:]:
is_centurion = int(handle) in _centurion_handles
mark_ok = is_centurion or reply_data[4:5] == request_data[-1:]
if reply_data[:2] == request_data[:2] and mark_ok:
# HID++ 2.0+ device, currently connected
return ord(reply_data[2:3]) + ord(reply_data[3:4]) / 10.0
major = ord(reply_data[2:3])
minor = ord(reply_data[3:4])
if is_centurion:
_centurion_handles[int(handle)].protocol_version = (major, minor)
return major + minor / 10.0
if (
report_id == HIDPP_SHORT_MESSAGE_ID
@ -656,8 +849,8 @@ def ping(handle, devnumber, long_message: bool = False):
return 1.0
if error in [Hidpp10ErrorCode.RESOURCE_ERROR, Hidpp10ErrorCode.CONNECTION_REQUEST_FAILED]:
return # device unreachable
if error == Hidpp10ErrorCode.UNKNOWN_DEVICE: # no paired device with that number
logger.error("(%s) device %d error on ping request: unknown device", handle, devnumber)
if error == Hidpp10ErrorCode.UNKNOWN_DEVICE: # no device with that number currently accessible
logger.info("(%s) device %d error on ping request: unknown device", handle, devnumber)
raise exceptions.NoSuchDevice(number=devnumber, request=request_id)
if notifications_hook:
@ -675,17 +868,21 @@ def _read_input_buffer(handle, ihandle, notifications_hook):
Used by request() and ping() before their write.
"""
is_centurion = ihandle in _centurion_handles
read_size = CENTURION_FRAME_SIZE if is_centurion else _MAX_READ_SIZE
while True:
try:
# read whatever is already in the buffer, if any
data = hidapi.read(ihandle, _MAX_READ_SIZE, 0)
data = hidapi.read(ihandle, read_size, 0)
except Exception as reason:
logger.error("read failed, assuming receiver %s no longer available", handle)
close(handle)
raise exceptions.NoReceiver(reason=reason) from reason
if data:
if is_centurion and ord(data[:1]) in _CENTURION_REPORT_IDS:
data = _unwrap_centurion_frame(data, ihandle, handle)
if _is_relevant_message(data): # only process messages that pass check
# report_id = ord(data[:1])
if notifications_hook:
@ -697,17 +894,22 @@ def _read_input_buffer(handle, ihandle, notifications_hook):
return
# HID++ Software ID claimed by Solaar. Fixed (not rotated) so cooperative
# userspace HID++ clients sharing the same device can pick a different value
# and reliably filter Solaar's traffic out of their reply stream.
#
# Known values in use by other tools at the time of writing:
#
# 0x07 OpenRGB
# 0x0A LGSTrayEx
# 0x0D Logitech G HUB (host-side)
# 0x0F Logitech firmware (sub-device self-enumeration on wired transports)
#
# 0x0B avoids those and keeps the high bit set so notifications (sw_id=0)
# remain trivially distinguishable from replies.
SOLAAR_SOFTWARE_ID = 0x0B
def _get_next_sw_id() -> int:
"""Returns 'random' software ID to separate replies from different devices.
Cycle the HID++ 2.0 software ID from 0x2 to 0xF to separate
results and notifications.
"""
if not hasattr(_get_next_sw_id, "software_id"):
_get_next_sw_id.software_id = 0xF
if _get_next_sw_id.software_id < 0xF:
_get_next_sw_id.software_id += 1
else:
_get_next_sw_id.software_id = 2
return _get_next_sw_id.software_id
"""Return Solaar's HID++ Software ID (fixed, see SOLAAR_SOFTWARE_ID)."""
return SOLAAR_SOFTWARE_ID

View File

@ -123,7 +123,8 @@ def _lightspeed_receiver(product_id: int) -> dict:
"usb_interface": 2,
"receiver_kind": "lightspeed",
"name": _("Lightspeed Receiver"),
"may_unpair": False,
"may_unpair": True,
"re_pairs": False,
}
@ -174,6 +175,7 @@ LIGHTSPEED_RECEIVER_C53F = _lightspeed_receiver(0xC53F)
LIGHTSPEED_RECEIVER_C541 = _lightspeed_receiver(0xC541)
LIGHTSPEED_RECEIVER_C545 = _lightspeed_receiver(0xC545)
LIGHTSPEED_RECEIVER_C547 = _lightspeed_receiver(0xC547)
LIGHTSPEED_RECEIVER_C54D = _lightspeed_receiver(0xC54D)
# EX100 old style receiver pre-unifying protocol
EX100_27MHZ_RECEIVER_C517 = _ex100_receiver(0xC517)
@ -202,6 +204,7 @@ KNOWN_RECEIVERS = {
0xC541: LIGHTSPEED_RECEIVER_C541,
0xC545: LIGHTSPEED_RECEIVER_C545,
0xC547: LIGHTSPEED_RECEIVER_C547,
0xC54D: LIGHTSPEED_RECEIVER_C54D,
0xC517: EX100_27MHZ_RECEIVER_C517,
}

View File

@ -0,0 +1,591 @@
## Copyright (C) 2012-2013 Daniel Pavel
## Copyright (C) 2014-2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Centurion device protocol — receiver class, factory, device info/firmware/battery queries.
CenturionReceiver is a lightweight receiver-like container for Centurion
(PRO X 2 LIGHTSPEED and similar) dongles. Protocol functions query device
info, firmware, serial, name, and battery via Centurion-specific HID++ features.
"""
from __future__ import annotations
import errno
import logging
import struct
from typing import Callable
from solaar import configuration
from . import base
from . import exceptions
from . import hidpp10
from . import hidpp10_constants
from .centurion_constants import CenturionCoreFeature
from .common import Alert
from .common import Battery
from .common import BatteryStatus
from .common import FirmwareKind
from .common import _read_usb_product_string
from .hidpp20_constants import SupportedFeature
logger = logging.getLogger(__name__)
# --- Centurion protocol functions (standalone, operate on any device-like object) ---
def get_firmware_centurion(device):
"""Reads firmware info from a Centurion device via DeviceInfo (0x0100) function 1."""
from . import common
fw = []
seen = set() # track response signatures to detect duplicates
for index in range(0, 8): # try up to 8 entities
try:
report = device.feature_request(SupportedFeature.CENTURION_DEVICE_INFO, 0x10, index)
except exceptions.FeatureCallError:
break
if not report or len(report) < 5:
break
# Dedup: parent device returns the same response for every entity index
sig = bytes(report[: 5 + report[4]])
if sig in seen:
break
seen.add(sig)
fw_type = report[0]
version = struct.unpack("!H", report[2:4])[0]
name_len = report[4]
name = report[5 : 5 + name_len].decode("ascii", errors="replace").rstrip("\x00") if name_len else ""
version_str = f"{version >> 8}.{version & 0xFF:02d}"
kind = FirmwareKind(fw_type) if fw_type <= 3 else FirmwareKind.Other
fw.append(common.FirmwareInfo(kind, name, version_str, None))
return tuple(fw) if fw else None
def get_serial_centurion(device):
"""Reads the serial number from a Centurion device via DeviceInfo (0x0100) function 2."""
try:
report = device.feature_request(SupportedFeature.CENTURION_DEVICE_INFO, 0x20)
except exceptions.FeatureCallError:
return None
if not report or len(report) < 2:
return None
str_len = report[0]
return report[1 : 1 + str_len].decode("ascii", errors="replace").rstrip("\x00")
def get_hardware_info_centurion(device):
"""Reads hardware info from a Centurion device via DeviceInfo (0x0100) function 0.
Returns (modelId, hardwareRevision, productId) or None.
"""
try:
report = device.feature_request(SupportedFeature.CENTURION_DEVICE_INFO)
except exceptions.FeatureCallError:
return None
if not report or len(report) < 4:
return None
model_id = report[0]
hw_revision = report[1]
product_id = struct.unpack("!H", report[2:4])[0]
return model_id, hw_revision, product_id
def _centurion_sub_device_info_request(device, function=0x00, *params):
"""Send a DeviceInfo (0x0100) request to the sub-device via bridge."""
sub_indices = getattr(device, "_centurion_sub_indices", {})
sub_idx = sub_indices.get(SupportedFeature.CENTURION_DEVICE_INFO)
if sub_idx is None:
return None
return device.centurion_bridge_request(sub_idx, function, *params)
def get_firmware_centurion_sub(device):
"""Reads firmware info from the Centurion sub-device (headset) via bridge."""
from . import common
fw = []
seen = set()
for index in range(0, 8):
report = _centurion_sub_device_info_request(device, 0x10, index)
if not report or len(report) < 5:
break
sig = bytes(report[: 5 + report[4]])
if sig in seen:
break
seen.add(sig)
fw_type = report[0]
version = struct.unpack("!H", report[2:4])[0]
name_len = report[4]
name = report[5 : 5 + name_len].decode("ascii", errors="replace").rstrip("\x00") if name_len else ""
version_str = f"{version >> 8}.{version & 0xFF:02d}"
kind = FirmwareKind(fw_type) if fw_type <= 3 else FirmwareKind.Other
fw.append(common.FirmwareInfo(kind, name, version_str, None))
return tuple(fw) if fw else None
def get_serial_centurion_sub(device):
"""Reads the serial number from the Centurion sub-device (headset) via bridge."""
report = _centurion_sub_device_info_request(device, 0x20)
if not report or len(report) < 2:
return None
str_len = report[0]
return report[1 : 1 + str_len].decode("ascii", errors="replace").rstrip("\x00")
def get_hardware_info_centurion_sub(device):
"""Reads hardware info from the Centurion sub-device (headset) via bridge.
Returns (modelId, hardwareRevision, productId) or None.
"""
report = _centurion_sub_device_info_request(device)
if not report or len(report) < 4:
return None
model_id = report[0]
hw_revision = report[1]
product_id = struct.unpack("!H", report[2:4])[0]
return model_id, hw_revision, product_id
def get_name_centurion(device):
"""Reads a Centurion device's name via DeviceName (0x0101).
Tries two response formats:
1. Inline: function 0 returns [name_len, name_bytes...] (like serial)
2. Chunked: function 0 returns [name_len], function 1 returns [name_bytes...] (like standard DeviceName)
"""
try:
reply = device.feature_request(SupportedFeature.CENTURION_DEVICE_NAME)
except exceptions.FeatureCallError:
return None
if not reply:
return None
name_length = reply[0]
if name_length == 0:
return None
# If the full name is inline (length + name bytes in one response)
if len(reply) >= 1 + name_length:
return reply[1 : 1 + name_length].decode("utf-8", errors="replace").rstrip("\x00")
# Otherwise, fetch name in chunks via function 1 (like standard DEVICE_NAME)
name = b""
while len(name) < name_length:
try:
fragment = device.feature_request(SupportedFeature.CENTURION_DEVICE_NAME, 0x10, len(name))
except exceptions.FeatureCallError:
break
if fragment:
name += fragment[: name_length - len(name)]
else:
break
return name.decode("utf-8", errors="replace").rstrip("\x00") if name else None
def get_battery_centurion(device):
"""Query battery via CENTURION_BATTERY_SOC."""
try:
report = device.feature_request(SupportedFeature.CENTURION_BATTERY_SOC)
if report is not None:
return decipher_battery_centurion(report)
except exceptions.FeatureCallError:
if SupportedFeature.CENTURION_BATTERY_SOC in device.features:
return SupportedFeature.CENTURION_BATTERY_SOC
return None
def decipher_battery_centurion(report) -> tuple[SupportedFeature, Battery]:
"""Decipher CENTURION_BATTERY_SOC (0x0104) response.
Response format (3 bytes):
Byte 0: Battery Percentage (0-100)
Byte 1: Battery Percentage (duplicate)
Byte 2: Charging Status (0=discharging, 1=charging, 2=charging via USB, 3=charge complete)
"""
if len(report) < 1:
return SupportedFeature.CENTURION_BATTERY_SOC, Battery(None, None, BatteryStatus.DISCHARGING, None)
soc = report[0]
logger.debug("centurion battery SOC raw: %s", report[:8].hex())
charging_status = report[2] if len(report) >= 3 else 0
if charging_status in (1, 2):
status = BatteryStatus.RECHARGING
elif charging_status == 3:
status = BatteryStatus.FULL
else:
status = BatteryStatus.DISCHARGING
return SupportedFeature.CENTURION_BATTERY_SOC, Battery(soc, None, status, None)
# --- CenturionReceiver class ---
class CenturionReceiver:
"""A lightweight receiver-like container for Centurion (PRO X 2 LIGHTSPEED) dongles.
Provides the Receiver interface to the UI so the dongle appears as a parent
with the headset as an indented child device. NOT a subclass of Receiver
Receiver's __init__ does HID++ 1.0 register reads and pairing setup that
don't apply to Centurion.
All centurion communication (bridge, features, settings, battery) lives in
the child Device; this class is just a UI container + handle owner.
"""
read_register: Callable = hidpp10.read_register
write_register: Callable = hidpp10.write_register
number = 0xFF
kind = None
isDevice = False
may_unpair = False
re_pairs = False
max_devices = 1
def __init__(self, low_level, handle, device_info, setting_callback=None):
assert handle
self.low_level = low_level
self.handle = handle
self.path = device_info.path
self.product_id = device_info.product_id
self.setting_callback = setting_callback
self.status_callback = None
self.notification_flags = None
self._devices = {}
self._firmware = None
self._dongle_features = None # independently probed dongle features
self._pending = False # True when device_addr unknown; deferred init completes on first RX
self.cleanups = []
# Receiver identity
self.serial = None
self._usb_name = getattr(device_info, "product", None)
if not self._usb_name and self.path:
self._usb_name = _read_usb_product_string(self.path)
# User-facing name: "Centurion" is Logitech's internal codename for this
# headset-dongle transport, kept in code/logs but not shown to users.
self.name = "Lightspeed Headset Receiver"
# Dummy pairing object — lock_open stays False
from .receiver import Pairing
self.pairing = Pairing()
# Discover dongle features independently
self._discover_dongle_features()
# Read serial from dongle's CENTURION_DEVICE_INFO if available
if self.serial is None:
try:
s = get_serial_centurion(self)
if s and s.strip() and s.strip().isprintable():
self.serial = s.strip()
except Exception:
pass
def enable_connection_notifications(self, enable=True):
return False
def remaining_pairings(self, cache=True):
return None
def device_codename(self, n):
return self._usb_name
def request(self, request_id, *params, no_reply=False):
"""Send an HID++ request directly to the dongle (not through bridge)."""
if self.handle:
return self.low_level.request(
self.handle, 0xFF, request_id, *params, no_reply=no_reply, long_message=True, protocol=2.0
)
def feature_request(self, feature, function=0x00, *params, no_reply=False):
"""Send a feature request to the dongle using discovered feature indices."""
if self._dongle_features is None:
self._discover_dongle_features()
feature_int = int(feature)
for _feat, feat_id, index in self._dongle_features or []:
if feat_id == feature_int:
request_id = (index << 8) | (function & 0xFF)
return self.request(request_id, *params, no_reply=no_reply)
raise exceptions.FeatureNotSupported(feature=feature)
def _discover_dongle_features(self):
"""Independently discover features on the dongle hardware."""
self._dongle_features = []
try:
# Query ROOT for FEATURE_SET index
response = self.request(0x0000, 0x00, 0x01)
if response is None or response[0] == 0:
return
fs_index = response[0]
# Get feature count
count_resp = self.request(fs_index << 8)
if count_resp is None:
return
feature_count = count_resp[0]
# Enumerate features via CenturionFeatureSet (func 1 = 0x10, per-index query)
for idx in range(feature_count):
resp = self.request((fs_index << 8) | 0x10, idx)
if resp is None or len(resp) < 3:
continue
feat_id = struct.unpack("!H", resp[1:3])[0]
try:
feature = SupportedFeature(feat_id)
except ValueError:
feature = f"unknown:{feat_id:04X}"
self._dongle_features.append((feature, feat_id, idx))
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Centurion dongle features: %s", self._dongle_features)
except Exception:
logger.debug("Centurion dongle feature discovery failed", exc_info=True)
@property
def dongle_features(self):
"""Return list of (feature, feat_id, index) tuples for dongle features."""
if self._dongle_features is None:
self._discover_dongle_features()
return self._dongle_features
def count(self):
return len([d for d in self._devices.values() if d is not None])
@property
def firmware(self):
if self._firmware is None and self.handle and not self._pending:
self._firmware = get_firmware_centurion(self)
return self._firmware or ()
def _complete_deferred_init(self):
"""Re-run feature discovery after device_addr has been learned.
Called once from the notification handler when the first 0x50 frame
arrives on a pending CenturionReceiver.
"""
if not self._pending:
return False
self._pending = False
ihandle = int(self.handle)
state = base._centurion_handles.get(ihandle)
learned_addr = state.device_addr if state else None
logger.debug(
"CenturionReceiver %s: completing deferred init (device_addr=0x%02X)",
self.path,
learned_addr or 0,
)
self._dongle_features = None
self._discover_dongle_features()
logger.debug(
"CenturionReceiver %s: deferred discovery found %d feature(s): %s",
self.path,
len(self._dongle_features or []),
[(f"{feat_id:#06x}", idx) for _, feat_id, idx in (self._dongle_features or [])],
)
if self.serial is None:
try:
s = get_serial_centurion(self)
if s and s.strip() and s.strip().isprintable():
self.serial = s.strip()
except Exception:
pass
has_bridge = any(feat_id == CenturionCoreFeature.CENT_PP_BRIDGE for _, feat_id, _ in (self._dongle_features or []))
if has_bridge:
self.notify_devices()
return True
logger.warning(
"CenturionReceiver %s: deferred init completed but no bridge found " "(features: %s)",
self.path,
[f"{feat_id:#06x}" for _, feat_id, _ in (self._dongle_features or [])],
)
return False
def notify_devices(self):
"""Create child Device for the headset and trigger its initialization."""
# Import Device locally to avoid circular import (centurion.py ↔ device.py)
from .device import Device
if self._pending:
# Don't create children yet — feature discovery hasn't succeeded.
# Signal receiver to UI so the tray entry exists.
self.changed(alert=Alert.NONE)
return
# Signal receiver to UI first — tray/window need the receiver entry
# before a child device can be added under it.
self.changed(alert=Alert.NONE)
# Create child Device with receiver=self, number=1
pairing_info = {
"wpid": self.product_id,
"kind": hidpp10_constants.DEVICE_KIND.headset, # every Centurion-transport device so far is a headset
"serial": None,
"polling": None,
"power_switch": None,
}
dev = Device(
self.low_level,
self,
1,
None,
pairing_info=pairing_info,
setting_callback=self.setting_callback,
)
# Set centurion attributes on the child
dev.centurion = True
dev.product_id = self.product_id
dev.hidpp_long = True
dev._centurion_usb_name = self._usb_name
# Pre-set bridge index from dongle features so ping can probe the headset
for _feat, feat_id, idx in self._dongle_features or []:
if feat_id == CenturionCoreFeature.CENT_PP_BRIDGE:
dev._centurion_bridge_index = idx
break
self._devices[1] = dev
configuration.attach_to(dev)
dev.status_callback = self.status_callback
# Ping to determine online status.
# Notify UI either way — offline devices show as greyed out (matching receiver behavior).
online = dev.ping()
logger.debug(
"CenturionReceiver %s: child device created, bridge_idx=%s, online=%s, protocol=%s",
self.path,
getattr(dev, "_centurion_bridge_index", None),
online,
dev._protocol,
)
dev.changed(active=online)
if self.status_callback is not None:
self.status_callback(dev)
def changed(self, alert=Alert.NOTIFICATION, reason=None):
if self.status_callback is not None:
self.status_callback(self, alert=alert, reason=reason)
def status_string(self):
count = self.count()
if count == 0:
return "No devices."
return f"{count} device connected."
def close(self):
handle, self.handle = self.handle, None
for _n, d in self._devices.items():
if d:
d.close()
self._devices.clear()
for cleanup in self.cleanups:
cleanup(self)
return handle and self.low_level.close(handle)
def __del__(self):
self.close()
def __iter__(self):
for dev in self._devices.values():
if dev is not None:
yield dev
def __getitem__(self, key):
dev = self._devices.get(key)
if dev is not None:
return dev
raise IndexError(key)
def __len__(self):
return len([d for d in self._devices.values() if d is not None])
def __contains__(self, dev):
if isinstance(dev, int):
return self._devices.get(dev) is not None
return self.__contains__(dev.number)
def __bool__(self):
return self.handle is not None
__nonzero__ = __bool__
def __eq__(self, other):
return other is not None and self.kind == other.kind and self.path == other.path
def __ne__(self, other):
return other is None or self.kind != other.kind or self.path != other.path
def __hash__(self):
return self.path.__hash__()
def __str__(self):
return "<%s(%s,%s%s)>" % (
self.name.replace(" ", "") if self.name else "CenturionReceiver",
self.path,
"" if isinstance(self.handle, int) else "T",
self.handle,
)
__repr__ = __str__
def create_centurion_receiver(low_level, device_info, setting_callback=None):
"""Opens a Centurion dongle and wraps it as a receiver-like container.
Creates a CenturionReceiver, discovers its features, then checks if
CentPPBridge (0x0003) is among them. If not, this is a direct-connected
device wired headset, or a Bluetooth-paired Centurion headset where
there is no separate dongle. Close and return None so the caller can
fall back to create_device().
:returns: A CenturionReceiver, or None.
"""
try:
handle = low_level.open_path(device_info.path)
if handle:
report_id = getattr(device_info, "centurion_report_id", None) or base.CENTURION_REPORT_ID
state = base.CenturionHandleState(report_id=report_id)
base._centurion_handles[int(handle)] = state
base.probe_centurion_device_addr(handle, state)
cr = CenturionReceiver(low_level, handle, device_info, setting_callback)
# Check if any discovered feature is CentPPBridge (0x0003)
has_bridge = any(feat_id == CenturionCoreFeature.CENT_PP_BRIDGE for _, feat_id, _ in (cr.dongle_features or []))
if has_bridge:
return cr
# No bridge found. Distinguish "silent 0x50 dongle" (device_addr
# unknown, headset not yet powered on) from "wired 0x50 device"
# (responded to probe, features found, but no bridge).
is_0x50 = state.report_id == base.CENTURION_ADDRESSED_REPORT_ID
if is_0x50 and state.device_addr is None and not cr.dongle_features:
logger.debug(
"Centurion 0x50 device %s: probe and discovery failed, " "deferring init until first RX frame",
device_info.path,
)
cr._pending = True
return cr
logger.info("Centurion device %s has no bridge, treating as direct device", device_info.path)
base._centurion_handles.pop(int(handle), None)
cr.handle = None # prevent __del__ from double-closing
low_level.close(handle)
return None
except OSError as e:
logger.exception("open %s", device_info)
if e.errno == errno.EACCES:
raise e
except Exception as e:
logger.exception("open %s", device_info)
raise e

View File

@ -0,0 +1,38 @@
"""Centurion transport-specific constants.
Feature IDs that collide with HID++ 2.0 core features live here
so they can coexist with SupportedFeature (which requires unique values).
"""
from __future__ import annotations
from enum import IntEnum
from .hidpp20_constants import SupportedFeature
class CenturionCoreFeature(IntEnum):
"""Centurion transport-specific features that collide with HID++ 2.0 core IDs."""
CENTURION_ROOT = 0x0000
CENTURION_FEATURE_SET = 0x0001
CENT_PP_BRIDGE = 0x0003
MULTI_HOST_CONTROL = 0x0005
KEEP_ALIVE = 0x0007
def __str__(self):
return self.name.replace("_", " ")
def resolve_feature(feat_id: int, centurion: bool = False):
"""Resolve a feature ID to the appropriate enum, checking centurion-specific
features first when on the centurion transport."""
if centurion:
try:
return CenturionCoreFeature(feat_id)
except ValueError:
pass
try:
return SupportedFeature(feat_id)
except ValueError:
return None

View File

@ -361,6 +361,53 @@ yaml.SafeLoader.add_constructor("!NamedInt", NamedInt.from_yaml)
yaml.add_representer(NamedInt, NamedInt.to_yaml)
class ColorInt(int):
"""A 24-bit RGB color (``0x000000``-``0xFFFFFF``) as an int subclass.
Renders as ``0xrrggbb`` in ``str()`` / ``repr()`` and as a YAML hex int
literal in dumped configs (e.g. ``color: 0xfc3300``), which loads back
natively as a plain int via YAML 1.1's hex int parsing — so the value
round-trips cleanly with no special loader registration. The constructor
accepts both ints and hex strings (``0xfc3300`` or ``#fc3300``) so configs
saved before this type existed continue to load unchanged.
Negative or out-of-range values fall back to plain decimal formatting so
sentinels like ``COLORSPLUS["No change"] = -1`` keep their natural display.
"""
def __new__(cls, value):
if isinstance(value, str):
s = value.strip().lower()
if s.startswith("#"):
value = int(s[1:], 16)
elif s.startswith(("0x", "0X")):
value = int(s, 16)
else:
value = int(s)
else:
value = int(value)
return super().__new__(cls, value)
def __str__(self):
v = int(self)
if 0 <= v <= 0xFFFFFF:
return "0x%06x" % v
return str(v)
def __repr__(self):
return self.__str__()
def color_int_representer(dumper, data):
v = int(data)
if 0 <= v <= 0xFFFFFF:
return dumper.represent_scalar("tag:yaml.org,2002:int", "0x%06x" % v)
return dumper.represent_scalar("tag:yaml.org,2002:int", str(v))
yaml.add_representer(ColorInt, color_int_representer)
class NamedInts:
"""An ordered set of NamedInt values.
@ -598,6 +645,8 @@ class BatteryStatus(Flag):
SLOW_RECHARGE = 0x04
INVALID_BATTERY = 0x05
THERMAL_ERROR = 0x06
# Solaar internal — not a HID++ protocol value
OFFLINE = 0xFF
class BatteryLevelApproximation(IntEnum):
@ -674,3 +723,17 @@ class Notification(IntEnum):
class BusID(IntEnum):
USB = 0x03
BLUETOOTH = 0x05
def _read_usb_product_string(hidraw_path):
"""Read the USB product string from sysfs for a hidraw device path."""
import pathlib
try:
# /sys/class/hidraw/hidrawN/device/../../product → USB device product string
hidraw_name = pathlib.Path(hidraw_path).name
product_path = pathlib.Path("/sys/class/hidraw") / hidraw_name / "device" / ".." / ".." / "product"
product = product_path.read_text().strip()
return product if product else None
except (OSError, ValueError):
return None

View File

@ -213,7 +213,7 @@ _D(
_D("Wireless Keyboard K520", codename="K520", protocol=1.0, wpid="2011", registers=(Reg.BATTERY_STATUS,))
_D("Wireless Solar Keyboard K750", codename="K750", protocol=2.0, wpid="4002")
_D("Wireless Keyboard K270 (unifying)", codename="K270", protocol=2.0, wpid="4003")
_D("Wireless Keyboard K360", codename="K360", protocol=2.0, wpid="4004")
# incorrect _D("Wireless Keyboard K360", codename="K360", protocol=2.0, wpid="4004")
_D("Wireless Keyboard K230", codename="K230", protocol=2.0, wpid="400D")
_D("Wireless Touch Keyboard K400", codename="K400", protocol=2.0, wpid=("400E", "4024"))
_D("Wireless Keyboard MK270", codename="MK270", protocol=2.0, wpid="4023")
@ -237,7 +237,7 @@ _D("G213 Prodigy Gaming Keyboard", codename="G213", usbid=0xC336, interface=1)
_D("G512 RGB Mechanical Gaming Keyboard", codename="G512", usbid=0xC33C, interface=1)
_D("G815 Mechanical Keyboard", codename="G815", usbid=0xC33F, interface=1)
_D("diNovo Edge Keyboard", codename="diNovo", protocol=1.0, wpid="C714")
_D("K845 Mechanical Keyboard", codename="K845", usbid=0xC341, interface=3)
_D("K845 Mechanical Keyboard", codename="K845", usbid=0xC341, interface=1)
# Mice
@ -424,6 +424,7 @@ _D("G903 Hero Gaming Mouse", codename="G903 Hero", usbid=0xC091)
_D(None, kind=DEVICE_KIND.mouse, usbid=0xC092, interface=1) # two mice share this ID
_D("M500S Mouse", codename="M500S", usbid=0xC093, interface=1)
# _D('G600 Gaming Mouse', codename='G600 Gaming', usbid=0xc24a, interface=1) # not an HID++ device
_D("G500 Gaming Mouse", codename="G500 Gaming", usbid=0xC068, interface=1, protocol=1.0)
_D("G500s Gaming Mouse", codename="G500s Gaming", usbid=0xC24E, interface=1, protocol=1.0)
_D("G502 Proteus Spectrum Optical Mouse", codename="G502 Proteus Spectrum", usbid=0xC332, interface=1)
_D("Logitech PRO Gaming Keyboard", codename="PRO Gaming Keyboard", usbid=0xC339, interface=1)
@ -464,3 +465,5 @@ _D(
kind=DEVICE_KIND.headset,
usbid=0x0ABA,
)
# PRO X 2 LIGHTSPEED Gaming Headset (0x0AF7) — fully probed via Centurion transport, no static descriptor needed
# G522 LIGHTSPEED Gaming Headset (0x0B18 dongle, 0x0B19 wired) — Centurion 0x50 variant, no static descriptor needed

View File

@ -19,6 +19,7 @@ from __future__ import annotations
import errno
import logging
import struct
import threading
import time
import typing
@ -29,6 +30,7 @@ from typing import Protocol
from solaar import configuration
from . import base
from . import descriptors
from . import exceptions
from . import hidpp10
@ -38,6 +40,8 @@ from . import settings
from . import settings_templates
from .common import Alert
from .common import Battery
from .common import BatteryStatus
from .common import _read_usb_product_string
from .hidpp10_constants import NotificationFlag
from .hidpp20_constants import SupportedFeature
@ -74,6 +78,11 @@ def create_device(low_level: LowLevelInterface, device_info, setting_callback=No
try:
handle = low_level.open_path(device_info.path)
if handle:
if getattr(device_info, "centurion", False):
report_id = getattr(device_info, "centurion_report_id", None) or base.CENTURION_REPORT_ID
state = base.CenturionHandleState(report_id=report_id)
base._centurion_handles[int(handle)] = state
base.probe_centurion_device_addr(handle, state)
# a direct connected device might not be online (as reported by user)
return Device(
low_level,
@ -124,12 +133,24 @@ class Device:
self.product_id = device_info.product_id if device_info else None
self.hidpp_short = device_info.hidpp_short if device_info else None
self.hidpp_long = device_info.hidpp_long if device_info else None
self.centurion = device_info.centurion if device_info else False
self._centurion_usb_name = None
if self.centurion:
self.hidpp_long = True # Centurion devices always use long HID++ messages
# Read USB product string for device name — avoids slow bridge probe via CENTURION_DEVICE_NAME.
# device_info.product is often None (udev reads USB interface attrs, not device attrs),
# so fall back to reading from sysfs.
self._centurion_usb_name = getattr(device_info, "product", None) if device_info else None
if not self._centurion_usb_name and self.path:
self._centurion_usb_name = _read_usb_product_string(self.path)
self.bluetooth = device_info.bus_id == 0x0005 if device_info else False # Bluetooth needs long messages
self.hid_serial = device_info.serial if device_info else None
self.setting_callback = setting_callback # for changes to settings
self.status_callback = None # for changes to other potentially visible aspects
self.wpid = pairing_info["wpid"] if pairing_info else None # the Wireless PID is unique per device model
self._kind = pairing_info["kind"] if pairing_info else None # mouse, keyboard, etc (see hidpp10.DEVICE_KIND)
if self._kind is None and self.centurion:
self._kind = hidpp10_constants.DEVICE_KIND.headset # every Centurion-transport device so far is a headset
self._serial = pairing_info["serial"] if pairing_info else None # serial number (an 8-char hex string)
self._polling_rate = pairing_info["polling"] if pairing_info else None
self._power_switch = pairing_info["power_switch"] if pairing_info else None
@ -141,6 +162,7 @@ class Device:
self._tid_map = None # map from transports to product identifiers
self._persister = None # persister holds settings
self._led_effects = self._firmware = self._keys = self._remap_keys = self._gestures = self._force_buttons = None
self._keyboard_layout = None # lazy: country code from HID++ 0x4540, None if unsupported
self._profiles = self._backlight = self._settings = None
self.registers = []
self.notification_flags = None
@ -153,6 +175,7 @@ class Device:
self._gestures_lock = threading.Lock()
self._settings_lock = threading.Lock()
self._persister_lock = threading.Lock()
self._simple_lock = threading.Lock()
self._notification_handlers = {} # See `add_notification_handler`
self.cleanups = [] # functions to run on the device when it is closed
@ -183,13 +206,16 @@ class Device:
self.descriptor = (
descriptors.get_btid(self.product_id) if self.bluetooth else descriptors.get_usbid(self.product_id)
)
if self.number is None: # for direct-connected devices get 'number' from descriptor protocol else use 0xFF
if self.descriptor and self.descriptor.protocol and self.descriptor.protocol < 2.0:
number = 0x00
# for direct-connected devices get 'number' from descriptor protocol else use 0xFF
self.number = 0x00 if self.descriptor and self.descriptor.protocol and self.descriptor.protocol < 2.0 else 0xFF
try: # determine whether a direct-connected device is online
self.ping()
except exceptions.NoSuchDevice as e:
if self.number == 0xFF: # guessed wrong number?
self.number = 0x00
self.ping()
else:
number = 0xFF
self.number = number
self.ping() # determine whether a direct-connected device is online
raise e
if self.descriptor:
self._name = self.descriptor.name
@ -200,8 +226,10 @@ class Device:
self._protocol = self.descriptor.protocol if self.descriptor.protocol else None
self.registers = self.descriptor.registers if self.descriptor.registers else []
if self._protocol is not None:
self.features = None if self._protocol < 2.0 else hidpp20.FeaturesArray(self)
# Centurion devices always use HID++ 2.0 features regardless of the
# protocol version the dongle reports (e.g. G522 reports 1.1).
if self._protocol is not None and not self.centurion:
self.features = {} if self._protocol < 2.0 else hidpp20.FeaturesArray(self)
else:
self.features = hidpp20.FeaturesArray(self) # may be a 2.0 device; if not, it will fix itself later
@ -216,16 +244,33 @@ class Device:
@property
def protocol(self):
if not self._protocol:
self.ping()
return self._protocol or 0
try:
self.ping()
except exceptions.NoSuchDevice:
logger.warning("device %s inaccessible - no protocol set", self)
result = self._protocol or 0
# Centurion devices always use HID++ 2.0 features regardless of the
# protocol version the dongle reports (e.g. G522 reports 1.1).
# Ensure all `protocol < 2.0` gates route through the 2.0 code path.
if self.centurion and result < 2.0:
return 2.0
return result
@property
def codename(self):
if not self._codename:
if self.online and self.protocol >= 2.0:
self._codename = _hidpp20.get_friendly_name(self)
if not self._codename:
self._codename = self.name.split(" ", 1)[0] if self.name else None
if not self.centurion:
self._codename = _hidpp20.get_friendly_name(self)
if not self._codename and self.name:
# Use the full live name; only drop a leading "Logitech".
# Truncating at the first space mangled good names like
# "G502 X PLUS" (direct USB connection, no friendly name).
names = self.name.split(" ")
if not self.centurion and len(names) > 1 and names[0] == "Logitech":
self._codename = " ".join(names[1:])
else:
self._codename = self.name
if not self._codename and self.receiver:
codename = self.receiver.device_codename(self.number)
if codename:
@ -237,17 +282,45 @@ class Device:
@property
def name(self):
if not self._name:
if self.online and self.protocol >= 2.0:
self._name = _hidpp20.get_name(self)
with self._simple_lock:
if self._name is None:
if self.online and self.centurion:
self._name = _hidpp20.get_name_centurion(self) or getattr(self, "_centurion_usb_name", None)
if not self._name:
self._name = f"Unknown device {self.wpid or self.product_id}"
elif self.online and self.protocol >= 2.0:
self._name = _hidpp20.get_name(self)
return self._name or self._codename or f"Unknown device {self.wpid or self.product_id}"
def get_ids(self):
if self.centurion:
self._get_ids_centurion()
return
ids = _hidpp20.get_ids(self)
if ids:
self._unitId, self._modelId, self._tid_map = ids
if logger.isEnabledFor(logging.INFO) and self._serial and self._serial != self._unitId:
logger.info("%s: unitId %s does not match serial %s", self, self._unitId, self._serial)
def _get_ids_centurion(self):
if getattr(self, "_centurion_ids_done", False):
return
self._centurion_ids_done = True
serial = _hidpp20.get_serial_centurion(self)
if not serial or not serial.strip() or not serial.strip().isprintable():
serial = _hidpp20.get_serial_centurion_sub(self)
if serial and serial.strip() and serial.strip().isprintable():
self._serial = serial.strip()
self._unitId = self._serial
hw_info = _hidpp20.get_hardware_info_centurion(self)
if hw_info:
model_id, hw_revision, product_id = hw_info
# modelId is the stable per-model disambiguator (G522 0x32, G325
# 0x44). productId is shared across the headset family and varies
# by firmware (G522 0x0508 -> 0x0509), so it must NOT key modelId.
self._modelId = f"{model_id:02X}"
self._tid_map = {"usbid": f"{product_id:04X}"}
@property
def unitId(self):
if not self._unitId and self.online and self.protocol >= 2.0:
@ -268,6 +341,8 @@ class Device:
@property
def kind(self):
# Centurion devices are seeded with kind=headset at construction, so
# this online lookup only runs for descriptor-less HID++ 2.0 devices.
if not self._kind and self.online and self.protocol >= 2.0:
self._kind = _hidpp20.get_kind(self)
return self._kind or "?"
@ -275,7 +350,9 @@ class Device:
@property
def firmware(self) -> tuple[common.FirmwareInfo]:
if self._firmware is None and self.online:
if self.protocol >= 2.0:
if self.centurion:
self._firmware = _hidpp20.get_firmware_centurion_sub(self) or _hidpp20.get_firmware_centurion(self)
elif self.protocol >= 2.0:
self._firmware = _hidpp20.get_firmware(self)
else:
self._firmware = _hidpp10.get_firmware(self)
@ -283,6 +360,8 @@ class Device:
@property
def serial(self):
if not self._serial and self.online and self.centurion:
self.get_ids()
return self._serial or ""
@property
@ -300,6 +379,13 @@ class Device:
self._polling_rate = rate if rate else self._polling_rate
return self._polling_rate
@property
def keyboard_layout(self):
if self._keyboard_layout is None and self.online and self.protocol >= 2.0:
if SupportedFeature.KEYBOARD_LAYOUT_2 in self.features:
self._keyboard_layout = _hidpp20.get_keyboard_layout(self)
return self._keyboard_layout
@property
def led_effects(self):
if not self._led_effects and self.online and self.protocol >= 2.0:
@ -356,6 +442,65 @@ class Device:
if self.online and self.protocol >= 2.0:
_hidpp20.config_change(self, configuration_, no_reply=no_reply)
def signal_configuration_complete(self, cookie=None):
"""SetComplete on ConfigChange to ack end of configuration.
With no cookie, sends the host's session counter (see
Hidpp20.set_configuration_complete)."""
if self.online and self.protocol >= 2.0:
_hidpp20.set_configuration_complete(self, cookie=cookie)
def _record_config_cookie(self):
"""After a successful apply, SetComplete with the next session cookie
and persist it so the dedup gate in apply_settings_if_needed can
detect drift on follow-up reconfig notifications within this session."""
if self.protocol < 2.0:
return
if not (self.features and SupportedFeature.CONFIG_CHANGE in self.features):
return
cookie = _hidpp20.next_session_cookie()
self.signal_configuration_complete(cookie=cookie)
if self.persister is not None:
self.persister["_config_cookie"] = [cookie[0], cookie[1]]
def apply_settings_if_needed(self):
"""Cookie-gated dedup helper for repeat WIRELESS_DEVICE_STATUS
reconfig notifications on an already-active device. Skips when the
live ConfigChange cookie matches the value stored by the most
recent apply, otherwise applies and re-records. Must NOT be used as
the initial-activation apply path across power cycles, devices
whose firmware resets the cookie to a fixed value would falsely
match a stored cookie from a prior session and skip the apply the
device actually needs.
Returns True if apply ran, False if it was skipped."""
if not self.online:
return False
if self.protocol >= 2.0 and self.features and SupportedFeature.CONFIG_CHANGE in self.features:
live = _hidpp20.get_configuration_cookie(self)
if live and len(live) >= 2:
stored = self.persister.get("_config_cookie") if self.persister else None
live_pair = [live[0], live[1]]
if stored == live_pair:
if logger.isEnabledFor(logging.INFO):
logger.info(
"%s: config cookie %02X%02X matches stored — skip apply_all_settings",
self,
live[0],
live[1],
)
return False
if logger.isEnabledFor(logging.INFO):
logger.info(
"%s: config cookie live=%02X%02X stored=%s — apply_all_settings",
self,
live[0],
live[1],
"%02X%02X" % (stored[0], stored[1]) if stored else "None",
)
settings.apply_all_settings(self)
self._record_config_cookie()
return True
def reset(self, no_reply=False):
self.set_configuration(0, no_reply)
@ -392,6 +537,15 @@ class Device:
def battery(self): # None or level, next, status, voltage
if self.protocol < 2.0:
if self.centurion:
logger.debug(
"%s: battery() dispatching HID++ 1.0 path for a Centurion device "
"(protocol=%s, _protocol=%s) — device_addr probe likely failed, "
"expect INVALID_SUB_ID_COMMAND",
self,
self.protocol,
self._protocol,
)
return _hidpp10.get_battery(self)
else:
battery_feature = self.persister.get("_battery", None) if self.persister else None
@ -455,15 +609,28 @@ class Device:
):
if logger.isEnabledFor(logging.INFO):
logger.info("%s pushing device settings %s", self, self.settings)
# Activation apply must be unconditional — across power
# cycles, the device may have lost state while its cookie
# reset to a value that happens to match what we stored
# last session. Cookie comparison is only a valid dedup
# signal for repeat reconfig notifications within an
# already-active session (see apply_settings_if_needed).
settings.apply_all_settings(self)
self._record_config_cookie()
if not was_active:
if self.protocol < 2.0: # Make sure to set notification flags on the device
self.notification_flags = self.enable_connection_notifications()
else:
self.set_configuration(0x11) # signal end of configuration
self.read_battery() # battery information may have changed so try to read it now
elif was_active and self.receiver: # need to set configuration pending flag in receiver
elif was_active and self.receiver and not isinstance(self.receiver, CenturionReceiver):
hidpp10.set_configuration_pending_flags(self.receiver, 0xFF)
if not active and self.receiver and self.battery_info is not None and self.battery_info.level is not None:
self.battery_info = Battery(
self.battery_info.level,
self.battery_info.next_level,
BatteryStatus.OFFLINE,
self.battery_info.voltage,
self.battery_info.light_level,
)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("device %d changed: active=%s %s", self.number, self._active, self.battery_info)
if self.status_callback is not None:
@ -527,9 +694,12 @@ class Device:
long = self.hidpp_long is True or (
self.hidpp_long is None and (self.bluetooth or self._protocol is not None and self._protocol >= 2.0)
)
# Centurion child: CPL framing strips devnumber and responses always
# have devnumber=0xFF, so we must send 0xFF to match responses.
devnumber = 0xFF if (self.centurion and self.receiver and not self.handle) else self.number
return self.low_level.request(
self.handle or (self.receiver.handle if self.receiver else None),
self.number,
devnumber,
request_id,
*params,
no_reply=no_reply,
@ -539,11 +709,305 @@ class Device:
def feature_request(self, feature, function=0x00, *params, no_reply=False):
if self.protocol >= 2.0:
if self.centurion:
# Ensure sub-device features are discovered before routing decision
if self.features is not None:
self.features._check()
# Guard against Centurion/HID++ 2.0 feature ID collisions. IntEnum
# members with the same int value hash equal, so a dict lookup for
# SupportedFeature.DEVICE_NAME (0x0005) succeeds even when the
# device actually has CenturionCoreFeature.MULTI_HOST_CONTROL at
# that slot. If the type of the stored enum differs from what the
# caller asked for, treat the feature as unsupported.
if self.features is not None:
idx = self.features.get(feature)
if idx is not None:
stored = self.features.inverse.get(idx)
if stored is not None and type(stored) is not type(feature):
return None
if feature in getattr(self, "_centurion_sub_features", ()):
sub_idx = self.features.get(feature)
if sub_idx is not None:
return self.centurion_bridge_request(sub_idx, function, *params, no_reply=no_reply)
return hidpp20.feature_request(self, feature, function, *params, no_reply=no_reply)
# Max sub-message bytes in the first bridge fragment (for 0x51):
# 64 - 1 (report ID) - 1 (cpl_len) - 1 (flags) - 2 (bridge prefix) - 2 (bridge hdr) = 57;
# one byte of conservative margin gives 56. For 0x50 the device_addr byte
# eats one more, so first_chunk = 55 (handled dynamically below).
_BRIDGE_FIRST_CHUNK = 56
# Continuation fragments carry raw sub_msg data (no bridge prefix/hdr):
# 64 - 1 (report ID) - 1 (cpl_len) - 1 (flags) = 61; one byte of margin
# gives 60.
_BRIDGE_CONT_CHUNK = 60
def centurion_bridge_request(self, sub_feat_idx, sub_function=0x00, *params, no_reply=False):
"""Send a request to a Centurion sub-device via CentPPBridge.
Builds the 4-layer nested message:
Layer 1: [report_id] (0x51 or 0x50)
Layer 2: [device_addr (0x50 only),] cpl_length, flags
Layer 3: [bridge_idx, sendFragment_func|swid, bridge_hdr...]
Layer 4: [sub_cpl=0x00, sub_feat_idx, sub_func|swid, params...]
For multi-fragment sends, only the first fragment includes the bridge
prefix and header. Continuation fragments carry raw sub_msg data.
The CPL flags byte encodes fragment index and continuation:
flags = (fragment_index << 1) | (1 if more_fragments else 0)
Single-frame messages use flags=0x00.
Returns the sub-device response data (after bridge header), or None.
"""
if not getattr(self, "centurion", False):
raise ValueError("centurion_bridge_request called on non-Centurion device")
bridge_idx = getattr(self, "_centurion_bridge_index", None)
if bridge_idx is None:
raise ValueError("CentPPBridge index not discovered yet")
handle = self.handle or (self.receiver.handle if self.receiver else None)
if not handle:
return None
# Adjust bridge chunk sizes for 0x50 variant (device_addr byte takes 1 frame byte)
cent_state = base._centurion_handles.get(int(handle))
addr_overhead = 1 if cent_state and cent_state.report_id == base.CENTURION_ADDRESSED_REPORT_ID else 0
first_chunk = self._BRIDGE_FIRST_CHUNK - addr_overhead
cont_chunk = self._BRIDGE_CONT_CHUNK - addr_overhead
sw_id = base._get_next_sw_id()
# Build sub-device message: [sub_cpl=0x00, sub_feat_idx, sub_func|swid, params...]
# sub_function is in standard HID++ format: func_number << 4 (e.g. 0x10 for function 1)
sub_params = b"".join(struct.pack("B", p) if isinstance(p, int) else p for p in params) if params else b""
sub_msg = struct.pack("BBB", 0x00, sub_feat_idx, (sub_function & 0xF0) | sw_id) + sub_params
# Build bridge header: [device_id<<4 | len_hi, len_lo]
# device_id=0 for the headset, len is the total sub-message length
sub_len = len(sub_msg)
bridge_hdr = struct.pack("BB", (0x00 << 4) | ((sub_len >> 8) & 0x0F), sub_len & 0xFF)
bridge_prefix = struct.pack("BB", bridge_idx, (0x01 << 4) | sw_id)
timeout = base.DEFAULT_TIMEOUT
with base.acquire_timeout(base.handle_lock(handle), handle, timeout):
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"bridge TX: sub_idx=%d func=0x%02X sw_id=%d payload=%s",
sub_feat_idx,
sub_function,
sw_id,
sub_params.hex() if sub_params else "",
)
if sub_len <= first_chunk:
# Single-frame path
layer3 = bridge_prefix + bridge_hdr + sub_msg
base.write_centurion_cpl(handle, layer3)
else:
# Multi-fragment send
# Fragment 0: bridge_prefix + bridge_hdr + first chunk of sub_msg
# Fragments 1+: raw sub_msg continuation data (no bridge overhead)
# CPL flags = (frag_index << 1) | (1 if more_fragments else 0)
# All fragments are sent back-to-back without waiting for
# intermediate ACKs. The device reassembles internally and
# sends a single ACK + MessageEvent after the last fragment.
frag_index = 0
offset = 0
while offset < sub_len:
if frag_index == 0:
chunk_size = first_chunk
chunk = sub_msg[offset : offset + chunk_size]
layer3 = bridge_prefix + bridge_hdr + chunk
else:
chunk_size = cont_chunk
chunk = sub_msg[offset : offset + chunk_size]
layer3 = chunk
has_more = (offset + chunk_size) < sub_len
flags = (frag_index << 1) | (1 if has_more else 0)
base.write_centurion_cpl(handle, layer3, flags=flags)
offset += len(chunk)
frag_index += 1
if no_reply:
return None
# The device echoes our exact sub-device function+swid byte in
# MessageEvent responses. Match on that to reject cross-contamination
# from late-arriving responses to other function calls on the same
# feature (e.g. GetRGBZoneInfo response showing up on a later
# GetHostModeState read).
expected_sub_func_sw = (sub_function & 0xF0) | sw_id
# Read ACK + MessageEvent response
request_started = time.time()
ack_received = False
while time.time() - request_started < timeout:
reply = base._read(handle, timeout)
if not reply:
continue
_report_id, _devnumber, reply_data = reply
# ACK: short response echoing feat_idx and func|swid
if len(reply_data) >= 2 and reply_data[0] == bridge_idx:
func_sw = reply_data[1]
if (func_sw >> 4) == 0x01 and (func_sw & 0x0F) == sw_id:
ack_received = True
break
if (func_sw >> 4) == 0x01 and (func_sw & 0x0F) == 0:
# MessageEvent arrived before ACK — validate it's for our request
if self._is_bridge_response_for(reply_data, sub_feat_idx, expected_sub_func_sw):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("bridge idx=%d fn=0x%02X -> OK", sub_feat_idx, sub_function)
return self._parse_bridge_response(reply_data)
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"bridge skipping reply (pre-ACK): got sub_cpl=0x%02X sub_idx=0x%02X func_sw=0x%02X"
" (expected idx=0x%02X func_sw=0x%02X) data=%s",
reply_data[4] if len(reply_data) > 4 else 0,
reply_data[5] if len(reply_data) > 5 else 0,
reply_data[6] if len(reply_data) > 6 else 0,
sub_feat_idx,
expected_sub_func_sw,
reply_data.hex(),
)
if not ack_received:
logger.warning("centurion_bridge_request: no ACK received")
return None
# Read MessageEvent response (bridge function 1 with SW ID 0 = event)
while time.time() - request_started < timeout:
reply = base._read(handle, timeout)
if not reply:
continue
_report_id, _devnumber, reply_data = reply
if len(reply_data) >= 2 and reply_data[0] == bridge_idx:
func_sw = reply_data[1]
if (func_sw >> 4) == 0x01 and (func_sw & 0x0F) == 0:
if self._is_bridge_response_for(reply_data, sub_feat_idx, expected_sub_func_sw):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("bridge idx=%d fn=0x%02X -> OK", sub_feat_idx, sub_function)
return self._parse_bridge_response(reply_data)
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"bridge skipping reply (post-ACK): got sub_cpl=0x%02X sub_idx=0x%02X func_sw=0x%02X"
" (expected idx=0x%02X func_sw=0x%02X) data=%s",
reply_data[4] if len(reply_data) > 4 else 0,
reply_data[5] if len(reply_data) > 5 else 0,
reply_data[6] if len(reply_data) > 6 else 0,
sub_feat_idx,
expected_sub_func_sw,
reply_data.hex(),
)
logger.warning("centurion_bridge_request: no MessageEvent received")
return None
@staticmethod
def _wait_for_bridge_ack(handle, bridge_idx, sw_id, timeout):
"""Wait for a bridge ACK response between multi-fragment sends."""
started = time.time()
while time.time() - started < timeout:
reply = base._read(handle, timeout)
if not reply:
continue
_report_id, _devnumber, reply_data = reply
if len(reply_data) >= 2 and reply_data[0] == bridge_idx:
func_sw = reply_data[1]
if (func_sw >> 4) == 0x01 and (func_sw & 0x0F) == sw_id:
return True
return False
@staticmethod
def _is_bridge_response_for(reply_data, expected_sub_feat_idx, expected_sub_func_sw=None):
"""Check if a bridge MessageEvent is a response for our specific sub-feature request.
Accepts both normal responses (sub_feat_idx matches) and error responses
(sub_feat_idx=0xFF with original feat_idx in next byte).
Unsolicited notifications (sub_cpl=0xFF) are rejected.
If `expected_sub_func_sw` is provided, also matches on the echoed
sub-device function byte (`(function << 4) | sw_id`). This prevents
cross-talk between different function calls on the SAME feature, which
can happen when a late-arriving response for one function gets picked
up by a later request on the same feature (observed on G522 where a
GetRGBZoneInfo response contaminated a subsequent GetHostModeState).
"""
if len(reply_data) < 6:
return False
sub_cpl = reply_data[4]
sub_feat_idx = reply_data[5]
# Notifications have sub_cpl=0xFF; our responses have sub_cpl=0x00
if sub_cpl != 0x00:
return False
if sub_feat_idx == expected_sub_feat_idx:
if expected_sub_func_sw is not None and len(reply_data) >= 7:
if reply_data[6] != expected_sub_func_sw:
return False
return True
# Error response: sub_feat_idx=0xFF, next byte is the original feat_idx that errored
if sub_feat_idx == 0xFF and len(reply_data) >= 7 and reply_data[6] == expected_sub_feat_idx:
if expected_sub_func_sw is not None and len(reply_data) >= 8:
if reply_data[7] != expected_sub_func_sw:
return False
return True
return False
@staticmethod
def _parse_bridge_response(reply_data):
"""Extract sub-device response from a CentPPBridge MessageEvent.
reply_data layout (after report_id and devnumber have been stripped):
[bridge_idx, func_sw, dev_id<<4|len_hi, len_lo, sub_cpl, sub_feat_idx, sub_func_sw, data...]
Returns the sub-device data starting from sub_feat_idx onward.
Error responses have sub_feat_idx=0xFF: [... sub_cpl, 0xFF, orig_feat_idx, orig_func_sw, error_code]
These return None.
"""
if len(reply_data) < 7:
return None
sub_feat_idx = reply_data[5]
# Error response from sub-device
if sub_feat_idx == 0xFF:
# Error frame layout after sub_cpl: [0xFF, orig_feat_idx, orig_func_sw, error_code, ...]
orig_feat_idx = reply_data[6] if len(reply_data) > 6 else 0
orig_func_sw = reply_data[7] if len(reply_data) > 7 else 0
error_code = reply_data[8] if len(reply_data) > 8 else 0
logger.debug(
"bridge sub-device error: orig_feat_idx=%d orig_func=0x%02X error=0x%02X",
orig_feat_idx,
orig_func_sw,
error_code,
)
return None
return reply_data[7:] # response data after sub_cpl, sub_feat_idx, sub_func_sw
def _record_ping_protocol(self, handle, protocol):
"""Record a successful ping's protocol version, including raw Centurion (major, minor)."""
self._protocol = protocol
cent_state = base._centurion_handles.get(int(handle))
if cent_state and cent_state.protocol_version:
self._centurion_protocol = cent_state.protocol_version
def ping(self):
"""Checks if the device is online and present, returns True of False.
Some devices are integral with their receiver but may not be present even if the receiver responds to ping."""
if self.centurion and self.receiver and not self.handle:
# Centurion child: first check if dongle is reachable
handle = self.receiver.handle
try:
protocol = self.low_level.ping(handle, 0xFF, long_message=True)
except exceptions.NoReceiver:
self.online = False
return False
if protocol:
self._record_ping_protocol(handle, protocol)
# Dongle responded — now check if headset is actually on by probing through bridge.
# Send ROOT.GetFeature(0x0001) to the sub-device via CentPPBridge.
bridge_idx = getattr(self, "_centurion_bridge_index", None)
if bridge_idx is not None:
try:
result = self.centurion_bridge_request(0, 0x00, 0x00, 0x01)
self.online = result is not None and self.present
except Exception:
self.online = False
else:
self.online = False
return self.online
long = self.hidpp_long is True or (
self.hidpp_long is None and (self.bluetooth or self._protocol is not None and self._protocol >= 2.0)
)
@ -554,7 +1018,7 @@ class Device:
protocol = None
self.online = protocol is not None and self.present
if protocol:
self._protocol = protocol
self._record_ping_protocol(handle, protocol)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("pinged %s: online %s protocol %s present %s", self.number, self.online, protocol, self.present)
return self.online
@ -563,12 +1027,16 @@ class Device:
pass
def close(self):
handle, self.handle = self.handle, None
if self in Device.instances:
Device.instances.remove(self)
# Run device.cleanups before clearing self.handle — cleanup callbacks
# typically need to issue final feature_request() writes (e.g. release
# SW control, restore device-side state) and feature_request() relies
# on self.handle being set.
if hasattr(self, "cleanups"):
for cleanup in self.cleanups:
cleanup(self)
handle, self.handle = self.handle, None
if self in Device.instances:
Device.instances.remove(self)
return handle and self.low_level.close(handle)
def __index__(self):
@ -604,3 +1072,8 @@ class Device:
def __del__(self):
self.close()
# Re-export from centurion.py — must be after Device class to avoid circular import
from .centurion import CenturionReceiver # noqa: E402,F401
from .centurion import create_centurion_receiver # noqa: E402,F401

View File

@ -0,0 +1,92 @@
"""Per-device-model quirks for RGB lighting.
Keyed by ``device.modelId``. For normal HID++ devices that is the string
Logitech composes by concatenating every transport PID (btid + btleid + wpid
+ usbid) one entry covers the model on any transport. For Centurion
headsets it is the firmware-stable model byte (G522 ``"32"``, G325 ``"44"``);
see ``device._get_ids_centurion``.
Two postures, by feature class:
* **Effect parameters that do NOT persist** (zone effects, LED directions)
default-ALLOW. Those are validated and low-harm (a wrong value is cosmetic
and transient). They use blocklists elsewhere, e.g. ``LedDirectionBlocklist``
in ``hidpp20.py``. Nothing of that kind lives here.
* **NVconfig-saved colors** (0x8071 RGBEffects boot effects, 0x0622 HeadsetRGB
signature effects) default-DENY. Those writes persist to non-volatile
storage, so an unvalidated control can durably misconfigure a device. Every
field is hidden and every slot suppressed unless the exact model is listed
here as known-good.
Setting ``SOLAAR_EXPERIMENTAL`` truthy bypasses the allowlists entirely for
testers / reverse-engineering on devices not yet validated.
Entries are hand-curated; document the observation in a comment.
"""
from __future__ import annotations
import os
_ALL_NVCONFIG_FIELDS = {"color1", "color2", "speed"}
def _experimental() -> bool:
"""True when SOLAAR_EXPERIMENTAL is set truthy — bypasses allowlist masking."""
return os.environ.get("SOLAAR_EXPERIMENTAL", "").strip().lower() in ("1", "true", "yes", "on")
# Feature 0x8071 RGBEffects, Function 3 NvConfig — persistent boot/shutdown
# effects. Default-DENY allowlist: modelId -> cap_id -> set of color fields
# the firmware is KNOWN to honor. A listed cap shows the setting (its On/Off
# toggle plus the listed color pickers); an empty set shows the toggle only.
# An unlisted cap or unlisted model suppresses the setting entirely.
RGB_EFFECTS_NVCONFIG_ALLOWED: dict[str, dict[int, set[str]]] = {
# G502 X PLUS — startup (0x0001): color bytes are inert, only the enabled
# flag is honored, so no color fields are allowed (toggle only). Shutdown
# (0x0040) is not supported and is suppressed by build()-time probe anyway.
"4099C0950000": {0x0001: set()},
# G515 LIGHTSPEED TKL — startup and shutdown both honor both colors.
"B38940B4C355": {0x0001: {"color1", "color2"}, 0x0040: {"color1", "color2"}},
}
# Feature 0x0622 HeadsetRGB signature effects — persistent startup / shutdown
# / passive effects. Default-DENY allowlist: modelId -> effect_id -> set of
# fields the firmware is KNOWN to honor. An unlisted effect_id or model
# suppresses that signature-effect setting entirely.
HEADSET_SIGNATURE_EFFECTS_ALLOWED: dict[str, dict[int, set[str]]] = {
# G522 LIGHTSPEED (Centurion model byte 0x32, from DeviceInfo func 0;
# confirmed against multiple diagnostic logs). Verified against user
# reports: startup honors only the primary color (secondary unused, speed
# has no effect); shutdown honors both colors (speed has no effect). The
# passive slot (effect_id 2) is omitted — its behavior is not understood,
# so the whole passive setting is suppressed.
"32": {
0: {"color1"},
1: {"color1", "color2"},
},
}
def rgb_effects_nvconfig_allowed_fields(device, cap_id: int) -> set[str] | None:
"""Color fields to expose for an 0x8071 NvConfig boot effect.
Returns the allowed field set (possibly empty On/Off toggle only), or
``None`` to suppress the setting entirely.
"""
if _experimental():
return set(_ALL_NVCONFIG_FIELDS)
model_id = getattr(device, "modelId", None) or ""
return RGB_EFFECTS_NVCONFIG_ALLOWED.get(model_id, {}).get(cap_id)
def headset_signature_allowed_fields(device, effect_id: int) -> set[str] | None:
"""Fields to expose for an 0x0622 signature effect slot.
Returns the allowed field set, or ``None`` to suppress the slot entirely.
"""
if _experimental():
return set(_ALL_NVCONFIG_FIELDS)
model_id = getattr(device, "modelId", None) or ""
return HEADSET_SIGNATURE_EFFECTS_ALLOWED.get(model_id, {}).get(effect_id)

View File

@ -73,9 +73,10 @@ logger = logging.getLogger(__name__)
# KeyPress action gets the current keyboard group using XkbGetState from libX11.so using ctypes definitions
# under Wayland the keyboard group is None resulting in using the first keyboard group
# KeyPress action translates keysyms to keycodes using the GDK keymap
# KeyPress, MouseScroll, and MouseClick actions use XTest (under X11) or uinput.
# KeyPress, MouseScroll, and MouseClick actions use uinput.
# For uinput to work the user must have write access for /dev/uinput.
# To get this access run sudo setfacl -m u:${user}:rw /dev/uinput
# The Solaar udev rule should set this up
# Otherwise run sudo setfacl -m u:${user}:rw /dev/uinput
#
# Rule GUI keyname determination uses a local file generated
# from http://cgit.freedesktop.org/xorg/proto/x11proto/plain/keysymdef.h
@ -85,8 +86,7 @@ logger = logging.getLogger(__name__)
# Setting up is complex because there are several systems that each provide partial facilities:
# GDK - always available (when running with a window system) but only provides access to keymap
# X11 - provides access to active process and process with window under mouse and current modifier keys
# Xtest extension to X11 - provides input simulation, partly works under Wayland
# Wayland - provides input simulation
# uinput and evdev - provides input simulation
XK_KEYS: Dict[str, int] = keysymdef.key_symbols
@ -111,14 +111,11 @@ if wayland:
)
try:
import Xlib
_x11 = None # X11 might be available
except Exception:
_x11 = False # X11 is not available
# Globals
xtest_available = True # Xtest might be available
xdisplay = None
@ -170,7 +167,7 @@ class XkbStateRec(ctypes.Structure):
def x11_setup():
global _x11, xdisplay, modifier_keycodes, NET_ACTIVE_WINDOW, NET_WM_PID, WM_CLASS, xtest_available
global _x11, xdisplay, modifier_keycodes, NET_ACTIVE_WINDOW, NET_WM_PID, WM_CLASS
if _x11 is not None:
return _x11
try:
@ -187,7 +184,6 @@ def x11_setup():
except Exception:
logger.warning("X11 not available - some rule capabilities inoperable", exc_info=sys.exc_info())
_x11 = False
xtest_available = False
return _x11
@ -272,10 +268,6 @@ def setup_uinput():
logger.warning("cannot create uinput device: %s", e)
if wayland: # Wayland can't use xtest so may as well set up uinput now
setup_uinput()
def kbdgroup():
if xkb_setup():
state = XkbStateRec()
@ -324,31 +316,6 @@ def xy_direction(_x, _y):
return "noop"
def simulate_xtest(code, event):
global xtest_available
if x11_setup() and xtest_available:
try:
event = (
Xlib.X.KeyPress
if event == _KEY_PRESS
else Xlib.X.KeyRelease
if event == _KEY_RELEASE
else Xlib.X.ButtonPress
if event == _BUTTON_PRESS
else Xlib.X.ButtonRelease
if event == _BUTTON_RELEASE
else None
)
Xlib.ext.xtest.fake_input(xdisplay, event, code)
xdisplay.sync()
if logger.isEnabledFor(logging.DEBUG):
logger.debug("xtest simulated input %s %s %s", xdisplay, event, code)
return True
except Exception as e:
xtest_available = False
logger.warning("xtest fake input failed: %s", e)
def simulate_uinput(what, code, arg):
global udevice
if setup_uinput():
@ -364,30 +331,11 @@ def simulate_uinput(what, code, arg):
def simulate_key(code, event): # X11 keycode but Solaar event code
if not wayland and simulate_xtest(code, event):
return True
if evdev and simulate_uinput(evdev.ecodes.EV_KEY, code - 8, event):
return True
logger.warning("no way to simulate key input")
def click_xtest(button, count):
if isinstance(count, int):
for _ in range(count):
if not simulate_xtest(button[0], _BUTTON_PRESS):
return False
if not simulate_xtest(button[0], _BUTTON_RELEASE):
return False
else:
if count != RELEASE:
if not simulate_xtest(button[0], _BUTTON_PRESS):
return False
if count != DEPRESS:
if not simulate_xtest(button[0], _BUTTON_RELEASE):
return False
return True
def click_uinput(button, count):
if isinstance(count, int):
for _ in range(count):
@ -406,8 +354,6 @@ def click_uinput(button, count):
def click(button, count):
if not wayland and click_xtest(button, count):
return True
if click_uinput(button, count):
return True
logger.warning("no way to simulate mouse click")
@ -415,14 +361,6 @@ def click(button, count):
def simulate_scroll(dx, dy):
if not wayland and xtest_available:
success = True
if dx:
success = click_xtest(buttons["scroll_right" if dx > 0 else "scroll_left"], count=abs(dx))
if dy and success:
success = click_xtest(buttons["scroll_up" if dy > 0 else "scroll_down"], count=abs(dy))
if success:
return True
if setup_uinput():
success = True
if dx:

View File

@ -0,0 +1,211 @@
## Copyright (C) 2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Shared helpers for devices exposing Feature 0x0620 HEADSET_RGB_HOSTMODE.
The G522 is currently the only Solaar-supported device advertising this
feature, but anything else presenting 0x0620 will pick the same code path
automatically. The module deliberately avoids G522-specific assumptions
so future RGB-capable headsets can reuse it.
Two entry points the settings templates rely on:
- `discover_zones(device)` one-shot zone enumeration run at setting
build time. Briefly claims Solaar host control so GetRGBZoneInfo
returns a non-empty zone list, then restores the previous host-mode
state. Result is cached on the device.
- `write_zone_map(device, zone_color_map)` the shared write path used
by both the "LEDs Primary" and "Per-zone Lighting" settings. Groups
zones by final RGB color and emits one SetRgbZonesSingleValue per
unique color, then a single FrameEnd to commit.
"""
from __future__ import annotations
import logging
from typing import Iterable
from .hidpp20_constants import SupportedFeature
logger = logging.getLogger(__name__)
# Function IDs on Feature 0x0620 we actually use.
FN_GET_RGB_ZONE_INFO = 0x10
FN_SET_RGB_ZONES_SINGLE = 0x50
FN_FRAME_END = 0x60
FN_GET_HOST_MODE_STATE = 0x70
FN_SET_HOST_MODE_STATE = 0x80
# Frame type sent with FrameEnd. 0x01 = transient commit (re-applies on the
# next refresh). 0x02 would be persistent, but G522 firmware rejects it
# with LOGITECH_INTERNAL (0x05) unless an onboard profile precondition we
# haven't mapped yet is satisfied.
FRAME_TYPE_TRANSIENT = 0x01
_HOST_MODE_SOLAAR = 1
_HOST_MODE_DEVICE = 0
def _device_cache_attr() -> str:
return "_headset_rgb_zone_ids"
def _read_host_mode(device) -> int | None:
"""Read the current host-mode state byte, or None on any failure."""
try:
resp = device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_GET_HOST_MODE_STATE)
except Exception as e:
logger.debug("headset_rgb: GetHostModeState raised %s", e)
return None
if not resp or len(resp) < 1:
return None
return resp[0]
def _set_host_mode(device, value: int) -> bool:
try:
device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_SET_HOST_MODE_STATE, bytes([value & 0xFF]))
except Exception as e:
logger.debug("headset_rgb: SetHostModeState(%d) raised %s", value, e)
return False
return True
def _parse_zone_info(resp: bytes) -> list[int]:
"""Parse a GetRGBZoneInfo response into a zone-id list.
Two formats observed: "tight" ([count, zone_ids...]) on G522, and
the canonical protocol-doc layout (3-byte gap + 1-byte reserved
before zone IDs). Both are tried; whichever yields exactly `count`
IDs wins. Zone id 0 isn't filtered — some devices may use it.
"""
if not resp or len(resp) < 1:
return []
zone_count = resp[0]
tight = list(resp[1 : 1 + zone_count]) if 1 <= zone_count <= len(resp) - 1 else []
if tight and len(tight) == zone_count:
return tight
gap = list(resp[5 : 5 + zone_count]) if len(resp) >= 5 + zone_count else []
if gap and len(gap) == zone_count:
return gap
return []
def discover_zones(device) -> list[int] | None:
"""Return the list of RGB zone IDs on `device`, or None on failure.
Caches the result on `device._headset_rgb_zone_ids` so subsequent
callers don't repeat the round-trip. Briefly claims Solaar host mode
if needed GetRGBZoneInfo has been observed to return count=0 when
the device is still under firmware control and restores the prior
state afterward so user-configured onboard effects resume.
"""
cached = getattr(device, _device_cache_attr(), None)
if cached:
return cached
if not getattr(device, "online", False):
return None
prior_mode = _read_host_mode(device)
claimed = False
if prior_mode != _HOST_MODE_SOLAAR:
if not _set_host_mode(device, _HOST_MODE_SOLAAR):
return None
claimed = True
try:
try:
resp = device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_GET_RGB_ZONE_INFO)
except Exception as e:
logger.debug("headset_rgb: GetRGBZoneInfo raised %s", e)
return None
zones = _parse_zone_info(bytes(resp) if resp else b"")
if not zones:
logger.debug(
"headset_rgb: GetRGBZoneInfo returned no zones (raw=%s)",
resp.hex() if resp else resp,
)
return None
logger.debug("headset_rgb: discovered %d zone(s) %s", len(zones), [f"0x{z:02X}" for z in zones])
setattr(device, _device_cache_attr(), zones)
return zones
finally:
if claimed and prior_mode is not None:
_set_host_mode(device, prior_mode)
def _split_rgb(color_int: int) -> tuple[int, int, int]:
return (color_int >> 16) & 0xFF, (color_int >> 8) & 0xFF, color_int & 0xFF
def write_zone_map(device, zone_color_map: dict) -> bool:
"""Apply a zone->RGB mapping to the device.
`zone_color_map` maps zone id (int) to 24-bit RGB color (int,
`(r<<16)|(g<<8)|b`). Claims host mode, groups zones by color,
emits one SetRgbZonesSingleValue per unique color, then a single
FrameEnd. Returns True on success, False on any transport error.
"""
if not zone_color_map:
return False
if not getattr(device, "online", False):
logger.debug("headset_rgb: device offline, skipping write")
return False
# Group zones by color for batched writes.
groups: dict[int, list[int]] = {}
for zone, color in zone_color_map.items():
groups.setdefault(int(color), []).append(int(zone))
try:
_set_host_mode(device, _HOST_MODE_SOLAAR)
for color_int, zones in groups.items():
r, g, b = _split_rgb(color_int)
# SetRgbZonesSingleValue: [R, G, B, count, zone_ids...]
payload = bytes([r, g, b, len(zones)]) + bytes(zones)
device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_SET_RGB_ZONES_SINGLE, payload)
logger.debug(
"headset_rgb: set (%02X,%02X,%02X) on %d zone(s) %s",
r,
g,
b,
len(zones),
[f"0x{z:02X}" for z in zones],
)
# FrameEnd commits the pending per-zone updates. Transient commit
# only — persistent (0x02) requires onboard-profile preconditions
# that aren't mapped yet.
device.feature_request(
SupportedFeature.HEADSET_RGB_HOSTMODE,
FN_FRAME_END,
bytes([FRAME_TYPE_TRANSIENT, 0x00, 0x00, 0x00]),
)
except Exception as e:
logger.warning("headset_rgb: write_zone_map failed: %s", e)
return False
return True
def zone_named_ints(zones: Iterable[int]):
"""Build a list of NamedInt keys suitable for a ChoicesMap setting.
Factored out so settings code can import without pulling common.NamedInt
at module-load time if preferred.
"""
from . import common
return [common.NamedInt(int(z), f"Zone {int(z)}") for z in zones]

View File

@ -189,7 +189,9 @@ class Hidpp10:
write_register(device, Registers.THREE_LEDS, v1, v2)
def get_notification_flags(self, device: Device):
return self._get_register(device, Registers.NOTIFICATIONS)
flags = self._get_register(device, Registers.NOTIFICATIONS)
if flags is not None:
return NotificationFlag(flags)
def set_notification_flags(self, device: Device, *flag_bits: NotificationFlag):
assert device is not None

View File

@ -16,8 +16,8 @@
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
from __future__ import annotations
from enum import Flag
from enum import IntEnum
from enum import IntFlag
from typing import List
from .common import NamedInts
@ -68,7 +68,7 @@ class PowerSwitchLocation(IntEnum):
return cls.UNKNOWN
class NotificationFlag(Flag):
class NotificationFlag(IntFlag):
"""Some flags are used both by devices and receivers.
The Logitech documentation mentions that the first and last (third)
@ -89,23 +89,14 @@ class NotificationFlag(Flag):
"""
@classmethod
def flag_names(cls, flag_bits: int) -> List[str]:
def flag_names(cls, flags) -> List[str]:
"""Extract the names of the flags from the integer."""
indexed = {item.value: item.name for item in cls}
flag_names = []
unknown_bits = flag_bits
for k in indexed:
# Ensure that the key (flag value) is a power of 2 (a single bit flag)
assert bin(k).count("1") == 1
if k & flag_bits == k:
unknown_bits &= ~k
flag_names.append(indexed[k].replace("_", " ").lower())
# Yield any remaining unknown bits
if unknown_bits != 0:
flag_names.append(f"unknown:{unknown_bits:06X}")
return flag_names
if flags is None:
return []
if flags.name is not None:
return flags.name.replace("_", " ").lower().split("|")
# Python < 3.11: .name is None for composite flags, decompose manually
return [m.name.replace("_", " ").lower() for m in cls if m.value and m in flags]
NUMPAD_NUMERICAL_KEYS = 0x800000
F_LOCK_STATUS = 0x400000
@ -125,13 +116,13 @@ class NotificationFlag(Flag):
THREED_GESTURE = 0x000001
def flags_to_str(flag_bits: int | None, fallback: str) -> str:
def flags_to_str(flags, fallback: str) -> str:
flag_names = []
if flag_bits is not None:
if flag_bits == 0:
if flags is not None and flags is not False:
if flags.value == 0:
flag_names = (fallback,)
else:
flag_names = NotificationFlag.flag_names(flag_bits)
flag_names = NotificationFlag.flag_names(flags)
return f"\n{' ':15}".join(sorted(flag_names))
@ -156,11 +147,19 @@ class PairingError(IntEnum):
TOO_MANY_DEVICES = 0x03
SEQUENCE_TIMEOUT = 0x06
@property
def label(self) -> str:
return self.name.lower().replace("_", " ")
class BoltPairingError(IntEnum):
DEVICE_TIMEOUT = 0x01
FAILED = 0x02
@property
def label(self) -> str:
return self.name.lower().replace("_", " ")
class Registers(IntEnum):
"""Known HID registers.
@ -213,7 +212,7 @@ class InfoSubRegisters(IntEnum):
BOLT_DEVICE_NAME = 0x60 # 0x6N01, by connected device
class DeviceFeature(Flag):
class DeviceFeature(IntFlag):
"""Features for devices.
Flags taken from

View File

@ -24,6 +24,7 @@ import threading
from collections import UserDict
from enum import Flag
from enum import IntEnum
from random import getrandbits
from typing import Any
from typing import Dict
from typing import Generator
@ -35,10 +36,13 @@ import yaml
from solaar.i18n import _
from typing_extensions import Protocol
from . import centurion as _centurion
from . import common
from . import exceptions
from . import hidpp10_constants
from . import special_keys
from .centurion_constants import CenturionCoreFeature
from .centurion_constants import resolve_feature
from .common import Battery
from .common import BatteryLevelApproximation
from .common import BatteryStatus
@ -118,6 +122,7 @@ class MappingFlag(Flag):
UNUSED_4000 = 0x4000
UNUSED_1000 = 0x1000
RAW_WHEEL = 0x400
UNKNOWN_200 = 0x200 # seen on a Wireless Mouse M510 WPID 4004
ANALYTICS_KEY_EVENTS_REPORTING = 0x100
FORCE_RAW_XY_DIVERTED = 0x40
RAW_XY_DIVERTED = 0x10
@ -138,6 +143,7 @@ class FeaturesArray(dict):
self.supported = True # Actually don't know whether it is supported yet
self.device = device
self.inverse = {}
self.sub_inverse = {}
self.version = {}
self.flags = {}
self.count = 0
@ -161,23 +167,156 @@ class FeaturesArray(dict):
logger.warning("FEATURE_SET found, but failed to read features count")
return False
else:
self.count = count[0] + 1 # ROOT feature not included in count
self[SupportedFeature.ROOT] = 0
self[SupportedFeature.FEATURE_SET] = fs_index
if getattr(self.device, "centurion", False):
self._check_centurion(fs_index, count)
else:
self.count = count[0] + 1 # ROOT feature not included in count
return True
else:
self.supported = False
return False
def _check_centurion(self, fs_index, count_response):
"""Enumerate features on a Centurion device (parent + sub-device via CentPPBridge).
Phase A: Enumerate parent device features via CenturionFeatureSet.
Find the CentPPBridge index (feature ID 0x0003 on Centurion = CentPPBridge).
Phase B: Route through CentPPBridge to discover sub-device features.
Use CenturionFeatureSet bulk query to get all sub-device features.
Store sub-device features keyed by SupportedFeature enum.
"""
# Phase A: Parent features
feature_count = count_response[0] # includes ROOT on Centurion
self.count = feature_count
bridge_index = None
for index in range(feature_count):
if self.inverse.get(index) is not None:
continue # already registered (ROOT=0, FEATURE_SET=fs_index)
response = self.device.request((fs_index << 8) | 0x10, index)
if response is None or len(response) < 3:
continue
# Centurion FeatureSet response: [remaining_count, feat_hi, feat_lo, type, version]
feat_id = struct.unpack("!H", response[1:3])[0]
feat_type = response[3] if len(response) > 3 else 0
feat_version = response[4] if len(response) > 4 else 0
feature = resolve_feature(feat_id, centurion=True)
if feature is None:
feature = f"unknown:{feat_id:04X}"
self[feature] = index
self.inverse[index] = feature
# Record version/flags so version-gated settings (sidetone, auto-sleep)
# use the correct payload format on direct USB Centurion devices too.
self.version[feature] = feat_version
self.flags[feature] = feat_type
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"Centurion parent feature: %s at index %d, version=%d, flags=0x%02X",
feature,
index,
feat_version,
feat_type,
)
if feature is CenturionCoreFeature.CENT_PP_BRIDGE:
bridge_index = index
if bridge_index is not None:
self.device._centurion_bridge_index = bridge_index
self.device._centurion_sub_features = set()
self.device._centurion_sub_indices = {}
self._discover_sub_device_features(bridge_index)
def _discover_sub_device_features(self, bridge_index):
"""Phase B: Discover sub-device features via CentPPBridge.
Uses per-index queries: GetCount (func 0) returns total count, then
GetFeatureId (func 1) returns one feature per call. Avoids the
single-frame truncation of bulk queries a Centurion frame is 64
bytes so a bulk reply can only fit ~13 features regardless of how
many the sub-device actually has.
"""
# First, find the sub-device's FeatureSet index via CenturionRoot (sub_feat_idx=0)
# Query: CenturionRoot.GetFeature(0x0001) to find FeatureSet index on sub-device
fs_id_hi = (SupportedFeature.FEATURE_SET >> 8) & 0xFF
fs_id_lo = SupportedFeature.FEATURE_SET & 0xFF
response = self.device.centurion_bridge_request(0x00, 0x00, fs_id_hi, fs_id_lo)
if response is None or len(response) < 1:
logger.warning("Failed to find FeatureSet on Centurion sub-device")
return
sub_fs_index = response[0]
if sub_fs_index == 0:
logger.warning("Sub-device FeatureSet not found (index=0)")
return
# Query feature count (function 0 = GetCount). Response: [count, ...].
count_resp = self.device.centurion_bridge_request(sub_fs_index, 0x00)
if count_resp is None or len(count_resp) < 1:
logger.warning("Failed to read Centurion sub-device feature count")
return
total_count = count_resp[0]
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Centurion sub-device: FeatureSet reports %d features", total_count)
# Per-index query: GetFeatureId (function 1 = 0x10).
# Response: [remaining, feat_hi, feat_lo, type, version].
# We now also record `type` (flags) and `version` for each feature so
# version-gated settings (sidetone, auto-sleep, etc.) can use the
# correct payload format instead of defaulting to V0.
sub_feat_idx = 0
for idx in range(total_count):
response = self.device.centurion_bridge_request(sub_fs_index, 0x10, idx)
if response is None or len(response) < 3:
logger.debug("Centurion sub-device: no response at index %d", idx)
continue
feat_id = struct.unpack("!H", response[1:3])[0]
feat_type = response[3] if len(response) > 3 else 0
feat_version = response[4] if len(response) > 4 else 0
try:
feature = SupportedFeature(feat_id)
except ValueError:
feature = f"unknown:{feat_id:04X}"
self.device._centurion_sub_indices[feature] = sub_feat_idx
if dict.get(self, feature) is None:
dict.__setitem__(self, feature, sub_feat_idx)
self.device._centurion_sub_features.add(feature)
self.sub_inverse[sub_feat_idx] = feature
# Record version/flags so downstream settings can version-gate their
# payload format. get_feature_version(feature) reads self.version[feature].
self.version[feature] = feat_version
self.flags[feature] = feat_type
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"Centurion sub-device feature: %s at sub-index %d, version=%d, flags=0x%02X",
feature,
sub_feat_idx,
feat_version,
feat_type,
)
sub_feat_idx += 1
self._sub_feature_count = sub_feat_idx
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Centurion sub-device: discovered %d features total", sub_feat_idx)
def get_feature(self, index: int) -> SupportedFeature | None:
feature = self.inverse.get(index)
if feature is not None:
return feature
# Sub-device index; bridge unwrap offsets by 0x100 (see listener).
if index >= 0x100:
return self.sub_inverse.get(index - 0x100)
elif self._check():
feature = self.inverse.get(index)
if feature is not None:
return feature
response = self.device.feature_request(SupportedFeature.FEATURE_SET, 0x10, index)
# On Centurion devices, all features are discovered upfront (parent + sub-device)
if getattr(self.device, "centurion", False):
return None
try:
response = self.device.feature_request(SupportedFeature.FEATURE_SET, 0x10, index)
except exceptions.FeatureCallError:
logger.warning("failed to retrieve feature at index %d", index)
return None
if response:
data = struct.unpack("!H", response[:2])[0]
try:
@ -193,7 +332,14 @@ class FeaturesArray(dict):
if self._check():
for index in range(self.count):
feature = self.get_feature(index)
yield feature, index
if feature is not None:
yield feature, index
# Also yield sub-device features for Centurion devices
sub_count = getattr(self, "_sub_feature_count", 0)
for sub_idx in range(sub_count):
feature = self.sub_inverse.get(sub_idx)
if feature is not None:
yield feature, sub_idx
def get_feature_version(self, feature: NamedInt) -> Optional[int]:
if self[feature]:
@ -223,7 +369,16 @@ class FeaturesArray(dict):
index = super().get(feature)
if index is not None:
return index
response = self.device.request(0x0000, struct.pack("!H", feature))
# Centurion devices enumerate all features upfront in _check_centurion().
# If the feature isn't in the dict after _check(), it genuinely doesn't
# exist — skip the raw ROOT.GetFeature query that the dongle rejects
# with LOGITECH_ERROR and that creates cycling log spam during settings init.
if getattr(self.device, "centurion", False):
return None
try:
response = self.device.request(0x0000, struct.pack("!H", feature))
except exceptions.FeatureCallError:
return None
if response:
index = response[0]
self[feature] = index if index else False
@ -242,7 +397,7 @@ class FeaturesArray(dict):
raise ValueError("Don't delete features from FeatureArray")
def __len__(self) -> int:
return self.count
return self.count + getattr(self, "_sub_feature_count", 0)
__bool__ = __nonzero__ = _check
@ -1014,22 +1169,37 @@ class LEDParam:
ramp = "ramp"
form = "form"
saturation = "saturation"
direction = "direction"
class LedRampChoice(IntEnum):
DEFAULT = 0
YES = 1
NO = 2
# NamedInts (not IntEnum) so the GTK ComboBoxText shows readable labels.
LedRampChoice = common.NamedInts(Default=0, Yes=1, No=2)
LedFormChoices = common.NamedInts(
Default=0,
Sine=1,
Square=2,
Triangle=3,
Sawtooth=4,
Shark_fin=5,
Exponential=6,
)
class LedFormChoices(IntEnum):
DEFAULT = 0
SINE = 1
SQUARE = 2
TRIANGLE = 3
SAWTOOTH = 4
SHARKFIN = 5
EXPONENTIAL = 6
LedDirectionChoices = common.NamedInts()
LedDirectionChoices[0] = _("Cycle")
LedDirectionChoices[1] = _("Right")
LedDirectionChoices[2] = _("Down")
LedDirectionChoices[3] = _("Center Out")
LedDirectionChoices[4] = _("In")
LedDirectionChoices[5] = _("Out")
LedDirectionChoices[6] = _("Left")
LedDirectionChoices[7] = _("Up")
LedDirectionChoices[8] = _("Center In")
# Direction values to hide on devices whose LED grid can't render them.
LedDirectionBlocklist = {
"40B4": {4, 5}, # G515 LS TKL — no edge-radiating wave geometry
}
LEDParamSize = {
@ -1040,33 +1210,77 @@ LEDParamSize = {
LEDParam.ramp: 1,
LEDParam.form: 1,
LEDParam.saturation: 1,
LEDParam.direction: 1,
}
# not implemented from x8070 Wave=4, Stars=5, Press=6, Audio=7
# not implemented from x8071 Custom=12, Kitt=13, HSVPulsing=20,
# WaveC=22, RippleC=23, SignatureActive=24, SignaturePassive=25
# Entry: [NamedInt, params, defaults, ranges] — trailing dicts optional.
# ranges overrides a field's global min/max, e.g. period: (2, 200).
LEDEffects = {
0x00: [NamedInt(0x00, _("Disabled")), {}],
0x01: [NamedInt(0x01, _("Static")), {LEDParam.color: 0, LEDParam.ramp: 3}],
0x02: [NamedInt(0x02, _("Pulse")), {LEDParam.color: 0, LEDParam.speed: 3}],
0x03: [NamedInt(0x03, _("Cycle")), {LEDParam.period: 5, LEDParam.intensity: 7}],
0x03: [
NamedInt(0x03, _("Cycle")),
{LEDParam.period: 5, LEDParam.intensity: 7},
{LEDParam.period: 5000, LEDParam.intensity: 100},
],
# No probe device enumerates base Wave; assume the 0x16 layout so the
# UI matches what 0x16-capable hardware shows.
0x04: [
NamedInt(0x04, _("Wave")),
{LEDParam.period: 6, LEDParam.direction: 9},
{LEDParam.period: 5000},
],
0x08: [NamedInt(0x08, _("Boot")), {}],
0x09: [NamedInt(0x09, _("Demo")), {}],
0x0A: [
NamedInt(0x0A, _("Breathe")),
{LEDParam.color: 0, LEDParam.period: 3, LEDParam.form: 5, LEDParam.intensity: 6},
{LEDParam.period: 5000, LEDParam.intensity: 100},
],
0x0B: [
NamedInt(0x0B, _("Ripple")),
{LEDParam.color: 0, LEDParam.period: 4},
{LEDParam.period: 20},
{LEDParam.period: (2, 200)},
],
0x0B: [NamedInt(0x0B, _("Ripple")), {LEDParam.color: 0, LEDParam.period: 4}],
0x0E: [NamedInt(0x0E, _("Decomposition")), {LEDParam.period: 6, LEDParam.intensity: 8}],
0x0F: [NamedInt(0x0F, _("Signature1")), {LEDParam.period: 5, LEDParam.intensity: 7}],
0x10: [NamedInt(0x10, _("Signature2")), {LEDParam.period: 5, LEDParam.intensity: 7}],
0x15: [NamedInt(0x15, _("CycleS")), {LEDParam.saturation: 1, LEDParam.period: 6, LEDParam.intensity: 8}],
0x15: [
NamedInt(0x15, _("Cycle")),
{LEDParam.saturation: 1, LEDParam.period: 6, LEDParam.intensity: 8},
{LEDParam.saturation: 255, LEDParam.period: 5000, LEDParam.intensity: 100},
],
0x16: [
NamedInt(0x16, _("Wave")),
{LEDParam.saturation: 1, LEDParam.period: 6, LEDParam.intensity: 8, LEDParam.direction: 9},
{LEDParam.saturation: 255, LEDParam.period: 5000, LEDParam.intensity: 100},
],
# Saturation derivative of Ripple 0x0B; pcap layout: color @ 0-2,
# saturation @ 3, period @ 6-7.
0x17: [
NamedInt(0x17, _("Ripple")),
{LEDParam.color: 0, LEDParam.saturation: 3, LEDParam.period: 6},
{LEDParam.saturation: 255, LEDParam.period: 20},
{LEDParam.period: (2, 200)},
],
# Synthetic — host-side dim ramp, no wire effect.
0x80: [NamedInt(0x80, _("Dim")), {LEDParam.intensity: 0}],
}
class LEDEffectSetting: # an effect plus its parameters
# Params whose value space is an RGB color; wrapped in ColorInt so the
# value self-formats as ``0xrrggbb`` in solaar show and the YAML config.
_COLOR_PARAMS = (str(LEDParam.color),)
def __init__(self, **kwargs):
self.ID = None
for key, val in kwargs.items():
# type(val) is int — exact match excludes NamedInt/ColorInt and
# any other int subclass; only "raw" ints get wrapped here.
if key in self._COLOR_PARAMS and type(val) is int and 0 <= val <= 0xFFFFFF: # noqa: E721
val = common.ColorInt(val)
setattr(self, key, val)
@classmethod
@ -1545,6 +1759,11 @@ def feature_request(device, feature, function=0x00, *params, no_reply=False):
class Hidpp20:
# Host-side counter for SetComplete cookies (see set_configuration_complete).
# Seeded to a non-zero random 16-bit value at import so successive sessions
# don't trivially collide; we just need to never send 0x0000.
_session_cookie = getrandbits(16) or 1
def get_firmware(self, device) -> tuple[common.FirmwareInfo] | None:
"""Reads a device's firmware info.
@ -1574,6 +1793,27 @@ class Hidpp20:
fw.append(fw_info)
return tuple(fw)
def get_firmware_centurion(self, device):
return _centurion.get_firmware_centurion(device)
def get_serial_centurion(self, device):
return _centurion.get_serial_centurion(device)
def get_hardware_info_centurion(self, device):
return _centurion.get_hardware_info_centurion(device)
def _centurion_sub_device_info_request(self, device, function=0x00, *params):
return _centurion._centurion_sub_device_info_request(device, function, *params)
def get_firmware_centurion_sub(self, device):
return _centurion.get_firmware_centurion_sub(device)
def get_serial_centurion_sub(self, device):
return _centurion.get_serial_centurion_sub(device)
def get_hardware_info_centurion_sub(self, device):
return _centurion.get_hardware_info_centurion_sub(device)
def get_ids(self, device):
"""Reads a device's ids (unit and model numbers)"""
ids = device.feature_request(SupportedFeature.DEVICE_FW_VERSION)
@ -1625,6 +1865,9 @@ class Hidpp20:
return name.decode("utf-8")
def get_name_centurion(self, device):
return _centurion.get_name_centurion(device)
def get_friendly_name(self, device: Device):
"""Reads a device's friendly name.
@ -1669,6 +1912,9 @@ class Hidpp20:
except exceptions.FeatureCallError:
return SupportedFeature.ADC_MEASUREMENT if SupportedFeature.ADC_MEASUREMENT in device.features else None
def get_battery_centurion(self, device: Device):
return _centurion.get_battery_centurion(device)
def get_battery(self, device, feature):
"""Return battery information - feature, approximate level, next, charging, voltage
or battery feature if there is one but it is not responding or None for no battery feature"""
@ -1689,10 +1935,10 @@ class Hidpp20:
def get_keys(self, device: Device):
# TODO: add here additional variants for other REPROG_CONTROLS
count = None
if SupportedFeature.REPROG_CONTROLS_V2 in device.features:
if device.features and SupportedFeature.REPROG_CONTROLS_V2 in device.features:
count = device.feature_request(SupportedFeature.REPROG_CONTROLS_V2)
return KeysArrayV2(device, ord(count[:1]))
elif SupportedFeature.REPROG_CONTROLS_V4 in device.features:
elif device.features and SupportedFeature.REPROG_CONTROLS_V4 in device.features:
count = device.feature_request(SupportedFeature.REPROG_CONTROLS_V4)
return KeysArrayV4(device, ord(count[:1]))
return None
@ -1891,7 +2137,41 @@ class Hidpp20:
SupportedFeature._fallback = lambda x: f"unknown:{x:04X}"
return result
def get_keyboard_layout(self, device: Device):
"""Return the device's keyboard layout country code, or None.
Country code semantics match the HID HUT keyboard country codes that
Logitech's KEYBOARD_LAYOUT_2 (0x4540) feature reports in the first byte.
Used by the per-key painter to pick the matching regional layout.
"""
result = device.feature_request(SupportedFeature.KEYBOARD_LAYOUT_2, 0x00)
if result:
return struct.unpack("!B", result[:1])[0]
return None
def get_configuration_cookie(self, device: Device):
"""ConfigChange (0x0020) GetCookie — read the device's current configuration cookie."""
response = device.feature_request(SupportedFeature.CONFIG_CHANGE, 0x00)
return response[:2] if response else None
def next_session_cookie(self):
"""Bump and return the host-side counter used as the SetComplete cookie."""
Hidpp20._session_cookie = (Hidpp20._session_cookie + 1) & 0xFFFF or 1
return bytes([Hidpp20._session_cookie >> 8, Hidpp20._session_cookie & 0xFF])
def set_configuration_complete(self, device: Device, cookie=None, no_reply=False):
"""ConfigChange (0x0020) SetComplete — acknowledge host has synced with device configuration.
Sends a host-side monotonic counter, incremented per call and
always non-zero. Cookie 0x0000 has been observed to release the
SW effect-engine claim on at least the G515 LS TKL; we avoid it."""
if cookie is None:
cookie = self.next_session_cookie()
if cookie and len(cookie) >= 2:
return device.feature_request(SupportedFeature.CONFIG_CHANGE, 0x10, cookie[0], cookie[1], no_reply=no_reply)
def config_change(self, device: Device, configuration, no_reply=False):
"""Deprecated — use set_configuration_complete() instead."""
return device.feature_request(SupportedFeature.CONFIG_CHANGE, 0x10, configuration, no_reply=no_reply)
@ -1900,6 +2180,7 @@ battery_functions = {
SupportedFeature.BATTERY_VOLTAGE: Hidpp20.get_battery_voltage,
SupportedFeature.UNIFIED_BATTERY: Hidpp20.get_battery_unified,
SupportedFeature.ADC_MEASUREMENT: Hidpp20.get_adc_measurement,
SupportedFeature.CENTURION_BATTERY_SOC: Hidpp20.get_battery_centurion,
}
@ -1980,6 +2261,9 @@ def decipher_battery_unified(report) -> tuple[SupportedFeature, Battery]:
return SupportedFeature.UNIFIED_BATTERY, Battery(discharge if discharge else approx_level, None, status, None)
decipher_battery_centurion = _centurion.decipher_battery_centurion
def decipher_adc_measurement(report) -> tuple[SupportedFeature, Battery]:
# partial implementation - needs mapping to levels
adc_voltage, flags = struct.unpack("!HB", report[:3])
@ -2119,3 +2403,22 @@ class ForceSensingButtonArray(UserDict):
def acceptable_current_key(self, index: int, value: int) -> bool:
return self[index].acceptable(value)
# --- OnboardEQ (0x0636) — re-exported from onboard_eq.py ---
# --- AdvancedParaEQ (0x020D) — re-exported from advanced_para_eq.py ---
from .advanced_para_eq import FILTER_TYPE_HP # noqa: E402, F401
from .advanced_para_eq import FILTER_TYPE_PEAKING # noqa: E402, F401
from .advanced_para_eq import FILTER_TYPE_PEAKING_G522 # noqa: E402, F401
from .advanced_para_eq import get_advanced_eq_active_slot # noqa: E402, F401
from .advanced_para_eq import get_advanced_eq_defaults # noqa: E402, F401
from .advanced_para_eq import get_advanced_eq_friendly_name # noqa: E402, F401
from .advanced_para_eq import get_advanced_eq_info # noqa: E402, F401
from .advanced_para_eq import get_advanced_eq_params # noqa: E402, F401
from .advanced_para_eq import parse_v2_bands # noqa: E402, F401
from .advanced_para_eq import probe_advanced_eq_slots # noqa: E402, F401
from .advanced_para_eq import probe_all_presets as probe_advanced_eq_presets # noqa: E402, F401
from .onboard_eq import _build_set_eq_payload # noqa: E402, F401
from .onboard_eq import get_onboard_eq_info # noqa: E402, F401
from .onboard_eq import get_onboard_eq_params # noqa: E402, F401
from .onboard_eq import set_onboard_eq_params # noqa: E402, F401

View File

@ -40,6 +40,7 @@ class SupportedFeature(IntEnum):
DEVICE_GROUPS = 0x0006
DEVICE_FRIENDLY_NAME = 0x0007
KEEP_ALIVE = 0x0008
PROPERTY_ACCESS = 0x0011
CONFIG_CHANGE = 0x0020
CRYPTO_ID = 0x0021
TARGET_SOFTWARE = 0x0030
@ -61,6 +62,7 @@ class SupportedFeature(IntEnum):
CONFIG_DEVICE_PROPS = 0x1806
CHANGE_HOST = 0x1814
HOSTS_INFO = 0x1815
BLE_PRO_PRE_PAIRING = 0x1816
BACKLIGHT = 0x1981
BACKLIGHT2 = 0x1982
BACKLIGHT3 = 0x1983
@ -74,10 +76,16 @@ class SupportedFeature(IntEnum):
REPROG_CONTROLS_V2_2 = 0x1B02 # LogiOptions 2.10.73 features.xml
REPROG_CONTROLS_V3 = 0x1B03
REPROG_CONTROLS_V4 = 0x1B04
ANALOG_BUTTONS = 0x1B0C # Analog button tuning (actuation point, rapid trigger, haptics)
FULL_KEY_CUSTOMIZATION = 0x1B05
CONTROL_LIST = 0x1B10
SWITCH_SWAPABILITY = 0x1B20
DEVICE_MODE = 0x1B30
REPORT_HID_USAGE = 0x1BC0
PERSISTENT_REMAPPABLE_ACTION = 0x1C00
WIRELESS_DEVICE_STATUS = 0x1D4B
REMAINING_PAIRING = 0x1DF0
ENABLE_HIDDEN_FEATURES = 0x1E00
FIRMWARE_PROPERTIES = 0x1F1F
ADC_MEASUREMENT = 0x1F20
# Mouse
@ -110,6 +118,7 @@ class SupportedFeature(IntEnum):
KEYBOARD_LAYOUT = 0x4520
KEYBOARD_DISABLE_KEYS = 0x4521
KEYBOARD_DISABLE_BY_USAGE = 0x4522
KEYBOARD_DISABLE_CONTROLS = 0x4523
DUALPLATFORM = 0x4530
MULTIPLATFORM = 0x4531
KEYBOARD_LAYOUT_2 = 0x4540
@ -132,22 +141,98 @@ class SupportedFeature(IntEnum):
MKEYS = 0x8020
MR = 0x8030
BRIGHTNESS_CONTROL = 0x8040
LOGI_MODIFIERS = 0x8051
REPORT_RATE = 0x8060
EXTENDED_ADJUSTABLE_REPORT_RATE = 0x8061
COLOR_LED_EFFECTS = 0x8070
RGB_EFFECTS = 0x8071
RPM_INDICATOR = 0x807A
RPM_LED_PATTERN = 0x807B
PER_KEY_LIGHTING = 0x8080
PER_KEY_LIGHTING_V2 = 0x8081
MODE_STATUS = 0x8090
LEGACY_AXIS_RESPONSE_CURVE = 0x80A3
AXIS_RESPONSE_CURVE = 0x80A4
BANDED_AXIS = 0x80B1
COMBINED_PEDALS = 0x80D0
BUNNY_HOPPING = 0x80E0
ONBOARD_PROFILES = 0x8100
PROFILE_MANAGEMENT = 0x8101
MOUSE_BUTTON_SPY = 0x8110
LATENCY_MONITORING = 0x8111
GAMING_ATTACHMENTS = 0x8120
FORCE_FEEDBACK = 0x8123
DUAL_CLUTCH = 0x8127
WHEEL_CENTER_POSITION = 0x812C
DISPLAY_GAME_DATA = 0x8130
CENTER_SPRING = 0x8131
AXIS_MAPPING = 0x8132
GLOBAL_DAMPING = 0x8133
BRAKE_FORCE = 0x8134
PEDAL_STATUS = 0x8135
TORQUE_LIMIT = 0x8136
CONFIGURATION_PROFILES = 0x8137
OPERATING_RANGE = 0x8138
TRUE_FORCE = 0x8139
FFB_FILTER = 0x8140
# Headsets
SIDETONE = 0x8300
EQUALIZER = 0x8310
HEADSET_OUT = 0x8320
# Centurion core
CENTURION_DEVICE_INFO = 0x0100
CENTURION_DEVICE_NAME = 0x0101
CENTURION_ROOT = 0x0102
CENTURION_MEMFAULT = 0x0103
CENTURION_BATTERY_SOC = 0x0104
CENTURION_AUTO_SLEEP = 0x0108
CENTURION_GENERIC_DFU = 0x010A
CENTURION_LED_BRIGHTNESS = 0x0110
CENTURION_EU_POWER_MODE = 0x0115
CENTURION_DEVICE_BOOL_STATE = 0x0116
# Headsets (Centurion-era)
HEADSET_VOLUME = 0x0200
HEADSET_EQ = 0x0201
HEADSET_ADVANCED_PARA_EQ = 0x020D
HEADSET_MIC_TEST = 0x020E
HEADSET_EQ_STYLES = 0x0213
BT_HOST_INFO = 0x0305
LIGHTSPEED_PAIRING = 0x0309
BT_GAMING_MODE = 0x030A
HEADSET_RGB_EFFECTS = 0x0600
HEADSET_MIC_MUTE = 0x0601
HEADSET_MIC_SNR = 0x0602
HEADSET_AUDIO_SIDETONE = 0x0604
HEADSET_HOST_SWITCH = 0x0607
HEADSET_MIX = 0x0609
HEADSET_TONES = 0x060B
HEADSET_NOISE_EXPOSURE = 0x060D
HEADSET_AI_NOISE_REDUCTION = 0x060E
HEADSET_MIC_GAIN = 0x0611
HEADSET_USAGE_TRACKING = 0x0617
HEADSET_BATTERY_SAVER = 0x0618
HEADSET_RGB_HOSTMODE = 0x0620
HEADSET_RGB_ONBOARD_EFFECTS = 0x0621
HEADSET_RGB_SIGNATURE_EFFECTS = 0x0622
HEADSET_DO_NOT_DISTURB = 0x0631
CENTURION_ONBOARD_PROFILES = 0x0634
HEADSET_RGB_STREAMING = 0x0635
HEADSET_ONBOARD_EQ = 0x0636
# Audio mixing / LogiVoice
MIXER_AUDIO = 0x0800
MIXER_MIC = 0x0801
LOGIVOICE = 0x0900
LOGIVOICE_NOISE_REDUCTION = 0x0901
LOGIVOICE_NOISE_GATE = 0x0902
LOGIVOICE_COMPRESSOR = 0x0903
LOGIVOICE_DE_ESSER = 0x0904
LOGIVOICE_DE_POPPER = 0x0905
LOGIVOICE_LIMITER = 0x0906
LOGIVOICE_HIGH_PASS_FILTER = 0x0907
LOGIVOICE_EQUALIZER = 0x0908
LOGIVOICE_AINR = 0x0909
METERING = 0x0B01
MIC_GAIN_AUTO_MODE = 0x0B02
# Fake features for Solaar internal use
MOUSE_GESTURE = 0xFE00

View File

@ -15,6 +15,7 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import dataclasses
import logging
import queue
import threading
@ -52,6 +53,11 @@ class _ThreadedHandle:
else:
# if logger.isEnabledFor(logging.DEBUG):
# logger.debug("%r opened new handle %d", self, handle)
# If original handle was centurion, copy state to new per-thread handle
for h in self._handles:
if h in base._centurion_handles:
base._centurion_handles[handle] = dataclasses.replace(base._centurion_handles[h])
break
self._local.handle = handle
self._handles.append(handle)
return handle
@ -145,7 +151,8 @@ class EventsListener(threading.Thread):
self.receiver.close()
break
if n:
n = base.make_notification(*n)
report_id, devnumber, data = n
n = base.make_notification(report_id, devnumber, data)
else:
n = self._queued_notifications.get() # deliver any queued notifications
if n:

View File

@ -0,0 +1,300 @@
## Copyright (C) 2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""LogiVoice (0x0900 + 0x0901-0x0907) read helpers.
Each LogiVoice processing module exposes the same 5-function API:
fn 0 SetState
fn 1 GetState -> u8 state (boolean)
fn 2 SetParameters
fn 3 GetParameters -> module-specific payload (see PARAMETERS_FIELDS)
fn 4 GetInfo -> per-field [min, max] bounds (see parse_info)
All multi-byte integers on the wire are big-endian. Parameters layouts are
module-specific; PARAMETERS_FIELDS encodes per-field offset / width /
signedness / range / label metadata. The first field is at offset 0 there
is no leading "state" byte (the state toggle is on fn 0/1 only).
Writes are NOT implemented yet. State toggles via fn 0x00/0x10 are
shipping as boolean settings; per-field Parameters writes need a live
round-trip verification before they're safe to expose.
"""
from __future__ import annotations
import logging
import struct
from typing import Iterable
from .hidpp20_constants import SupportedFeature
logger = logging.getLogger(__name__)
# Wire function IDs (standard across all LogiVoice modules).
FN_SET_STATE = 0x00
FN_GET_STATE = 0x10
FN_SET_PARAMETERS = 0x20
FN_GET_PARAMETERS = 0x30
FN_GET_INFO = 0x40
# Human-readable names for the modules Solaar may see on a LogiVoice device.
MODULE_NAMES = {
SupportedFeature.LOGIVOICE: "LogiVoice",
SupportedFeature.LOGIVOICE_NOISE_REDUCTION: "Noise Reduction",
SupportedFeature.LOGIVOICE_NOISE_GATE: "Noise Gate",
SupportedFeature.LOGIVOICE_COMPRESSOR: "Compressor",
SupportedFeature.LOGIVOICE_DE_ESSER: "De-esser",
SupportedFeature.LOGIVOICE_DE_POPPER: "De-popper",
SupportedFeature.LOGIVOICE_LIMITER: "Limiter",
SupportedFeature.LOGIVOICE_HIGH_PASS_FILTER: "High Pass Filter",
}
# Short slugs used in Solaar setting IDs (`logivoice-<slug>-<field>`).
MODULE_SLUGS = {
SupportedFeature.LOGIVOICE_NOISE_REDUCTION: "nr",
SupportedFeature.LOGIVOICE_NOISE_GATE: "ng",
SupportedFeature.LOGIVOICE_COMPRESSOR: "comp",
SupportedFeature.LOGIVOICE_DE_ESSER: "deesser",
SupportedFeature.LOGIVOICE_DE_POPPER: "depopper",
SupportedFeature.LOGIVOICE_LIMITER: "limiter",
SupportedFeature.LOGIVOICE_HIGH_PASS_FILTER: "hpf",
}
class Field:
"""Metadata for one decoded Parameters field.
offset: byte offset within the GetParameters payload.
byte_count: width (1 or 2 for fields we currently decode).
signed: whether to interpret as signed int.
min_value/max_value: range for the Solaar slider validator. For opaque
fields, use the full representable range (0..255 or 0..65535).
label: human-readable name for UI.
opaque: True if the field's wire encoding isn't pinned down label
shows raw units and the caller should treat as round-trip.
"""
def __init__(self, name, offset, byte_count, signed, min_value, max_value, label, opaque=False):
self.name = name
self.offset = offset
self.byte_count = byte_count
self.signed = signed
self.min_value = min_value
self.max_value = max_value
self.label = label
self.opaque = opaque
# Per-module field layout for GetParameters / SetParameters payload. Each
# module's struct is the union of named fields below; there is no separate
# "state" byte at offset 0 — that toggle is only on fn 0x00/0x10. Field
# encodings (signedness, byte order, units) and value ranges come from the
# device's GetInfo response (see parse_info) and are confirmed against
# captured bring-up bytes; ranges hardcoded here are the bounds the device
# reports and the values it ships as factory defaults.
#
# `opaque=True` is reserved for fields whose unit scale isn't pinned down
# (currently width_q on De-esser / De-popper — the host-side scale constant
# is loaded at runtime and not statically resolvable). Treat opaque values
# as monotonic raw integers until a live probe anchors the units.
PARAMETERS_FIELDS: dict[SupportedFeature, list[Field]] = {
SupportedFeature.LOGIVOICE_NOISE_REDUCTION: [
Field("sensitivity", 0, 1, False, 0, 40, "Sensitivity"),
Field("release", 1, 2, False, 1, 1000, "Release (ms)"),
Field("bias", 3, 1, False, 0, 5, "Bias"),
Field("attenuation", 4, 1, True, -20, 0, "Attenuation (dB)"),
],
SupportedFeature.LOGIVOICE_NOISE_GATE: [
Field("threshold", 0, 1, True, -60, -35, "Threshold (dB)"),
Field("attenuation", 1, 1, True, -50, -3, "Attenuation (dB)"),
Field("attack", 2, 2, False, 1, 200, "Attack (ms)"),
Field("hold", 4, 2, False, 1, 1000, "Hold (ms)"),
Field("release", 6, 2, False, 1, 1000, "Release (ms)"),
],
SupportedFeature.LOGIVOICE_COMPRESSOR: [
Field("threshold", 0, 1, True, -40, 0, "Threshold (dB)"),
Field("attack", 1, 2, False, 1, 200, "Attack (ms)"),
Field("release", 3, 2, False, 50, 1000, "Release (ms)"),
Field("post_gain", 5, 1, True, -12, 12, "Post Gain (dB)"),
Field("pre_gain", 6, 1, True, -12, 12, "Pre Gain (dB)"),
# Ratio reports min=1 max=20 from GetInfo; whether the device interprets
# it as a literal X:1 ratio or a curve-table index is unconfirmed.
Field("ratio", 7, 1, False, 1, 20, "Ratio"),
],
SupportedFeature.LOGIVOICE_DE_ESSER: [
Field("threshold", 0, 1, True, -50, 0, "Threshold (dB)"),
Field("frequency", 1, 2, False, 1000, 10000, "Frequency (Hz)"),
# width_q is a Q-format quantization with a device-loaded scale we
# don't know; range/default come straight from GetInfo.
Field("width_q", 3, 1, False, 2, 120, "Width/Q", opaque=True),
Field("attack", 4, 2, False, 1, 200, "Attack (ms)"),
Field("release", 6, 2, False, 20, 1000, "Release (ms)"),
Field("attenuation", 8, 1, True, -40, 0, "Attenuation (dB)"),
],
SupportedFeature.LOGIVOICE_DE_POPPER: [
Field("threshold", 0, 1, True, -50, 0, "Threshold (dB)"),
Field("frequency", 1, 2, False, 60, 500, "Frequency (Hz)"),
Field("width_q", 3, 1, False, 2, 120, "Width/Q", opaque=True),
Field("attack", 4, 2, False, 1, 200, "Attack (ms)"),
Field("release", 6, 2, False, 20, 1000, "Release (ms)"),
Field("attenuation", 8, 1, True, -40, 0, "Attenuation (dB)"),
],
SupportedFeature.LOGIVOICE_LIMITER: [
Field("boost", 0, 1, True, -128, 127, "Boost (dB)"),
Field("attack", 1, 2, False, 1, 65535, "Attack (ms)"),
Field("release", 3, 2, False, 1, 65535, "Release (ms)"),
],
SupportedFeature.LOGIVOICE_HIGH_PASS_FILTER: [
Field("frequency", 0, 2, False, 60, 300, "Cutoff (Hz)"),
],
}
def expected_payload_length(feature: SupportedFeature) -> int:
fields = PARAMETERS_FIELDS.get(feature)
if not fields:
return 0
return max(f.offset + f.byte_count for f in fields)
def get_state(device, feature: SupportedFeature):
"""Read the module's on/off state via fn 1. Returns int 0-255 or None."""
result = device.feature_request(feature, FN_GET_STATE)
if result is None or len(result) < 1:
return None
return result[0]
def get_parameters(device, feature: SupportedFeature):
"""Read the module's Parameters struct via fn 3. Returns raw bytes or None."""
result = device.feature_request(feature, FN_GET_PARAMETERS)
if result is None:
return None
return bytes(result)
def get_info(device, feature: SupportedFeature):
"""Read module capability info via fn 4. Returns raw bytes or None.
Decoded per-field bounds are available via parse_info().
"""
result = device.feature_request(feature, FN_GET_INFO)
if result is None:
return None
return bytes(result)
def _decode_field(chunk: bytes, byte_count: int, signed: bool) -> int:
"""Decode `byte_count` bytes from `chunk` as an integer per the field's wire
encoding. Multi-byte values are big-endian (matches Parameters)."""
if byte_count == 1:
return struct.unpack("b" if signed else "B", chunk[:1])[0]
if byte_count == 2:
return struct.unpack(">h" if signed else ">H", chunk[:2])[0]
return int.from_bytes(chunk[:byte_count], "big", signed=signed)
def parse_info(feature: SupportedFeature, payload: bytes) -> dict:
"""Decode a GetInfo response into per-field {min, max} bounds.
Layout: for each field in PARAMETERS_FIELDS in order, the payload carries
[min_value, max_value] back-to-back using the field's wire encoding (so
a u16 field contributes 4 bytes 2 for min, 2 for max). Trailing bytes
in the response are pad/zero.
Returns a dict mapping field name to {"min": int, "max": int}. Fields
that don't fit in the payload are omitted.
"""
fields = PARAMETERS_FIELDS.get(feature)
if not fields or not payload:
return {}
out = {}
offset = 0
for f in fields:
end = offset + 2 * f.byte_count
if end > len(payload):
break
min_val = _decode_field(payload[offset : offset + f.byte_count], f.byte_count, f.signed)
max_val = _decode_field(payload[offset + f.byte_count : end], f.byte_count, f.signed)
out[f.name] = {"min": min_val, "max": max_val}
offset = end
return out
def parse_parameters(feature: SupportedFeature, payload: bytes) -> dict:
"""Decode Parameters bytes into a dict per the per-module field table.
Returns {} on unknown feature or short payload caller still has the raw
hex via get_parameters() for corpus logging.
"""
fields = PARAMETERS_FIELDS.get(feature)
if not fields or payload is None:
return {}
parsed = {}
for f in fields:
end = f.offset + f.byte_count
if end > len(payload):
continue
chunk = payload[f.offset : end]
if f.byte_count == 1:
val = struct.unpack("b" if f.signed else "B", chunk)[0]
elif f.byte_count == 2:
val = struct.unpack(">h" if f.signed else ">H", chunk)[0]
else:
val = int.from_bytes(chunk, "big", signed=f.signed)
parsed[f.name] = val
return parsed
def probe_module(device, feature: SupportedFeature) -> None:
"""One-shot corpus probe. Logs state + raw parameters + parsed + raw info
+ decoded info bounds."""
name = MODULE_NAMES.get(feature, f"0x{int(feature):04X}")
state = get_state(device, feature)
params = get_parameters(device, feature)
info = get_info(device, feature)
logger.debug(
"LogiVoice %s [0x%04X]: state=%s parameters=%s info=%s",
name,
int(feature),
state,
params.hex() if params else None,
info.hex() if info else None,
)
parsed = parse_parameters(feature, params) if params else {}
if parsed:
logger.debug("LogiVoice %s parsed: %s", name, parsed)
bounds = parse_info(feature, info) if info else {}
if bounds:
logger.debug("LogiVoice %s info bounds: %s", name, bounds)
def probe_all_modules(device, features: Iterable[SupportedFeature]) -> None:
"""Probe every LogiVoice module present on the device.
Call once at device-bring-up so the -dd corpus has a full snapshot.
Caller passes whichever subset of LogiVoice features are actually
discovered (usually derived from device.features).
"""
for feature in features:
if feature not in PARAMETERS_FIELDS and feature != SupportedFeature.LOGIVOICE:
continue
try:
probe_module(device, feature)
except Exception as e:
logger.debug("LogiVoice probe_module(%s) raised %s", feature, e)

View File

@ -34,6 +34,7 @@ from . import diversion
from . import hidpp10
from . import hidpp10_constants
from . import hidpp20
from . import rgb_power
from . import settings_templates
from .common import Alert
from .common import BatteryStatus
@ -287,6 +288,9 @@ def _process_feature_notification(device: Device, notification: HIDPPNotificatio
else:
logger.warning("%s: unknown ADC MEASUREMENT %s", device, notification)
elif feature == SupportedFeature.CENTURION_BATTERY_SOC:
device.set_battery_info(hidpp20.decipher_battery_centurion(notification.data)[1])
elif feature == SupportedFeature.SOLAR_DASHBOARD:
if notification.data[5:9] == b"GOOD":
charge, lux, adc = struct.unpack("!BHH", notification.data[:5])
@ -319,9 +323,14 @@ def _process_feature_notification(device: Device, notification: HIDPPNotificatio
if logger.isEnabledFor(logging.DEBUG):
logger.debug("wireless status: %s", notification)
reason = "powered on" if notification.data[2] == 1 else None
if notification.data[1] == 1: # device is asking for software reconfiguration so need to change status
if notification.data[1] == 1: # device is asking for software reconfiguration
alert = Alert.NONE
device.changed(active=True, alert=alert, reason=reason, push=True)
device.changed(active=True, alert=alert, reason=reason)
# changed(active=True) already runs apply_settings_if_needed on
# the first transition; for follow-up reconfig notifications
# on an already-active device, fire the gate here so the
# cookie comparison decides whether to re-push.
device.apply_settings_if_needed()
else:
logger.warning("%s: unknown WIRELESS %s", device, notification)
@ -419,6 +428,55 @@ def _process_feature_notification(device: Device, notification: HIDPPNotificatio
brightness = struct.unpack("!H", device.feature_request(SupportedFeature.BRIGHTNESS_CONTROL, 0x10)[:2])[0]
device.setting_callback(device, settings_templates.BrightnessControl, [brightness])
elif feature == SupportedFeature.RGB_EFFECTS:
fn = notification.address >> 4
if fn == 1: # onUserActivity: type=0 is IDLE, type!=0 is ACTIVE
activity_type = notification.data[0] if notification.data else 0xFF
rgb_power.on_user_activity(device, activity_type)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: RGB_EFFECTS notification addr=%02x: %s", device, notification.address, notification)
elif feature == SupportedFeature.HEADSET_ADVANCED_PARA_EQ:
# G522 emits change events with the same payload shape as the
# corresponding setter request:
# fn 0 — band change (3-byte header [dir, slot, pad] + bands)
# fn 2 — friendly-name change (header + nameLen + name)
# fn 3 — UUID change (header + 16-byte UUID)
# Low nibble of `address` is the swid the firmware echoes back —
# match on the function index only.
fn = notification.address >> 4
if fn == 0:
info = getattr(device, "_advanced_eq_info", None)
payload = notification.data[3:] if notification.data else b""
if info and len(payload) >= 5:
bands = hidpp20.parse_v2_bands(b"\x00" + payload, info)
if bands and device.setting_callback:
band_map = {i: int(round(g)) for i, (_t, _f, g) in enumerate(bands)}
device.setting_callback(device, settings_templates.HeadsetAdvancedEQ, [band_map])
elif logger.isEnabledFor(logging.DEBUG):
logger.debug(
"%s: HEADSET_ADVANCED_PARA_EQ band-change event with no parseable payload %s", device, notification
)
elif fn in (2, 3) and logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: HEADSET_ADVANCED_PARA_EQ fn=%d change event %s", device, fn, notification)
elif logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: unknown HEADSET_ADVANCED_PARA_EQ %s", device, notification)
elif feature == SupportedFeature.HEADSET_MIC_MUTE:
# G522 emits state-change events on two function indices, both carrying
# the new state in data[0] (0 = unmuted, 1 = muted):
# fn 0 — physical mute switch press
# fn 1 — echo following a host-driven SetState (fn 2) write
# Low nibble of `address` is the swid the firmware echoes back, which
# varies with the request — match on the function index only.
fn = notification.address >> 4
if fn in (0, 1) and notification.data:
muted = bool(notification.data[0])
if device.setting_callback:
device.setting_callback(device, settings_templates.HeadsetMicMute, [muted])
elif logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: unknown HEADSET_MIC_MUTE %s", device, notification)
diversion.process_notification(device, notification, feature)
return True
@ -433,7 +491,8 @@ def handle_pairing_lock(receiver: Receiver, notification: HIDPPNotification) ->
receiver.pairing.new_device = None
pair_error = ord(notification.data[:1])
if pair_error:
receiver.pairing.error = error_string = hidpp10_constants.PairingError(pair_error).name
error_string = hidpp10_constants.PairingError(pair_error).label
receiver.pairing.error = error_string
receiver.pairing.new_device = None
logger.warning("pairing error %d: %s", pair_error, error_string)
receiver.changed(reason=reason)
@ -453,7 +512,7 @@ def handle_discovery_status(receiver: Receiver, notification: HIDPPNotification)
receiver.pairing.device_passkey = None
discover_error = ord(notification.data[:1])
if discover_error:
receiver.pairing.error = discover_string = hidpp10_constants.BoltPairingError(discover_error).name
receiver.pairing.error = discover_string = hidpp10_constants.BoltPairingError(discover_error).label
logger.warning("bolt discovering error %d: %s", discover_error, discover_string)
receiver.changed(reason=reason)
return True
@ -495,7 +554,7 @@ def handle_pairing_status(receiver: Receiver, notification: HIDPPNotification) -
elif notification.address == 0x02 and not pair_error:
receiver.pairing.new_device = receiver.register_new_device(notification.data[7])
if pair_error:
receiver.pairing.error = error_string = hidpp10_constants.BoltPairingError(pair_error).name
receiver.pairing.error = error_string = hidpp10_constants.BoltPairingError(pair_error).label
receiver.pairing.new_device = None
logger.warning("pairing error %d: %s", pair_error, error_string)
receiver.changed(reason=reason)

View File

@ -0,0 +1,184 @@
## Copyright (C) 2012-2013 Daniel Pavel
## Copyright (C) 2014-2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""OnboardEQ (0x0636) biquad coefficient math and payload builders.
Pure computation no device or transport dependencies beyond feature_request().
"""
from __future__ import annotations
import math
import struct
from .hidpp20_constants import SupportedFeature
# Opaque bytes observed between band params and coefficient header. First
# byte matches band_count; bytes 2-3 look like LE16 coeff blob size. Keep
# verbatim until a device counter-example forces a re-derivation.
_EQ_MYSTERY_BYTES = b"\x05\x5a\xe3\x00"
def _peaking_eq_biquad(freq_hz, gain_db, Q, sample_rate=48000.0):
"""Compute peaking EQ biquad coefficients (Audio EQ Cookbook).
Returns (b0/a0, b1/a0, b2/a0, a1/a0, a2/a0) normalised coefficients.
"""
A = 10.0 ** (gain_db / 40.0)
w0 = 2.0 * math.pi * freq_hz / sample_rate
cos_w0 = math.cos(w0)
alpha = math.sin(w0) / (2.0 * Q)
a0 = 1.0 + alpha / A
return (
(1.0 + alpha * A) / a0,
(-2.0 * cos_w0) / a0,
(1.0 - alpha * A) / a0,
(-2.0 * cos_w0) / a0,
(1.0 - alpha / A) / a0,
)
def _quantize_coeffs(b0, b1, b2, a1, a2):
"""Quantize biquad coefficients to mixed Q1.31 / Q2.30 fixed-point.
b0, b2, a2 use Q1.31 (x 2^31); b1, a1 use Q2.30 (x 2^30).
Values are truncated to 24-bit precision (low byte zeroed) matching
the device DSP's internal format.
Returns list of 10 uint16 values (5 coefficients x 2 LE words each,
high word first).
"""
scales = [2**31, 2**30, 2**31, 2**30, 2**31] # b0, b1, b2, a1, a2
words = []
for val, scale in zip([b0, b1, b2, a1, a2], scales):
q = int(round(val * scale))
q = max(-(1 << 31), min((1 << 31) - 1, q))
q = q & 0xFFFFFF00 # 24-bit precision (low byte always zero)
words.append((q >> 16) & 0xFFFF) # high word
words.append(q & 0xFFFF) # low word
return words
def _build_coeff_section(bands, sample_rate, section_type=1):
"""Build one coefficient section for a DSP processing block.
Returns bytes: 4-byte section header + coefficient data as LE uint16 words.
Section header: [type, 0x00, count_lo, count_hi].
Coefficients are normalized by a rescale factor to prevent Q1.31 overflow.
Only feedforward coefficients (b0, b1, b2) are divided by rescale; feedback
coefficients (a1, a2) are left unchanged. The DSP multiplies the output by
rescale to restore correct gain.
"""
_HEADROOM = 1.19 # 19% headroom margin before quantization
num_bands = len(bands)
all_words = [num_bands] # first uint16 = num_bands
# First pass: compute raw biquad coefficients for all bands
raw_coeffs = []
for freq, gain, Q in bands:
raw_coeffs.append(_peaking_eq_biquad(freq, gain, max(Q, 0.1), sample_rate))
# Compute rescale: ensure max |b0| fits in Q1.31 with headroom
max_b0 = max(abs(c[0]) for c in raw_coeffs)
rescale = max(1.0, max_b0) * _HEADROOM
# Second pass: normalize b-coefficients and quantize
for b0, b1, b2, a1, a2 in raw_coeffs:
all_words.extend(_quantize_coeffs(b0 / rescale, b1 / rescale, b2 / rescale, a1, a2))
# Rescale factor as Q6.26, 24-bit precision
rs = int(round(rescale * (1 << 26)))
rs = max(-(1 << 31), min((1 << 31) - 1, rs)) & 0xFFFFFF00
all_words.append((rs >> 16) & 0xFFFF)
all_words.append(rs & 0xFFFF)
coeff_count = num_bands * 10 + 3 # num_bands word + 10 per band + 2 rescale words
hdr = bytes([section_type, 0x00, coeff_count & 0xFF, (coeff_count >> 8) & 0xFF])
data = struct.pack(f"<{len(all_words)}H", *all_words)
return hdr + data
def _build_eq_coeffs_payload(bands):
"""Build the full EQCoeffs wire payload for SetEQParameters.
Two coefficient sections: type=1 (48 kHz playback) and type=2 (16 kHz mic).
Returns bytes: 7-byte header + sections (no trailing padding).
"""
section_count = 2
header = bytes([0x03, 0x0E, 0x00, section_count, 0x00, 0x00, 0x00])
sections = _build_coeff_section(bands, 48000.0, section_type=1)
sections += _build_coeff_section(bands, 16000.0, section_type=2)
return header + sections
def _build_set_eq_payload(slot, bands):
"""Build complete SetEQParameters payload: band params + biquad coefficients.
bands: list of (freq_hz, gain_db, Q) tuples.
Returns bytes ready to send as sub-device params.
"""
params = bytes([slot, len(bands)])
for freq, gain, Q in bands:
params += struct.pack(">H", freq) + bytes([gain & 0xFF, Q & 0xFF])
params += _EQ_MYSTERY_BYTES
params += _build_eq_coeffs_payload(bands)
return params
def get_onboard_eq_info(device):
"""Query HEADSET_ONBOARD_EQ GetEQInfos (function 0).
Returns (has_hw_eq, num_bands) or None.
"""
result = device.feature_request(SupportedFeature.HEADSET_ONBOARD_EQ, 0x00)
if result is None or len(result) < 5:
return None
has_hw_eq = bool(result[0] & 0x80)
num_bands = result[4]
return (has_hw_eq, num_bands)
def get_onboard_eq_params(device, slot=0x00):
"""Query HEADSET_ONBOARD_EQ GetEQParameters (function 0x10).
Returns list of (freq_hz, gain_db, q) tuples, or None.
"""
result = device.feature_request(SupportedFeature.HEADSET_ONBOARD_EQ, 0x10, slot)
if result is None or len(result) < 2:
return None
band_count = result[1]
bands = []
offset = 2
for _i in range(band_count):
if offset + 4 > len(result):
break
freq_hz = struct.unpack(">H", result[offset : offset + 2])[0]
gain_db = struct.unpack("b", bytes([result[offset + 2]]))[0] # signed
q = result[offset + 3]
bands.append((freq_hz, gain_db, q))
offset += 4
return bands
def set_onboard_eq_params(device, bands, slot=0x00):
"""Send HEADSET_ONBOARD_EQ SetEQParameters (function 0x20).
bands: list of (freq_hz, gain_db, Q) tuples.
Returns response or None.
"""
payload = _build_set_eq_payload(slot, bands)
return device.feature_request(SupportedFeature.HEADSET_ONBOARD_EQ, 0x20, payload)

View File

@ -413,6 +413,31 @@ class Receiver:
"""Receiver specific unpairing."""
return self.write_register(Registers.RECEIVER_PAIRING, 0x03, key)
def force_unpair_slot(self, slot: int) -> bool:
"""Force-unpair a slot by writing the unpair register, ignoring cache state.
Intended for clearing stale pairings on receivers (Lightspeed in particular)
where Solaar cannot read pairing info for a slot. Bypasses the ``may_unpair``
and ``re_pairs`` gates that ``_unpair_device`` applies. Returns True if the
register write was acknowledged by the receiver.
"""
if not self.handle:
return False
slot = int(slot)
reply = self._unpair_device_per_receiver(slot)
if reply:
cached = self._devices.get(slot)
if cached:
cached.online = False
cached.wpid = None
if slot in self._devices:
del self._devices[slot]
if logger.isEnabledFor(logging.INFO):
logger.info("%s force-unpaired slot %d", self, slot)
return True
logger.warning("%s failed to force-unpair slot %d", self, slot)
return False
def __len__(self):
return len([d for d in self._devices.values() if d is not None])

View File

@ -0,0 +1,219 @@
"""Read-only corpus probe for the headset RGB feature pair:
- HEADSET_RGB_ONBOARD_EFFECTS (0x0621)
- HEADSET_RGB_SIGNATURE_EFFECTS (0x0622)
Logs raw response bytes and lengths at INFO so field testers without
``-dd`` can still capture the data. All calls are strictly read-side
no setters are invoked. If a feature isn't present the probe
short-circuits cleanly.
Pcap analysis of G HUB's color-set traffic confirmed that on 0x0621,
``setRGBClusterEffect`` (fn 0x30) takes a 10-byte payload
``[cluster, effect_id_BE_u16, R, G, B, ...]`` where ``effect_id=0x0000``
means "Static (with RGB)" this is also the slot-0 entry in the
fn 0x10 ``getRGBClusterInfo`` reply, which we decode structurally so
the test corpus shows effect-id semantics in plaintext.
"""
from __future__ import annotations
import logging
from . import exceptions
from .hidpp20_constants import SupportedFeature
logger = logging.getLogger(__name__)
def _hex_or_none(data) -> str | None:
return data.hex() if data else None
def _format_feature(feat) -> str:
"""Render a feature for the log: 0x{id:04X}{:NAME} when known, else raw.
Unknown features are stored as the string "unknown:HHHH" by the feature
discovery code, so handle that shape explicitly int(feat) on those
raises ValueError. Wrap the rest in a broad except so a future unhandled
feature shape can't kill the whole table dump.
"""
if feat is None:
return "?"
if isinstance(feat, str):
if feat.startswith("unknown:") and len(feat) > 8:
return f"0x{feat[8:].upper()}"
return feat
try:
return f"0x{int(feat):04X}:{feat.name}"
except (AttributeError, TypeError, ValueError):
try:
return f"0x{int(feat):04X}"
except (TypeError, ValueError):
return repr(feat)
def _log_feature_table(device) -> None:
if not device.features:
return
try:
# Parent features live in FeaturesArray.inverse, indexed by their
# parent feature-set position. On Centurion devices these are the
# ones the dongle itself exposes (typically 5-6 entries).
parent = []
for idx in range(len(device.features)):
parent.append(f"{idx}:{_format_feature(device.features[idx])}")
logger.debug("RGB probe: parent features for %s: %s", device, ", ".join(parent))
# Centurion sub-device features live in FeaturesArray.sub_inverse,
# keyed by sub-device feature index. These are where the actual
# headset features (0x0620/0x0621/0x0622, LogiVoice, EQ, mic mute,
# …) live — without dumping them the log shows only the dongle's
# parent features and gives the wrong impression that the device
# has nothing else.
sub_inverse = getattr(device.features, "sub_inverse", None)
if sub_inverse:
sub = [f"{idx}:{_format_feature(feat)}" for idx, feat in sorted(sub_inverse.items())]
logger.debug("RGB probe: sub-device features for %s: %s", device, ", ".join(sub))
except Exception as e:
logger.debug("RGB probe: feature-table dump failed: %s", e)
def _call(device, feature: SupportedFeature, fn: int, *params):
"""Wrap feature_request with uniform INFO logging.
Returns the raw bytes on success, None on transport/no-feature, and
doesn't raise — FeatureCallError is caught and logged as an error code.
"""
label = f"0x{int(feature):04X}.fn{fn:02X}"
if params:
label += "(" + ",".join(f"{b:02X}" for b in params) + ")"
try:
resp = device.feature_request(feature, fn, *params)
except exceptions.FeatureCallError as e:
logger.debug("RGB probe: %s err=0x%02X", label, getattr(e, "error", 0) & 0xFF)
return None
except Exception as e:
logger.debug("RGB probe: %s raised %r", label, e)
return None
if resp is None:
logger.debug("RGB probe: %s no reply (feature unsupported or transport failure)", label)
return None
logger.debug("RGB probe: %s resp=%s len=%d", label, _hex_or_none(resp), len(resp))
return resp
# Names for known effect_ids on the headset RGB cluster. Confirmed via
# pcap analysis of G HUB color-set traffic: setRGBClusterEffect with
# effect_id=0x0000 + RGB writes a static color, so 0x0000 is "Static"
# rather than the "Off / Disabled" we'd guessed from cluster ordering.
# Other ids haven't been observed on the wire yet — names are placeholder
# until further pcap traffic confirms them.
_EFFECT_ID_NAMES = {
0x0000: "Static",
0x0001: "Effect 0x0001",
0x0006: "Effect 0x0006",
0x0007: "Effect 0x0007",
0x000F: "Effect 0x000F",
0x007F: "Effect 0x007F",
}
def _decode_cluster_info(resp) -> str | None:
"""Decode a 0x0621 fn 0x10 getRGBClusterInfo reply into a readable
summary. Best-effort returns None on unexpected length/shape.
Observed shape on G522: 4-byte records (effect_id LE u16, slot_idx
LE u16). The effect_id at slot 0 is 0x0000 = "Static" (with RGB),
confirmed by pcap of G HUB color-set traffic. Records continue
until trailing-zero padding.
Note: most HID++ multi-byte fields are BE, but this particular
response uses LE confirmed against captured factory-default bytes
on G522 where the values 0x0001 / 0x000F / 0x007F appear at byte 0
of each record with byte 1 = 0x00 (consistent with LE u16).
"""
if not resp or len(resp) < 4:
return None
effects = []
seen_static = False
for i in range(0, len(resp) - 3, 4):
eid = resp[i] | (resp[i + 1] << 8)
slot = resp[i + 2] | (resp[i + 3] << 8)
# Skip purely-zero padding once we've seen the (effect=0, slot=0) entry.
if eid == 0 and slot == 0:
if seen_static:
continue
seen_static = True
name = _EFFECT_ID_NAMES.get(eid, f"0x{eid:04X}")
effects.append(f"slot={slot}:{name}")
return ", ".join(effects) if effects else None
def probe_onboard_effects(device) -> None:
"""Probe 0x0621 RGBOnboardEffects read-side functions."""
feature = SupportedFeature.HEADSET_RGB_ONBOARD_EFFECTS
if not device.features or feature not in device.features:
return
logger.debug("RGB probe: 0x0621 HEADSET_RGB_ONBOARD_EFFECTS present on %s", device)
# fn 0x00 getInfo — empty payload
_call(device, feature, 0x00)
# fn 0x10 getRGBClusterInfo — iterate cluster indexes 0..7, stop on error.
for cluster_idx in range(8):
resp = _call(device, feature, 0x10, cluster_idx)
if resp is None:
break
decoded = _decode_cluster_info(resp)
if decoded:
logger.debug("RGB probe: 0x0621.fn10(%02X) decoded: %s", cluster_idx, decoded)
# fn 0x20 getRGBClusterEffect — current state per cluster.
for cluster_idx in range(8):
resp = _call(device, feature, 0x20, cluster_idx)
if resp is None:
break
# fn 0x40 getRGBCustomEffectName — single call, documented.
_call(device, feature, 0x40)
def probe_signature_effects(device) -> None:
"""Probe 0x0622 RGBSignatureEffects read-side functions."""
feature = SupportedFeature.HEADSET_RGB_SIGNATURE_EFFECTS
if not device.features or feature not in device.features:
return
logger.debug("RGB probe: 0x0622 HEADSET_RGB_SIGNATURE_EFFECTS present on %s", device)
# fn 0x00 getSignatureEffectsInfo.
_call(device, feature, 0x00)
# fn 0x10 getSignatureEffectParams — iterate effectId 0..2 (Startup/Shutdown/Passive).
# effectId is u16 BE.
for eid in range(3):
_call(device, feature, 0x10, (eid >> 8) & 0xFF, eid & 0xFF)
# fn 0x30 getSignatureEffectState — same effectId range.
for eid in range(3):
_call(device, feature, 0x30, (eid >> 8) & 0xFF, eid & 0xFF)
def probe(device) -> None:
"""Run both read-only RGB-effects probes once per device.
Gated via ``_rgb_effects_probed`` so re-entry on reconnect / setting
rebuild doesn't spam the log with duplicate corpus dumps.
"""
if getattr(device, "_rgb_effects_probed", False):
return
device._rgb_effects_probed = True
_log_feature_table(device)
try:
probe_onboard_effects(device)
except Exception as e:
logger.debug("RGB probe: onboard-effects probe raised %r", e)
try:
probe_signature_effects(device)
except Exception as e:
logger.debug("RGB probe: signature-effects probe raised %r", e)

View File

@ -0,0 +1,979 @@
## Copyright (C) 2012-2013 Daniel Pavel
## Copyright (C) 2014-2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Software-driven RGB power management for devices that hand off LED
control to the host (RGB_EFFECTS / 0x8071).
Handles the firmware onUserActivity events, the two-stage idle effect
(smooth dim ramp or animation), and the software sleep timer that fires
after idle_timeout has elapsed.
"""
from __future__ import annotations
import logging
from time import sleep
from . import exceptions
from . import hidpp20_constants
from . import settings
from . import special_keys
from .hidpp20_constants import SupportedFeature
logger = logging.getLogger(__name__)
try:
from gi.repository import GLib
_has_glib = True
except ImportError:
_has_glib = False
# SetSWControl flag bits for RGB_EFFECTS (0x8071).
FLAG_EFFECT = 0x01
FLAG_POWER = 0x02
FLAG_NVCONFIG = 0x04
# SetSWControl payloads: [subfn=set, mode=3, flags]
SW_ACTIVE = bytes([0x01, 0x03, FLAG_NVCONFIG]) # firmware monitors idle
SW_IDLE = bytes([0x01, 0x03, FLAG_POWER]) # firmware monitors activity
SW_RELEASE = bytes([0x01, 0x00, 0x00])
_managers = {} # keyed by id(device)
def get_manager(device):
"""Return the active RGBPowerManager for `device`, or None."""
return _managers.get(id(device))
def on_user_activity(device, activity_type):
"""Dispatch firmware onUserActivity events to the device's power manager."""
mgr = _managers.get(id(device))
if mgr:
mgr.on_user_activity(activity_type)
def translate_color_for_display(color, state, dim_pct, dim_step, dim_steps):
"""Map a saved (undimmed) color to the display color for `state`.
Returns None for SLEEPING."""
if state == RGBPowerManager.ACTIVE:
return color
if state == RGBPowerManager.SLEEPING:
return None
target = RGBPowerManager._compute_dim_color(color, dim_pct)
if state == RGBPowerManager.IDLE:
return target
# DIMMING — interpolate from saved toward dimmed target by ramp progress.
t = (dim_step / dim_steps) if dim_steps else 1.0
return RGBPowerManager._interpolate_color(color, target, t)
def translate_for_device(device, color):
"""Translate `color` through the device's RGBPowerManager state, or
return it unchanged when no manager is registered. None signals SLEEPING."""
mgr = _managers.get(id(device))
if mgr is None:
return color
return mgr.translate_color(color)
_EFFECT_STATIC = 0x01
def perkey_has_paint(device):
"""Return ``(perkey_setting, has_paint)``. has_paint is True when the
per-key buffer has at least one real color and the user hasn't opted
out via the lock icon (sensitivity == IGNORE). The locked-but-applied
state (False) still counts as paint."""
perkey = None
for s in getattr(device, "settings", []) or []:
if s.name == "per-key-lighting":
perkey = s
break
if perkey is None:
return None, False
validator = getattr(perkey, "_validator", None)
choices = getattr(validator, "choices", None)
if not choices:
return perkey, False
# Apply path runs rgb_zone_ before per-key, so _value may still be None
# when this gate is consulted — fall back to the persister.
value = getattr(perkey, "_value", None)
persister = getattr(device, "persister", None)
if value is None and persister is not None:
value = persister.get("per-key-lighting")
if not value:
return perkey, False
no_change = special_keys.COLORSPLUS["No change"]
if not any(c != no_change and isinstance(c, int) and c >= 0 for c in value.values()):
return perkey, False
if persister is not None and persister.get_sensitivity("per-key-lighting") == settings.SENSITIVITY_IGNORE:
return perkey, False
return perkey, True
def zone_effect_is_static(device):
"""True when the persisted zone effect is Static, or when no
rgb_zone_* setting exists at all (per-key-only hardware)."""
has_zone = False
persister = getattr(device, "persister", None)
for s in getattr(device, "settings", []) or []:
if s.name.startswith("rgb_zone_"):
has_zone = True
value = getattr(s, "_value", None)
if value is None and persister is not None:
value = persister.get(s.name)
if value is not None and int(getattr(value, "ID", 0) or 0) == _EFFECT_STATIC:
return True
return not has_zone
def zone_effect_is_ignored(device):
"""True when every rgb_zone_* setting on `device` is marked
SENSITIVITY_IGNORE in the persister."""
persister = getattr(device, "persister", None)
if persister is None:
return False
zones = [s for s in getattr(device, "settings", []) or [] if s.name.startswith("rgb_zone_")]
if not zones:
return False
return all(persister.get_sensitivity(s.name) == settings.SENSITIVITY_IGNORE for s in zones)
def effective_zone_base_color(device):
"""Color to use for per-key unset cells: 0 (off/black) when the zone
effect is ignored (or unavailable), the persisted zone color otherwise.
Reads through the persister so we still get the saved color even before
apply has populated _value.
During an idle-Static transition the saved color is substituted with
the idle effect's color so unset cells track the idle primary. Reverts
on wake when state returns to ACTIVE."""
if zone_effect_is_ignored(device):
return 0
mgr = _managers.get(id(device))
if mgr is not None and mgr._state == RGBPowerManager.IDLE and mgr._idle_effect_id() == 0x01:
return int(getattr(mgr._idle_effect, "color", 0) or 0)
persister = getattr(device, "persister", None)
for s in getattr(device, "settings", []) or []:
if not s.name.startswith("rgb_zone_"):
continue
value = getattr(s, "_value", None)
if value is None and persister is not None:
value = persister.get(s.name)
if value is not None:
color = getattr(value, "color", None)
if isinstance(color, int):
return int(color)
return 0
_RETRY_BUSY_BACKOFF_MS = (30, 60, 90)
def feature_request_acked(device, feature, function, data=b"", retries=3):
"""feature_request with BUSY/timeout retries. Returns reply bytes
on ACK, None on hard failure (logged WARNING)."""
busy_attempt = 0
max_busy = len(_RETRY_BUSY_BACKOFF_MS)
for attempt in range(retries + 1):
try:
reply = device.feature_request(feature, function, data)
except exceptions.FeatureCallError as e:
if getattr(e, "error", None) == hidpp20_constants.ErrorCode.BUSY and busy_attempt < max_busy:
delay_ms = _RETRY_BUSY_BACKOFF_MS[busy_attempt]
busy_attempt += 1
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"%s: feature 0x%04x fn 0x%02x BUSY, retry %d/%d after %dms",
device,
int(feature),
function,
busy_attempt,
max_busy,
delay_ms,
)
sleep(delay_ms / 1000.0)
continue
logger.warning("%s: feature 0x%04x fn 0x%02x rejected: %s", device, int(feature), function, e)
return None
if reply is not None:
if (attempt > 0 or busy_attempt > 0) and logger.isEnabledFor(logging.DEBUG):
logger.debug(
"%s: feature 0x%04x fn 0x%02x succeeded after %d timeout retries, %d BUSY retries",
device,
int(feature),
function,
attempt,
busy_attempt,
)
return reply
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"%s: feature 0x%04x fn 0x%02x timed out (attempt %d/%d)",
device,
int(feature),
function,
attempt + 1,
retries + 1,
)
logger.warning("%s: feature 0x%04x fn 0x%02x no ACK after %d attempts", device, int(feature), function, retries + 1)
return None
def _probe_tmpl_bytes(device):
"""GetEffectSpecificInfo page 1: returns (tmpl_0, tmpl_1) or
(None, None) if the device has no firmware effect cards."""
try:
reply = device.feature_request(SupportedFeature.RGB_EFFECTS, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x01)
except exceptions.FeatureCallError:
return (None, None)
if reply is None or len(reply) < 12:
return (None, None)
return (reply[10], reply[11])
def push_artanis_perkey_prep(device):
"""Disable the firmware effects engine on mice with firmware effect cards.
Returns True if the call ACKed."""
infos = getattr(device, "led_effects", None)
if not infos or not infos.zones:
return False
num_effects = len(infos.zones[0].effects)
# SetEffectByIndex: cluster + effectIdx + 10 param bytes + persist.
# Shipping with call 2 only — sufficient on tested hardware (G502 X PLUS).
# Call 1 (TMPL-handshake) left commented for reactivation if broader
# testing turns up a device that needs it; uncomment the _probe_tmpl_bytes
# use and the call1 block together.
# tmpl_0, tmpl_1 = _probe_tmpl_bytes(device)
# if tmpl_0 is None:
# return False
# call1 = b"\xff\x02" + b"\x00" * 6 + bytes([tmpl_0, tmpl_1]) + b"\x00\x00" + b"\x01"
# if feature_request_acked(device, SupportedFeature.RGB_EFFECTS, 0x10, call1) is None:
# return False
call2 = b"\xff" + bytes([num_effects]) + b"\x00" * 10 + b"\x01"
return feature_request_acked(device, SupportedFeature.RGB_EFFECTS, 0x10, call2) is not None
def start(device):
"""Begin software RGB power management for `device`. No-op without GLib."""
if not _has_glib:
return
key = id(device)
if key not in _managers:
mgr = RGBPowerManager(device)
_managers[key] = mgr
mgr.start()
else:
mgr = _managers[key]
mgr.reset()
# Push persisted settings into the manager. Settings marked ignore via the
# lock icon are skipped so the manager keeps its built-in default.
from . import hidpp20
persister = getattr(device, "persister", None)
def _ignored(name):
return persister is not None and persister.get_sensitivity(name) == settings.SENSITIVITY_IGNORE
for s in device.settings:
if _ignored(s.name):
continue
if s.name == "rgb_idle_timeout":
val = s._value if s._value is not None else 60
mgr.set_idle_timeout(int(val))
elif s.name == "rgb_sleep_timeout":
val = s._value if s._value is not None else 300
mgr.set_sleep_timeout(int(val))
elif s.name == "rgb_idle_effect":
val = s._value if s._value is not None else hidpp20.LEDEffectSetting(ID=0x80, intensity=50)
mgr.set_idle_effect(val)
def stop(device):
"""End software RGB power management for `device`."""
key = id(device)
mgr = _managers.pop(key, None)
if mgr:
mgr.stop()
def cleanup(device):
"""device.cleanups handler — restore firmware control on device close.
On devices that support NvConfig cap 0x0040 (shutdown effect), also fires
SetRgbPowerMode(0) as the final step so the firmware plays the configured
shutdown animation during the activeoff transition. If the cap is
disabled, the firmware powers down LEDs silently. Matches LGHUB exit.
See solaar_shutdown_effect_trigger_spec.md.
rgb_control is the gate: when LED Control is off, skip every wire write
here. We never claimed, so there's nothing to release; firing the shutdown
animation would visibly contradict the user's "leave my lighting alone".
"""
stop(device)
if any(s.name == "rgb_control" and not s._value for s in getattr(device, "settings", []) or []):
return
try:
device.feature_request(SupportedFeature.RGB_EFFECTS, 0x50, SW_RELEASE)
if device.features and SupportedFeature.PROFILE_MANAGEMENT in device.features:
device.feature_request(SupportedFeature.PROFILE_MANAGEMENT, 0x60, b"\x03")
elif device.features and SupportedFeature.ONBOARD_PROFILES in device.features:
device.feature_request(SupportedFeature.ONBOARD_PROFILES, 0x10, b"\x01")
except Exception:
pass # Device may already be offline
if getattr(device, "_rgb_has_shutdown_cap", False):
try:
# SetRgbPowerMode(set=1, mode=0) — firmware off transition.
# no_reply: device goes offline; don't block waiting for an ACK.
device.feature_request(SupportedFeature.RGB_EFFECTS, 0x90, b"\x01\x00", no_reply=True)
except Exception:
pass
class RGBPowerManager:
"""Two-stage idle handler driven by firmware onUserActivity events.
State machine: ACTIVE DIMMING IDLE SLEEPING.
Stage 1 (idle) runs a host-side dim ramp or hands off to a firmware
animation. Stage 2 (sleep) is a software timer that fires
sleep_timeout - idle_timeout after IDLE.
"""
ACTIVE = 0
DIMMING = 1
IDLE = 2
SLEEPING = 3
_DIM_INTERVAL_MS = 200
_DIM_STEPS = 25 # ~5s dim ramp
def __init__(self, device):
self._device = device
self._state = self.ACTIVE
self._idle_timeout = 60
self._sleep_timeout = 300
# LEDEffectSetting with ID in {0x00 Disabled, 0x80 Dim, 0x0A
# Breathe, 0x0B Ripple}. Populated by start() from the persister.
self._idle_effect = None
self._sleep_timer_id = None
self._dim_timer_id = None
self._dim_step = 0
self._dim_zones = []
self._dim_perkey = None
def start(self):
self._state = self.ACTIVE
self._read_firmware_timers()
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"%s: RGB power manager started (firmware idle=%ds, sleep=%ds)",
self._device,
self._idle_timeout,
self._sleep_timeout,
)
def stop(self):
self._cancel_dim_timer()
self._cancel_sleep_timer()
if self._state != self.ACTIVE:
try:
self._wake()
except Exception:
pass # Best effort during shutdown
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: RGB power manager stopped", self._device)
def reset(self):
"""Reset to ACTIVE on device reconnect. Re-reads firmware timers so
externally-updated values (other tool wrote NV between sessions)
are picked up even when our settings are ignored."""
self._cancel_dim_timer()
self._cancel_sleep_timer()
self._state = self.ACTIVE
self._read_firmware_timers()
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: RGB power manager reset to ACTIVE", self._device)
def set_idle_timeout(self, seconds):
self._idle_timeout = seconds
self._cancel_sleep_timer()
if seconds == 0 and self._state in (self.DIMMING, self.IDLE):
self._wake()
self._write_firmware_idle_timeout(seconds)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: RGB idle timeout set to %ss", self._device, seconds)
def set_sleep_timeout(self, seconds):
"""0 disables sleep."""
self._sleep_timeout = seconds
self._cancel_sleep_timer()
if seconds == 0 and self._state == self.SLEEPING:
self._wake()
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: RGB sleep timeout set to %ss", self._device, seconds)
def set_idle_effect(self, effect):
"""`effect` is an LEDEffectSetting. Wake immediately if the user
switched to Disabled while we're mid-idle."""
self._idle_effect = effect
if self._idle_effect_id() == 0x00 and self._state in (self.DIMMING, self.IDLE):
self._wake()
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"%s: RGB idle effect set to ID=0x%02X (period=%s, intensity=%s)",
self._device,
self._idle_effect_id(),
getattr(self._idle_effect, "period", None),
getattr(self._idle_effect, "intensity", None),
)
def _idle_effect_id(self):
"""Return the ID of the current idle effect, or 0 if unset."""
return int(getattr(self._idle_effect, "ID", 0) or 0)
# --- Firmware activity events ---
def on_user_activity(self, activity_type):
"""Handle firmware onUserActivity event from RGB_EFFECTS (0x8071).
activity_type=0: IDLE user stopped typing, firmware idle timer expired.
activity_type!=0: ACTIVE user resumed typing after being idle.
The firmware sends a burst of ~8 events with exponential backoff.
Only the first event matters; subsequent events for the same transition are ignored.
"""
if not self._device.online:
return
if activity_type == 0:
# IDLE event — firmware detected inactivity at idle_timeout
if self._state != self.ACTIVE:
return # Already idle/dimming/sleeping, ignore burst
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: firmware IDLE event — starting idle sequence", self._device)
# Switch to flags=3 so firmware monitors for activity during dim/idle
try:
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x50, SW_IDLE)
except Exception:
pass
idle_enabled = self._idle_effect_id() != 0 and self._idle_timeout > 0 and not self._is_ignored("rgb_idle_effect")
if idle_enabled:
self._start_idle_effect()
else:
self._state = self.IDLE
# Sleep is host-driven only — schedule whenever _sleep_timeout > 0,
# regardless of the setting's ignore flag (which only blocks pushing
# the user value to firmware, see start()).
sleep_enabled = self._sleep_timeout > 0
if sleep_enabled:
delay = max(self._sleep_timeout - self._idle_timeout, 0)
if delay == 0:
self._start_sleep()
else:
self._sleep_timer_id = GLib.timeout_add_seconds(delay, self._sleep_timer_fired)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: sleep timer scheduled in %ds", self._device, delay)
else:
# ACTIVE event — user resumed typing
if self._state == self.ACTIVE:
return # Already active, ignore burst
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: firmware ACTIVE event — waking", self._device)
self._cancel_sleep_timer()
self._wake()
def _sleep_timer_fired(self):
"""GLib callback — software sleep timer expired after IDLE."""
self._sleep_timer_id = None
if self._state in (self.IDLE, self.DIMMING) and self._device.online:
self._start_sleep()
return False # One-shot timer
def _cancel_sleep_timer(self):
if self._sleep_timer_id is not None:
GLib.source_remove(self._sleep_timer_id)
self._sleep_timer_id = None
def _read_firmware_timers(self):
"""Read idle/sleep timeouts from firmware as the manager's defaults."""
try:
resp = self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x70, b"\x00")
if resp and len(resp) >= 7:
idle_s = (resp[3] << 8) | resp[4]
sleep_s = (resp[5] << 8) | resp[6]
if idle_s > 0:
self._idle_timeout = idle_s
if sleep_s > 0:
self._sleep_timeout = sleep_s
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"%s: firmware timers: idle=%ds, sleep=%ds",
self._device,
idle_s,
sleep_s,
)
except Exception as e:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: could not read firmware timers, using defaults: %s", self._device, e)
def _write_firmware_idle_timeout(self, seconds):
"""Push idle/sleep timeouts back to firmware so it fires IDLE on time."""
try:
idle_hi = (seconds >> 8) & 0xFF
idle_lo = seconds & 0xFF
sleep_hi = (self._sleep_timeout >> 8) & 0xFF
sleep_lo = self._sleep_timeout & 0xFF
payload = bytes([0x01, 0x00, 0x00, idle_hi, idle_lo, sleep_hi, sleep_lo])
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x70, payload)
except Exception as e:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: could not write firmware idle timeout: %s", self._device, e)
# --- Idle effect ---
def _start_idle_effect(self):
idle_id = self._idle_effect_id()
if idle_id == 0x80: # Dim
dim_pct = int(getattr(self._idle_effect, "intensity", 50) or 50)
self._start_dim_ramp(dim_pct)
elif idle_id == 0x01: # Static — snap to idle color
self._start_static_idle()
elif idle_id != 0x00:
self._apply_animation(idle_id)
def _start_static_idle(self):
"""Snap to the idle effect's color exactly as if the user had set
the active Static zone color to it. Per-key paint continues to
display; unset cells repaint to the idle primary color via
effective_zone_base_color's IDLE-state substitution. No animation
instant transition. Wake reverts via _restore_colors()."""
idle_color = int(getattr(self._idle_effect, "color", 0) or 0)
infos = getattr(self._device, "led_effects", None)
if not infos or not infos.zones:
self._state = self.IDLE
return
self._state = self.IDLE
perkey_setting, has_paint = perkey_has_paint(self._device)
perkey_dominates = has_paint and zone_effect_is_static(self._device)
if perkey_dominates and perkey_setting is not None:
# Per-key is the visible layer — repaint unset cells with the
# idle color (effective_zone_base_color now returns it because
# state == IDLE and idle effect ID == Static).
try:
if perkey_setting._fill_unset_zones_with_base_color():
perkey_setting._send_with_retry(0x70, b"\x00") # FrameEnd
except Exception as e:
if logger.isEnabledFor(logging.WARNING):
logger.warning("%s: static idle per-key repaint failed: %s", self._device, e)
return
# Zone is the visible layer — push Static at idle.color to each zone.
for zone in infos.zones:
if 0x01 in (e.ID for e in zone.effects):
try:
self._push_static_effect(zone, idle_color)
except Exception as e:
if logger.isEnabledFor(logging.WARNING):
logger.warning("%s: static idle zone push failed: %s", self._device, e)
def _start_dim_ramp(self, dim_pct):
"""Smooth ~5s dim ramp. Dims the per-key buffer when it's the visible
layer (any real per-key paint), otherwise the zone effect."""
infos = getattr(self._device, "led_effects", None)
if not infos or not infos.zones:
self._state = self.IDLE
return
perkey_setting, has_paint = perkey_has_paint(self._device)
# Per-key only dominates when zone is Static. Under animations, the
# firmware engine owns the visible layer — dim the zone instead.
perkey_active = has_paint and zone_effect_is_static(self._device)
self._dim_perkey = None
if perkey_active:
self._dim_zones = []
self._dim_perkey = self._build_full_perkey_dim_map(perkey_setting, dim_pct)
if self._dim_perkey:
# Push base color to unset cells first so they don't start from stale.
self._init_unset_perkey_zones(perkey_setting)
else:
self._dim_zones = []
for zone in infos.zones:
if 0x01 in (e.ID for e in zone.effects):
start_color = self._get_zone_color(zone)
target_color = self._compute_dim_color(start_color, dim_pct)
self._dim_zones.append((zone, start_color, target_color))
if not self._dim_zones and not self._dim_perkey:
self._state = self.IDLE
return
self._dim_step = 0
self._state = self.DIMMING
self._dim_timer_id = GLib.timeout_add(self._DIM_INTERVAL_MS, self._dim_ramp_step)
if logger.isEnabledFor(logging.DEBUG):
n_zones = len(self._dim_zones)
n_perkey = len(self._dim_perkey) if self._dim_perkey else 0
logger.debug(
"%s: starting dim ramp to %d%% brightness (%d zones, %d per-key%s)",
self._device,
dim_pct,
n_zones,
n_perkey,
", per-key masking zones" if perkey_active else "",
)
def _dim_ramp_step(self):
if self._state != self.DIMMING or not self._device.online:
self._dim_timer_id = None
return False
self._dim_step += 1
t = self._dim_step / self._DIM_STEPS
for zone, start_color, target_color in self._dim_zones:
try:
self._push_static_effect(zone, self._interpolate_color(start_color, target_color, t))
except Exception as e:
if logger.isEnabledFor(logging.WARNING):
logger.warning("%s: dim ramp step failed for zone %s: %s", self._device, zone.index, e)
if self._dim_perkey:
try:
self._push_perkey_dimmed(t)
except Exception as e:
if logger.isEnabledFor(logging.WARNING):
logger.warning("%s: dim ramp step failed for per-key: %s", self._device, e)
if self._dim_step >= self._DIM_STEPS:
self._state = self.IDLE
self._dim_timer_id = None
return False
return True
def _push_static_effect(self, zone, color):
"""Non-persistent Static effect, one zone."""
static_effect = next((e for e in zone.effects if e.ID == 0x01), None)
if static_effect is None:
return
r = (color >> 16) & 0xFF
g = (color >> 8) & 0xFF
b = color & 0xFF
params = bytes([r, g, b, 0, 0, 0, 0, 0, 0, 0])
payload = bytes([zone.index, static_effect.index]) + params + b"\x01"
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x10, payload)
def _push_perkey_dimmed(self, t):
"""Push interpolated per-key colors for one dim ramp step.
Groups keys by their interpolated color and uses SetRgbZonesSingleValue
(0x8081 function 6) for efficient bulk writes up to 13 zone IDs per
HID message when multiple keys share the same dimmed color.
"""
# Build color -> [zone_ids] map for this interpolation step
color_groups = {}
for zone_id, (start_color, target_color) in self._dim_perkey.items():
color = self._interpolate_color(start_color, target_color, t)
if color not in color_groups:
color_groups[color] = []
color_groups[color].append(zone_id)
feat = SupportedFeature.PER_KEY_LIGHTING_V2
for color, zone_ids in color_groups.items():
r = (color >> 16) & 0xFF
g = (color >> 8) & 0xFF
b = color & 0xFF
# Function 6: SetRgbZonesSingleValue — color(3) + zone_ids (up to 13 per report)
while zone_ids:
batch = zone_ids[:13]
zone_ids = zone_ids[13:]
data = bytes([r, g, b]) + bytes(batch)
self._device.feature_request(feat, 0x60, data)
# Commit the frame
self._device.feature_request(feat, 0x70, b"\x00\x00\x00\x00\x00")
def _apply_animation(self, effect_id):
"""Hand off to a firmware animation. Generic over any effect in
hidpp20.LEDEffects: builds the 10-byte param block from the
effect's param map, sourcing color from the zone and other
params from the persisted _idle_effect."""
from . import hidpp20
infos = getattr(self._device, "led_effects", None)
if not infos or not infos.zones:
self._state = self.IDLE
return
entry = hidpp20.LEDEffects.get(effect_id)
if entry is None:
self._state = self.IDLE
return
param_map = entry[1]
for zone in infos.zones:
effect_info = next((e for e in zone.effects if e.ID == effect_id), None)
if effect_info is None:
continue
color = self._get_zone_color(zone)
params = bytearray(10)
if hidpp20.LEDParam.color in param_map:
offset = param_map[hidpp20.LEDParam.color]
params[offset] = (color >> 16) & 0xFF
params[offset + 1] = (color >> 8) & 0xFF
params[offset + 2] = color & 0xFF
for pname, poff in param_map.items():
if pname == hidpp20.LEDParam.color:
continue
psize = hidpp20.LEDParamSize.get(pname, 1)
user_val = getattr(self._idle_effect, str(pname), None)
if user_val is None:
user_val = effect_info.period or 3000 if pname == hidpp20.LEDParam.period else 0
params[poff : poff + psize] = int(user_val).to_bytes(psize, "big")
if effect_id == 0x01:
params[3] = 0x02 # Static fixed-color marker
payload = bytes([zone.index, effect_info.index]) + bytes(params) + b"\x01"
try:
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x10, payload)
except Exception as exc:
if logger.isEnabledFor(logging.WARNING):
logger.warning(
"%s: failed to apply animation 0x%02x to zone %d: %s",
self._device,
effect_id,
zone.index,
exc,
)
self._state = self.IDLE
# --- Sleep ---
def _start_sleep(self):
"""Enter firmware-managed sleep. Firmware fades from current level."""
self._cancel_dim_timer()
try:
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x80, b"\x01\x03\x00")
self._state = self.SLEEPING
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: RGB entering sleep (firmware power-down)", self._device)
except Exception as e:
if logger.isEnabledFor(logging.WARNING):
logger.warning("%s: failed to enter RGB sleep: %s", self._device, e)
# --- Wake ---
def _wake(self):
"""Restore full lighting from any non-ACTIVE state."""
if self._state == self.ACTIVE:
return
prev_state = self._state
self._cancel_dim_timer()
self._cancel_sleep_timer()
# State must be ACTIVE before _restore_colors() — the paint paths
# translate through it, and writes would otherwise go at the old dim.
self._state = self.ACTIVE
try:
if prev_state == self.SLEEPING:
self._set_power_mode_with_retry(1)
# Re-claim full LED pipeline control
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x50, SW_ACTIVE)
# Firmware engine has re-engaged during sleep — re-arm per-key
# one-shots so the next write re-fires the prep + double-send.
for s in self._device.settings:
if s.name == "per-key-lighting":
s._frame_settled = False
s._prep_pushed = False
break
self._restore_colors()
if logger.isEnabledFor(logging.DEBUG):
state_names = {self.DIMMING: "dimming", self.IDLE: "idle", self.SLEEPING: "sleep"}
logger.debug("%s: RGB woken from %s", self._device, state_names.get(prev_state, "unknown"))
except Exception as e:
if logger.isEnabledFor(logging.WARNING):
logger.warning("%s: failed to wake RGB LEDs: %s", self._device, e)
def _cancel_dim_timer(self):
if self._dim_timer_id is not None:
GLib.source_remove(self._dim_timer_id)
self._dim_timer_id = None
def _get_zone_color(self, zone):
location = int(zone.location)
setting_name = f"rgb_zone_{location}"
for s in self._device.settings:
if s.name == setting_name and s._value is not None:
return getattr(s._value, "color", 0xFFFFFF)
return 0xFFFFFF
def _get_zone_base_color(self):
"""Color used as the base for unset per-key cells. Black when the
zone effect is marked ignore, the saved zone color otherwise."""
return effective_zone_base_color(self._device)
@staticmethod
def _has_real_perkey_colors(perkey_setting):
if not perkey_setting._value:
return False
no_change = special_keys.COLORSPLUS["No change"]
return any(color != no_change and isinstance(color, int) and color >= 0 for color in perkey_setting._value.values())
def _build_full_perkey_dim_map(self, perkey_setting, dim_pct):
"""{zone_id: (start, target)} for every zone — user-set keys from
their color, unset from the zone base."""
no_change = special_keys.COLORSPLUS["No change"]
zone_base = self._get_zone_base_color()
user_colors = {int(k): c for k, c in perkey_setting._value.items() if c != no_change and isinstance(c, int) and c >= 0}
return {
int(k): (start, self._compute_dim_color(start, dim_pct))
for k in perkey_setting._validator.choices
for start in (user_colors.get(int(k), zone_base),)
}
def _init_unset_perkey_zones(self, perkey_setting):
"""Push the zone base color to per-key cells the user hasn't painted —
avoids the white-default flash when per-key takes over the buffer."""
no_change = special_keys.COLORSPLUS["No change"]
zone_base = self._get_zone_base_color()
r = (zone_base >> 16) & 0xFF
g = (zone_base >> 8) & 0xFF
b = zone_base & 0xFF
user_set = {int(k) for k, c in perkey_setting._value.items() if c != no_change and isinstance(c, int) and c >= 0}
unset_zones = [int(k) for k in perkey_setting._validator.choices if int(k) not in user_set]
if not unset_zones:
return
feat = SupportedFeature.PER_KEY_LIGHTING_V2
remaining = list(unset_zones)
try:
while remaining:
batch = remaining[:13]
remaining = remaining[13:]
self._device.feature_request(feat, 0x60, bytes([r, g, b]) + bytes(batch))
self._device.feature_request(feat, 0x70, b"\x00\x00\x00\x00\x00")
except exceptions.FeatureCallError as e:
logger.warning("%s: per-key zone init failed (device busy?): %s", self._device, e)
@staticmethod
def _compute_dim_color(color, dim_pct):
r = ((color >> 16) & 0xFF) * dim_pct // 100
g = ((color >> 8) & 0xFF) * dim_pct // 100
b = (color & 0xFF) * dim_pct // 100
return (r << 16) | (g << 8) | b
@staticmethod
def _interpolate_color(start, target, t):
r_s, g_s, b_s = (start >> 16) & 0xFF, (start >> 8) & 0xFF, start & 0xFF
r_t, g_t, b_t = (target >> 16) & 0xFF, (target >> 8) & 0xFF, target & 0xFF
r = int(r_s + (r_t - r_s) * t)
g = int(g_s + (g_t - g_s) * t)
b = int(b_s + (b_t - b_s) * t)
return (r << 16) | (g << 8) | b
def _current_dim_pct(self):
"""100 unless we're in Dim mode — animations run at firmware brightness."""
if self._idle_effect_id() != 0x80:
return 100
return int(getattr(self._idle_effect, "intensity", 50) or 50)
def translate_color(self, color):
"""Map a saved (undimmed) per-key color to what should be displayed
on the device right now, given the current power-management state.
Returns None to signal SLEEPING caller should persist and skip the
wire write; _restore_colors on wake will re-push the saved value."""
# Static idle is a color swap, not a brightness change — user-painted
# cells render their saved color unchanged, and the unset-cell
# substitution happens upstream via effective_zone_base_color.
if self._state == self.IDLE and self._idle_effect_id() == 0x01:
return color
return translate_color_for_display(color, self._state, self._current_dim_pct(), self._dim_step, self._DIM_STEPS)
def notify_perkey_changed(self, zone_id, new_color):
"""Resync a per-key zone's dim ramp entry to a user-repainted color."""
if self._state != self.DIMMING or not self._dim_perkey or zone_id not in self._dim_perkey:
return
self._dim_perkey[zone_id] = (
new_color,
self._compute_dim_color(new_color, self._current_dim_pct()),
)
def notify_perkey_bulk_changed(self, color_map):
"""Bulk notify_perkey_changed, skipping 'No change' entries."""
if self._state != self.DIMMING or not self._dim_perkey:
return
no_change = special_keys.COLORSPLUS["No change"]
for zone_id, color in color_map.items():
if color == no_change or not isinstance(color, int) or color < 0:
continue
self.notify_perkey_changed(int(zone_id), int(color))
def notify_zone_changed(self, cluster_index, new_color):
"""Resync a zone-effect dim ramp entry to a user-repainted color."""
if self._state != self.DIMMING or not self._dim_zones:
return
dim_pct = self._current_dim_pct()
for i, (zone, _start, _target) in enumerate(self._dim_zones):
if int(zone.index) == int(cluster_index):
self._dim_zones[i] = (zone, new_color, self._compute_dim_color(new_color, dim_pct))
return
def _set_power_mode_with_retry(self, mode):
"""First command after wake may fail; retry."""
params = bytes([0x01, mode, 0x00])
for attempt in range(3):
try:
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x80, params)
return
except Exception:
if attempt == 2:
raise
import time as _time
_time.sleep(0.1)
def _is_ignored(self, setting_name):
"""True if marked ignore via the lock icon."""
persister = getattr(self._device, "persister", None)
if persister is None:
return False
return persister.get_sensitivity(setting_name) == settings.SENSITIVITY_IGNORE
def _restore_colors(self):
"""Re-push lighting state after waking. Per-key dominates only when
zone is Static under animations, the zone wire push goes through
and per-key is skipped."""
_perkey_setting, has_paint = perkey_has_paint(self._device)
zone_static = zone_effect_is_static(self._device)
perkey_dominates = has_paint and zone_static
for s in self._device.settings:
if s._value is None:
continue
if self._is_ignored(s.name):
continue
if s.name == "per-key-lighting":
if not self._has_real_perkey_colors(s):
continue
if not zone_static:
continue # firmware animation owns the visible layer
elif s.name.startswith("rgb_zone_"):
if perkey_dominates:
continue
else:
continue
try:
s.write(s._value, save=False)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: restored %s after wake", self._device, s.name)
except Exception as e:
if logger.isEnabledFor(logging.WARNING):
logger.warning("%s: failed to restore %s: %s", self._device, s.name, e)

View File

@ -27,6 +27,7 @@ from solaar.i18n import _
from . import common
from . import hidpp20_constants
from . import settings_validator
from .centurion_constants import CenturionCoreFeature
from .common import NamedInt
logger = logging.getLogger(__name__)
@ -42,6 +43,7 @@ class Kind(IntEnum):
MAP_CHOICE = 0x0A
MULTIPLE_TOGGLE = 0x10
PACKED_RANGE = 0x20
GRAPHIC_EQ = 0x21
MULTIPLE_RANGE = 0x40
HETERO = 0x80
MAP_RANGE = 0x102
@ -59,6 +61,15 @@ class Setting:
validator_class = None
validator_options = {}
display = True # display setting in UI
# Set False for settings whose value cannot be read back from the device
# (e.g. PerKeyLighting — the 0x8081 protocol has no GetIndividualRgbZones).
# `solaar show` uses this to suppress the "(live)" output line that would
# otherwise print a fabricated value misleadingly.
live_readable = True
# Optional UI editor override as "module.path:ClassName". Resolved by the
# config panel before the Kind dispatch. Kept as a string so this module
# stays free of GTK imports — the FE/BE seam is preserved.
editor_class: str | None = None
def __init__(self, device, rw, validator):
self._device = device
@ -170,8 +181,16 @@ class Setting:
logger.debug("%s: prepare write(%s) => %r", self.name, value, data_bytes)
reply = self._rw.write(self._device, data_bytes)
if not reply:
# tell whomever is calling that the write failed
# HID++ 2.0 "set" operations often return an empty ACK (b"").
# Treating empty bytes as failure (`not reply`) would misreport
# successful writes as errors to the GUI. Only report failure
# when the transport actually returned None (error or timeout).
if reply is None:
logger.info(
"%s: write on %s returned no reply (transport error/timeout)",
self.name,
self._device,
)
return None
return value
@ -627,7 +646,7 @@ class FeatureRW:
read_prefix=b"",
no_reply=False,
):
assert isinstance(feature, hidpp20_constants.SupportedFeature)
assert isinstance(feature, (hidpp20_constants.SupportedFeature, CenturionCoreFeature))
self.feature = feature
self.read_fnid = read_fnid
self.write_fnid = write_fnid
@ -664,7 +683,7 @@ class FeatureRWMap(FeatureRW):
key_byte_count=default_key_byte_count,
no_reply=False,
):
assert isinstance(feature, hidpp20_constants.SupportedFeature)
assert isinstance(feature, (hidpp20_constants.SupportedFeature, CenturionCoreFeature))
self.feature = feature
self.read_fnid = read_fnid
self.write_fnid = write_fnid

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from enum import IntEnum
from logitech_receiver import common
@ -531,12 +532,13 @@ class RangeValidator(Validator):
kwargs["max_value"] = setting_class.max_value
return cls(**kwargs)
def __init__(self, min_value=0, max_value=255, byte_count=1, read_skip_byte_count=0, write_prefix_bytes=b""):
def __init__(self, min_value=0, max_value=255, byte_count=1, read_skip_byte_count=0, write_prefix_bytes=b"", signed=False):
assert max_value > min_value
self.min_value = min_value
self.max_value = max_value
self.read_skip_byte_count = read_skip_byte_count
self.write_prefix_bytes = write_prefix_bytes
self._signed = signed
self.needs_current_value = True # read and check before write (needed for ADC power and probably a good idea anyway)
self._byte_count = math.ceil(math.log(max_value + 1, 256))
if byte_count:
@ -545,7 +547,9 @@ class RangeValidator(Validator):
assert self._byte_count < 8
def validate_read(self, reply_bytes):
reply_value = common.bytes2int(reply_bytes[self.read_skip_byte_count : self.read_skip_byte_count + self._byte_count])
reply_value = common.bytes2int(
reply_bytes[self.read_skip_byte_count : self.read_skip_byte_count + self._byte_count], signed=self._signed
)
assert reply_value >= self.min_value, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}"
assert reply_value <= self.max_value, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}"
return reply_value
@ -554,14 +558,14 @@ class RangeValidator(Validator):
if new_value < self.min_value or new_value > self.max_value:
raise ValueError(f"invalid choice {new_value!r}")
current_value = self.validate_read(current_value) if current_value is not None else None
to_write = self.write_prefix_bytes + common.int2bytes(new_value, self._byte_count)
to_write = self.write_prefix_bytes + common.int2bytes(new_value, self._byte_count, signed=self._signed)
# current value is known and same as value to be written return None to signal not to write it
return None if current_value is not None and current_value == new_value else to_write
def acceptable(self, args, current):
arg = args[0]
# None if len(args) != 1 or type(arg) != int or arg < self.min_value or arg > self.max_value else args)
return None if len(args) != 1 or isinstance(arg, int) or arg < self.min_value or arg > self.max_value else args
return None if len(args) != 1 or not isinstance(arg, int) or arg < self.min_value or arg > self.max_value else args
def compare(self, args, current):
if len(args) == 1:
@ -580,7 +584,8 @@ class HeteroValidator(Validator):
return cls(**kwargs)
def __init__(self, data_class=None, options=None, readable=True):
assert data_class is not None and options is not None
# options=None for purely host-side settings — data_class handles bytes[0] as the ID.
assert data_class is not None
self.data_class = data_class
self.options = options
self.readable = readable
@ -743,3 +748,114 @@ class MultipleRangeValidator(Validator):
def compare(self, args, current):
logger.warning("compare not implemented for multiple range settings")
return False
@dataclass(frozen=True)
class Range:
"""Inclusive integer range used as the value side of a MapRangeValidator.
`byte_count` is the wire encoding width. `signed` selects two's-complement.
`value_type` is the int factory used to wrap values returned from
`validate_read` defaults to `int`, but settings that store RGB colors
pass `common.ColorInt` so the values self-format as ``0xrrggbb`` in
`solaar show` output and the YAML config file.
Settings whose value space is a continuous integer range (e.g. per-key RGB
colors as 24-bit ints) use this in place of a NamedInts choice list.
"""
min: int
max: int
byte_count: int = 1
signed: bool = False
value_type: type = int
def contains(self, value: int) -> bool:
return isinstance(value, int) and self.min <= value <= self.max
class MapRangeValidator(Validator):
"""Map of keys → integer in a per-key Range. Open value space (no choice list).
Reports `kind = Kind.MAP_CHOICE` so the existing config-panel/CLI/rule-engine
dispatch keeps routing without new branches; consumers that need to tell
"choice list" from "open range" check `isinstance(setting.choices[k], Range)`.
TODO: complete `Kind.MAP_RANGE` infrastructure (UI dispatch, generic rule-UI
handling, generalize `cli/config.py:299`) and migrate this validator's
`kind` over. Today MAP_RANGE is only honored by `ForceSensing` via the
`settings_new` framework; bridging both frameworks is a separate task.
"""
kind = Kind.MAP_CHOICE
def __init__(self, choices_map, key_byte_count=1, write_prefix_bytes=b""):
assert isinstance(choices_map, dict)
for k, v in choices_map.items():
assert isinstance(k, NamedInt), f"MapRangeValidator key must be NamedInt, got {type(k).__name__}"
assert isinstance(v, Range), f"MapRangeValidator value must be Range, got {type(v).__name__}"
self.choices = choices_map
self.needs_current_value = False
self._key_byte_count = key_byte_count
self._write_prefix_bytes = write_prefix_bytes
def to_string(self, value) -> str:
if not isinstance(value, dict):
return str(value)
# Persisted dicts loaded from YAML come back as plain ints regardless
# of the choice's `value_type`. Re-wrap raw ints through the configured
# value_type (e.g. ColorInt) so they self-format consistently here and
# in `solaar show`. Skip wrapping for NamedInt sentinels / subclasses
# (`type(v) is int` is the exact-match guard).
rng_by_int_key = {int(k): rng for k, rng in self.choices.items()}
def _fmt(k):
v = value[k]
rng = rng_by_int_key.get(int(k))
if rng is not None and type(v) is int and rng.value_type is not int: # noqa: E721
try:
v = rng.value_type(v)
except Exception:
pass
return f"{k}:{v}"
return "{" + ", ".join(_fmt(k) for k in sorted(value)) + "}"
def validate_read(self, reply_bytes, key):
rng = self.choices.get(key)
if rng is None:
return None
end = self._key_byte_count + rng.byte_count
return rng.value_type(common.bytes2int(reply_bytes[self._key_byte_count : end], signed=rng.signed))
def prepare_key(self, key):
return int(key).to_bytes(self._key_byte_count, "big")
def prepare_write(self, key, new_value):
rng = self.choices.get(key)
if rng is None:
logger.error("invalid key %r for map-range setting", key)
return None
if not rng.contains(new_value):
logger.error("value %r out of range [%d, %d] for key %s", new_value, rng.min, rng.max, key)
return None
return self._write_prefix_bytes + int(new_value).to_bytes(rng.byte_count, "big", signed=rng.signed)
def acceptable(self, args, current):
if not isinstance(args, list) or len(args) != 2:
return None
key = next((k for k in self.choices if int(k) == int(args[0])), None)
if key is None:
return None
rng = self.choices[key]
if not rng.contains(args[1]):
return None
return [int(key), int(args[1])]
def compare(self, args, current):
if not isinstance(args, list) or len(args) != 2 or not isinstance(current, dict):
return False
key = next((k for k in self.choices if int(k) == int(args[0])), None)
if key is None:
return False
return current.get(int(key)) == args[1]

View File

@ -39,7 +39,7 @@ def _create_parser():
)
subparsers = parser.add_subparsers(title="actions", help="command-line action to perform")
sp = subparsers.add_parser("show", help="show information about devices")
sp = subparsers.add_parser("show", description="Show information about device or all devices.")
sp.add_argument(
"device",
nargs="?",
@ -49,7 +49,7 @@ def _create_parser():
)
sp.set_defaults(action="show")
sp = subparsers.add_parser("probe", help="probe a receiver (debugging use only)")
sp = subparsers.add_parser("probe", description="Probe a receiver (debugging use only).")
sp.add_argument(
"receiver", nargs="?", help="select receiver by name substring or serial number when more than one is present"
)
@ -57,25 +57,26 @@ def _create_parser():
sp = subparsers.add_parser(
"profiles",
help="read or write onboard profiles",
description="Print or load YAML dump of profiles.",
epilog="Only works on active devices.",
)
sp.add_argument(
"device",
help="device to read or write profiles of; may be a device number (1..6), a serial number, "
"a substring of a device's name",
help="device to read or load profiles; may be a device number (1..6), a serial number, "
"or a substring of a device's name",
)
sp.add_argument("profiles", nargs="?", help="file containing YAML dump of profiles")
sp.add_argument("profiles", nargs="?", help="file containing YAML dump of profiles to load")
sp.set_defaults(action="profiles")
sp = subparsers.add_parser(
"config",
help="read/write device-specific settings",
description="Print or load device-specific settings. Only some settings can be loaded. "
"Loading complex settings uses the same syntax as in ~/.config/solaar/config.yaml",
epilog="Please note that configuration only works on active devices.",
)
sp.add_argument(
"device",
help="device to configure; may be a device number (1..6), a serial number, " "or a substring of a device's name",
help="device to configure; may be a device number (1..6), a serial number, or a substring of a device's name",
)
sp.add_argument("setting", nargs="?", help="device-specific setting; leave empty to list available settings")
sp.add_argument("value_key", nargs="?", help="new value for the setting or key for keyed settings")
@ -85,7 +86,7 @@ def _create_parser():
sp = subparsers.add_parser(
"pair",
help="pair a new device",
description="Pair a new device with a receiver. The device has to be compatible with the receiver.",
epilog="The Logitech Unifying Receiver supports up to 6 paired devices at the same time.",
)
sp.add_argument(
@ -93,10 +94,30 @@ def _create_parser():
)
sp.set_defaults(action="pair")
sp = subparsers.add_parser("unpair", help="unpair a device")
sp = subparsers.add_parser("unpair", description="Unpair a device from its receiver. Not all receivers allow unpairing.")
sp.add_argument(
"device",
help="device to unpair; may be a device number (1..6), a serial number, " "or a substring of a device's name.",
nargs="?",
help="device to unpair; may be a device number (1..6), a serial number, "
"or a substring of a device's name. Omit when using --slot.",
)
sp.add_argument(
"--receiver",
help="select receiver by name substring or serial number when more than one is present; "
"required with --slot if multiple receivers are attached.",
)
sp.add_argument(
"--slot",
type=int,
help="force-unpair a specific slot number directly, even if Solaar has no cached device there "
"or the device is currently reachable. Lightspeed receivers only. The slot contents are "
"printed before the write so you can confirm what is about to be cleared.",
)
sp.add_argument(
"--dry-run",
action="store_true",
help="with --slot, run all safety checks but do not issue the unpair register write. "
"Use to verify the active-device guard before committing to a real write.",
)
sp.set_defaults(action="unpair")
@ -127,7 +148,14 @@ def _receivers_and_devices(dev_path=None):
continue
try:
if dev_info.isDevice:
d = device.create_device(base, dev_info)
if getattr(dev_info, "centurion", False):
d = device.create_centurion_receiver(base, dev_info)
if d is not None:
d.notify_devices()
else:
d = device.create_device(base, dev_info)
else:
d = device.create_device(base, dev_info)
else:
d = receiver.create_receiver(base, dev_info)

View File

@ -20,12 +20,28 @@ from logitech_receiver import settings
from logitech_receiver import settings_templates
from logitech_receiver.common import NamedInts
from logitech_receiver.settings_templates import SettingsProtocol
from logitech_receiver.settings_validator import Range
from solaar import configuration
APP_ID = "io.github.pwr_solaar.solaar"
def _parse_int_or_hex(s) -> int | None:
"""Parse 0xRRGGBB / #RRGGBB / decimal int. Returns None on bad input."""
if not isinstance(s, str):
return None
s = s.strip()
try:
if s.startswith("#"):
return int(s[1:], 16)
if s.lower().startswith("0x"):
return int(s, 16)
return int(s, 10)
except ValueError:
return None
def _print_setting(s, verbose=True):
print("#", s.label)
if verbose:
@ -70,7 +86,11 @@ def _print_setting_keyed(s, key, verbose=True):
if k is None:
print(s.name, "=? (key not found)")
else:
print("# possible values: one of [", ", ".join(str(v) for v in s.choices[k]), "]")
value_space = s.choices[k]
if isinstance(value_space, Range):
print(f"# possible values: integer in [{value_space.min}, {value_space.max}] (decimal or 0xHEX)")
else:
print("# possible values: one of [", ", ".join(str(v) for v in value_space), "]")
value = s.read(cached=False)
if value is None:
print(s.name, "= ? (failed to read from device)")
@ -210,7 +230,8 @@ def run(receivers, args, _find_receiver, find_device):
if remote:
argl = ["config", dev.serial or dev.unitId, setting.name]
argl.extend([a for a in [args.value_key, args.extra_subkey, args.extra2] if a is not None])
application.run(yaml.safe_dump(argl))
args = yaml.dump(argl)
application.run([args])
else:
if dev.persister and setting.persist:
dev.persister[setting.name] = setting._value
@ -244,12 +265,21 @@ def set(dev, setting: SettingsProtocol, args, save):
k = next((k for k in setting.choices.keys() if key == k), None)
if k is None and ikey is not None:
k = next((k for k in setting.choices.keys() if ikey == k), None)
if k is not None:
value = select_choice(args.extra_subkey, setting.choices[k], setting, key)
args.extra_subkey = int(value)
args.value_key = str(int(k))
else:
if k is None:
raise Exception(f"{setting.name}: key '{key}' not in setting")
value_space = setting.choices[k]
if isinstance(value_space, Range):
ivalue = _parse_int_or_hex(args.extra_subkey)
if ivalue is None or not value_space.contains(ivalue):
raise Exception(
f"{setting.name}: value '{args.extra_subkey}' must be an integer in "
f"[{value_space.min}, {value_space.max}] (decimal or 0xHEX / #HEX)"
)
value = ivalue
else:
value = select_choice(args.extra_subkey, value_space, setting, key)
args.extra_subkey = int(value)
args.value_key = str(int(k))
message = f"Setting {setting.name} of {dev.name} key {k!r} to {value!r}"
result = setting.write_key_value(int(k), value, save=save)
@ -278,8 +308,6 @@ def set(dev, setting: SettingsProtocol, args, save):
key = args.value_key
all_keys = getattr(setting, "choices_universe", None)
ikey = all_keys[int(key) if key.isdigit() else key] if isinstance(all_keys, NamedInts) else to_int(key)
print("S", args.extra2, key, type(all_keys), ikey)
print("SS", args)
if args.extra2 is None or to_int(args.extra2) is None:
raise Exception(f"{setting.name}: setting needs an integer value, not {args.extra2}")
if not setting._value: # ensure that there are values to look through
@ -308,8 +336,14 @@ def set(dev, setting: SettingsProtocol, args, save):
message = f"Setting {setting.name} of {dev.name} key {key} to {value}"
result = setting.write_key_value(key, value, save=save)
elif setting.kind == settings.Kind.HETERO:
value = yaml.safe_load(args.value_key)
args.value_key = value
message = f"Setting {setting.name} of {dev.name} to {value}"
result = setting.write(value, save=save)
else:
print("KIND", setting.kind)
print(f"Setting {setting.name}, with kind {setting.kind.name}, not implemented")
raise Exception("NotImplemented")
return result, message, value

View File

@ -38,7 +38,7 @@ def run(receivers, args, find_receiver, _ignore):
assert receiver
# check if it's necessary to set the notification flags
old_notification_flags = _hidpp10.get_notification_flags(receiver) or 0
old_notification_flags = _hidpp10.get_notification_flags(receiver)
if not (old_notification_flags & hidpp10_constants.NotificationFlag.WIRELESS):
_hidpp10.set_notification_flags(receiver, old_notification_flags | hidpp10_constants.NotificationFlag.WIRELESS)
@ -79,33 +79,36 @@ def run(receivers, args, find_receiver, _ignore):
name = receiver.pairing.device_name
authentication = receiver.pairing.device_authentication
kind = receiver.pairing.device_kind
print(f"Bolt Pairing: discovered {name}")
receiver.pair_device(
address=address,
authentication=authentication,
entropy=20 if kind == hidpp10_constants.DEVICE_KIND.keyboard else 10,
)
pairing_start = time()
patience = 5 # the discovering notification may come slightly later, so be patient
while receiver.pairing.lock_open or time() - pairing_start < patience:
if receiver.pairing.device_passkey:
break
n = base.read(receiver.handle)
n = base.make_notification(*n) if n else None
if n:
receiver.handle.notifications_hook(n)
if authentication & 0x01:
print(f"Bolt Pairing: type passkey {receiver.pairing.device_passkey} and then press the enter key")
if authentication is None: # no compatible device stepped forward
print("No Bolt-compatible device requested pairing.")
else:
passkey = f"{int(receiver.pairing.device_passkey):010b}"
passkey = ", ".join(["right" if bit == "1" else "left" for bit in passkey])
print(f"Bolt Pairing: press {passkey}")
print("and then press left and right buttons simultaneously")
while receiver.pairing.lock_open:
n = base.read(receiver.handle)
n = base.make_notification(*n) if n else None
if n:
receiver.handle.notifications_hook(n)
print(f"Bolt Pairing: discovered {name}")
receiver.pair_device(
address=address,
authentication=authentication,
entropy=20 if kind == hidpp10_constants.DEVICE_KIND.keyboard else 10,
)
pairing_start = time()
patience = 5 # the discovering notification may come slightly later, so be patient
while receiver.pairing.lock_open or time() - pairing_start < patience:
if receiver.pairing.device_passkey:
break
n = base.read(receiver.handle)
n = base.make_notification(*n) if n else None
if n:
receiver.handle.notifications_hook(n)
if authentication & 0x01:
print(f"Bolt Pairing: type passkey {receiver.pairing.device_passkey} and then press the enter key")
else:
passkey = f"{int(receiver.pairing.device_passkey):010b}"
passkey = ", ".join(["right" if bit == "1" else "left" for bit in passkey])
print(f"Bolt Pairing: press {passkey}")
print("and then press left and right buttons simultaneously")
while receiver.pairing.lock_open:
n = base.read(receiver.handle)
n = base.make_notification(*n) if n else None
if n:
receiver.handle.notifications_hook(n)
else:
receiver.set_lock(False, timeout=timeout)

View File

@ -25,6 +25,7 @@ from logitech_receiver import settings_templates
from logitech_receiver.common import LOGITECH_VENDOR_ID
from logitech_receiver.common import NamedInt
from logitech_receiver.common import strhex
from logitech_receiver.device import CenturionReceiver
from logitech_receiver.hidpp20_constants import SupportedFeature
from solaar import NAME
@ -35,36 +36,91 @@ _hidpp20 = hidpp20.Hidpp20()
def _print_receiver(receiver):
is_centurion = isinstance(receiver, CenturionReceiver)
paired_count = receiver.count()
print(receiver.name)
print(" Device path :", receiver.path)
print(f" USB id : {LOGITECH_VENDOR_ID:04x}:{receiver.product_id}")
print(" Serial :", receiver.serial)
pending = hidpp10.get_configuration_pending_flags(receiver)
if pending:
print(f" C Pending : {pending:02x}")
if is_centurion:
print(" Protocol : Centurion")
if receiver.serial:
print(" Serial :", receiver.serial)
if not is_centurion:
pending = hidpp10.get_configuration_pending_flags(receiver)
if pending:
print(f" C Pending : {pending:02x}")
if receiver.firmware:
for f in receiver.firmware:
print(" %-11s: %s" % (f.kind, f.version))
print(" Has", paired_count, f"paired device(s) out of a maximum of {int(receiver.max_devices)}.")
if receiver.remaining_pairings() and receiver.remaining_pairings() >= 0:
print(f" Has {int(receiver.remaining_pairings())} successful pairing(s) remaining.")
if is_centurion:
print(" Has", paired_count, f"device(s) out of a maximum of {int(receiver.max_devices)}.")
else:
print(" Has", paired_count, f"paired device(s) out of a maximum of {int(receiver.max_devices)}.")
if receiver.remaining_pairings() and receiver.remaining_pairings() >= 0:
print(f" Has {int(receiver.remaining_pairings())} successful pairing(s) remaining.")
notification_flags = _hidpp10.get_notification_flags(receiver)
if notification_flags is not None:
if notification_flags:
notification_names = hidpp10_constants.NotificationFlag.flag_names(notification_flags)
print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags:06X})")
if is_centurion:
_print_centurion_dongle_features(receiver)
else:
notification_flags = _hidpp10.get_notification_flags(receiver)
if notification_flags is not None:
if notification_flags:
notification_names = hidpp10_constants.NotificationFlag.flag_names(notification_flags)
print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags.value:06X})")
else:
print(" Notifications: (none)")
activity = receiver.read_register(hidpp10_constants.Registers.DEVICES_ACTIVITY)
if activity:
activity = [(d, ord(activity[d - 1 : d])) for d in range(1, receiver.max_devices)]
activity_text = ", ".join(f"{int(d)}={int(a)}" for d, a in activity if a > 0)
print(" Device activity counters:", activity_text or "(empty)")
def _print_centurion_dongle_features(receiver):
"""Print dongle-level features, probed independently on the dongle hardware."""
features = receiver.dongle_features
if not features:
return
print(f" Supports {len(features)} dongle features:")
for feature, feat_id, index in features:
display_name = "CENTPP BRIDGE" if feat_id == 0x0003 else feature
feat_bytes = feat_id.to_bytes(2, byteorder="big")
try:
flags_resp = receiver.request(0x0000, feat_bytes[0], feat_bytes[1])
except Exception:
flags_resp = None
if flags_resp is not None and len(flags_resp) >= 2:
flags = flags_resp[1]
flag_names = common.flag_names(hidpp20_constants.FeatureFlag, flags)
print(" %2d: %-22s {%04X} %s " % (index, display_name, feat_id, ", ".join(flag_names)))
else:
print(" Notifications: (none)")
print(" %2d: %-22s {%04X}" % (index, display_name, feat_id))
if feature == SupportedFeature.CENTURION_DEVICE_INFO:
fw_list = _hidpp20.get_firmware_centurion(receiver)
serial = _hidpp20.get_serial_centurion(receiver)
hw_info = _hidpp20.get_hardware_info_centurion(receiver)
if fw_list:
for fw in fw_list:
print(f" Firmware: {(str(fw.kind) + ' ' + fw.name).strip()} {fw.version}")
if serial and serial.strip() and serial.strip().isprintable():
print(f" Serial: {serial}")
if hw_info:
model_id, hw_rev, product_id = hw_info
print(f" Hardware: model {model_id}" f" rev {hw_rev} product {product_id:04X}")
activity = receiver.read_register(hidpp10_constants.Registers.DEVICES_ACTIVITY)
if activity:
activity = [(d, ord(activity[d - 1 : d])) for d in range(1, receiver.max_devices)]
activity_text = ", ".join(f"{int(d)}={int(a)}" for d, a in activity if a > 0)
print(" Device activity counters:", activity_text or "(empty)")
_LED_CAPS_BITS = ((0x0001, "color"), (0x0002, "fade"), (0x0004, "period"), (0x0010, "direction"), (0xC000, "fw"))
def _decode_led_caps(caps):
names = [name for mask, name in _LED_CAPS_BITS if caps & mask]
other = caps & ~sum(m for m, _ in _LED_CAPS_BITS)
if other:
names.append(f"+{other:#06x}")
return f"0x{caps:04x}=" + ("+".join(names) if names else "none")
def _battery_text(level) -> str:
@ -91,9 +147,11 @@ def _battery_line(dev):
def _print_device(dev, num=None):
assert dev is not None
is_centurion = getattr(dev, "centurion", False)
is_centurion_child = is_centurion and isinstance(getattr(dev, "receiver", None), CenturionReceiver)
# try to ping the device to see if it actually exists and to wake it up
try:
dev.ping()
online = dev.ping()
except exceptions.NoSuchDevice:
print(f" {num}: Device not found" or dev.number)
return
@ -102,18 +160,29 @@ def _print_device(dev, num=None):
print(f" {int(num or dev.number)}: {dev.name}")
else:
print(f"{dev.name}")
print(" Device path :", dev.path)
if dev.wpid:
if not online:
print(" Device is offline.")
return
# Centurion child has no separate hidraw path — show receiver's path
device_path = dev.path or (dev.receiver.path if is_centurion_child else None)
print(" Device path :", device_path)
if dev.wpid and not is_centurion_child:
print(f" WPID : {dev.wpid}")
if dev.product_id:
print(f" USB id : {LOGITECH_VENDOR_ID:04x}:{dev.product_id}")
print(" Codename :", dev.codename)
print(" Kind :", dev.kind)
if dev.protocol:
print(f" Protocol : HID++ {dev.protocol:1.1f}")
proto_name = "Centurion" if is_centurion else "HID++"
cent_proto = getattr(dev, "_centurion_protocol", None)
if cent_proto:
print(f" Protocol : {proto_name} {cent_proto[0]}.{cent_proto[1]}")
else:
print(f" Protocol : {proto_name} {dev.protocol:1.1f}")
else:
print(" Protocol : unknown (device is offline)")
if dev.polling_rate:
if not is_centurion and dev.polling_rate:
print(" Report Rate :", dev.polling_rate)
print(" Serial number:", dev.serial)
if dev.modelId:
@ -127,12 +196,13 @@ def _print_device(dev, num=None):
if dev.power_switch_location:
print(f" The power switch is located on the {dev.power_switch_location}.")
if dev.online:
# Skip HID++ 1.0 register reads for centurion devices — they don't support these
if dev.online and not is_centurion:
notification_flags = _hidpp10.get_notification_flags(dev)
if notification_flags is not None:
if notification_flags:
notification_names = hidpp10_constants.NotificationFlag.flag_names(notification_flags)
print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags:06X}).")
print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags.value:06X}).")
else:
print(" Notifications: (none).")
device_features = _hidpp10.get_device_features(dev)
@ -144,21 +214,56 @@ def _print_device(dev, num=None):
print(" Features: (none)")
if dev.online and dev.features:
print(f" Supports {len(dev.features)} HID++ 2.0 features:")
is_centurion = getattr(dev, "centurion", False)
parent_count = dev.features.count
sub_count = getattr(dev.features, "_sub_feature_count", 0)
# For centurion child devices, dongle features are shown on the receiver —
# only show sub-device (headset) features here.
if is_centurion_child and sub_count > 0:
print(f" Supports {sub_count} HID++ 2.0 features:")
elif is_centurion and sub_count > 0:
print(f" Supports {parent_count} dongle + {sub_count} headset features:")
else:
print(f" Supports {len(dev.features)} HID++ 2.0 features:")
dev_settings = []
settings_templates.check_feature_settings(dev, dev_settings)
feature_num = 0
in_sub_device = False
for feature, index in dev.features.enumerate():
if is_centurion and not in_sub_device and feature_num >= parent_count:
in_sub_device = True
if not is_centurion_child:
print(" Headset (via CentPPBridge):")
feature_num += 1
# For centurion child, skip dongle features (already shown on the receiver)
if is_centurion_child and not in_sub_device:
continue
if isinstance(feature, str):
feature_bytes = bytes.fromhex(feature[-4:])
else:
feature_bytes = feature.to_bytes(2, byteorder="little")
feature_int = int.from_bytes(feature_bytes, byteorder="little")
flags = dev.request(0x0000, feature_bytes)
flags = 0 if flags is None else ord(flags[1:2])
flags = common.flag_names(hidpp20_constants.FeatureFlag, flags)
version = dev.features.get_feature_version(feature_int)
version = version if version else 0
print(" %2d: %-22s {%04X} V%s %s " % (index, feature, feature_int, version, ", ".join(flags)))
display_name = feature
if is_centurion_child and in_sub_device:
# Use cached version — skip slow bridge ROOT queries
version = dev.features.get_feature_version(feature_int) or 0
print(" %2d: %-22s {%04X} V%s" % (index, display_name, feature_int, version))
else:
try:
flags = dev.request(0x0000, feature_bytes)
except Exception:
flags = None
if flags is not None:
flags = ord(flags[1:2])
flag_names = common.flag_names(hidpp20_constants.FeatureFlag, flags)
version = dev.features.get_feature_version(feature_int)
version = version if version else 0
print(
" %2d: %-22s {%04X} V%s %s "
% (index, display_name, feature_int, version, ", ".join(flag_names))
)
else:
print(" %2d: %-22s {%04X}" % (index, display_name, feature_int))
if feature == SupportedFeature.HIRES_WHEEL:
wheel = _hidpp20.get_hires_wheel(dev)
if wheel:
@ -226,7 +331,25 @@ def _print_device(dev, num=None):
print(f" Kind: {_hidpp20.get_kind(dev)}")
elif feature == SupportedFeature.DEVICE_FRIENDLY_NAME:
print(f" Friendly Name: {_hidpp20.get_friendly_name(dev)}")
elif feature == SupportedFeature.DEVICE_FW_VERSION:
elif feature == SupportedFeature.CENTURION_DEVICE_INFO:
if in_sub_device:
# Use cached device properties to avoid redundant bridge requests
fw_list = dev.firmware
serial = dev.serial
hw_info = _hidpp20.get_hardware_info_centurion_sub(dev)
else:
fw_list = _hidpp20.get_firmware_centurion(dev)
serial = _hidpp20.get_serial_centurion(dev)
hw_info = _hidpp20.get_hardware_info_centurion(dev)
if fw_list:
for fw in fw_list:
print(f" Firmware: {(str(fw.kind) + ' ' + fw.name).strip()} {fw.version}")
if serial and serial.strip() and serial.strip().isprintable():
print(f" Serial: {serial}")
if hw_info:
model_id, hw_rev, product_id = hw_info
print(f" Hardware: model {model_id}" f" rev {hw_rev} product {product_id:04X}")
elif isinstance(feature, SupportedFeature) and feature == SupportedFeature.DEVICE_FW_VERSION:
for fw in _hidpp20.get_firmware(dev):
extras = strhex(fw.extras) if fw.extras else ""
print(f" Firmware: {fw.kind} {fw.name} {fw.version} {extras}")
@ -247,6 +370,28 @@ def _print_device(dev, num=None):
else:
mode = "On-Board"
print(f" Device Mode: {mode}")
elif feature == SupportedFeature.HEADSET_ONBOARD_EQ:
bands = hidpp20.get_onboard_eq_params(dev)
if bands:
print(f" EQ: {', '.join(f'{f}Hz:{g:+d}dB' for f, g, _q in bands)}")
elif feature == SupportedFeature.RGB_EFFECTS or feature == SupportedFeature.COLOR_LED_EFFECTS:
try:
infos = dev.led_effects
except Exception as e:
print(f" Effect enumeration failed: {e}")
infos = None
if infos and infos.zones:
for zone in infos.zones:
print(f" Zone {int(zone.index)} ({zone.location}): {len(zone.effects)} effect(s)")
for e in zone.effects:
entry = hidpp20.LEDEffects.get(e.ID)
name = entry[0].name if entry else f"Unknown(0x{e.ID:02x})"
caps = _decode_led_caps(e.capabilities)
params = ", ".join(str(p) for p in entry[1]) if entry and entry[1] else ""
print(
f" [{e.index}] 0x{e.ID:02x} {name:<14} "
f"caps {caps:<28} default {e.period}ms params: {params}"
)
elif hidpp20.battery_functions.get(feature, None):
print("", end=" ")
_battery_line(dev)
@ -259,14 +404,19 @@ def _print_device(dev, num=None):
):
v = setting.val_to_string(setting._device.persister.get(setting.name))
print(f" {setting.label} (saved): {v}")
try:
v = setting.read(False)
v = setting.val_to_string(v)
except exceptions.FeatureCallError as e:
v = "HID++ error " + str(e)
except AssertionError as e:
v = "AssertionError " + str(e)
print(f" {setting.label} : {v}")
# Settings whose value cannot be read back from the device
# (e.g. PerKeyLighting — 0x8081 has no GetIndividualRgbZones)
# suppress the live-read line; the saved line above is the
# authoritative record. See Setting.live_readable.
if getattr(setting, "live_readable", True):
try:
v = setting.read(False)
v = setting.val_to_string(v)
except exceptions.FeatureCallError as e:
v = "HID++ error " + str(e)
except AssertionError as e:
v = "AssertionError " + str(e)
print(f" {setting.label} : {v}")
if dev.online and dev.keys:
print(f" Has {len(dev.keys)} reprogrammable keys:")
@ -320,7 +470,7 @@ def run(devices, args, find_receiver, find_device):
if device_name == "all":
for d in devices:
if isinstance(d, receiver.Receiver):
if isinstance(d, (receiver.Receiver, CenturionReceiver)):
_print_receiver(d)
count = d.count()
if count:
@ -332,8 +482,8 @@ def run(devices, args, find_receiver, find_device):
break
print("")
else:
print("")
_print_device(d)
print("")
return
dev = find_receiver(devices, device_name)

View File

@ -17,7 +17,12 @@
def run(receivers, args, find_receiver, find_device):
assert receivers
assert args.device
if getattr(args, "slot", None) is not None:
_run_slot_unpair(receivers, args, find_receiver)
return
assert args.device, "unpair requires a device name, or use --slot"
device_name = args.device.lower()
dev = next(find_device(receivers, device_name), None)
@ -36,3 +41,50 @@ def run(receivers, args, find_receiver, find_device):
print(f"Unpaired {int(number)}: {dev.name} ({codename}) [{wpid}:{serial}]")
except Exception as e:
raise e
def _run_slot_unpair(receivers, args, find_receiver):
if args.receiver:
rcv = find_receiver(receivers, args.receiver.lower())
if not rcv:
raise Exception(f"no receiver found matching '{args.receiver}'")
elif len(receivers) == 1:
rcv = receivers[0]
else:
names = ", ".join(f"{r.name} [{r.serial}]" for r in receivers)
raise Exception(f"multiple receivers present, pass --receiver to pick one (found: {names})")
if rcv.receiver_kind != "lightspeed":
raise Exception(
f"--slot unpair is currently only supported on Lightspeed receivers "
f"(this is a {rcv.receiver_kind or 'unknown'} receiver: {rcv.name})"
)
slot = int(args.slot)
max_slots = rcv.max_devices or 1
if slot < 1 or slot > max_slots:
raise Exception(f"--slot {slot} out of range (valid: 1..{max_slots} on {rcv.name})")
# Populate the cache from the receiver's pairing registers so we can report
# what the slot currently holds. Truthy cache does NOT imply the device is
# reachable on RF — it only means the pairing registers are readable.
list(rcv)
cached = rcv._devices.get(slot)
if cached:
slot_desc = f"{cached.name} [{cached.wpid}:{cached.serial}]"
elif slot in rcv._devices:
slot_desc = "cached None sentinel (pairing info unreadable)"
else:
slot_desc = "no pairing info cached"
print(f"Slot {slot} on {rcv.name} [{rcv.serial}]: {slot_desc}")
if getattr(args, "dry_run", False):
print(f"[dry-run] would force-unpair slot {slot} — no register write issued")
return
ok = rcv.force_unpair_slot(slot)
if ok:
print(f"Slot {slot} unpair register write acknowledged by receiver")
else:
print(f"Slot {slot} unpair register write was not acknowledged (may be a no-op)")

View File

@ -231,8 +231,16 @@ yaml.add_representer(NamedInt, named_int_representer)
# So new entries are not created for unseen off-line receiver-connected devices
def persister(device):
def match(wpid, serial, modelId, unitId, c):
return (wpid and wpid == c.get(_KEY_WPID) and serial and serial == c.get(_KEY_SERIAL)) or (
modelId and modelId == c.get(_KEY_MODEL_ID) and unitId and unitId == c.get(_KEY_UNIT_ID)
return (
(wpid and wpid == c.get(_KEY_WPID) and serial and serial == c.get(_KEY_SERIAL))
or (modelId and modelId == c.get(_KEY_MODEL_ID) and unitId and unitId == c.get(_KEY_UNIT_ID))
or (
c.get(_KEY_WPID) is None
and c.get(_KEY_SERIAL) is None
and c.get(_KEY_UNIT_ID) is None
and modelId
and modelId == c.get(_KEY_MODEL_ID)
)
)
with configuration_lock:
@ -240,8 +248,8 @@ def persister(device):
_load()
entry = None
# some devices report modelId and unitId as zero so use name and serial for them
modelId = device.modelId if device.modelId != "000000000000" else device._name if device.modelId else None
unitId = device.unitId if device.modelId != "000000000000" else device._serial if device.unitId else None
modelId = device.modelId if device.modelId != "000000000000" else device._name if device._name else None
unitId = device.unitId if device.unitId != "00000000" else device._serial if device._serial else None
for c in _config:
if isinstance(c, _DeviceEntry) and match(device.wpid, device._serial, modelId, unitId, c):
entry = c

View File

@ -58,7 +58,10 @@ temp = tempfile.NamedTemporaryFile(prefix="Solaar_", mode="w", delete=True)
def create_parser():
arg_parser = argparse.ArgumentParser(
prog=NAME.lower(), epilog="For more information see https://pwr-solaar.github.io/Solaar"
prog=NAME.lower(),
description="Solaar is a program to manage many Logitech devices, "
"changing how they operate and maintaining the changes whenever the device connects.",
epilog="For more information see https://pwr-solaar.github.io/Solaar",
)
arg_parser.add_argument(
"-d",
@ -73,7 +76,7 @@ def create_parser():
action="store",
dest="hidraw_path",
metavar="PATH",
help="unifying receiver to use; the first detected receiver if unspecified. Example: /dev/hidraw2",
help="device or receiver path to use if needed. Example: /dev/hidraw2",
)
arg_parser.add_argument(
"--restart-on-wake-up",

View File

@ -77,7 +77,7 @@ class SolaarListener(listener.EventsListener):
def has_started(self):
logger.info("%s: notifications listener has started (%s)", self.receiver, self.receiver.handle)
nfs = self.receiver.enable_connection_notifications()
if not self.receiver.isDevice and not ((nfs if nfs else 0) & hidpp10_constants.NotificationFlag.WIRELESS.value):
if not self.receiver.isDevice and (not nfs or not (nfs & hidpp10_constants.NotificationFlag.WIRELESS)):
logger.warning(
"Receiver on %s might not support connection notifications, GUI might not show its devices",
self.receiver.path,
@ -115,7 +115,6 @@ class SolaarListener(listener.EventsListener):
reason or "",
)
else:
device.ping()
logger.info(
"status_changed %r: %s %s (%X) %s",
device,
@ -149,6 +148,18 @@ class SolaarListener(listener.EventsListener):
def _notifications_handler(self, n):
assert self.receiver
if n.devnumber == 0xFF:
# For CenturionReceiver, intercept bridge notifications and dispatch to child device
from logitech_receiver.device import CenturionReceiver
if isinstance(self.receiver, CenturionReceiver):
if self.receiver._pending:
ihandle = int(self.receiver.handle)
state = base._centurion_handles.get(ihandle)
if state and state.device_addr is not None:
self.receiver._complete_deferred_init()
self._status_changed(self.receiver)
self._handle_centurion_notification(n)
return
# a receiver notification
notifications.process(self.receiver, n)
return
@ -228,6 +239,102 @@ class SolaarListener(listener.EventsListener):
elif dev.online is None:
dev.ping()
def _handle_centurion_notification(self, n):
"""Handle notifications from a CenturionReceiver dongle.
Bridge events have sub_id == bridge_index. The event function number
is in bits 7-4 of n.address:
- Function 0: ConnectionStateChangedEvent sub-device connect/disconnect
- Function 1: MessageEvent wrapped sub-device HID++ notification
ConnectionStateChangedEvent payload (same format as getConnectionInfo):
n.data[0]: high nibble = connection type, low nibble = len_hi
n.data[1]: len_lo
n.data[2+]: sub-device descriptors (if any)
Empty sub-device list (length=0) means disconnected.
MessageEvent data layout:
n.data[0:2] = dev_id<<4|len_hi, len_lo
n.data[2] = sub_cpl (0x00 for both responses and notifications)
n.data[3] = sub_feat_idx
n.data[4] = sub_func_sw (sw_id=0 for unsolicited notifications)
n.data[5:] = payload
"""
child = self.receiver._devices.get(1)
if not child:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("CenturionReceiver: notification ignored (no child device): %s", n)
return
bridge_idx = getattr(child, "_centurion_bridge_index", None)
if bridge_idx is None or n.sub_id != bridge_idx:
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"CenturionReceiver: non-bridge notification sub_id=%d addr=0x%02X data=%s",
n.sub_id,
n.address,
n.data[:12].hex() if n.data else "",
)
return
event_func = (n.address >> 4) & 0x0F
if event_func == 0:
# ConnectionStateChangedEvent — parse sub-device list length
if len(n.data) < 2:
return
data_len = ((n.data[0] & 0x0F) << 8) | n.data[1]
if data_len > 0 and not child.online:
if logger.isEnabledFor(logging.INFO):
logger.info("CenturionReceiver: headset connected (ConnectionStateChangedEvent, len=%d)", data_len)
child.changed(active=True)
self._status_changed(child)
elif data_len == 0 and child.online:
if logger.isEnabledFor(logging.INFO):
logger.info("CenturionReceiver: headset disconnected (ConnectionStateChangedEvent, len=0)")
child.changed(active=False)
self._status_changed(child)
return
if event_func == 1:
# MessageEvent — unwrap sub-device notification
# n.data layout: [dev_id<<4|len_hi, len_lo, sub_cpl, sub_feat_idx, sub_func_sw, payload...]
if len(n.data) < 5:
return
# A MessageEvent from the headset proves it's online. If we missed the
# ConnectionStateChangedEvent (e.g. cold-start power-on), bring it online now.
if not child.online:
if logger.isEnabledFor(logging.INFO):
logger.info("CenturionReceiver: headset online (MessageEvent received while offline)")
child.changed(active=True)
self._status_changed(child)
sub_feat_idx = n.data[3]
sub_func_sw = n.data[4]
payload = n.data[5:]
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"CenturionReceiver: bridge MessageEvent sub_feat=%d func=0x%02X payload=%s -> child %s",
sub_feat_idx,
sub_func_sw,
payload[:8].hex() if payload else "",
child,
)
# Create synthetic notification and dispatch directly to feature processing.
# Sub-device features use 0x100 offset in FeaturesArray.inverse.
synthetic = base.HIDPPNotification(n.report_id, child.number, sub_feat_idx + 0x100, sub_func_sw, payload)
child.online = True
if child.features:
notifications._process_feature_notification(child, synthetic)
return
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"CenturionReceiver: unhandled bridge event func=%d addr=0x%02X data=%s",
event_func,
n.address,
n.data[:12].hex() if n.data else "",
)
def __str__(self):
return f"<SolaarListener({self.receiver.path},{self.receiver.handle})>"
@ -255,18 +362,38 @@ def _cleanup_bluez_dbus(device: Device):
_all_listeners = {} # all known receiver listeners, listeners that stop on their own may remain here
def _post_attach_device(device):
"""Shared post-create_device wiring: hook configuration up to the new
Device, and if it's BT-paired, install the bluez-dbus connect watcher
so disconnect/reconnect events propagate to the UI without a restart.
Both the Centurion-direct fallback (no-dongle device, e.g. BT-paired
G522 / PRO X 2) and the regular non-Centurion device path call this
previously only the latter wired the bluez watcher, so a BT-paired
Centurion device wouldn't see reconnect events."""
configuration.attach_to(device)
if device.bluetooth and device.hid_serial:
dbus.watch_bluez_connect(device.hid_serial, partial(_process_bluez_dbus, device))
device.cleanups.append(_cleanup_bluez_dbus)
def _start(device_info: DeviceInfo):
assert _status_callback and _setting_callback
if not device_info.isDevice:
receiver_ = logitech_receiver.receiver.create_receiver(base, device_info, _setting_callback)
elif getattr(device_info, "centurion", False):
receiver_ = logitech_receiver.device.create_centurion_receiver(base, device_info, _setting_callback)
if receiver_ is None:
# No bridge found — treat as a direct-connected centurion device
# (wired headset, or BT-paired headset with no LIGHTSPEED dongle).
receiver_ = logitech_receiver.device.create_device(base, device_info, _setting_callback)
if receiver_:
_post_attach_device(receiver_)
else:
receiver_ = logitech_receiver.device.create_device(base, device_info, _setting_callback)
if receiver_:
configuration.attach_to(receiver_)
if receiver_.bluetooth and receiver_.hid_serial:
dbus.watch_bluez_connect(receiver_.hid_serial, partial(_process_bluez_dbus, receiver_))
receiver_.cleanups.append(_cleanup_bluez_dbus)
_post_attach_device(receiver_)
if receiver_:
rl = SolaarListener(receiver_, _status_callback)
@ -314,10 +441,13 @@ def ping_all(resuming=False):
for dev in listener_thread.receiver:
if resuming:
dev._active = None # ensure that settings are pushed
if dev.ping():
dev.changed(active=True, push=True)
listener_thread._status_changed(dev)
count -= 1
try: # sometimes the device is not set up already, it should come back later
if dev.ping():
dev.changed(active=True, push=True)
listener_thread._status_changed(dev)
except exceptions.NoSuchDevice:
logger.debug("can't ping device on resume: %s", dev)
if not count:
break

View File

@ -122,7 +122,9 @@ def run_loop(
def _status_changed(device, alert, reason, refresh=False):
assert device is not None
if device is None:
logger.debug("status changed on nil device: %s (%s) %s", device, alert, reason)
return
logger.debug("status changed: %s (%s) %s", device, alert, reason)
if alert is None:
alert = Alert.NONE

View File

@ -56,11 +56,12 @@ class AboutModel:
"El Jinete Sin Cabeza (Español)",
"Ferdina Kusumah (Indonesia)",
"John Erling Blad (Norwegian Bokmål, Norwegian Nynorsk)",
"Oleksandr Afanasiev (Ukrainian)",
]
def get_credit_sections(self) -> List[Tuple[str, List[str]]]:
return [
(_("Additional Programming"), ["Filipe Laíns", "Peter F. Patel-Schneider"]),
(_("Additional Programming"), ["Filipe Laíns", "Peter F. Patel-Schneider", "Ken Sanislo"]),
(_("GUI design"), ["Julien Gascard", "Daniel Pavel"]),
(
_("Testing"),

View File

@ -98,6 +98,10 @@ def unpair(window, device):
device_number = device.number
try:
del receiver[device_number]
# force=True ensures the unpair register write is issued even on
# re_pairs receivers (Lightspeed, Nano); otherwise _unpair_device
# short-circuits to cache-invalidation only and the slot stays
# bound on the hardware.
receiver._unpair_device(device_number, True)
except Exception:
common.error_dialog(common.ErrorReason.UNPAIR, device)

View File

@ -24,6 +24,7 @@ import gi
from logitech_receiver import hidpp20
from logitech_receiver import settings
from logitech_receiver import settings_templates
from solaar.i18n import _
from solaar.i18n import ngettext
@ -56,7 +57,8 @@ def _read_async(setting, force_read, sbox, device_is_online, sensitive):
except Exception as e:
v = None
logger.warning("%s: error reading so use None (%s): %s", s.name, s._device, repr(e))
GLib.idle_add(_update_setting_item, sb, v, online, sensitive, True, priority=99)
null_okay = not getattr(getattr(s, "_validator", None), "readable", True)
GLib.idle_add(_update_setting_item, sb, v, online, sensitive, null_okay, priority=99)
ui_async(_do_read, setting, force_read, sbox, device_is_online, sensitive)
@ -144,6 +146,13 @@ class SliderControl(Gtk.Scale, Control):
self.set_round_digits(0)
self.set_digits(0)
self.set_increments(1, 5)
# Halving tick marks are an intensity-slider feature only.
if self.sbox.setting.name == "brightness_control":
validator = getattr(self.sbox.setting, "_validator", None)
steps = getattr(validator, "steps", 0) if validator is not None else 0
if steps:
for mark in settings_templates.halving_marks(validator.max_value, steps):
self.add_mark(mark, Gtk.PositionType.BOTTOM, None)
self.connect(GtkSignal.VALUE_CHANGED.value, self.changed)
def set_value(self, value):
@ -545,7 +554,94 @@ class PackedRangeControl(MultipleRangeControl):
self._button.set_tooltip_text(b)
class GraphicEQControl(MultipleControl):
def setup(self, setting):
self._items = []
validator = setting._validator
row = Gtk.ListBoxRow()
hbox = Gtk.HBox(homogeneous=True, spacing=8)
for item in range(validator.count):
vbox = Gtk.VBox(homogeneous=False, spacing=2)
scale = Gtk.Scale.new_with_range(Gtk.Orientation.VERTICAL, validator.min_value, validator.max_value, 1)
scale.set_inverted(True)
scale.set_round_digits(0)
scale.set_digits(0)
scale.set_draw_value(True)
scale.connect("format-value", lambda s, v: f"{int(v)} dB")
scale.set_has_origin(True)
scale.set_size_request(-1, 150)
scale.add_mark(0, Gtk.PositionType.LEFT, "0")
scale.connect(GtkSignal.VALUE_CHANGED.value, self._changed, validator.keys[item])
lbl = Gtk.Label(label=str(validator.keys[item]))
lbl.set_line_wrap(True)
lbl.set_justify(Gtk.Justification.CENTER)
vbox.pack_start(scale, True, True, 0)
vbox.pack_end(lbl, False, False, 0)
vbox._setting_item = validator.keys[item]
vbox.control = scale
hbox.pack_start(vbox, True, True, 0)
self._items.append(vbox)
row.add(hbox)
self.add(row)
def _changed(self, control, item):
if control.get_sensitive():
if hasattr(control, "_timer"):
control._timer.cancel()
control._timer = Timer(0.5, lambda: GLib.idle_add(self._write, control, item))
control._timer.start()
def _write(self, control, item):
control._timer.cancel()
delattr(control, "_timer")
new_state = int(control.get_value())
value = self.sbox.setting._value
if not isinstance(value, dict):
return
if value.get(int(item)) != new_state:
value[int(item)] = new_state
_write_async(self.sbox.setting, value[int(item)], self.sbox, key=int(item))
def set_value(self, value):
if value is None:
return
b = ""
n = len(self._items)
stored = self.sbox.setting._value if isinstance(self.sbox.setting._value, dict) else {}
for vbox in self._items:
item = vbox._setting_item
v = value.get(int(item))
if v is not None:
vbox.control.set_value(v)
else:
v = stored.get(int(item), 0)
b += f"{str(item)}: ({str(v)}) "
lbl_text = ngettext("%d value", "%d values", n) % n
self._button.set_label(lbl_text)
self._button.set_tooltip_text(b)
# control with an ID key that determines what else to show
class _HeteroToggleSwitch(Gtk.Switch):
"""Gtk.Switch with int-valued get/set_value for HeteroKeyControl.
Maps switch True/False to the field's wire on/off integer values so the
surrounding control machinery (changed handler, get_value, set_value) can
stay int-based like every other field kind.
"""
def __init__(self, on_value: int = 1, off_value: int = 2, **kwargs):
super().__init__(**kwargs)
self._on = int(on_value)
self._off = int(off_value)
def get_value(self) -> int:
return self._on if self.get_state() else self._off
def set_value(self, value) -> None:
self.set_state(int(value) == self._on)
class HeteroKeyControl(Gtk.HBox, Control):
def __init__(self, sbox, delegate=None):
super().__init__(homogeneous=False, spacing=6)
@ -566,6 +662,17 @@ class HeteroKeyControl(Gtk.HBox, Control):
item_box.set_active(0)
item_box.connect(GtkSignal.CHANGED.value, self.changed)
self.pack_start(item_box, False, False, 0)
elif item["kind"] == settings.Kind.TOGGLE:
# Right-align like standard TOGGLE settings — pack_end so the
# switch hugs the right edge while other fields stay left.
item_box = _HeteroToggleSwitch(
on_value=item.get("on_value", 1),
off_value=item.get("off_value", 2),
halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER,
)
item_box.connect(GtkSignal.NOTIFY_ACTIVE.value, self.changed)
self.pack_end(item_box, False, False, 0)
elif item["kind"] == settings.Kind.COLOR:
item_box = Gtk.ColorButton()
item_box.connect(GtkSignal.COLOR_SET.value, self.changed)
@ -576,6 +683,15 @@ class HeteroKeyControl(Gtk.HBox, Control):
item_box.set_round_digits(0)
item_box.set_digits(0)
item_box.set_increments(1, 5)
# Halving tick marks are an intensity-slider feature only.
if item.get("halving") and str(item.get("name")) == str(hidpp20.LEDParam.intensity):
steps = getattr(sbox.setting._device, "_brightness_steps", 0) or settings_templates.auto_step_count(
item["max"]
)
for mark in settings_templates.halving_marks(item["max"], steps):
item_box.add_mark(mark, Gtk.PositionType.BOTTOM, None)
if item.get("display_seconds", False):
item_box.connect("format-value", lambda _s, v: f"{int(v) / 1000:.2f}s")
item_box.connect(GtkSignal.VALUE_CHANGED.value, self.changed)
self.pack_start(item_box, True, True, 0)
item_box.set_visible(False)
@ -592,11 +708,14 @@ class HeteroKeyControl(Gtk.HBox, Control):
result[str(k)] = (r << 16) | (g << 8) | b
else:
result[str(k)] = box.get_value()
result = hidpp20.LEDEffectSetting(**result)
data_class = getattr(self.sbox.setting._validator, "data_class", hidpp20.LEDEffectSetting)
result = data_class(**result)
return result
def set_value(self, value):
self.set_sensitive(False)
id_ = value.ID if value is not None else 0
self._apply_id_ranges(id_)
if value is not None:
for k, v in value.__dict__.items():
if k in self._items:
@ -608,9 +727,7 @@ class HeteroKeyControl(Gtk.HBox, Control):
box.set_rgba(rgba)
else:
box.set_value(v)
else:
self.sbox._failed.set_visible(True)
self.setup_visibles(value.ID if value is not None else 0)
self.setup_visibles(id_)
def setup_visibles(self, id_):
fields = self.sbox.setting.fields_map[id_][1] if id_ in self.sbox.setting.fields_map else {}
@ -620,15 +737,61 @@ class HeteroKeyControl(Gtk.HBox, Control):
lblbox.set_visible(visible)
box.set_visible(visible)
def changed(self, control):
def changed(self, control, *_args):
# *_args swallows the extra GParamSpec passed by Gtk.Switch's
# "notify::active" signal — other field signals pass just (widget,).
if self.get_sensitive() and control.get_sensitive():
if "ID" in self._items and control == self._items["ID"][1]:
self.setup_visibles(int(self._items["ID"][1].get_value()))
new_id = int(self._items["ID"][1].get_value())
self.setup_visibles(new_id)
self._apply_id_ranges(new_id)
self._apply_id_defaults(new_id)
if hasattr(control, "_timer"):
control._timer.cancel()
control._timer = Timer(0.3, lambda: GLib.idle_add(self._write, control))
control._timer.start()
def _apply_id_ranges(self, id_):
"""Reset every RANGE widget to its field's global min/max, then apply
per-effect overrides from fields_map[id_][3]. Reset-first ensures
switching from an override (e.g. Ripple 2-200) to an effect without
one restores the global range instead of inheriting the narrow one."""
fields_map = getattr(self.sbox.setting, "fields_map", None)
entry = fields_map.get(id_) if fields_map else None
ranges = entry[3] if entry and len(entry) > 3 else {}
for field in self.sbox.setting.possible_fields:
if field.get("kind") != settings.Kind.RANGE:
continue
name = str(field["name"])
if name not in self._items:
continue
_, box = self._items[name]
lo, hi = ranges.get(field["name"], (field.get("min", 0), field.get("max", 0)))
box.set_range(lo, hi)
def _apply_id_defaults(self, id_):
"""Apply fields_map[id_][2] defaults to RANGE widgets sitting at min."""
fields_map = getattr(self.sbox.setting, "fields_map", None)
if not fields_map or id_ not in fields_map:
return
entry = fields_map[id_]
if len(entry) < 3:
return
defaults = entry[2]
ranges = entry[3] if len(entry) > 3 else {}
field_by_name = {str(f["name"]): f for f in self.sbox.setting.possible_fields}
for param_name, default_value in defaults.items():
name = str(param_name)
if name not in self._items:
continue
field = field_by_name.get(name)
if field is None or field.get("kind") != settings.Kind.RANGE:
continue
_, box = self._items[name]
effective_min = ranges[param_name][0] if param_name in ranges else field.get("min", 0)
if box.get_value() == effective_min:
box.set_value(default_value)
def _write(self, control):
control._timer.cancel()
delattr(control, "_timer")
@ -648,6 +811,144 @@ _icons_allowables = {v: k for k, v in _allowables_icons.items()}
# clicking on the lock icon changes from changeable to unchangeable to ignore
# Settings whose operation depends on LED Control being set to Solaar.
# Zone settings (rgb_zone_*) are matched by prefix because their name carries
# the zone index (rgb_zone_1, rgb_zone_2, ...).
_SW_CONTROL_DEPENDENT_NAMES = ("rgb_idle_timeout", "rgb_idle_effect", "rgb_sleep_timeout")
_SW_CONTROL_DEPENDENT_PREFIXES = ("rgb_zone_",)
# headset_led_control = whether Solaar holds the live-coloring claim (off lets
# another app drive the LEDs). The 0x0620 per-zone painting and the 0x0621
# onboard effect are both live LED control, so both need the claim; per-zone
# additionally needs the onboard effect on Static (the per-key analog of
# needs-rgb_control + zone-Static). The 0x0622 signature effects are stored
# settings (startup/shutdown colors) and stay ungated.
_HEADSET_LED_DEPENDENT_NAMES = ("headset_per_zone_lighting", "headset-onboard-effect")
def _sw_control_blocked(device):
"""True when LED Control is not Solaar. Reads from setting._value first,
then the persister, so the gate is right at panel load before live reads
have populated. Accepts either the current bool (BooleanValidator) or the
legacy int 3/0 (older ChoicesValidator persister entries)."""
persister = getattr(device, "persister", None)
if persister is None:
return False
value = None
for s in getattr(device, "settings", []) or []:
if s.name == "rgb_control":
value = s._value
break
if value is None:
value = persister.get("rgb_control")
if value is None:
return False
# Solaar = bool True or legacy int 3; everything else (False, 0, …) blocks.
return value not in (True, 3)
def _headset_led_blocked(device):
"""True when the headset's LED Control is off (Device/firmware mode).
Reads setting._value first, then the persister; accepts the current bool
or a legacy int 0/1 from the old ChoicesValidator persister entries."""
persister = getattr(device, "persister", None)
if persister is None:
return False
value = None
for s in getattr(device, "settings", []) or []:
if s.name == "headset_led_control":
value = s._value
break
if value is None:
value = persister.get("headset_led_control")
if value is None:
return False
return value not in (True, 1)
def _cluster_effect_blocks_perzone(device):
"""True when the headset's 0x0621 onboard effect is not Fixed — a
non-Fixed cluster animation masks the per-zone buffer. Mirrors
`_zone_effect_blocks_perkey`. False when the device has no
onboard-effect setting (nothing to mask against)."""
persister = getattr(device, "persister", None)
value = None
for s in getattr(device, "settings", []) or []:
if s.name == "headset-onboard-effect":
value = s._value if s._value is not None else (persister.get(s.name) if persister else None)
break
else:
return False
if value is None:
return False
return int(getattr(value, "ID", 0)) != 0
def _zone_effect_blocks_perkey(device):
"""True when any zone effect's saved ID is not Static (0x01) — zone
animations mask the per-key buffer regardless of SW control state."""
persister = getattr(device, "persister", None)
if persister is None:
return False
for s in getattr(device, "settings", []) or []:
if not s.name.startswith("rgb_zone_"):
continue
v = s._value if s._value is not None else persister.get(s.name)
if v is None:
continue
if int(getattr(v, "ID", 0) or 0) != 0x01:
return True
return False
def _set_row_sensitive(device, name, can_function):
"""Apply sensitivity to a single setting's control row. Combines the
user's lock-icon opt-in (persister sensitivity) with the can-function
gate so neither alone can override the other."""
device_id = (device.receiver.path if device.receiver else device.path, device.number)
sbox = _items.get((device_id[0], device_id[1], name))
if sbox is None or not hasattr(sbox, "_control"):
return
persister = getattr(device, "persister", None)
user_allowed = persister.get_sensitivity(name) if persister else True
sbox._control.set_sensitive(user_allowed is True and can_function)
def _gate_blocks(device, name):
"""Single source of truth for "is this setting's row gated off?". Used by
`_apply_rgb_gates` to grey rows and by `_update_setting_item` so async-read
completions can't undo the grey-out when their callbacks land later."""
if name in _SW_CONTROL_DEPENDENT_NAMES or any(name.startswith(p) for p in _SW_CONTROL_DEPENDENT_PREFIXES):
return _sw_control_blocked(device)
if name == "per-key-lighting":
return _sw_control_blocked(device) or _zone_effect_blocks_perkey(device)
if name in _HEADSET_LED_DEPENDENT_NAMES:
if _headset_led_blocked(device):
return True
# Per-zone painting additionally needs the onboard effect on Static.
return name == "headset_per_zone_lighting" and _cluster_effect_blocks_perzone(device)
return False
def _apply_rgb_gates(device):
"""Grey out RGB settings whose prerequisites aren't met. Visual-only:
leaves persister _sensitive flags (user lock-icon opt-ins) intact.
- rgb_zone_* and rgb_idle_*/rgb_sleep_timeout need LED Control = Solaar
(rgb_control == 3).
- per-key-lighting needs LED Control = Solaar AND every zone effect on
Static (0x01), because non-Static zone animations mask per-key writes.
"""
for s in getattr(device, "settings", []) or []:
name = s.name
if (
name in _SW_CONTROL_DEPENDENT_NAMES
or any(name.startswith(p) for p in _SW_CONTROL_DEPENDENT_PREFIXES)
or name == "per-key-lighting"
or name in _HEADSET_LED_DEPENDENT_NAMES
):
_set_row_sensitive(device, name, not _gate_blocks(device, name))
def _change_click(button, sbox):
icon = button.get_children()[0]
icon_name, _ = icon.get_icon_name()
@ -665,6 +966,37 @@ def _change_click(button, sbox):
_write_async(setting, persisted, sbox)
else:
_read_async(setting, True, sbox, bool(sbox.setting._device.online), sbox._control.get_sensitive())
elif new_allowed == settings.SENSITIVITY_IGNORE and sbox.setting.name == "per-key-lighting":
# User just opted out of per-key lighting. The firmware effect engine
# is currently in its OOR "direct mode" slot showing the per-key buffer
# (entered when the prep sequence wrote effectIdx=numEffects via
# SetEffectByIndex on 0x8071). Writing a regular in-range effectIdx
# with persist=1 displaces that slot and the saved zone effect becomes
# the visible layer again. See LOGITECH_HIDPP2_PROTOCOL.md
# "Per-key prep sequence" and 0x8071 SetEffectByIndex persist=1
# requirement.
device = sbox.setting._device
for s in device.settings:
if s.name.startswith("rgb_zone_") and s._value is not None:
_write_async(s, s._value, None)
break # one zone-effect write is enough to flip the engine
if sbox.setting.name.startswith("rgb_zone_"):
# Toggling zone-effect sensitivity changes the effective base color
# for per-key unset cells (zone color ↔ black). When per-key is
# opted-in, repaint it so the unset cells pick up the new base.
from logitech_receiver import rgb_power
device = sbox.setting._device
perkey, has_paint = rgb_power.perkey_has_paint(device)
if has_paint:
_write_async(perkey, perkey._value, None)
# The lock icon on rgb_control, any zone, per-key, or headset_led_control
# can change whether a dependent row is functional — re-evaluate the gate.
name = sbox.setting.name
if name in ("rgb_control", "per-key-lighting", "headset_led_control", "headset-onboard-effect") or name.startswith(
"rgb_zone_"
):
_apply_rgb_gates(sbox.setting._device)
return True
@ -701,7 +1033,24 @@ def _create_sbox(s, _device):
change.set_sensitive(True)
change.connect(GtkSignal.CLICKED.value, _change_click, sbox)
if s.kind == settings.Kind.TOGGLE:
editor_path = getattr(s, "editor_class", None)
if editor_path:
try:
mod_name, _sep, cls_name = editor_path.partition(":")
import importlib
mod = importlib.import_module(mod_name)
cls = getattr(mod, cls_name)
control = cls(sbox)
except Exception as e:
logger.warning("setting %s editor_class %r failed (%s); falling back to default", s.name, editor_path, repr(e))
control = None
else:
control = None
if control is not None:
pass
elif s.kind == settings.Kind.TOGGLE:
control = ToggleControl(sbox)
elif s.kind == settings.Kind.RANGE:
control = SliderControl(sbox)
@ -715,6 +1064,8 @@ def _create_sbox(s, _device):
control = MultipleRangeControl(sbox, change)
elif s.kind == settings.Kind.PACKED_RANGE:
control = PackedRangeControl(sbox, change)
elif s.kind == settings.Kind.GRAPHIC_EQ:
control = GraphicEQControl(sbox, change)
elif s.kind == settings.Kind.HETERO:
control = HeteroKeyControl(sbox, change)
else:
@ -733,8 +1084,10 @@ def _create_sbox(s, _device):
def _update_setting_item(sbox, value, is_online=True, sensitive=True, null_okay=False):
sbox._spinner.stop()
sensitive = sbox._change_icon._allowed if sensitive is None else sensitive
name = sbox.setting.name
can_function = not _gate_blocks(sbox.setting._device, name)
if value is None and not null_okay:
sbox._control.set_sensitive(sensitive is True)
sbox._control.set_sensitive(sensitive is True and can_function)
_change_icon(sensitive, sbox._change_icon)
sbox._failed.set_visible(is_online)
return
@ -744,8 +1097,12 @@ def _update_setting_item(sbox, value, is_online=True, sensitive=True, null_okay=
sbox._control.set_value(value)
except TypeError as e:
logger.warning("%s: error setting control value (%s): %s", sbox.setting.name, sbox.setting._device, repr(e))
sbox._control.set_sensitive(sensitive is True)
sbox._control.set_sensitive(sensitive is True and can_function)
_change_icon(sensitive, sbox._change_icon)
# rgb_control / rgb_zone_* gate per-key; headset_led_control and the
# headset-onboard-effect gate the per-zone row — re-evaluate on a change.
if name in ("rgb_control", "headset_led_control", "headset-onboard-effect") or name.startswith("rgb_zone_"):
_apply_rgb_gates(sbox.setting._device)
def _disable_listbox_highlight_bg(lb):
@ -805,6 +1162,7 @@ def update(device, is_online=None):
sensitive = device.persister.get_sensitivity(s.name) if device.persister else True
_read_async(s, False, sbox, is_online, sensitive)
_apply_rgb_gates(device)
_box.set_visible(True)

View File

@ -42,6 +42,7 @@ from logitech_receiver.common import UnsortedNamedInts
from logitech_receiver.settings import Kind
from logitech_receiver.settings import Setting
from logitech_receiver.settings_templates import SETTINGS
from logitech_receiver.settings_validator import Range
from solaar.i18n import _
from solaar.ui import rule_actions
@ -1675,6 +1676,15 @@ class _SettingWithValueUI:
if kind in (Kind.TOGGLE, Kind.MULTIPLE_TOGGLE):
self.value_field.make_toggle()
elif kind in (Kind.CHOICE, Kind.MAP_CHOICE):
# Open-value-space MAP_CHOICE settings (per-key RGB) have a Range
# rather than a NamedInts value list — there's no meaningful value
# picker to render in the rule editor, so fall through to unsupported.
if kind == Kind.MAP_CHOICE and device_setting:
val = device_setting._validator
choices = getattr(val, "choices", None)
if isinstance(choices, dict) and any(isinstance(v, Range) for v in choices.values()):
self.value_field.make_unsupported()
return
all_values, extra = self._all_choices(device_setting or setting_name)
self.value_field.make_choice(all_values, extra)
supported_values = None

View File

@ -29,16 +29,16 @@ TRAY_OKAY = "solaar"
TRAY_ATTENTION = "solaar-attention"
_default_theme = None
_has_level_icons = False
_has_padded_level_icons = False
def _init_icon_paths():
global _default_theme
global _default_theme, _has_level_icons, _has_padded_level_icons
if _default_theme:
return
_default_theme = Gtk.IconTheme.get_default()
logger.debug("icon theme paths: %s", _default_theme.get_search_path())
if gtk.battery_icons_style == "symbolic":
global TRAY_OKAY
TRAY_OKAY = TRAY_INIT # use monochrome tray icon
@ -49,6 +49,10 @@ def _init_icon_paths():
if not _default_theme.has_icon("battery-good"):
logger.warning("failed to detect icons")
gtk.battery_icons_style = "solaar"
suffix = "-symbolic" if gtk.battery_icons_style == "symbolic" else ""
_has_level_icons = _default_theme.has_icon(f"battery-level-50{suffix}")
_has_padded_level_icons = not _has_level_icons and _default_theme.has_icon(f"battery-050{suffix}")
logger.debug("battery level icons available: %s (padded scheme: %s)", _has_level_icons, _has_padded_level_icons)
def battery(level=None, charging=False):
@ -68,16 +72,45 @@ def _first_res(val, pairs):
def _battery_icon_name(level, charging):
_init_icon_paths()
suffix = "-symbolic" if gtk.battery_icons_style == "symbolic" else ""
if level is None or level < 0:
return "battery-missing" + ("-symbolic" if gtk.battery_icons_style == "symbolic" else "")
return f"battery-missing{suffix}"
level_name = _first_res(level, ((90, "full"), (30, "good"), (20, "low"), (5, "caution"), (0, "empty")))
return "battery-%s%s%s" % (
level_name,
"-charging" if charging else "",
"-symbolic" if gtk.battery_icons_style == "symbolic" else "",
)
rounded = min(100, max(0, round(level / 10) * 10))
# Try precise level icons (battery-level-N or battery-0N0 naming scheme)
if _has_level_icons or _has_padded_level_icons:
if charging and rounded == 100:
charging_str = "-charged"
elif charging:
charging_str = "-charging"
else:
charging_str = ""
if _has_level_icons:
icon_name = f"battery-level-{rounded}{charging_str}{suffix}"
else:
icon_name = f"battery-{rounded:03}{charging_str}{suffix}"
if _default_theme.has_icon(icon_name):
logger.debug("battery level icon for %s:%s = %s", level, charging, icon_name)
return icon_name
# Fall back to semantic names
level_name = _first_res(level, ((90, "full"), (60, "good"), (20, "low"), (5, "caution"), (0, "empty")))
if level_name:
if charging:
charging_str = "-charging"
else:
charging_str = ""
icon_name = f"battery-{level_name}{charging_str}{suffix}"
if _default_theme.has_icon(icon_name):
logger.debug("battery semantic icon for %s:%s = %s", level, charging, icon_name)
return icon_name
# Last resort: plain battery icon
icon_name = f"battery{suffix}"
logger.debug("battery generic icon for %s:%s = %s", level, charging, icon_name)
return icon_name
def lux(level=None):

View File

@ -0,0 +1,22 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
from .layout import Cell
from .layout import Layout
from .layouts import layout_for
from .layouts import register_layout
__all__ = ("Cell", "Layout", "layout_for", "register_layout")

View File

@ -0,0 +1,133 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Theme-aware loader for Solaar's per-key UI icons.
Loads SVG icons from ``share/solaar/icons/`` and recolors them at load
time to match the active GTK theme's text foreground, by substituting
``currentColor`` in the SVG before passing it to GdkPixbuf. GTK's stock
symbolic loader is bypassed because it only recolors specific palette
fill stand-ins and ignores ``stroke="currentColor"``.
"""
from __future__ import annotations
import logging
from pathlib import Path
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import GdkPixbuf # NOQA: E402
from gi.repository import Gio # NOQA: E402
from gi.repository import Gtk # NOQA: E402
logger = logging.getLogger(__name__)
ICON_PIXEL_SIZE = 22
_search_path_added = False
def ensure_icon_path() -> None:
"""Register share/solaar/icons with the default GtkIconTheme so our
custom symbolic tool icons resolve by name. Idempotent."""
global _search_path_added
if _search_path_added:
return
theme = Gtk.IconTheme.get_default()
existing = set(theme.get_search_path() or [])
# _icons.py: lib/solaar/ui/perkey/_icons.py -> parents[4] = repo root
candidates = [
Path(__file__).resolve().parents[4] / "share" / "solaar" / "icons",
]
for c in candidates:
if c.is_dir() and str(c) not in existing:
theme.append_search_path(str(c))
_search_path_added = True
def themed_icon_image(icon_name: str, style_widget: Gtk.Widget) -> Gtk.Image | None:
"""Load a Solaar tool icon and recolor it to match the given widget's
text foreground color, so the icons follow the active GTK theme
(light / dark / custom). Returns None if the icon can't be loaded.
"""
ensure_icon_path()
theme = Gtk.IconTheme.get_default()
icon_info = theme.lookup_icon(icon_name, ICON_PIXEL_SIZE, Gtk.IconLookupFlags.FORCE_SIZE)
if icon_info is None:
return None
path = icon_info.get_filename()
if not path:
return None
fg = style_widget.get_style_context().get_color(Gtk.StateFlags.NORMAL)
color = f"#{int(fg.red * 255):02x}{int(fg.green * 255):02x}{int(fg.blue * 255):02x}"
try:
with open(path, "r", encoding="utf-8") as f:
svg = f.read()
svg = svg.replace("currentColor", color)
stream = Gio.MemoryInputStream.new_from_data(svg.encode("utf-8"))
pixbuf = GdkPixbuf.Pixbuf.new_from_stream_at_scale(stream, ICON_PIXEL_SIZE, ICON_PIXEL_SIZE, True)
return Gtk.Image.new_from_pixbuf(pixbuf)
except Exception as e:
logger.debug("recolor failed for %s: %s", icon_name, e)
return None
def _fg_color_key(widget: Gtk.Widget) -> tuple[float, float, float]:
fg = widget.get_style_context().get_color(Gtk.StateFlags.NORMAL)
return (round(fg.red, 3), round(fg.green, 3), round(fg.blue, 3))
def attach_themed_icon(button: Gtk.Container, icon_name: str) -> int | None:
"""Add a themed icon to `button` and re-render it whenever the active
GTK theme changes the button's foreground color. Returns the
style-updated signal handler ID, or None if the icon couldn't be
loaded (in which case the button is left unchanged so the caller can
fall back to a text label).
Listening to the button's own ``style-updated`` signal — instead of
``Gtk.Settings notify::gtk-theme-name`` means we read the
foreground color *after* GTK has re-resolved CSS for the new theme.
Subscribing to the Settings notify fires too early; it returns the
stale (pre-switch) color and produces icons that all settle on the
previous theme's tone. We guard the rebuild with a per-button color
key so unrelated style updates (hover, focus, active) don't trigger
needless re-renders.
"""
image = themed_icon_image(icon_name, button)
if image is None:
return None
button.add(image)
image.show()
state = {"color_key": _fg_color_key(button)}
def _refresh(_widget) -> None:
new_key = _fg_color_key(button)
if new_key == state["color_key"]:
return
state["color_key"] = new_key
new_image = themed_icon_image(icon_name, button)
if new_image is None:
return
old = button.get_child()
if old is not None:
button.remove(old)
button.add(new_image)
new_image.show()
return button.connect("style-updated", _refresh)

View File

@ -0,0 +1,63 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Bind a Layout to a sink's reported zone list.
Cells whose `zone_id` the device reports are marked bound. Cells whose zone
the device does not report stay disabled (greyed). Device-reported zones not
covered by any cell get synthesized as strip cells using the sink's labels —
this catches G-keys, logo, media keys and any device-specific extras.
"""
from __future__ import annotations
from collections.abc import Callable
from .layout import BoundCell
from .layout import BoundLayout
from .layout import Cell
from .layout import Layout
def bind(layout: Layout, zones: list[int], label_for: Callable[[int], str]) -> BoundLayout:
reported = set(zones)
claimed: set[int] = set()
matrix: list[BoundCell] = []
strip: list[BoundCell] = []
for c in layout.matrix_cells():
bound = c.zone_id in reported
if bound:
claimed.add(c.zone_id)
matrix.append(BoundCell(cell=c, bound=bound))
for c in layout.strip_cells():
bound = c.zone_id in reported
if bound:
claimed.add(c.zone_id)
strip.append(BoundCell(cell=c, bound=bound))
unmapped_all = tuple(z for z in zones if z not in claimed)
# Filter unmapped zones through the layout's curated allowlist. Without
# this, firmware-reported phantoms (G515 reports 47, 97, 99-103, 254)
# would surface as paintable strip cells that don't address any LED.
if layout.extra_zones is None:
showable = unmapped_all
else:
showable = tuple(z for z in unmapped_all if z in layout.extra_zones)
next_col = max((bc.cell.col for bc in strip), default=-1) + 1
for z in showable:
synth = Cell(zone_id=z, row=0, col=next_col, group="strip", label=label_for(z))
strip.append(BoundCell(cell=synth, bound=True))
next_col += 1
return BoundLayout(matrix=tuple(matrix), strip=tuple(strip), unmapped=unmapped_all)

View File

@ -0,0 +1,452 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Cairo-rendered keyboard canvas. Renders a BoundLayout as colored rectangles
and dispatches paint events through a configurable Tool to the editor.
"""
from __future__ import annotations
import logging
from enum import Enum
from typing import Callable
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk # NOQA: E402
from gi.repository import GObject # NOQA: E402
from gi.repository import Gtk # NOQA: E402
from .layout import BoundCell # NOQA: E402
from .layout import BoundLayout # NOQA: E402
from .layout import Cell # NOQA: E402
from .tools import TOOLS # NOQA: E402
from .tools import ToolContext # NOQA: E402
logger = logging.getLogger(__name__)
class GtkSignal(Enum):
DRAW = "draw"
BUTTON_PRESS_EVENT = "button-press-event"
BUTTON_RELEASE_EVENT = "button-release-event"
MOTION_NOTIFY_EVENT = "motion-notify-event"
LEAVE_NOTIFY_EVENT = "leave-notify-event"
CELL_PX = 36
GUTTER_PX = 4
STRIP_GAP_PX = 16
PADDING_PX = 8
class KeyboardCanvas(Gtk.DrawingArea):
__gsignals__ = {
# Emitted on stroke release. delta is dict[zone_id, color].
"paint": (GObject.SignalFlags.RUN_FIRST, None, (object,)),
}
def __init__(self) -> None:
super().__init__()
self._bound: BoundLayout | None = None
self._colors: dict[int, int] = {} # zone_id -> packed RGB or -1 (unset)
self._active_color: int = 0xFF0000
self._gradient_colors_source: Callable[[], tuple[int, int]] | None = None
self._zone_base_color: int | None = None
self._tool_name: str = "single"
self._press_cell: BoundCell | None = None
self._motion_cell: BoundCell | None = None
self._brush_path: list[int] = []
self._dragging: bool = False
self.set_can_focus(True)
self.add_events(
Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.BUTTON_RELEASE_MASK
| Gdk.EventMask.POINTER_MOTION_MASK
| Gdk.EventMask.LEAVE_NOTIFY_MASK
)
self.connect(GtkSignal.DRAW.value, self._on_draw)
self.connect(GtkSignal.BUTTON_PRESS_EVENT.value, self._on_press)
self.connect(GtkSignal.BUTTON_RELEASE_EVENT.value, self._on_release)
self.connect(GtkSignal.MOTION_NOTIFY_EVENT.value, self._on_motion)
self.connect(GtkSignal.LEAVE_NOTIFY_EVENT.value, self._on_leave)
# ---- public API ----
def set_layout(self, bound: BoundLayout) -> None:
self._bound = bound
self._update_size()
self.queue_draw()
def set_colors(self, colors: dict[int, int]) -> None:
self._colors = dict(colors)
self.queue_draw()
def update_colors(self, deltas: dict[int, int]) -> None:
self._colors.update(deltas)
self.queue_draw()
def set_active_color(self, color: int) -> None:
self._active_color = int(color)
def set_gradient_colors_source(self, source: Callable[[], tuple[int, int]] | None) -> None:
self._gradient_colors_source = source
def set_zone_base_color(self, color: int | None) -> None:
self._zone_base_color = None if color is None else int(color)
self.queue_draw()
def set_tool(self, name: str) -> None:
if name in TOOLS:
self._tool_name = name
# ---- size / hit-test ----
def _matrix_size(self) -> tuple[int, int]:
if not self._bound:
return 0, 0
max_col = 0
max_row = 0
for bc in self._bound.matrix:
c = bc.cell
max_col = max(max_col, c.col + int(round(c.width)))
max_row = max(max_row, c.row + int(round(c.height)))
return max_row, max_col
def _strip_size(self) -> int:
if not self._bound:
return 0
return len(self._bound.strip)
def _update_size(self) -> None:
rows, cols = self._matrix_size()
strip_n = self._strip_size()
w = PADDING_PX * 2 + cols * CELL_PX + max(0, cols - 1) * GUTTER_PX
matrix_h = rows * CELL_PX + max(0, rows - 1) * GUTTER_PX
strip_h = (CELL_PX + STRIP_GAP_PX) if strip_n else 0
h = PADDING_PX * 2 + matrix_h + strip_h
# widen if strip is wider than matrix
if strip_n:
sw = PADDING_PX * 2 + strip_n * CELL_PX + max(0, strip_n - 1) * GUTTER_PX
w = max(w, sw)
self.set_size_request(w, h)
def _cell_rect(self, bc: BoundCell) -> tuple[float, float, float, float]:
c = bc.cell
if self._bound is not None and bc in self._bound.strip:
# strip cells: laid out in a flat row beneath the matrix
rows, _cols = self._matrix_size()
matrix_h = rows * CELL_PX + max(0, rows - 1) * GUTTER_PX
strip_idx = self._bound.strip.index(bc)
x = PADDING_PX + strip_idx * (CELL_PX + GUTTER_PX)
y = PADDING_PX + matrix_h + STRIP_GAP_PX
return (x, y, CELL_PX, CELL_PX)
x = PADDING_PX + c.col * (CELL_PX + GUTTER_PX)
y = PADDING_PX + c.row * (CELL_PX + GUTTER_PX)
w = c.width * CELL_PX + max(0.0, c.width - 1.0) * GUTTER_PX
h = c.height * CELL_PX + max(0.0, c.height - 1.0) * GUTTER_PX
return (x, y, w, h)
def _cell_at(self, x: float, y: float) -> BoundCell | None:
if not self._bound:
return None
for bc in list(self._bound.matrix) + list(self._bound.strip):
cx, cy, cw, ch = self._cell_rect(bc)
if cx <= x < cx + cw and cy <= y < cy + ch:
return bc
# Phantom anchor for gaps in the matrix grid — gives rect/gradient
# drags a valid endpoint where no real cell exists.
rows, cols = self._matrix_size()
matrix_w = cols * CELL_PX + max(0, cols - 1) * GUTTER_PX
matrix_h = rows * CELL_PX + max(0, rows - 1) * GUTTER_PX
if PADDING_PX <= x < PADDING_PX + matrix_w and PADDING_PX <= y < PADDING_PX + matrix_h:
col = int((x - PADDING_PX) // (CELL_PX + GUTTER_PX))
row = int((y - PADDING_PX) // (CELL_PX + GUTTER_PX))
if 0 <= col < cols and 0 <= row < rows:
return BoundCell(cell=Cell(zone_id=-1, row=row, col=col), bound=False)
return None
# ---- draw ----
def _on_draw(self, _widget, cr) -> bool:
if not self._bound:
return False
for bc in self._bound.matrix:
self._draw_cell(cr, bc)
for bc in self._bound.strip:
self._draw_cell(cr, bc)
if self._dragging and self._press_cell and self._motion_cell:
tool = TOOLS.get(self._tool_name)
if tool is not None:
if tool.overlay_shape == "rect":
self._draw_rect_overlay(cr, self._press_cell, self._motion_cell)
elif tool.overlay_shape == "line":
self._draw_line_overlay(cr, self._press_cell, self._motion_cell)
return False
def _draw_cell(self, cr, bc: BoundCell) -> None:
x, y, w, h = self._cell_rect(bc)
color = self._colors.get(bc.cell.zone_id, -1)
# background
if not bc.bound:
cr.set_source_rgba(0.18, 0.18, 0.20, 1.0)
elif color is None or color < 0:
self._fill_checker(cr, x, y, w, h)
cr.set_source_rgba(0, 0, 0, 0) # no overlay fill
else:
r = ((color >> 16) & 0xFF) / 255.0
g = ((color >> 8) & 0xFF) / 255.0
b = (color & 0xFF) / 255.0
cr.set_source_rgba(r, g, b, 1.0)
if bc.bound and (color is not None and color >= 0):
self._round_rect(cr, x, y, w, h, 4)
cr.fill_preserve()
elif not bc.bound:
self._round_rect(cr, x, y, w, h, 4)
cr.fill_preserve()
else:
self._round_rect(cr, x, y, w, h, 4)
# border
cr.set_source_rgba(0, 0, 0, 0.55)
cr.set_line_width(1.0)
cr.stroke()
# label
label = bc.cell.label or str(bc.cell.zone_id)
cr.set_source_rgba(*self._label_color(color, bc.bound))
cr.select_font_face("Sans")
cr.set_font_size(11.0 if len(label) <= 3 else 9.0)
try:
extents = cr.text_extents(label)
tx = x + (w - extents.width) / 2 - extents.x_bearing
ty = y + (h + extents.height) / 2 - extents.y_bearing - extents.height
cr.move_to(tx, ty)
cr.show_text(label)
except Exception as e:
logger.debug("text rendering failed for %r: %s", label, e)
def _fill_checker(self, cr, x, y, w, h) -> None:
# Diagonal hash for "no change" cells. The background is the zone
# base color (what these cells actually display on the keyboard).
# Stripes alternate darker / lighter than base in equal measure so
# the cell's perceived average stays at the base color, instead of
# being uniformly biased toward black or white as a single-overlay
# stripe would.
cr.save()
self._round_rect(cr, x, y, w, h, 4)
cr.clip()
base = self._zone_base_color
if base is not None and base >= 0:
r = ((base >> 16) & 0xFF) / 255.0
g = ((base >> 8) & 0xFF) / 255.0
b = (base & 0xFF) / 255.0
cr.set_source_rgba(r, g, b, 1.0)
cr.rectangle(x, y, w, h)
cr.fill()
# Per-channel ±offset. When a channel is too close to 0 or 1 to
# fit the full offset, halve the offset on the constrained side
# (per spec: average drifts at the limits, but stays centered on
# base elsewhere).
offset = 0.22
def _shift(v: float) -> tuple[float, float]:
down_off = offset if (v - offset) >= 0.0 else offset / 2.0
up_off = offset if (v + offset) <= 1.0 else offset / 2.0
return max(0.0, v - down_off), min(1.0, v + up_off)
rd, ru = _shift(r)
gd, gu = _shift(g)
bd, bu = _shift(b)
# Interleave darker and lighter stripes (period = step per set,
# other-color set offset by step/2). Equal coverage of the two
# colors keeps the perceived average at base.
cr.set_line_width(1.5)
step = 6
half = step // 2
d_max = int(w + h)
cr.set_source_rgba(rd, gd, bd, 1.0)
d = -int(h)
while d <= d_max:
cr.move_to(x + d, y + h)
cr.line_to(x + d + h, y)
cr.stroke()
d += step
cr.set_source_rgba(ru, gu, bu, 1.0)
d = -int(h) + half
while d <= d_max:
cr.move_to(x + d, y + h)
cr.line_to(x + d + h, y)
cr.stroke()
d += step
else:
# No zone base color known — fall back to a neutral dark bg with
# medium-gray stripes; "average = base" doesn't apply since there
# is no expected color to preserve.
cr.set_source_rgba(0.30, 0.30, 0.32, 1.0)
cr.rectangle(x, y, w, h)
cr.fill()
cr.set_source_rgba(0.55, 0.55, 0.60, 1.0)
cr.set_line_width(1.5)
step = 5
d_max = int(w + h)
d = -int(h)
while d <= d_max:
cr.move_to(x + d, y + h)
cr.line_to(x + d + h, y)
cr.stroke()
d += step
cr.restore()
def _round_rect(self, cr, x, y, w, h, r) -> None:
cr.new_sub_path()
cr.arc(x + w - r, y + r, r, -1.5708, 0)
cr.arc(x + w - r, y + h - r, r, 0, 1.5708)
cr.arc(x + r, y + h - r, r, 1.5708, 3.1416)
cr.arc(x + r, y + r, r, 3.1416, 4.7124)
cr.close_path()
def _label_color(self, color: int, bound: bool) -> tuple[float, float, float, float]:
if not bound:
return (0.50, 0.50, 0.52, 1.0)
if color is None or color < 0:
return (0.85, 0.85, 0.88, 1.0)
# luminance heuristic
r = ((color >> 16) & 0xFF) / 255.0
g = ((color >> 8) & 0xFF) / 255.0
b = (color & 0xFF) / 255.0
lum = 0.299 * r + 0.587 * g + 0.114 * b
return (0, 0, 0, 1.0) if lum > 0.55 else (1, 1, 1, 1.0)
def _draw_rect_overlay(self, cr, a: BoundCell, b: BoundCell) -> None:
ax, ay, aw, ah = self._cell_rect(a)
bx, by, bw, bh = self._cell_rect(b)
x0 = min(ax, bx) - 2
y0 = min(ay, by) - 2
x1 = max(ax + aw, bx + bw) + 2
y1 = max(ay + ah, by + bh) + 2
cr.set_source_rgba(0.30, 0.65, 1.0, 0.85)
cr.set_line_width(1.5)
cr.set_dash([4.0, 3.0])
cr.rectangle(x0, y0, x1 - x0, y1 - y0)
cr.stroke()
cr.set_dash([])
def _draw_line_overlay(self, cr, a: BoundCell, b: BoundCell) -> None:
ax, ay, aw, ah = self._cell_rect(a)
bx, by, bw, bh = self._cell_rect(b)
ax_c, ay_c = ax + aw / 2, ay + ah / 2
bx_c, by_c = bx + bw / 2, by + bh / 2
cr.set_source_rgba(0.30, 0.65, 1.0, 0.95)
cr.set_line_width(2.0)
cr.set_dash([5.0, 3.0])
cr.move_to(ax_c, ay_c)
cr.line_to(bx_c, by_c)
cr.stroke()
cr.set_dash([])
# endpoint dots — solid so the anchors read clearly
for cx, cy in ((ax_c, ay_c), (bx_c, by_c)):
cr.arc(cx, cy, 4.0, 0, 6.283)
cr.fill()
# ---- input ----
def _on_press(self, _w, event: Gdk.EventButton) -> bool:
if event.button != 1:
return False
bc = self._cell_at(event.x, event.y)
if bc is None:
return False
tool = TOOLS.get(self._tool_name)
# Endpoint tools (rect/gradient) anchor on cell centers regardless of
# bind state; brush/bucket need a real key to paint/flood.
if not (tool and tool.overlay_shape) and not bc.bound:
return False
self._press_cell = bc
self._motion_cell = bc
self._dragging = True
self._brush_path = [bc.cell.zone_id]
if tool is not None and tool.is_brush:
self.update_colors({bc.cell.zone_id: self._active_color})
else:
self.queue_draw()
return True
def _on_motion(self, _w, event: Gdk.EventMotion) -> bool:
if not self._dragging:
return False
bc = self._cell_at(event.x, event.y)
if bc is None:
return False
tool = TOOLS.get(self._tool_name)
if not (tool and tool.overlay_shape) and not bc.bound:
return False
if bc is self._motion_cell:
return False
self._motion_cell = bc
if tool is not None and tool.is_brush:
if bc.cell.zone_id not in self._brush_path:
self._brush_path.append(bc.cell.zone_id)
self.update_colors({bc.cell.zone_id: self._active_color})
else:
self.queue_draw()
return True
def _on_release(self, _w, event: Gdk.EventButton) -> bool:
if event.button != 1 or not self._dragging:
return False
self._dragging = False
if self._press_cell is None:
return False
if self._bound:
bound_zones = {bc.cell.zone_id: bc for bc in list(self._bound.matrix) + list(self._bound.strip)}
strip_zones = frozenset(bc.cell.zone_id for bc in self._bound.strip)
else:
bound_zones = {}
strip_zones = frozenset()
if self._tool_name == "gradient" and self._gradient_colors_source is not None:
grad_active, grad_previous = self._gradient_colors_source()
ctx = ToolContext(
active_color=int(grad_active),
last_color=int(grad_previous),
cells_by_zone=bound_zones,
strip_zones=strip_zones,
current_colors=dict(self._colors),
)
else:
ctx = ToolContext(
active_color=self._active_color,
last_color=self._active_color,
cells_by_zone=bound_zones,
strip_zones=strip_zones,
current_colors=dict(self._colors),
)
tool = TOOLS.get(self._tool_name)
delta: dict[int, int] = {}
if tool is not None:
delta = tool.compute(self._press_cell, self._motion_cell, list(self._brush_path), ctx)
self._press_cell = None
self._motion_cell = None
self._brush_path = []
self.queue_draw()
if delta:
self.update_colors(delta)
self.emit("paint", delta)
return True
def _on_leave(self, _w, _event) -> bool:
# don't cancel drags on leave; let the user re-enter
return False

View File

@ -0,0 +1,264 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Inline placeholder control replacing MapChoiceControl for opted-in settings.
Renders a summary line + button. Click opens the per-key editor dialog,
backed by a SettingSink adapter that bridges the editor protocol to the
Solaar Setting object. The editor never touches the Setting directly.
"""
from __future__ import annotations
import logging
from enum import Enum
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # NOQA: E402
from solaar.i18n import _ # NOQA: E402
from . import dialog as dialog_mod # NOQA: E402
from .layouts import layout_for # NOQA: E402
logger = logging.getLogger(__name__)
class GtkSignal(Enum):
CLICKED = "clicked"
# Sentinel matching special_keys.COLORSPLUS["No change"].
NO_CHANGE = -1
class _SettingSink:
"""Bridge between a Solaar Setting and the editor's PerKeyColorSink protocol."""
def __init__(self, setting, sbox) -> None:
self._setting = setting
self._sbox = sbox
self._listeners: list = []
@property
def title(self) -> str:
device = getattr(self._setting, "_device", None)
name = getattr(device, "name", None) or getattr(device, "codename", None) or ""
return name or self._setting.label
@property
def zones(self) -> list[int]:
return [int(k) for k in self._setting.choices]
@property
def current(self) -> dict[int, int]:
return dict(self._setting._value or {})
def label(self, zone: int) -> str:
for k in self._setting.choices:
if int(k) == int(zone):
return str(k)
return f"KEY {zone}"
def write_one(self, zone: int, color: int) -> None:
if self._setting._value is None:
self._setting._value = {}
self._setting._value[int(zone)] = int(color)
# Lazy import to avoid a circular module-load between config_panel and perkey.
from solaar.ui.config_panel import _write_async
_write_async(self._setting, int(color), self._sbox, key=int(zone))
self._notify()
def write_bulk(self, deltas: dict[int, int]) -> None:
if not deltas:
return
if self._setting._value is None:
self._setting._value = {}
merged = dict(self._setting._value)
merged.update({int(k): int(v) for k, v in deltas.items()})
self._setting._value = merged
from solaar.ui.config_panel import _write_async
_write_async(self._setting, merged, self._sbox, key=None)
self._notify()
def subscribe(self, listener):
self._listeners.append(listener)
def unsubscribe() -> None:
# Idempotent: the editor calls this on shutdown, but the listener
# may already be gone if the sink itself was torn down first.
try:
self._listeners.remove(listener)
except ValueError:
pass
return unsubscribe
def _palette_key(self) -> str:
return f"_palette:{self._setting.name}"
def palette_state(self) -> tuple[int, int] | None:
device = getattr(self._setting, "_device", None)
persister = getattr(device, "persister", None)
if persister is None:
return None
entry = persister.get(self._palette_key())
if not isinstance(entry, dict):
return None
active = entry.get("active")
previous = entry.get("previous", active)
if not isinstance(active, int) or not isinstance(previous, int):
return None
return (int(active), int(previous))
def set_palette_state(self, active: int, previous: int) -> None:
device = getattr(self._setting, "_device", None)
persister = getattr(device, "persister", None)
if persister is None:
return
persister[self._palette_key()] = {"active": int(active), "previous": int(previous)}
def zone_base_color(self) -> int | None:
"""Color used to render per-key unset cells in the editor. Matches
rgb_power.effective_zone_base_color: black when zone is ignored,
the saved zone color otherwise."""
device = getattr(self._setting, "_device", None)
if device is None:
return None
from logitech_receiver import rgb_power
return int(rgb_power.effective_zone_base_color(device))
def _notify(self) -> None:
snapshot = self.current
for cb in list(self._listeners):
try:
cb(snapshot)
except Exception as e:
logger.warning("perkey listener raised: %s", e)
def push_external_value(self, value) -> None:
"""Called from the inline control when the framework reports a value change."""
if isinstance(value, dict):
self._notify()
class PerKeyControl(Gtk.Box):
"""Replaces MapChoiceControl for per-key color settings.
Ducktypes the four `Control` methods (`set_sensitive`, `set_value`,
`get_value`, `layout`) used by `_create_sbox` / `_update_setting_item`.
"""
def __init__(self, sbox) -> None:
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
self.sbox = sbox
self._setting = sbox.setting
self._value: dict | None = None
self._sink = _SettingSink(self._setting, sbox)
self._summary = Gtk.Label(label=_("(not loaded)"))
self._summary.set_xalign(0.0)
self.pack_start(self._summary, True, True, 0)
self._open_btn = Gtk.Button(label=_("Open editor…"))
self._open_btn.set_tooltip_text(_("Paint key colors on a keyboard layout"))
self._open_btn.connect(GtkSignal.CLICKED.value, self._on_open)
self.pack_end(self._open_btn, False, False, 0)
# ---- Control protocol ----
def set_sensitive(self, sensitive: bool) -> None:
super().set_sensitive(bool(sensitive))
self._open_btn.set_sensitive(bool(sensitive))
def set_value(self, value) -> None:
if value is None:
return
if not isinstance(value, dict):
return
# _write_async wraps single-key writes as `{key: written_value}` so
# MapChoiceControl can update one combo cell. We need to keep the
# full picture for the summary count, so merge instead of replace
# when a partial dict comes in.
existing = self._setting._value if isinstance(self._setting._value, dict) else None
if existing and len(value) < len(existing):
merged = dict(existing)
merged.update(value)
self._value = merged
else:
self._value = value
self._sink.push_external_value(self._value)
self._refresh_summary()
def get_value(self):
return self._value
def layout(self, sbox, label, change, spinner, failed) -> bool:
# Match the standard Control packing order so our button sits where
# every other setting's widget sits, just left of spinner/change-icon.
sbox.pack_start(label, False, False, 0)
sbox.pack_end(change, False, False, 0)
sbox.pack_end(self, False, False, 0)
sbox.pack_end(spinner, False, False, 0)
sbox.pack_end(failed, False, False, 0)
return self
# ---- internal ----
def _refresh_summary(self) -> None:
if not isinstance(self._value, dict):
self._summary.set_text(_("(no zones)"))
return
total = len(self._value)
painted = sum(1 for v in self._value.values() if isinstance(v, int) and v != NO_CHANGE and v >= 0)
self._summary.set_text(_("{painted} / {total} keys painted").format(painted=painted, total=total))
def _on_open(self, _btn) -> None:
feature = getattr(self._setting, "feature", None)
feature_int = int(feature) if feature is not None else 0
device = getattr(self._setting, "_device", None)
kind_obj = getattr(device, "kind", None)
kind_str = str(kind_obj).lower() if kind_obj is not None else ""
hint = {
"kind": kind_str if kind_str else None,
"wpid": getattr(device, "wpid", None),
"codename": getattr(device, "codename", None),
"name": getattr(device, "name", None),
"keyboard_layout": getattr(device, "keyboard_layout", None),
"zones": list(self._sink.zones),
"zone_count": len(self._sink.zones),
}
layout = layout_for(feature_int, hint)
# Stable per-device key so the same physical device on USB and on
# the receiver shares a single dialog. unitId is read from the
# device firmware (via DeviceInformation) and is the same across
# transports; serial is per-pairing-slot. id(self._sink) is a
# last-resort fallback that should never be hit in practice.
key = (
getattr(device, "unitId", None)
or getattr(device, "serial", None)
or getattr(device, "hid_serial", None)
or getattr(device, "codename", None)
or id(self._sink)
)
dlg = dialog_mod.get_dialog(key)
dlg.present(self._sink, layout)

View File

@ -0,0 +1,119 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Per-device dialogs hosting a PerKeyEditor.
One dialog instance is kept per device key (firmware unit-id, falling
back to other stable identifiers see ``get_dialog``). The same
physical device on different transports (receiver vs direct USB) shares
a key so it doesn't open two windows.
"""
from __future__ import annotations
from enum import Enum
from typing import Hashable
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # NOQA: E402
from solaar.i18n import _ # NOQA: E402
from .editor import PerKeyEditor # NOQA: E402
from .layout import Layout # NOQA: E402
from .protocol import PerKeyColorSink # NOQA: E402
class GtkSignal(Enum):
DELETE_EVENT = "delete-event"
_dialogs: dict[Hashable, "PerKeyEditorDialog"] = {}
class PerKeyEditorDialog:
def __init__(self, key: Hashable) -> None:
self._key = key
self._window: Gtk.Window | None = None
self._wrapper: Gtk.Box | None = None
self._editor: PerKeyEditor | None = None
self._sink: PerKeyColorSink | None = None
def _on_delete(self, _w, _e) -> bool:
self._destroy()
_dialogs.pop(self._key, None)
return True
def _destroy(self) -> None:
if self._editor is not None:
self._editor.shutdown()
self._editor = None
if self._window is not None:
self._window.destroy()
self._window = None
self._wrapper = None
self._sink = None
def present(self, sink: PerKeyColorSink, layout: Layout | None) -> None:
# Re-opening for the same sink while the window is already open:
# just raise it (no rebuild flicker, preserves any in-progress
# interaction state).
if self._window is not None and self._sink is sink:
self._window.present()
return
# Otherwise build a fresh window. We always recreate rather than
# swap content in place because Gtk.Window.resize() after first
# show is unreliable across X11/Wayland WMs — the WM often keeps
# the original geometry — and a new window picks up the layout's
# natural size cleanly on first show.
self._destroy()
self._sink = sink
self._window = Gtk.Window()
self._window.set_title(_("Per-key Lighting") + "" + sink.title)
self._window.connect(GtkSignal.DELETE_EVENT.value, self._on_delete)
self._wrapper = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self._wrapper.set_border_width(8)
self._window.add(self._wrapper)
self._editor = PerKeyEditor(sink, layout)
self._wrapper.pack_start(self._editor, True, True, 0)
self._wrapper.show_all()
# Ask GTK what the wrapper actually wants to be — the canvas's
# size_request propagates up through ScrolledWindow + editor VBox
# (toolbar + scrolled canvas) + the wrapper's border, so the
# natural size already accounts for every layout contribution.
_min, nat = self._wrapper.get_preferred_size()
if nat.width > 0 and nat.height > 0:
self._window.resize(nat.width, nat.height)
self._window.present()
def get_dialog(key: Hashable) -> PerKeyEditorDialog:
"""Return the dialog for `key`, creating one if none is open.
`key` should be a stable per-device identifier. The caller (control.py)
builds it from `device.unitId` first that's read from the device
firmware via the DeviceInformation feature and is the same regardless
of whether the device is on a receiver or plugged directly via USB,
so the same physical device doesn't open two windows when its
transport changes.
"""
d = _dialogs.get(key)
if d is None:
d = PerKeyEditorDialog(key)
_dialogs[key] = d
return d

View File

@ -0,0 +1,206 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Editor widget: combines toolbar + palette + canvas into one VBox.
The editor consumes only the PerKeyColorSink protocol no device imports,
no Setting imports preserving the FE/BE seam.
"""
from __future__ import annotations
import logging
from enum import Enum
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # NOQA: E402
from solaar.i18n import _ # NOQA: E402
from . import binding # NOQA: E402
from ._icons import attach_themed_icon # NOQA: E402
from .canvas import KeyboardCanvas # NOQA: E402
from .layout import Layout # NOQA: E402
from .palette import GradientSwatch # NOQA: E402
from .palette import Palette # NOQA: E402
from .protocol import PerKeyColorSink # NOQA: E402
logger = logging.getLogger(__name__)
class GtkSignal(Enum):
COLOR_CHANGED = "color-changed"
PAINT = "paint"
TOGGLED = "toggled"
_TOOL_LABELS = {
"single": (_("Brush"), _("Click or drag to paint individual keys")),
"rect": (_("Rect"), _("Drag to select a rectangle of keys, painted on release")),
"bucket": (_("Fill"), _("Flood-fill connected keys of the same color with the active color")),
}
_TOOL_TOOLTIPS = {
"gradient": _("Drag to fade from previous color to active color"),
}
_TOOL_ICON_NAMES = {
"single": "solaar-tool-brush-symbolic",
"rect": "solaar-tool-rect-symbolic",
"bucket": "solaar-tool-bucket-symbolic",
}
class PerKeyEditor(Gtk.Box):
def __init__(self, sink: PerKeyColorSink, layout: Layout | None = None) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self._sink = sink
self._layout = layout
self._unsubscribe = None
# toolbar row
toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
self._tool_buttons: dict[str, Gtk.RadioButton] = {}
self._gradient_swatch: GradientSwatch | None = None
first: Gtk.RadioButton | None = None
supported = layout.supported_tools if layout else ("single", "rect", "bucket", "gradient")
for name in supported:
if name == "gradient":
btn = Gtk.RadioButton.new_from_widget(first)
btn.set_mode(False)
self._gradient_swatch = GradientSwatch()
btn.add(self._gradient_swatch)
btn.set_tooltip_text(_TOOL_TOOLTIPS["gradient"])
else:
label, tip = _TOOL_LABELS.get(name, (name, ""))
icon_name = _TOOL_ICON_NAMES.get(name)
btn = Gtk.RadioButton.new_from_widget(first)
btn.set_mode(False) # render as toggle button rather than radio
if icon_name and attach_themed_icon(btn, icon_name) is not None:
btn.set_tooltip_text(tip or label)
btn.get_accessible().set_name(label)
else:
btn.set_label(label)
btn.set_tooltip_text(tip)
btn.connect(GtkSignal.TOGGLED.value, self._on_tool_toggled, name)
if first is None:
first = btn
toolbar.pack_start(btn, False, False, 0)
self._tool_buttons[name] = btn
initial_active, initial_previous = 0xFF0000, 0xFF0000
try:
persisted = sink.palette_state()
except Exception as e:
logger.debug("palette_state read failed: %s", e)
persisted = None
if persisted is not None:
initial_active, initial_previous = persisted
self._palette = Palette(active=initial_active, previous=initial_previous)
self._palette.connect(GtkSignal.COLOR_CHANGED.value, self._on_color_changed)
toolbar.pack_end(self._palette, False, False, 0)
if self._gradient_swatch is not None:
self._gradient_swatch.update(self._palette.get_color(), self._palette.get_last_color())
self.pack_start(toolbar, False, False, 0)
# canvas inside a scrolled window so wide layouts can scroll if the
# window is shrunk below content size. propagate_natural_size lets the
# window auto-fit small layouts (e.g. an 8-LED mouse) without forcing
# an oversized minimum.
scroll = Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scroll.set_propagate_natural_width(True)
scroll.set_propagate_natural_height(True)
# Inset frame around the keyboard so it reads as a distinct panel
# rather than floating flat against the dialog background.
scroll.set_shadow_type(Gtk.ShadowType.IN)
self._canvas = KeyboardCanvas()
self._canvas.connect(GtkSignal.PAINT.value, self._on_canvas_paint)
scroll.add(self._canvas)
self.pack_start(scroll, True, True, 0)
self._canvas.set_active_color(self._palette.get_color())
if self._gradient_swatch is not None:
self._canvas.set_gradient_colors_source(self._gradient_swatch.get_colors)
try:
base = sink.zone_base_color()
except Exception as e:
logger.debug("zone_base_color read failed: %s", e)
base = None
self._canvas.set_zone_base_color(base)
self._refresh_layout()
self._sync_from_sink()
self._unsubscribe = sink.subscribe(self._on_sink_update)
def shutdown(self) -> None:
if self._unsubscribe:
try:
self._unsubscribe()
except Exception as e:
logger.debug("perkey sink unsubscribe failed: %s", e)
self._unsubscribe = None
try:
self._palette.shutdown()
except Exception as e:
logger.debug("palette shutdown failed: %s", e)
def _refresh_layout(self) -> None:
if self._layout is None:
# No registered layout: lay out all reported zones as a flat strip.
from .layout import Cell
zones = list(self._sink.zones)
cells = tuple(Cell(zone_id=z, row=0, col=i, group="strip", label=self._sink.label(z)) for i, z in enumerate(zones))
self._layout = Layout(cells=cells, rows=1, cols=max(1, len(zones)), description=f"flat strip ({len(zones)} zones)")
bound = binding.bind(
self._layout,
list(self._sink.zones),
self._sink.label,
)
self._canvas.set_layout(bound)
def _sync_from_sink(self) -> None:
self._canvas.set_colors(dict(self._sink.current))
def _on_sink_update(self, current: dict[int, int]) -> None:
self._canvas.set_colors(dict(current))
def _on_color_changed(self, _palette, color: int) -> None:
self._canvas.set_active_color(color)
# Gradient swatch tracks only real picker colors; toggling unset
# leaves it alone so the gradient setup isn't disturbed.
picker = self._palette.get_picker_color()
if self._gradient_swatch is not None:
self._gradient_swatch.update(picker, self._palette.get_last_color())
try:
self._sink.set_palette_state(picker, self._palette.get_last_color())
except Exception as e:
logger.debug("set_palette_state failed: %s", e)
def _on_tool_toggled(self, btn: Gtk.RadioButton, name: str) -> None:
if btn.get_active():
self._canvas.set_tool(name)
def _on_canvas_paint(self, _canvas, delta: dict) -> None:
if not delta:
return
if len(delta) == 1:
zone, color = next(iter(delta.items()))
self._sink.write_one(int(zone), int(color))
else:
self._sink.write_bulk({int(z): int(c) for z, c in delta.items()})

View File

@ -0,0 +1,102 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Visual layout primitives for the per-key color editor.
This module is pure data. It does not import GTK and does not import from
`lib.logitech_receiver`. It is therefore relocatable into a shared package
when the frontend/backend split happens.
"""
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import field
@dataclass(frozen=True)
class Cell:
"""One paintable cell in a layout.
`zone_id` is the firmware identifier the device uses for this LED. It is
matched against the device's reported zone list at bind time; cells with
no matching device zone are drawn disabled.
"""
zone_id: int
row: int
col: int
width: float = 1.0
height: float = 1.0
group: str = "main"
label: str = ""
x: float | None = None
y: float | None = None
@dataclass(frozen=True)
class Layout:
"""A device-class visual layout.
Cells in `strip_groups` are rendered as a flat row beneath the matrix
region, regardless of their row/col fields. Cells outside `strip_groups`
are placed by row/col on the main matrix.
`extra_zones` is a curated allowlist of zone ids that may appear in the
bottom strip when the device reports them but they are not covered by a
layout cell. Zones outside the allowlist are dropped Logitech firmware
bitmaps enumerate phantom/reserved slots (e.g. G515 reports 47, 97, 99-103,
254) that aren't physical keys. Set to `None` to disable filtering.
"""
cells: tuple[Cell, ...]
rows: int
cols: int
strip_groups: tuple[str, ...] = ("strip",)
supported_tools: tuple[str, ...] = ("single", "rect", "bucket", "gradient")
extra_zones: frozenset[int] | None = None
description: str = ""
def matrix_cells(self) -> tuple[Cell, ...]:
return tuple(c for c in self.cells if c.group not in self.strip_groups)
def strip_cells(self) -> tuple[Cell, ...]:
return tuple(c for c in self.cells if c.group in self.strip_groups)
def by_zone(self) -> dict[int, Cell]:
return {c.zone_id: c for c in self.cells}
@dataclass(frozen=True)
class BoundCell:
"""A Cell augmented with bind state, returned by `binding.bind`."""
cell: Cell
bound: bool
@dataclass(frozen=True)
class BoundLayout:
"""Result of binding a Layout against a sink's reported zones.
`matrix` and `strip` are tuples of BoundCell in render order. `unmapped`
holds zones the device reported that no Layout cell claimed; these get
appended to the strip with synthesized cells.
"""
matrix: tuple[BoundCell, ...] = field(default_factory=tuple)
strip: tuple[BoundCell, ...] = field(default_factory=tuple)
unmapped: tuple[int, ...] = field(default_factory=tuple)

View File

@ -0,0 +1,145 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Registry of per-key layouts, keyed by feature + a device-class match.
Layouts register themselves with a matcher callable. `layout_for(feature, hint)`
returns the first matching layout, or None when no model-specific layout is
known in which case the editor renders a flat strip of all reported zones.
"""
from __future__ import annotations
from collections.abc import Callable
from ..layout import Layout
from . import headset_g522
from . import keyboard_ansi
from . import keyboard_iso_azerty
from . import keyboard_iso_qwerty
from . import keyboard_iso_qwertz
from . import keyboard_jis
from . import mouse_g502x
# (feature_id, matcher, layout). Matcher receives a `hint` dict the editor
# assembles from the device (kind, wpid, codename, name, zones list, etc.).
_REGISTRY: list[tuple[int, Callable[[dict], bool], Layout]] = []
def register_layout(feature: int, matcher: Callable[[dict], bool], layout: Layout) -> None:
_REGISTRY.append((feature, matcher, layout))
def layout_for(feature: int, hint: dict) -> Layout | None:
for f, match, layout in _REGISTRY:
if f == feature and match(hint):
return layout
return None
def _name_contains(*needles: str) -> Callable[[dict], bool]:
"""Build a matcher that returns True if any needle is a substring of the
device's name or codename (case-insensitive). Useful for device-family
layouts where multiple wpids share an LED arrangement.
"""
folded = tuple(n.upper() for n in needles)
def match(hint: dict) -> bool:
for field in ("codename", "name"):
value = hint.get(field)
if not value:
continue
up = str(value).upper()
if any(n in up for n in folded):
return True
return False
return match
# --- Keyboard region routing ---
# Country code → layout family. Codes from HID++ feature 0x4540 KeyboardLayout.
_KEYBOARD_FAMILY_BY_COUNTRY: dict[int, str] = {
1: "ansi",
# ISO QWERTY (UK + ES/IT/PT/BE/Nordic — same shape, different keycap legends)
2: "iso_qwerty",
5: "iso_qwerty",
8: "iso_qwerty",
0x0B: "iso_qwerty",
0x0D: "iso_qwerty",
0x0E: "iso_qwerty",
0x0F: "iso_qwerty",
0x16: "iso_qwerty",
0x1D: "iso_qwerty",
0x21: "iso_qwerty",
0x24: "iso_qwerty",
# ISO QWERTZ (DE/Swiss)
3: "iso_qwertz",
7: "iso_qwertz",
# ISO AZERTY (FR)
4: "iso_azerty",
# JIS
9: "jis",
0x3E: "jis",
}
_FAMILY_LAYOUTS = {
"ansi": (keyboard_ansi.LAYOUT_FULL, keyboard_ansi.LAYOUT_TKL),
"iso_qwerty": (keyboard_iso_qwerty.LAYOUT_FULL, keyboard_iso_qwerty.LAYOUT_TKL),
"iso_qwertz": (keyboard_iso_qwertz.LAYOUT_FULL, keyboard_iso_qwertz.LAYOUT_TKL),
"iso_azerty": (keyboard_iso_azerty.LAYOUT_FULL, keyboard_iso_azerty.LAYOUT_TKL),
"jis": (keyboard_jis.LAYOUT_FULL, keyboard_jis.LAYOUT_TKL),
}
def _has_numpad(hint: dict) -> bool:
"""Numpad presence is read from the device's reported zone bitmap rather
than counting zones G515 reports phantom zones (47, 97, 99-103, 254)
that diverge from the keycap count.
"""
zones = set(hint.get("zones", ()))
return 80 in zones or 95 in zones
def _keyboard_family(hint: dict) -> str:
"""Pick a layout family from the device's HID++ keyboard layout country
code. Defaults to "ansi" when the code is missing or unknown.
"""
code = hint.get("keyboard_layout")
if code is None:
return "ansi"
return _KEYBOARD_FAMILY_BY_COUNTRY.get(int(code), "ansi")
def _keyboard_matcher(family: str, full_size: bool) -> Callable[[dict], bool]:
def match(hint: dict) -> bool:
if hint.get("kind") != "keyboard":
return False
if _has_numpad(hint) != full_size:
return False
return _keyboard_family(hint) == family
return match
# PER_KEY_LIGHTING_V2 = 0x8081
for _family, (_full, _tkl) in _FAMILY_LAYOUTS.items():
register_layout(0x8081, _keyboard_matcher(_family, full_size=True), _full)
register_layout(0x8081, _keyboard_matcher(_family, full_size=False), _tkl)
register_layout(0x8081, _name_contains("G502 X"), mouse_g502x.LAYOUT)
# HEADSET_RGB_HOSTMODE = 0x0620
register_layout(0x0620, _name_contains("G522"), headset_g522.LAYOUT)

View File

@ -0,0 +1,236 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Shared building blocks for regional keyboard layouts.
Each region (ANSI, ISO_QWERTY, ISO_QWERTZ, ISO_AZERTY, JIS) shares the function
row, nav-cluster, and numpad blocks; only the main alpha block differs (ANSI
includes the row 2 col 13 backslash, ISO doesn't). Regional label overrides on
top of either main block produce the final layout.
Cell positions and groupings adapted from OpenRGB's KeyboardLayoutManager.
Zone IDs are firmware values reported by Logitech HID++ feature 0x8081
(PER_KEY_LIGHTING_V2).
"""
from __future__ import annotations
from ..layout import Cell
from ..layout import Layout
# --- Function row: ESC + F1..F12 (shared across all regions).
FN_ROW: tuple[Cell, ...] = (
Cell(zone_id=38, row=0, col=0, group="fn_row", label="Esc"),
Cell(zone_id=55, row=0, col=2, group="fn_row", label="F1"),
Cell(zone_id=56, row=0, col=3, group="fn_row", label="F2"),
Cell(zone_id=57, row=0, col=4, group="fn_row", label="F3"),
Cell(zone_id=58, row=0, col=5, group="fn_row", label="F4"),
Cell(zone_id=59, row=0, col=6, group="fn_row", label="F5"),
Cell(zone_id=60, row=0, col=7, group="fn_row", label="F6"),
Cell(zone_id=61, row=0, col=8, group="fn_row", label="F7"),
Cell(zone_id=62, row=0, col=9, group="fn_row", label="F8"),
Cell(zone_id=63, row=0, col=10, group="fn_row", label="F9"),
Cell(zone_id=64, row=0, col=11, group="fn_row", label="F10"),
Cell(zone_id=65, row=0, col=12, group="fn_row", label="F11"),
Cell(zone_id=66, row=0, col=13, group="fn_row", label="F12"),
)
# --- Nav cluster + arrows (shared).
EXTRAS: tuple[Cell, ...] = (
Cell(zone_id=67, row=0, col=14, group="extras", label="PrtSc"),
Cell(zone_id=68, row=0, col=15, group="extras", label="ScrLk"),
Cell(zone_id=69, row=0, col=16, group="extras", label="Pause"),
Cell(zone_id=70, row=1, col=14, group="extras", label="Ins"),
Cell(zone_id=71, row=1, col=15, group="extras", label="Home"),
Cell(zone_id=72, row=1, col=16, group="extras", label="PgUp"),
Cell(zone_id=73, row=2, col=14, group="extras", label="Del"),
Cell(zone_id=74, row=2, col=15, group="extras", label="End"),
Cell(zone_id=75, row=2, col=16, group="extras", label="PgDn"),
Cell(zone_id=79, row=4, col=15, group="extras", label=""),
Cell(zone_id=77, row=5, col=14, group="extras", label=""),
Cell(zone_id=78, row=5, col=15, group="extras", label=""),
Cell(zone_id=76, row=5, col=16, group="extras", label=""),
)
# --- Numpad block (only on full-size keyboards).
NUMPAD: tuple[Cell, ...] = (
Cell(zone_id=80, row=1, col=17, group="numpad", label="Num"),
Cell(zone_id=81, row=1, col=18, group="numpad", label="/"),
Cell(zone_id=82, row=1, col=19, group="numpad", label="*"),
Cell(zone_id=83, row=1, col=20, group="numpad", label="-"),
Cell(zone_id=92, row=2, col=17, group="numpad", label="7"),
Cell(zone_id=93, row=2, col=18, group="numpad", label="8"),
Cell(zone_id=94, row=2, col=19, group="numpad", label="9"),
Cell(zone_id=84, row=2, col=20, height=2.0, group="numpad", label="+"),
Cell(zone_id=89, row=3, col=17, group="numpad", label="4"),
Cell(zone_id=90, row=3, col=18, group="numpad", label="5"),
Cell(zone_id=91, row=3, col=19, group="numpad", label="6"),
Cell(zone_id=86, row=4, col=17, group="numpad", label="1"),
Cell(zone_id=87, row=4, col=18, group="numpad", label="2"),
Cell(zone_id=88, row=4, col=19, group="numpad", label="3"),
Cell(zone_id=85, row=4, col=20, height=2.0, group="numpad", label="Enter"),
Cell(zone_id=95, row=5, col=17, width=2.0, group="numpad", label="0"),
Cell(zone_id=96, row=5, col=19, group="numpad", label="."),
)
# --- Main alpha block, ANSI (104-key). Includes row 2 col 13 backslash and
# omits POUND (row 3 col 12) + ISO_BACKSLASH (row 4 col 1).
MAIN_ANSI: tuple[Cell, ...] = (
# Row 1: backtick + numbers + minus/equals + backspace
Cell(zone_id=50, row=1, col=0, group="main", label="`"),
Cell(zone_id=27, row=1, col=1, group="main", label="1"),
Cell(zone_id=28, row=1, col=2, group="main", label="2"),
Cell(zone_id=29, row=1, col=3, group="main", label="3"),
Cell(zone_id=30, row=1, col=4, group="main", label="4"),
Cell(zone_id=31, row=1, col=5, group="main", label="5"),
Cell(zone_id=32, row=1, col=6, group="main", label="6"),
Cell(zone_id=33, row=1, col=7, group="main", label="7"),
Cell(zone_id=34, row=1, col=8, group="main", label="8"),
Cell(zone_id=35, row=1, col=9, group="main", label="9"),
Cell(zone_id=36, row=1, col=10, group="main", label="0"),
Cell(zone_id=42, row=1, col=11, group="main", label="-"),
Cell(zone_id=43, row=1, col=12, group="main", label="="),
Cell(zone_id=39, row=1, col=13, group="main", label="Bksp"),
# Row 2: tab + qwerty + brackets + backslash
Cell(zone_id=40, row=2, col=0, group="main", label="Tab"),
Cell(zone_id=17, row=2, col=1, group="main", label="Q"),
Cell(zone_id=23, row=2, col=2, group="main", label="W"),
Cell(zone_id=5, row=2, col=3, group="main", label="E"),
Cell(zone_id=18, row=2, col=4, group="main", label="R"),
Cell(zone_id=20, row=2, col=5, group="main", label="T"),
Cell(zone_id=25, row=2, col=6, group="main", label="Y"),
Cell(zone_id=21, row=2, col=7, group="main", label="U"),
Cell(zone_id=9, row=2, col=8, group="main", label="I"),
Cell(zone_id=15, row=2, col=9, group="main", label="O"),
Cell(zone_id=16, row=2, col=10, group="main", label="P"),
Cell(zone_id=44, row=2, col=11, group="main", label="["),
Cell(zone_id=45, row=2, col=12, group="main", label="]"),
Cell(zone_id=46, row=2, col=13, group="main", label="\\"),
# Row 3: caps + asdf-row + semi/quote + enter
Cell(zone_id=54, row=3, col=0, group="main", label="Caps"),
Cell(zone_id=1, row=3, col=1, group="main", label="A"),
Cell(zone_id=19, row=3, col=2, group="main", label="S"),
Cell(zone_id=4, row=3, col=3, group="main", label="D"),
Cell(zone_id=6, row=3, col=4, group="main", label="F"),
Cell(zone_id=7, row=3, col=5, group="main", label="G"),
Cell(zone_id=8, row=3, col=6, group="main", label="H"),
Cell(zone_id=10, row=3, col=7, group="main", label="J"),
Cell(zone_id=11, row=3, col=8, group="main", label="K"),
Cell(zone_id=12, row=3, col=9, group="main", label="L"),
Cell(zone_id=48, row=3, col=10, group="main", label=";"),
Cell(zone_id=49, row=3, col=11, group="main", label="'"),
Cell(zone_id=37, row=3, col=13, group="main", label="Enter"),
# Row 4: shift + zxcv-row + comma/period/slash + rshift
Cell(zone_id=105, row=4, col=0, group="main", label="Shift"),
Cell(zone_id=26, row=4, col=2, group="main", label="Z"),
Cell(zone_id=24, row=4, col=3, group="main", label="X"),
Cell(zone_id=3, row=4, col=4, group="main", label="C"),
Cell(zone_id=22, row=4, col=5, group="main", label="V"),
Cell(zone_id=2, row=4, col=6, group="main", label="B"),
Cell(zone_id=14, row=4, col=7, group="main", label="N"),
Cell(zone_id=13, row=4, col=8, group="main", label="M"),
Cell(zone_id=51, row=4, col=9, group="main", label=","),
Cell(zone_id=52, row=4, col=10, group="main", label="."),
Cell(zone_id=53, row=4, col=11, group="main", label="/"),
Cell(zone_id=109, row=4, col=13, group="main", label="Shift"),
# Row 5: bottom row. Space spans cols 3..9 visually.
Cell(zone_id=104, row=5, col=0, group="main", label="Ctrl"),
Cell(zone_id=107, row=5, col=1, group="main", label="Win"),
Cell(zone_id=106, row=5, col=2, group="main", label="Alt"),
Cell(zone_id=41, row=5, col=3, width=7.0, group="main", label="Space"),
Cell(zone_id=110, row=5, col=10, group="main", label="AltGr"),
Cell(zone_id=111, row=5, col=11, group="main", label="Win"),
Cell(zone_id=98, row=5, col=12, group="main", label="Menu"),
Cell(zone_id=108, row=5, col=13, group="main", label="Ctrl"),
)
# --- Main alpha block, ISO. Drops the row 2 col 13 backslash (zone 46 is the
# upper half of the L-shape Enter on ISO, addressed by zone 37) and adds
# the two ISO-only keys: POUND (zone 47) at row 3 col 12 between ' and
# Enter, and ISO_BACKSLASH (zone 97) at row 4 col 1 between Shift and Z.
# Regional layouts override the labels to match local keycaps (# / < on
# QWERTZ, # / \ on UK QWERTY, * / < on AZERTY).
_ISO_EXTRA_KEYS: tuple[Cell, ...] = (
Cell(zone_id=47, row=3, col=12, group="main", label="#"),
Cell(zone_id=97, row=4, col=1, group="main", label="\\"),
)
MAIN_ISO: tuple[Cell, ...] = tuple(c for c in MAIN_ANSI if not (c.row == 2 and c.col == 13)) + _ISO_EXTRA_KEYS
# --- Curated allowlist for unmapped device zones surfaced in the bottom strip.
# G-keys, logo, media, brightness — the canonical "extras" Logitech firmware
# actually addresses. Phantom zones (e.g. G515's 47, 97, 99-103, 254) drop.
EXTRAS_ALLOWLIST: frozenset[int] = frozenset(
{
153, # Brightness
155, # Play/Pause
156, # Mute
157, # Next
158, # Previous
180, # G1
181, # G2
182, # G3
183, # G4
184, # G5
210, # Logo
}
)
def _relabel(cells: tuple[Cell, ...], overrides: dict[int, str]) -> tuple[Cell, ...]:
"""Return a new tuple where any cell whose zone_id is in `overrides` has
its label replaced. Unaffected cells pass through unchanged.
"""
if not overrides:
return cells
return tuple(
Cell(
zone_id=c.zone_id,
row=c.row,
col=c.col,
width=c.width,
height=c.height,
group=c.group,
label=overrides[c.zone_id] if c.zone_id in overrides else c.label,
x=c.x,
y=c.y,
)
for c in cells
)
def build_layout(
main_cells: tuple[Cell, ...],
*,
include_numpad: bool,
label_overrides: dict[int, str] | None = None,
description: str = "",
) -> Layout:
"""Assemble a regional keyboard layout from a chosen main block + the
shared fn-row / extras / (optionally) numpad blocks. Apply per-zone
label overrides to every cell whose zone matches.
"""
cells = FN_ROW + main_cells + EXTRAS
if include_numpad:
cells = cells + NUMPAD
cells = _relabel(cells, label_overrides or {})
cols = 21 if include_numpad else 17
return Layout(
cells=cells,
rows=6,
cols=cols,
extra_zones=EXTRAS_ALLOWLIST,
description=description,
)

View File

@ -0,0 +1,53 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""LED layout for the G522 LIGHTSPEED headset.
Eight LEDs in two 2×2 grids one per earcup, viewed from outside:
Left earcup Right earcup
8 7 6 5
4 3 2 1
"""
from __future__ import annotations
from ..layout import Cell
from ..layout import Layout
_CELLS: tuple[Cell, ...] = (
# Left earcup (cols 0-1, outer view): top-left=8, top-right=7, bottom-left=4, bottom-right=3
Cell(zone_id=8, row=0, col=0, group="main", label="8"),
Cell(zone_id=7, row=0, col=1, group="main", label="7"),
Cell(zone_id=4, row=1, col=0, group="main", label="4"),
Cell(zone_id=3, row=1, col=1, group="main", label="3"),
# Right earcup (cols 3-4, outer view): top-left=6, top-right=5, bottom-left=2, bottom-right=1
Cell(zone_id=6, row=0, col=3, group="main", label="6"),
Cell(zone_id=5, row=0, col=4, group="main", label="5"),
Cell(zone_id=2, row=1, col=3, group="main", label="2"),
Cell(zone_id=1, row=1, col=4, group="main", label="1"),
)
LAYOUT: Layout = Layout(
cells=_CELLS,
rows=2,
cols=5,
description="Logitech G522 LIGHTSPEED headset (8 LEDs, 4 per earcup)",
)

View File

@ -0,0 +1,42 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""ANSI QWERTY keyboard layouts (full 104-key and TKL).
Cell positions and groupings derived from OpenRGB's KeyboardLayoutManager
(KeyboardLayoutManager.cpp), Copyright (C) Chris M (Dr_No), licensed under
GPL-2.0-or-later. This file ports the static ANSI data only; the runtime
opcode interpreter for regional overlays is intentionally not included.
"""
from __future__ import annotations
from ..layout import Layout
from ._keyboard_base import MAIN_ANSI
from ._keyboard_base import build_layout
LAYOUT_FULL: Layout = build_layout(
MAIN_ANSI,
include_numpad=True,
description="ANSI QWERTY 104-key full-size",
)
LAYOUT_TKL: Layout = build_layout(
MAIN_ANSI,
include_numpad=False,
description="ANSI QWERTY tenkeyless",
)

View File

@ -0,0 +1,77 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""ISO AZERTY layout (FR).
ISO shape plus French label overrides AQ, WZ, M repositioned, French
digit-row symbols (& é " ' ( - è _ ç à ). Adapted from OpenRGB.
"""
from __future__ import annotations
from ..layout import Layout
from ._keyboard_base import MAIN_ISO
from ._keyboard_base import build_layout
# zone_id → French label
_OVERRIDES: dict[int, str] = {
# Row 1 (digit row → French symbols)
50: "²", # backtick → super-2
27: "&", # 1
28: "é", # 2
29: '"', # 3
30: "'", # 4
31: "(", # 5
32: "-", # 6
33: "è", # 7
34: "_", # 8
35: "ç", # 9
36: "à", # 0
42: ")", # minus → close-paren
# Row 2 — Q/A and W/Z swaps, brackets relabeled
17: "A", # Q-position → A
23: "Z", # W-position → Z
44: "^", # [-position → caret
45: "$", # ]-position → dollar
# Row 3 — A → Q; M moves up to ; position
1: "Q", # A-position → Q
48: "M", # ;-position → M
49: "ù", # '-position → ù
# Row 4 — Z-position becomes W; comma row shifts
26: "W", # Z-position → W
13: ",", # M-position → comma
51: ";", # ,-position → semicolon
52: ":", # .-position → colon
53: "!", # /-position → exclamation
47: "*", # POUND key (row 3 col 12) — French * / µ
97: "<", # ISO_BACKSLASH (row 4 col 1), between Shift and W
}
LAYOUT_FULL: Layout = build_layout(
MAIN_ISO,
include_numpad=True,
label_overrides=_OVERRIDES,
description="ISO AZERTY (FR) full-size",
)
LAYOUT_TKL: Layout = build_layout(
MAIN_ISO,
include_numpad=False,
label_overrides=_OVERRIDES,
description="ISO AZERTY (FR) tenkeyless",
)

View File

@ -0,0 +1,45 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""ISO_QWERTY keyboard layouts (UK English ISO + other QWERTY ISO regions).
Same English keycap legends as ANSI; differs only in shape the row 2 col 13
backslash on ANSI doesn't exist on ISO (that position is the upper half of the
L-shape Enter, addressed by zone 37). Used for UK and any other region whose
country code maps to "iso_qwerty" without a more specific layout (Spanish,
Italian, Portuguese, Belgian, Nordic those keyboards have the same shape
as UK ISO; only their physical keycap legends differ, which our painter
doesn't reproduce verbatim).
"""
from __future__ import annotations
from ..layout import Layout
from ._keyboard_base import MAIN_ISO
from ._keyboard_base import build_layout
LAYOUT_FULL: Layout = build_layout(
MAIN_ISO,
include_numpad=True,
description="ISO QWERTY 103-key full-size",
)
LAYOUT_TKL: Layout = build_layout(
MAIN_ISO,
include_numpad=False,
description="ISO QWERTY tenkeyless",
)

View File

@ -0,0 +1,59 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""ISO QWERTZ layout (DE / CH).
ISO shape plus German label overrides (Y/Z swap, Ü/Ö/Ä/ß placement).
Adapted from OpenRGB.
"""
from __future__ import annotations
from ..layout import Layout
from ._keyboard_base import MAIN_ISO
from ._keyboard_base import build_layout
# zone_id → German label
_OVERRIDES: dict[int, str] = {
50: "^", # row 1 col 0 — caret/degree (DE keycap)
42: "ß", # row 1 col 11 — eszett
43: "´", # row 1 col 12 — acute accent
25: "Z", # row 2 col 6 — Y/Z swap
44: "Ü", # row 2 col 11
45: "+", # row 2 col 12
48: "Ö", # row 3 col 10
49: "Ä", # row 3 col 11
26: "Y", # row 4 col 2 — Y/Z swap
53: "-", # row 4 col 11
47: "#", # POUND key (row 3 col 12), between Ä and Enter
97: "<", # ISO_BACKSLASH (row 4 col 1), between Shift and Y
}
LAYOUT_FULL: Layout = build_layout(
MAIN_ISO,
include_numpad=True,
label_overrides=_OVERRIDES,
description="ISO QWERTZ (DE/CH) full-size",
)
LAYOUT_TKL: Layout = build_layout(
MAIN_ISO,
include_numpad=False,
label_overrides=_OVERRIDES,
description="ISO QWERTZ (DE/CH) tenkeyless",
)

View File

@ -0,0 +1,52 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""JIS layout (JP).
ISO shape with Japanese keycap relabels for the bracket / colon positions.
Adapted from OpenRGB. JIS keyboards also have additional kana-control keys
near the spacebar (henkan/muhenkan/kana) that aren't represented here —
matches OpenRGB's coverage.
"""
from __future__ import annotations
from ..layout import Layout
from ._keyboard_base import MAIN_ISO
from ._keyboard_base import build_layout
# zone_id → JIS label
_OVERRIDES: dict[int, str] = {
44: "@", # row 2 col 11 — bracket-position becomes at-sign
45: "[", # row 2 col 12 — bracket shifts left
49: ":", # row 3 col 11 — quote-position becomes colon
}
LAYOUT_FULL: Layout = build_layout(
MAIN_ISO,
include_numpad=True,
label_overrides=_OVERRIDES,
description="JIS (JP) full-size",
)
LAYOUT_TKL: Layout = build_layout(
MAIN_ISO,
include_numpad=False,
label_overrides=_OVERRIDES,
description="JIS (JP) tenkeyless",
)

View File

@ -0,0 +1,49 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""LED layout for the G502 X family (G502 X, G502 X PLUS, G502 X LIGHTSPEED).
Eight LEDs reported as zones 1..8 by the firmware. Positions may need
revision per actual hardware.
Row 0: 3 . . . . . 2
Row 1: . 4 8 7 6 5 .
Row 2: . . . . . . 1
"""
from __future__ import annotations
from ..layout import Cell
from ..layout import Layout
_CELLS: tuple[Cell, ...] = (
Cell(zone_id=1, row=2, col=6, group="main", label="1"),
Cell(zone_id=2, row=0, col=6, group="main", label="2"),
Cell(zone_id=3, row=0, col=0, group="main", label="3"),
Cell(zone_id=4, row=1, col=1, group="main", label="4"),
Cell(zone_id=5, row=1, col=5, group="main", label="5"),
Cell(zone_id=6, row=1, col=4, group="main", label="6"),
Cell(zone_id=7, row=1, col=3, group="main", label="7"),
Cell(zone_id=8, row=1, col=2, group="main", label="8"),
)
LAYOUT: Layout = Layout(
cells=_CELLS,
rows=3,
cols=7,
description="Logitech G502 X family",
)

View File

@ -0,0 +1,235 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Palette: active-color picker + a small gradient swatch widget.
The picker (`Palette`) is just a wrapped `Gtk.ColorButton` that emits
`color-changed` and remembers the previous active color. The previous
color is surfaced visually by the gradient tool button, not in the palette
itself see `GradientSwatch` below, used by `editor.py`.
"""
from __future__ import annotations
from enum import Enum
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk # NOQA: E402
from gi.repository import GObject # NOQA: E402
from gi.repository import Gtk # NOQA: E402
from solaar.i18n import _ # NOQA: E402
from ._icons import attach_themed_icon # NOQA: E402
_UNSET_ICON_NAME = "solaar-tool-palette-off-symbolic"
class GtkSignal(Enum):
DRAW = "draw"
COLOR_SET = "color-set"
TOGGLED = "toggled"
def _rgb_to_int(rgba: Gdk.RGBA) -> int:
r = max(0, min(255, int(round(rgba.red * 255))))
g = max(0, min(255, int(round(rgba.green * 255))))
b = max(0, min(255, int(round(rgba.blue * 255))))
return (r << 16) | (g << 8) | b
def _int_to_rgba(c: int) -> Gdk.RGBA:
rgba = Gdk.RGBA()
if c is None or c < 0:
rgba.red = rgba.green = rgba.blue = 0.5
rgba.alpha = 1.0
return rgba
rgba.red = ((c >> 16) & 0xFF) / 255.0
rgba.green = ((c >> 8) & 0xFF) / 255.0
rgba.blue = (c & 0xFF) / 255.0
rgba.alpha = 1.0
return rgba
# Sentinel for "no change" / unset paint. Matches special_keys.COLORSPLUS["No change"].
UNSET_COLOR = -1
class Palette(Gtk.Box):
__gsignals__ = {
"color-changed": (GObject.SignalFlags.RUN_FIRST, None, (int,)),
}
def __init__(self, active: int = 0xFF0000, previous: int = 0xFF0000) -> None:
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
# _color/_last_color are always real RGB values; the unset toggle is
# a separate channel so the gradient swatch (which mirrors these) is
# unaffected by switching to "no change" paint mode.
self._color: int = int(active)
self._last_color: int = int(previous)
self._unset_mode: bool = False
self._color_btn = Gtk.ColorButton()
self._color_btn.set_use_alpha(False)
self._color_btn.set_rgba(_int_to_rgba(self._color))
self._color_btn.set_tooltip_text(_("Active color"))
self._color_btn.connect(GtkSignal.COLOR_SET.value, self._on_color_set)
self.pack_start(self._color_btn, False, False, 0)
self._unset_btn = Gtk.ToggleButton()
self._unset_btn.set_tooltip_text(_("Paint as 'no change' — clears the cell to the zone base color"))
unset_label = _("Unset")
if attach_themed_icon(self._unset_btn, _UNSET_ICON_NAME) is not None:
self._unset_btn.get_accessible().set_name(unset_label)
else:
self._unset_btn.set_label(unset_label)
self._unset_btn.connect(GtkSignal.TOGGLED.value, self._on_unset_toggled)
self.pack_start(self._unset_btn, False, False, 0)
def shutdown(self) -> None:
# attach_themed_icon connects to the button's own style-updated
# signal; GTK disconnects it automatically when the button is
# destroyed, so there is nothing to clean up here.
pass
def _on_color_set(self, btn: Gtk.ColorButton) -> None:
c = _rgb_to_int(btn.get_rgba())
unset_was_on = self._unset_mode
if c == self._color and not unset_was_on:
return
if c != self._color:
self._last_color = self._color
self._color = c
if unset_was_on:
self._unset_mode = False
self._unset_btn.set_active(False)
self.emit("color-changed", self.get_color())
def _on_unset_toggled(self, btn: Gtk.ToggleButton) -> None:
new_state = bool(btn.get_active())
if new_state == self._unset_mode:
return
self._unset_mode = new_state
self.emit("color-changed", self.get_color())
def get_color(self) -> int:
return UNSET_COLOR if self._unset_mode else self._color
def get_picker_color(self) -> int:
"""The most recent real RGB pick — independent of the unset toggle.
Use this for visuals that should always reflect actual colors (e.g.
the gradient swatch).
"""
return self._color
def get_last_color(self) -> int:
return self._last_color
def is_unset(self) -> bool:
return self._unset_mode
class GradientSwatch(Gtk.DrawingArea):
"""Small icon: diagonal gradient from `previous` (bottom-left) to `active` (top-right).
Used as the visual on the gradient tool button so the user can see at a
glance which two colors the next gradient stroke will fade between.
"""
SIZE = 22
def __init__(self) -> None:
super().__init__()
self.set_size_request(self.SIZE, self.SIZE)
self._active: int = 0xFF0000
self._previous: int = 0xFF0000
self.connect(GtkSignal.DRAW.value, self._on_draw)
# Re-render when the GTK theme changes, so the rounded-square
# outline (drawn in the theme foreground color) stays in sync
# with the tool icons next to it.
self.connect("style-updated", lambda w: w.queue_draw())
def update(self, active: int, previous: int) -> None:
self._active = int(active)
self._previous = int(previous)
self.queue_draw()
def get_active(self) -> int:
return self._active
def get_previous(self) -> int:
return self._previous
def get_colors(self) -> tuple[int, int]:
"""Return (active, previous) — the colors the gradient tool will paint with."""
return (self._active, self._previous)
@staticmethod
def _rounded_rect_path(cr, x: float, y: float, w: float, h: float, r: float) -> None:
cr.new_sub_path()
cr.arc(x + w - r, y + r, r, -1.5708, 0)
cr.arc(x + w - r, y + h - r, r, 0, 1.5708)
cr.arc(x + r, y + h - r, r, 1.5708, 3.1416)
cr.arc(x + r, y + r, r, 3.1416, 4.7124)
cr.close_path()
def _on_draw(self, _w, cr) -> None:
import cairo # local: keeps the module light when GradientSwatch isn't built
def rgb(c: int) -> tuple[float, float, float]:
if c is None or c < 0:
return (0.5, 0.5, 0.5)
return (((c >> 16) & 0xFF) / 255.0, ((c >> 8) & 0xFF) / 255.0, (c & 0xFF) / 255.0)
# Render in Tabler "square" coordinates (24x24 viewBox, rounded
# rect from (3,3) to (21,21), corner radius 2, stroke 2) and let
# cairo scale to the swatch's pixel size. Matches the outline
# style of the tool icons exactly.
cr.save()
cr.scale(self.SIZE / 24.0, self.SIZE / 24.0)
# Build the rounded-square path once, clip+fill the gradient
# inside it, then re-build and stroke the outline in the theme
# foreground color.
self._rounded_rect_path(cr, 3, 3, 18, 18, 2)
cr.save()
cr.clip()
# Top-left (previous, gradient start) → bottom-right (active, end).
# Matches the directional behavior of dragging the line tool TL → BR.
# Endpoints are shifted inward by the arc inset (corner radius * (1
# - 1/sqrt(2)), ~0.586 for r=2) so t=0 lands on the actual visible
# TL corner pixel of the rounded rect — without this, the rendered
# corners sample at t≈0.033/0.967 and the displayed colors are
# ~8 RGB units short of the true endpoint colors.
inset = 2 * (1 - 1 / (2**0.5))
pat = cairo.LinearGradient(3 + inset, 3 + inset, 21 - inset, 21 - inset)
pat.add_color_stop_rgb(0.0, *rgb(self._previous))
pat.add_color_stop_rgb(1.0, *rgb(self._active))
cr.set_source(pat)
cr.rectangle(3, 3, 18, 18)
cr.fill()
cr.restore() # drop clip
self._rounded_rect_path(cr, 3, 3, 18, 18, 2)
fg = self.get_style_context().get_color(Gtk.StateFlags.NORMAL)
cr.set_source_rgba(fg.red, fg.green, fg.blue, fg.alpha)
cr.set_line_width(2)
cr.set_line_join(cairo.LINE_JOIN_ROUND)
cr.set_line_cap(cairo.LINE_CAP_ROUND)
cr.stroke()
cr.restore()

View File

@ -0,0 +1,73 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Narrow contract between the per-key editor and any color-map setting.
The editor consumes only this protocol, never `lib.logitech_receiver` directly.
This is the seam where a future frontend/backend split would cut cleanly.
"""
from __future__ import annotations
from typing import Callable
from typing import Protocol
class PerKeyColorSink(Protocol):
"""A device's per-key color buffer, exposed without device internals.
Colors are 24-bit packed RGB ints (0xRRGGBB). The sentinel value -1 means
"no change" / "unset" (matches `special_keys.COLORSPLUS["No change"]`).
"""
@property
def title(self) -> str:
...
@property
def zones(self) -> list[int]:
...
@property
def current(self) -> dict[int, int]:
...
def label(self, zone: int) -> str:
...
def write_one(self, zone: int, color: int) -> None:
...
def write_bulk(self, deltas: dict[int, int]) -> None:
...
def subscribe(self, listener: Callable[[dict[int, int]], None]) -> Callable[[], None]:
"""Register a callback for current-value changes; return an unsubscribe handle."""
...
def palette_state(self) -> tuple[int, int] | None:
"""Return the persisted (active_color, previous_color) for this device's palette, or None."""
...
def set_palette_state(self, active: int, previous: int) -> None:
"""Persist the palette's active and previous colors for this device."""
...
def zone_base_color(self) -> int | None:
"""Return the zone base color (what 'no change' cells actually display
on the keyboard), or None if the device has no zone effect.
"""
...

View File

@ -0,0 +1,223 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Paint tools for the per-key editor.
Each tool is a stateless policy object. The Canvas owns per-stroke state
(press cell, motion cell, brush path) and asks the active tool for the
final delta on release.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
from typing import Protocol
from .layout import BoundCell
from .layout import Cell
@dataclass
class ToolContext:
active_color: int
last_color: int
cells_by_zone: dict[int, BoundCell]
# zone ids that live in the bottom strip (e.g. logo, G-keys); kept separate
# because their on-screen position is decoupled from the matrix grid.
strip_zones: frozenset = frozenset()
# zone_id -> current packed RGB (or -1 sentinel for unset). Used by tools
# that need to compare colors, like the flood-fill bucket.
current_colors: dict = None # type: ignore[assignment]
def __post_init__(self) -> None:
if self.current_colors is None:
self.current_colors = {}
def bound_cells(self) -> list[BoundCell]:
return list(self.cells_by_zone.values())
def matrix_cells(self) -> list[BoundCell]:
cells = [bc for bc in self.cells_by_zone.values() if bc.bound and bc.cell.zone_id not in self.strip_zones]
if cells:
return cells
# No matrix region (e.g. a mouse, where every zone lives in the
# strip). Fall back to all bound cells so directional tools still
# have something to project across.
return [bc for bc in self.cells_by_zone.values() if bc.bound]
def cells_in_bbox(self, a: BoundCell, b: BoundCell) -> list[BoundCell]:
cx_a, cy_a = _cell_center(a.cell)
cx_b, cy_b = _cell_center(b.cell)
x0, x1 = (cx_a, cx_b) if cx_a <= cx_b else (cx_b, cx_a)
y0, y1 = (cy_a, cy_b) if cy_a <= cy_b else (cy_b, cy_a)
result = []
for bc in self.cells_by_zone.values():
if not bc.bound:
continue
cx, cy = _cell_center(bc.cell)
if x0 <= cx <= x1 and y0 <= cy <= y1:
result.append(bc)
return result
def cells_in_bbox_ordered(self, a: BoundCell, b: BoundCell) -> list[BoundCell]:
cells = self.cells_in_bbox(a, b)
return sorted(cells, key=lambda c: (c.cell.row, c.cell.col))
def cells_in_group(self, group: str) -> list[BoundCell]:
return [bc for bc in self.cells_by_zone.values() if bc.bound and bc.cell.group == group]
def _cell_center(cell: Cell) -> tuple[float, float]:
return (cell.col + cell.width / 2.0, cell.row + cell.height / 2.0)
def _cells_touch(a: Cell, b: Cell) -> bool:
"""Bounding-box edge adjacency in grid units. Handles variable widths
(Space spans multiple cols, Numpad+ spans multiple rows).
"""
a_c1, a_r1 = a.col + a.width, a.row + a.height
b_c1, b_r1 = b.col + b.width, b.row + b.height
rows_overlap = a.row < b_r1 and b.row < a_r1
cols_overlap = a.col < b_c1 and b.col < a_c1
if (a_c1 == b.col or b_c1 == a.col) and rows_overlap:
return True
if (a_r1 == b.row or b_r1 == a.row) and cols_overlap:
return True
return False
class Tool(Protocol):
name: str
is_brush: bool
overlay_shape: str # "" | "rect"
def compute(
self,
start: BoundCell | None,
end: BoundCell | None,
path: Iterable[int],
ctx: ToolContext,
) -> dict[int, int]:
...
class SingleTool:
name = "single"
is_brush = True
overlay_shape = ""
def compute(self, start, end, path, ctx):
return {z: ctx.active_color for z in path}
class RectTool:
name = "rect"
is_brush = False
overlay_shape = "rect"
def compute(self, start, end, path, ctx):
if start is None or end is None:
return {}
return {bc.cell.zone_id: ctx.active_color for bc in ctx.cells_in_bbox(start, end)}
class BucketTool:
"""Flood-fill: replace the clicked cell's color in every connected cell
of the same color (4-adjacent on the matrix grid). Strip cells aren't on
the matrix grid, so clicking one paints just that cell.
"""
name = "bucket"
is_brush = False
overlay_shape = ""
def compute(self, start, end, path, ctx):
if start is None:
return {}
new_color = ctx.active_color
target = ctx.current_colors.get(start.cell.zone_id, -1)
if target == new_color:
return {}
if start.cell.zone_id in ctx.strip_zones:
return {start.cell.zone_id: new_color}
# BFS over matrix cells
cells_by_id = {z: bc for z, bc in ctx.cells_by_zone.items() if bc.bound and z not in ctx.strip_zones}
visited = {start.cell.zone_id}
stack = [start]
result: dict[int, int] = {}
while stack:
bc = stack.pop()
result[bc.cell.zone_id] = new_color
for other in cells_by_id.values():
if other.cell.zone_id in visited:
continue
if ctx.current_colors.get(other.cell.zone_id, -1) != target:
continue
if not _cells_touch(bc.cell, other.cell):
continue
visited.add(other.cell.zone_id)
stack.append(other)
return result
class GradientTool:
"""Directional gradient: drag a line from A to B, the whole matrix gets a
gradient at that angle. Cells projecting before A clamp to the previous
color; cells past B clamp to the active color.
"""
name = "gradient"
is_brush = False
overlay_shape = "line"
def compute(self, start, end, path, ctx):
if start is None or end is None:
return {}
ax, ay = _cell_center(start.cell)
bx, by = _cell_center(end.cell)
vx, vy = bx - ax, by - ay
length_sq = vx * vx + vy * vy
if length_sq == 0:
return {start.cell.zone_id: ctx.active_color}
result: dict[int, int] = {}
for bc in ctx.matrix_cells():
cx, cy = _cell_center(bc.cell)
t = ((cx - ax) * vx + (cy - ay) * vy) / length_sq
t = 0.0 if t < 0.0 else 1.0 if t > 1.0 else t
result[bc.cell.zone_id] = _lerp_rgb(ctx.last_color, ctx.active_color, t)
return result
def _lerp_rgb(c0: int, c1: int, t: float) -> int:
if c0 < 0:
c0 = c1
if c1 < 0:
c1 = c0
r0, g0, b0 = (c0 >> 16) & 0xFF, (c0 >> 8) & 0xFF, c0 & 0xFF
r1, g1, b1 = (c1 >> 16) & 0xFF, (c1 >> 8) & 0xFF, c1 & 0xFF
r = int(round(r0 + (r1 - r0) * t))
g = int(round(g0 + (g1 - g0) * t))
b = int(round(b0 + (b1 - b0) * t))
return (r << 16) | (g << 8) | b
TOOLS: dict[str, Tool] = {
"single": SingleTool(),
"rect": RectTool(),
"bucket": BucketTool(),
"gradient": GradientTool(),
}

View File

@ -420,11 +420,11 @@ def _receiver_row(receiver_path, receiver=None):
def _device_row(receiver_path, device_number, device=None):
assert receiver_path
assert device_number is not None
if receiver_path is None:
return None
receiver_row = _receiver_row(receiver_path, None if device is None else device.receiver)
if device_number == 0xFF or device_number == 0x0: # direct-connected device, receiver row is device row
if receiver_row:
return receiver_row
@ -536,7 +536,13 @@ def _update_details(button):
if device.product_id:
yield _("Product ID"), f"{LOGITECH_VENDOR_ID:04x}:" + device.product_id
hid_version = device.protocol
yield _("Protocol"), f"HID++ {hid_version:1.1f}" if hid_version else _("Unknown")
cent_proto = getattr(device, "_centurion_protocol", None)
if cent_proto:
yield _("Protocol"), f"Centurion {cent_proto[0]}.{cent_proto[1]}"
elif hid_version:
yield _("Protocol"), f"HID++ {hid_version:1.1f}"
else:
yield _("Protocol"), _("Unknown")
if read_all and device.polling_rate:
yield _("Polling rate"), device.polling_rate

View File

@ -1 +1 @@
1.1.17
1.1.20rc2

2063
po/bg.po Normal file

File diff suppressed because it is too large Load Diff

3934
po/cs.po

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,8 @@
msgid ""
msgstr "Project-Id-Version: solaar 1.0.1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-28 17:40+0100\n"
"PO-Revision-Date: 2023-07-07 11:06+0200\n"
"POT-Creation-Date: 2026-05-11 19:38+0200\n"
"PO-Revision-Date: 2026-05-11 19:35+0200\n"
"Last-Translator: Daniel Frost <one@frostinfo.de>\n"
"Language-Team: none\n"
"Language: de\n"
@ -16,7 +16,7 @@ msgstr "Project-Id-Version: solaar 1.0.1\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Launchpad-Export-Date: 2021-04-17 16:52+0000\n"
"X-Generator: Poedit 3.3.2\n"
"X-Generator: Poedit 3.4.2\n"
#: lib/logitech_receiver/base_usb.py:46
msgid "Bolt Receiver"
@ -753,27 +753,28 @@ msgstr "Mit zwei Fingern vom oberen Bildschirmrand wischen"
#: lib/logitech_receiver/settings_templates.py:944
#: lib/logitech_receiver/settings_templates.py:948
msgid "Pinch to zoom out; spread to zoom in."
msgstr "Kneifgeste zum Verkleinern; Spreizgeste zum Vergrößern."
msgstr "Zusammenziehen-Geste zum Verkleinern; Auseinanderbewegen-Geste zum "
"Vergrößern."
#: lib/logitech_receiver/settings_templates.py:944
msgid "Zoom with two fingers."
msgstr "Vergrößerung mit zwei Fingern ändern."
msgstr "Zoom mit zwei Fingern."
#: lib/logitech_receiver/settings_templates.py:945
msgid "Pinch to zoom out."
msgstr "Kneifgeste zum Verkleinern."
msgstr "Zusammenziehen-Geste zum Verkleinern."
#: lib/logitech_receiver/settings_templates.py:946
msgid "Spread to zoom in."
msgstr "Spreizgeste zum Vergrößern."
msgstr "Auseinanderbewegen-Geste zum Vergrößern."
#: lib/logitech_receiver/settings_templates.py:947
msgid "Zoom with three fingers."
msgstr "Vergrößerung mit drei Fingern ändern."
msgstr "Zoom mit drei Fingern."
#: lib/logitech_receiver/settings_templates.py:948
msgid "Zoom with two fingers"
msgstr "Vergrößerung mit zwei Fingern"
msgstr "Zoom mit zwei Fingern"
#: lib/logitech_receiver/settings_templates.py:966
msgid "Pixel zone"
@ -1240,7 +1241,7 @@ msgstr "Gerät"
#: lib/solaar/ui/diversion_rules.py:524 lib/solaar/ui/diversion_rules.py:2297
msgid "Host"
msgstr ""
msgstr "Host"
#: lib/solaar/ui/diversion_rules.py:525 lib/solaar/ui/diversion_rules.py:2339
msgid "Setting"
@ -1480,7 +1481,7 @@ msgstr "Maustaste"
#: lib/solaar/ui/diversion_rules.py:1922
msgid "Count and Action"
msgstr ""
msgstr "Anzahl und Aktion"
#: lib/solaar/ui/diversion_rules.py:1972
msgid "Execute a command with arguments."
@ -1518,11 +1519,11 @@ msgstr "Das Gerät ist aktiv und die Einstellungen können geändert werden."
#: lib/solaar/ui/diversion_rules.py:2266
msgid "Device that originated the current notification."
msgstr ""
msgstr "Gerät, von dem die aktuelle Benachrichtigung stammt."
#: lib/solaar/ui/diversion_rules.py:2280
msgid "Name of host computer."
msgstr ""
msgstr "Name des Host-Computers."
#: lib/solaar/ui/diversion_rules.py:2347
msgid "Value"
@ -1665,7 +1666,7 @@ msgstr "\n"
#: lib/solaar/ui/tray.py:58
msgid "No supported device found"
msgstr ""
msgstr "Kein unterstütztes Gerät gefunden"
#: lib/solaar/ui/tray.py:64 lib/solaar/ui/window.py:319
#, python-format
@ -1882,9 +1883,6 @@ msgstr "%(light_level)d Lux"
#~ msgid "DPI Sliding Adjustment"
#~ msgstr "DPI-Anpassung durch seitliches Bewegen"
#~ msgid "Device originated the current notification."
#~ msgstr "Die aktuelle Be­nach­rich­ti­gung entspringt dem Gerät."
#~ msgid "Diverted key or button depressed or released.\n"
#~ "Use the Key/Button Diversion setting to divert keys and buttons."
#~ msgstr "Umgeleitete Taste oder Maustaste gedrückt bzw. losgelassen.\n"
@ -1926,9 +1924,6 @@ msgstr "%(light_level)d Lux"
#~ msgstr "Wenn Sie Solaar gerade neu installiert haben, versuchen Sie "
#~ "den Empfänger aus- und wieder einzustecken."
#~ msgid "No Logitech device found"
#~ msgstr "Kein Logitechgerät gefunden"
#~ msgid "No Logitech receiver found"
#~ msgstr "Kein Logitech-Empfänger gefunden"

3448
po/fi.po

File diff suppressed because it is too large Load Diff

1113
po/fr.po

File diff suppressed because it is too large Load Diff

2162
po/ka.po Normal file

File diff suppressed because it is too large Load Diff

1225
po/pl.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1932
po/sk.po

File diff suppressed because it is too large Load Diff

View File

@ -5,9 +5,9 @@
#
#, fuzzy
msgid ""
msgstr "Project-Id-Version: solaar 1.1.17rc3\n"
msgstr "Project-Id-Version: solaar 1.1.19\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-01 21:43+0300\n"
"POT-Creation-Date: 2026-03-05 14:00+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -34,7 +34,7 @@ msgstr ""
msgid "Lightspeed Receiver"
msgstr ""
#: lib/logitech_receiver/base_usb.py:135
#: lib/logitech_receiver/base_usb.py:136
msgid "EX100 Receiver 27 Mhz"
msgstr ""
@ -354,20 +354,20 @@ msgid "ADC measurement notification"
msgstr ""
#: lib/logitech_receiver/notifications.py:428
#: lib/logitech_receiver/notifications.py:483
#: lib/logitech_receiver/notifications.py:484
msgid "pairing lock is closed"
msgstr ""
#: lib/logitech_receiver/notifications.py:428
#: lib/logitech_receiver/notifications.py:483
#: lib/logitech_receiver/notifications.py:484
msgid "pairing lock is open"
msgstr ""
#: lib/logitech_receiver/notifications.py:446
#: lib/logitech_receiver/notifications.py:447
msgid "discovery lock is closed"
msgstr ""
#: lib/logitech_receiver/notifications.py:446
#: lib/logitech_receiver/notifications.py:447
msgid "discovery lock is open"
msgstr ""
@ -1151,7 +1151,7 @@ msgid "Force Sensing Button"
msgstr ""
#: lib/logitech_receiver/settings_templates.py:1814
msgid "Haptic Feeback Level"
msgid "Haptic Feedback Level"
msgstr ""
#: lib/logitech_receiver/settings_templates.py:1815
@ -1175,19 +1175,19 @@ msgid "Manages Logitech receivers,\n"
"keyboards, mice, and tablets."
msgstr ""
#: lib/solaar/ui/about/model.py:63
#: lib/solaar/ui/about/model.py:64
msgid "Additional Programming"
msgstr ""
#: lib/solaar/ui/about/model.py:64
#: lib/solaar/ui/about/model.py:65
msgid "GUI design"
msgstr ""
#: lib/solaar/ui/about/model.py:66
#: lib/solaar/ui/about/model.py:67
msgid "Testing"
msgstr ""
#: lib/solaar/ui/about/model.py:74
#: lib/solaar/ui/about/model.py:75
msgid "Logitech documentation"
msgstr ""

Some files were not shown because too many files have changed in this diff Show More