The PRO X RAPID (reported as "PRO X RAPID", USB 046D:C35B) wires five
dedicated media keys above the F-row with board-specific 0x8081 zone ids
(150 Bright, 155 Prev, 152 Play, 154 Next, 153 Mute) that the canonical
extras map (153-158) doesn't cover: 150/152/154 were dropped as phantoms
and never surfaced in the painter. Ids and positions are hardware-probed
(ported from the OpenRGB key-map for this model).
Add keyboard_pro_x_rapid.with_media_top_row(), which pushes any regional
TKL base down one row and prepends the media row as explicit bound cells.
No EXTRAS_ALLOWLIST change is needed - the media keys paint directly,
while the base's extra_zones keeps filtering the phantom zones this board
shares with the G515 (47, 97, 99-103, 254). Registered per region ahead
of the generic family matchers so country-code routing (ANSI/ISO/AZERTY/
JIS) still selects the main block; only the media top row differs. The
logo has no addressable LED on this model, so no logo zone is mapped.
Labels are words rather than media glyphs: the media/emoji codepoints
aren't in DejaVu (the baseline Linux font) and tofu on minimal installs,
unlike the nav-arrow glyphs which the base font covers.
Receiver.close() nulled its own handle first and only then closed its
paired devices. A paired device's feature_request() sends over the
receiver's handle, so the device.cleanups callbacks that run inside
Device.close() - release RGB SW control, restore onboard-profile mode -
were issued with handle=None, failed, and were silently swallowed. On
every clean Solaar quit a receiver-paired keyboard was left in host
mode; on the G915 TKL that means dead F-keys until a power cycle
(#3266). Wired devices were unaffected: Device.close() already runs
cleanups before clearing its handle, per its own comment.
Close the devices first, then drop the handle, mirroring Device.close().
Also log cleanup write failures in rgb_power.cleanup at debug level
instead of swallowing them silently, so a torn-down transport can't
hide; debug because failures are routine when the device is unplugged.
The regression test fails against the previous ordering.
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.
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.
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".
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.
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.
_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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
_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.
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.
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.
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.
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.
* 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.
"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.
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.
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.
_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.
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).
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.
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.