Rewrite PARAMETERS_FIELDS with the actual field layout for each module.
The previous table treated byte 0 as a "state" byte across the board
and shifted every other field by one position; the offset-0 byte is
just the first real field of each struct (NR sensitivity, NG/Comp/
DeEss/DePop threshold, Limiter boost, HPF frequency). Verified field-
by-field against the captured G522 bring-up samples — every parsed
default now matches the device-reported factory value:
NR : sensitivity=40 release=50ms bias=0 attenuation=-6dB
NG : threshold=-45dB attenuation=-15dB attack=1ms hold=150ms
release=250ms
Comp: threshold=-18dB attack=50ms release=400ms post_gain=3dB
pre_gain=0dB ratio=2
DeEss: threshold=-27dB frequency=8000Hz width_q=32 attack=1ms
release=250ms attenuation=-12dB
DePop: threshold=-12dB frequency=100Hz width_q=16 attack=1ms
release=250ms attenuation=-12dB
HPF : frequency=120Hz
Compressor `byte7_packed` was wrong — pre_gain and ratio occupy
distinct bytes (5 / 6 / 7) rather than being bit-packed into byte 7.
HPF frequency moved from offset 1 to offset 0 (we'd been reading
0x7800 = 30720 Hz instead of 0x0078 = 120 Hz).
Add parse_info() to decode GetInfo (fn 0x40) into per-field
{min, max} dicts. Layout is per-field [min, max] back-to-back using
each field's wire encoding. probe_module logs the bounds at INFO so
the corpus shows device-reported ranges alongside the parsed values.
Most fields drop their `(raw)` placeholder labels for proper unit
labels (dB, ms, Hz). De-esser/De-popper width_q stays opaque because
the device-loaded scale constant isn't pinned down.
_log_feature_table only walked FeaturesArray's parent inverse map, so
the corpus dump showed the dongle's 5-6 parent features and `?` for
indexes 6+ — which gave the wrong impression that the headset had
nothing else exposed. The actual headset features (0x0620 RGB
hostmode, 0x0621/0x0622 RGB effects, the LogiVoice 0x0901-0x0907 set,
0x0636 onboard EQ, 0x0601 mic mute, …) live behind the Centurion
bridge in FeaturesArray.sub_inverse, keyed by sub-device feature index.
Enumerate sub_inverse separately and emit a second log line so the
next probe run captures the full sub-device feature list. Also include
the SupportedFeature enum name when available so the analyst doesn't
have to keep cross-referencing IDs.
Rewrite parse_v2_bands to match the wire format the G522 firmware
actually emits: 3-byte header [direction_echo, slot_echo,
band_count_max], 5-byte stride [filter_type, gain_BE, freq_BE], and a
0..2 byte trailer that's opaque. band_count_max is the device's
max-bands capacity, not the populated-band count — bands are consumed
until <5 bytes remain or freq=0 marks end-of-bands.
Gain encoding switched from signed×step_db to offset-binary against
the gain bounds reported by getEQInfos, so raw 120 with steps=241 and
gain=[-6,6] decodes to 0 dB (flat) rather than +6 dB.
Add 0x16 to FILTER_TYPE_NAMES as peaking — that's the byte every G522
band carries at the textbook ISO third-octave centers (50, 125, 250,
500, 1000, 2500, 5000, 10000, 20000 Hz).
Previously the parser bailed because the 54-byte response wasn't a
clean multiple of 5, so the EQ panel never built.
G522 advertises HEADSET_MIC_MUTE (0x0601) in its FeatureSet at index 21
but the firmware returns 0x0A UNSUPPORTED for both GetState (fn 0x10)
and SetState (fn 0x00) — the physical mute switch on the headset doesn't
drive this feature, and G HUB silently swallows the failures. Hide the
broken toggle on G522 PIDs (0x0B18 wireless, 0x0B19 wired) by returning
None from build() so it never reaches the UI.
Also strip the trailing space on the sub-device comment in get_feature
that pre-commit's whitespace hook flags in CI.
Bridge MessageEvents arrive with sub_feat_idx + 0x100 as the notification
sub_id. get_feature() only checked self.inverse (parent features), so sub-
device feature notifications (including CENTURION_BATTERY_SOC charging
changes) were silently dropped with 'invalid feature index'.
Check sub_inverse at index - 0x100 for any index >= 0x100 so spontaneous
notifications from the headset (e.g. charging status change) are correctly
dispatched to the feature notification handler.
Logs raw response bytes + lengths for HEADSET_RGB_ONBOARD_EFFECTS
(0x0621) fn 0x00/0x10/0x20/0x40 and HEADSET_RGB_SIGNATURE_EFFECTS
(0x0622) fn 0x00/0x10/0x30 on any headset that exposes them. Data
goes to the RE pass pinning down the effect-payload shapes those
features advertise; per-call hex + len + error code at INFO so
testers without -dd still produce a useful corpus.
Probe is strictly read-side and gated per-device via
_rgb_effects_probed so reconnects and setting rebuilds don't log
duplicate dumps. Wired into HeadsetLEDControl.build() since that's
the existing headset-specific setup hook — devices without RGB
hostmode won't run the probe and incur no noise.
HeadsetLEDControl: when switching from Device to Solaar control, the
firmware drops any previously-programmed zone colors — so after the
mode write succeeds, reassert the saved Primary + per-zone overrides
via a single zone-map write. Previously the LEDs stayed on the last
device-driven effect until the user edited a color, which made the
mode switch look broken.
LogiVoice state: SetState (fn 0x00) / GetState (fn 0x10) carry a
single boolean byte across all modules 0x0901-0x0907 (confirmed via
G HUB reverse-engineering — LGHUB only ever writes 0x00 or 0x01).
Drop the write-block in _LogiVoiceStateSetting and remove persist=
False so the per-module toggles behave like every other boolean
setting. Per-module Parameters remain read-only until each field's
wire encoding is confirmed.
Labels: "LogiVoice Noise Reduction: State (read-only)" becomes just
"LogiVoice Noise Reduction"; description updated to "Enable the
headset ... processing block." now that it's user-writable.
LogiVoice parameters: _LogiVoiceParametersValidator extends
MultipleRangeValidator but Setting.read() calls validate_read(reply) —
which neither class defined — so every LogiVoice parameters setting
raised AttributeError on read. Add validate_read that wraps the
existing validate_read_item using the single-item shape, and key the
parsed dict by str(sub_item) so MultipleRangeControl.set_value can
find values via v[str(sub_item)] instead of falling back to controls'
current zero values.
Stale _absent cache: when a feature is cached absent from a prior
build but device.features now reports it present, the cache is stale.
Drop the entry and retry the probe instead of silently skipping with
a config-deletion hint — if the retry still returns None, the
existing branch at the end of the loop re-adds it to new_absent.
Replaces the ad-hoc HeadsetRGBHostMode / HeadsetRGBColor pair with three
settings that mirror Solaar's existing RGB UX for keyboards and mice:
HeadsetLEDControl — Device/Solaar dropdown (ChoicesValidator,
same style as LEDControl / RGBControl).
HeadsetLEDsPrimary — gtk color picker via HeteroValidator, one
Static effect with a single COLOR field.
Writes apply to all discovered zones, then
re-apply per-zone overrides on top.
HeadsetPerZoneLighting — Settings + ChoicesMapValidator (mirrors
PerKeyLighting syntax, labeled "Per-zone"
since the firmware/spec uses "zone"). Uses
COLORSPLUS so "No change" inherits the
Primary color.
Adds lib/logitech_receiver/headset_rgb.py with two reusable helpers:
discover_zones(device) — one-shot zone enumeration run at
setting build time, briefly claiming
Solaar host mode and restoring the
prior state; cached on the device.
write_zone_map(device, map) — shared write path that groups zones
by final color, emits one
SetRgbZonesSingleValue per unique
color, then FrameEnd(0x01) to
commit.
Any future RGB headset presenting 0x0620 picks up these settings
automatically — the new module is feature-keyed, not G522-specific.
Drops the stale logivoice.py import block (missing blank line between
stdlib and typing imports) that failed ruff's isort check in CI.
Introduces lib/logitech_receiver/logivoice.py with per-module
Parameters decoding (0x0901 NR, 0x0902 NG, 0x0903 Comp, 0x0904
De-esser, 0x0905 De-popper, 0x0906 Limiter, 0x0907 HPF) and a
probe_module helper that logs state + raw Parameters + raw Info
at INFO per module.
Auto-generates 14 settings: a State toggle per module (reads
GetState fn 1) plus a collapsible Parameters panel per module
(reads GetParameters fn 3 once, distributes bytes to per-field
sliders via Solaar's existing MultipleRangeControl widget).
Read-only for now — Parameters field encodings still have
ambiguous scales and bit-packing per-module, and a SetParameters
write must bundle all fields at once. Write support can be added
per-field once each encoding is confirmed live.
Downgrade INFO logs that served their purpose during format/bug
discovery to DEBUG (bridge TX per-call, Centurion feature enumeration
per-feature, RGB zone discovery) and drop the HeadsetRGBHostMode
diagnostic write wrapper entirely.
Keep all EQ read paths at INFO — AdvancedParaEQ work is still in the
data-collection phase, writes are gated, and at least one tester
can't surface DEBUG logs. Keep all failure paths (build failures,
_absent cache hits, bridge sub-device errors) and one-shot signals
(device_addr probe result, deferred-init completion) at INFO.
Routine -dd output shrinks notably; INFO becomes mostly actionable
events. Nothing is lost — everything is recoverable at DEBUG.
The RE pass against lghub_agent.arm64 plus the G522 live probe resolved
the V2 wire format. Key corrections to the previous implementation:
1. 5-byte stride is [filter_type, freq_hi, freq_lo, gain_hi, gain_lo].
The initial RE interpretation of [freq_hi, freq_lo, gain, q_hi, q_lo]
was wrong — the 0x78 byte is a filter-type sentinel (peaking), not
the high byte of a frequency.
2. No header before the bands. G522's default "header" was actually
band 0: a high-pass filter at 20 Hz (filter_type=0x00, freq=0x0014).
Total is 10 bands (1 HP + 9 peaking at ISO octaves), not 9.
3. Frequency is raw Hz as BE u16 — no log/ERB/bin transform. 0x4E20
is exactly 20000 Hz.
4. Gain is signed BE int16 (not int8), scaled by step_db from
getEQInfos. ±120 maps to ±6 dB at 0.05 dB/LSB on the G522.
5. No Q on the wire — firmware-fixed per filter type.
get_advanced_eq_info is unchanged (13-byte V2 decode was already right).
Parser tuple shape is now (filter_type_byte, freq_hz, gain_db) across
both V0/V1 and V2 paths; V0/V1 synthesises filter_type=peaking so the
shape is uniform. Band labels display real Hz — "HP 20Hz", "50Hz",
"125Hz", ..., "20000Hz" on G522.
Stays read-only. Will enable write once we round-trip-test with known
raw bytes.
Adds get_advanced_eq_defaults (function 5), get_advanced_eq_friendly_name
(function 6), and a probe_all_presets helper that reads every factory
and custom preset slot and logs its name + band data at INFO.
The G522 exposes 6 factory presets and 10 custom slots. Reading each
and dumping (freq_u16, gain_db, q_u16) across a corpus of named presets
(Flat, Bass Boost, Vocal, etc.) should reveal the u16->Hz and u16->Q
encodings by pattern-matching — without needing a LGHUB pcap. The
results land in the normal -dd log so the next tester run gives us the
data.
One-shot probe runs at HeadsetAdvancedEQ.build() for V2 devices only.
Failures are logged at INFO and don't block panel construction.
G522's 0x020D V2 uses a 5-byte band stride
[freq_hi, freq_lo, gain_i8, q_hi, q_lo] and a 13-byte getEQInfos
(gain bounds, gain_steps, format, xy, preset counts). Frequency and
Q are opaque u16 round-trip values — the u16->Hz and u16->Q mappings
need a LGHUB pcap to pin down (documented in
~/ghub/HEADSET_ADVANCED_PARA_EQ_WIRE_PROTOCOL.md).
get_advanced_eq_info now returns a dict with a `version` discriminator
and the union of V0/V1 and V2 fields; step_db is derived from the
gain_min/gain_max/gain_steps triple on V2 (0.05 dB/LSB on G522).
get_advanced_eq_params version-switches: V2 uses parse_v2_bands which
probes header length {5, 2, 0} until the tail is a clean multiple of
5, strips trailing all-zero terminator entries. V0/V1 falls through
to the legacy 3-byte stride so older devices still work.
HeadsetAdvancedEQ.build() no longer requires band_count from
getEQInfos (V2 doesn't advertise it); derives from getCustomEQ length
per the wire-protocol doc's recommendation. V2 band labels use
"Band N" since u16->Hz isn't confirmed. Read-only still — writes
stay gated until pcap confirms the encodings.
FrameEnd 0x02 (persistent) returns LOGITECH_INTERNAL (0x05) on the G522
even after a successful SetHostModeState + SetRgbZonesSingleValue
sequence. Persistent commit probably requires onboard profile state
we haven't mapped. Use 0x01 (transient) so the LEDs at least refresh
visually; sort out persistence once we have a wireshark capture of the
LGHUB commit sequence.
check_feature_settings now logs at INFO when it skips a setting cached
in the persister's _absent list despite the feature being present on
the device. Without this, a previous run's failed build() silently
suppresses the setting forever — users see no panel and no diagnostic.
Centurion feature discovery now logs each parent + sub-device feature at
INFO with name/index/version/flags. `check_feature` logs INFO when it
skips a setting for min_version or the INTERNAL flag. HeadsetAdvancedEQ
and HeadsetOnboardEQ `build()` paths log at every failure branch. The
three AdvancedParaEQ helpers log raw response bytes.
On the G522, `HEADSET_ADVANCED_PARA_EQ` (0x020D) is present in the
feature set but no settings panel appears and current logging gives us
no way to tell which step silently returns None.
Field testing revealed cross-contamination between function calls on
the same sub-device feature. A late-arriving MessageEvent for
GetRGBZoneInfo (function 1) was being accepted as the response to a
subsequent GetHostModeState (function 7) on the same feature (0x0620),
because _is_bridge_response_for only matched on sub_feat_idx.
Evidence from tester log:
HeadsetRGBHostMode.write: before=b'\x08\x01\x02\x03\x04\x05\x06\x07\x08...'
The "before" read of GetHostModeState returned what is clearly a
GetRGBZoneInfo response (count=8, zones=[1..8]) queued from earlier.
The device echoes our exact sub-device function byte (function<<4 | sw_id)
in the response. Plumb that expected value from centurion_bridge_request
through _is_bridge_response_for and reject any response whose
sub_func_sw doesn't match. Also validate orig_func_sw for error responses.
Also removes the zone_id==0 filter in HeadsetRGBColor._zone_ids — with
proper response matching the device should now consistently report real
zone IDs (G522 has 8 zones at IDs 1-8 per the delayed response capture),
and filtering is no longer needed.
Further RE finding refines FrameEnd byte 0 semantics:
0x02 = persistent commit — saves to onboard NVS as baseline, survives
the firmware's host-mode self-release window (color sticks)
0x01 = transient commit — for live preview/animation frames, doesn't
wear out NVS; requires keepalive or continuous frames
0x00 = silently discarded by firmware (the old bug)
For solaar's "pick a color and walk away" model, users expect the
color to persist. Use 0x02 when writing a real color. When writing
black (off), use 0x01 — matches LGHUB's turn_off_lighting so we don't
save an all-black baseline to the device's NVS.
This should make colors actually stay visible after the firmware
auto-releases host mode, which was the root cause of the "color
doesn't appear" symptom on the G522.
RE of lghub_agent.arm64 (see HEADSET_RGB_HOSTMODE_WIRE_PROTOCOL.md)
corrected the canonical protocol doc: FrameEnd byte 0 is a frame_type
tag where 0x01 = transient commit and 0x02 = persistent/final flush.
The firmware silently discards frames sent with byte 0 = 0x00 — the
HID++ ACK succeeds but the staged color writes are never committed.
This is why SetRgbZonesSingleValue appeared to succeed but LEDs never
changed color on the tester's G522.
The canonical doc's "For basic usage, all parameter bytes can be set
to 0x00" is wrong. Change FrameEnd payload from `\x00\x00\x00\x00` to
`\x01\x00\x00\x00`.
Also worth noting (not fixed here): firmware auto-releases the
host-mode claim if no Set+FrameEnd traffic arrives within a few
seconds. HeadsetRGBColor already re-issues SetHostModeState(1) on
every color change (the LGHUB "on-demand" model), so that covers the
common case. HeadsetRGBHostMode toggle alone will still appear to
"not stick" in GetHostModeState after the firmware release window —
that's the firmware's behavior, not a bug.
solaar show has been reporting headset-rgb-hostmode as False even
when SetHostModeState(1) just succeeded without error. Could be:
(a) the write doesn't actually stick on the device, OR
(b) our GetHostModeState response decode is wrong, OR
(c) the device uses a different function for "read" than we expect.
Log the raw response bytes returned by function 7 (GetHostModeState)
immediately before and after the SetHostModeState write so the next
test log will show:
- what byte the device returns for "off"
- what byte the device returns for "on"
- whether the byte changes across a write
Diagnostic only — temporary; remove once we've isolated the cause.
RE of lghub_agent.arm64 revealed that LGHUB's service-layer handler
for 0x0618 HeadsetBatterySaverMode
(on_headset_battery_saver_set_handler @ 0x100c21790) compares the
requested new state against its cached current state and ONLY invokes
the devio SetEcoModeState write on a genuine transition. Wire format
is confirmed 0/1 (canonical doc correct); the G522 firmware rejects
no-op writes with device-specific NACK 0x0B.
BooleanValidator's prepare_write already contains the "skip if same as
current" branch — it just fires only when current_value is supplied,
which requires needs_current_value=True. For default-mask (0xFF)
validators, needs_current_value defaults to False so Setting.write
skips the pre-read and prepare_write gets current_value=None.
HeadsetEcoMode now builds its validator explicitly and forces
needs_current_value=True so Setting.write reads first, compares in
prepare_write, and skips redundant writes — matching LGHUB exactly.
Same fix may apply to other Centurion boolean features whose firmware
rejects no-op writes, but leaving those unchanged until observed.
If the HeadsetMicGain GetInfo probe goes wrong, we were silently
falling back to the int8 default range with no log. Now we log each
fallback path distinctly so field diagnostics can tell:
- Exception during GetInfo (transport error)
- GetInfo returned non-None but too short (truncated response)
- GetInfo returned nonsense range (max <= min, probably wrong feature
format)
Separately, centurion_bridge_request now logs outgoing sub-messages at
INFO level showing sub_idx, function, sw_id, and payload hex. This
pairs with the existing "bridge sub-device error" INFO log so when a
NACK fires we can see both the rejected value and the device's error
code in one place. Verbose during normal operation — dial back to
DEBUG once the G522 writes are confirmed working.
Per newly-documented HEADSET_MIC_WIRE_PROTOCOL.md, feature 0x0611
returns device-specific NACK 0x0B when SetMicGain is written with a
value outside the device's supported range. LGHUB calls GetInfo
(function 0) once at startup to cache (min_gain, max_gain) as two
signed int8 bytes, then rescales subsequent writes into that range.
Solaar was using int8's full range (-128..127) as validator bounds
and accepting any UI value — which goes out-of-range on devices with
narrow ranges like the G522 (current gain is 8, suggesting small
range). Every gain change attempt NACK'd with 0x0B.
Fix: add HeadsetMicGain.build() that queries GetInfo at probe time,
parses the two-byte [min, max] response as signed int8, and hands
those to the RangeValidator. Falls back to the int8 default range
if GetInfo is unavailable or returns nonsense. Logs the reported
range at INFO so testers can verify in the log.
Does NOT address mic-mute NACK 0x0A — per the same doc that's the
hardware mic-flip boom position locking software mute on the G522,
and there's nothing software can do about it.
The previous commit fixed version recording for bridge-routed sub-device
features (Phase B). This applies the same fix to Phase A, which is used
for parent features on a wireless dongle AND for the whole feature set
on direct-USB Centurion devices like the wired G522 (PID 0x0B19).
Without this, a wired Centurion headset would enumerate all features
with version=0 and version-gated settings (sidetone 3-byte format,
auto-sleep 2/3-byte timer) would send the wrong payload and get
OUT_OF_RANGE rejections — same symptom the wireless G522 had before.
The Centurion sub-device discovery ignored the type/version bytes in
the per-index getFeatureId response and defaulted every feature's
version to 0. That made version-gated settings (sidetone, auto-sleep)
send V0 payload formats on features that may actually be V2/V3/V4 —
which the G522 rejects with OUT_OF_RANGE (error 0x03).
Evidence from tester's log (version acfd02ab):
bridge sub-device error: orig_feat_idx=13 orig_func=0x1B error=0x03
bridge sub-device error: orig_feat_idx=20 orig_func=0x1F error=0x03
feat_idx 13 is HeadsetAudioSidetone (0x0604); 20 is CenturionAutoSleep
(0x0108). Both have version-gated payload formats per the protocol doc.
Fixes:
1. In _discover_sub_device_features, extract response[3] (type) and
response[4] (version) and store them in self.version / self.flags,
so get_feature_version() returns the real version for downstream
callers.
2. Add HeadsetAutoSleep.build() that picks byte_count and max_value
based on the reported version (V<3=1 byte, V=3=2 bytes, V>=4=3).
HeadsetSidetone.build() already had version gating — it just wasn't
getting the real version before.
Does NOT address the mic-mute (error 0x0A) and mic-gain (error 0x0B)
rejections — those are non-standard error codes likely meaning the G522
either gates those behind the physical mic button (mic-mute) or rejects
writes for other reasons (mic-gain). Needs separate investigation once
the simpler version fix lands.
The feature_request path for Centurion sub-device features routes
through centurion_bridge_request, which returned None silently when
the sub-device replied with sub_feat_idx=0xFF (error marker). The
existing log was at DEBUG, invisible for users whose -dd doesn't
turn on DEBUG-level output.
The write-returned-no-reply INFO we added recently caught mic-gain /
sidetone / auto-sleep writes failing with no visible log about WHY.
Bumping this log to INFO surfaces the original feat_idx, function,
and error code so we can distinguish transport timeout vs device
rejection, and debug what exactly the G522 dislikes about our writes.
No behavior change — same return value (None), just more visible log.
HID++ 2.0 'set' operations (SetSidetone, SetAutoSleep, SetMicGain etc.)
frequently respond with an empty ACK — just the echoed request_id with
no data bytes after. device.feature_request() strips the request_id
echo and returns the remaining payload, so our reply variable is b""
for these successful writes.
The old check `if not reply:` evaluates b"" as falsy, so we returned
None from Setting.write, which the GUI interpreted as "Read/write
operation failed" — even though the device actually succeeded.
Change the check to `if reply is None:` so empty bytes (success) pass
through and only genuine transport failures (None return from base.request)
are flagged as errors. Also add an INFO log on the real failure path so
the user can tell from the log whether a write reached the device.
This likely fixes the G522 sidetone / mic-gain / auto-sleep "Read/write
operation failed" reports — all of those features return empty ACKs for
set operations.
Field testing on G522 showed two problems:
1. GetRGBZoneInfo returned all zeros (count=0, all-zero body) when
queried BEFORE SetHostModeState(1). Protocol doc's recommended
order is: claim host mode first, then enumerate zones. Our code
was querying zones first.
2. The response format the G522 returns does NOT match the doc's
layout. Doc says [count, 3 reserved, 1 reserved, zone_ids...] but
the G522 seems to pack them tight as [count, zone_ids...] — the
SetRgbZonesSingleValue response "0801020304050607080000" decodes
cleanly as count=8, zones=[1..8] under the tight format.
Fixes:
- Call SetHostModeState(1) before GetRGBZoneInfo.
- Try tight format first, fall back to doc format. Only cache a
parsed result if it makes sense (non-zero zone IDs, count matches).
- Don't cache ambiguous results so subsequent writes retry.
This also incidentally suggests the G522 has 8 RGB zones (not the 2
left/right earcups we were guessing) — the red set we sent in the
previous test probably did set zones 0x01 and 0x02 correctly, but
those zones are only a fraction of the total lighting so the color
change wasn't visible.
The G522 exposes AdvancedParaEQ (0x020D), a different EQ feature than
the PRO X 2's OnboardEQ (0x0636). Key differences:
- 3-byte-per-band wire format ([freq_hi, freq_lo, gain]) vs 0x0636's
4-byte-per-band ([freq_hi, freq_lo, gain, Q])
- Device handles biquad coefficient computation — no host-side DSP math
- Has explicit direction (playback/capture), multiple preset slots with
getActiveEQ/setActiveEQ for switching, preset friendly names
Adds new advanced_para_eq.py module with getInfos, getActiveEQ, and
getCustomEQ helpers (function 0, 3, 1 respectively). Re-exported from
hidpp20.py following the onboard_eq.py pattern.
HeadsetAdvancedEQ setting displays the currently-active playback EQ
using the same RangeFieldSetting + PackedRangeValidator UI pattern as
HeadsetOnboardEQ, so the graphic EQ widget looks the same.
**Writes are intentionally disabled for now** — prepare_write returns
None and write() logs "read-only mode" without sending anything. This
lets us verify the wire format matches the protocol doc against real
hardware before risking a write that could misconfigure the DSP. Once
read output is confirmed sensible, we'll wire up setCustomEQ (function
2) to enable writes.
Drop the ad-hoc 10-color preset list and feed special_keys.COLORS
(the Xorg rgb.txt palette solaar already uses for DPI button color
mapping) directly as the ChoicesValidator choices_universe. Users now
pick from the same named colors other parts of the app expose, and we
stop inventing a one-off naming scheme.
"Off" goes away as a color option — to disable host control, toggle
the separate headset-rgb-hostmode setting instead. Picking `black`
writes (0,0,0) which effectively turns the LEDs off while host mode
stays claimed.
This still isn't the "ideal" solaar pattern (Kind.COLOR with
Gtk.ColorButton via HeteroValidator), but it reuses existing
infrastructure — ChoicesValidator + NamedInts — instead of creating
new patterns just for this setting.
If the RGB write doesn't work as expected on a tester's G522, we now
get enough trace info at INFO level to see exactly what happened:
- Color chosen + RGB values + target zone IDs (hex)
- Each of the 3 feature requests logged with payload hex and response
hex: SetHostModeState(1), SetRgbZonesSingleValue, FrameEnd
- Zone discovery: logs raw GetRGBZoneInfo response when it succeeds,
WARNING when response shape is unexpected and we fall back to
[0x01, 0x02]
- WARNING on exceptions with color name context
Existing ERROR logs in base.py already surface device-returned error
responses (OUT_OF_RANGE, UNSUPPORTED etc.) for the individual feature
calls, so a stack of ERROR + our INFO context tells us which of the
three steps failed.
First pass at controlling the G522's RGB LEDs via feature 0x0620
HEADSET_RGB_HOSTMODE. Exposes two settings:
1. headset-rgb-hostmode — boolean toggle for SetHostModeState
(function 8). Turning this on claims LED control; off returns
control to firmware effects.
2. headset-rgb-color — preset color chooser (Off, Red, Green, Blue,
White, Yellow, Cyan, Magenta, Orange, Purple). On write it enables
host mode, queries zone IDs via GetRGBZoneInfo (function 1), calls
SetRgbZonesSingleValue (function 5) with the chosen RGB, then
commits via FrameEnd (function 6). "Off" issues SetHostModeState(0)
only.
Protocol reference: ~/ghub/LOGITECH_HIDPP2_PROTOCOL.md functions on
0x0620. We skip the SetSWControl on 0x0600 step because the G522
doesn't expose 0x0600. If the device rejects host-mode writes without
that prerequisite we'll add it back.
Falls back to zones 0x01/0x02 (typical left/right earcup) if
GetRGBZoneInfo returns an unexpected format.
The previous "bulk" query (CenturionFeatureSet.GetFeatureId with
start_index, per-batch response) was limited by the 64-byte Centurion
frame size to ~13 features per response, and the device apparently
does not fragment MessageEvent responses — so start_index=14 returned
empty and we missed the remaining features.
Evidence: wired G522 exposes 30 features directly but wireless via
bridge only ever reports 13. Same sub-device, different access path,
same underlying feature set.
Switch to per-index queries matching how _discover_dongle_features
already works on the parent:
1. CenturionFeatureSet.GetCount (func 0) -> total feature count
2. CenturionFeatureSet.GetFeatureId (func 1) per index -> one feature
per response
Slower (N round-trips instead of 1) but handles any feature count
without fragmentation. Expected to reveal RGB, advanced EQ, sidetone,
etc. on the wireless G522.
Updated test fixture with per-index response format.
IntEnum members with the same int value hash equal and compare equal,
so a dict lookup for SupportedFeature.DEVICE_NAME (0x0005) finds an
entry stored as CenturionCoreFeature.MULTI_HOST_CONTROL (same 0x0005).
The index is right for the Centurion feature but wrong for the HID++
2.0 feature the caller intended.
Concrete impact: `solaar show` on wired G522 called get_kind()
-> feature_request(DEVICE_NAME, 0x20), which resolved to
MULTI_HOST_CONTROL.function_2 via the collision, and the device
returned OUT_OF_RANGE -> FeatureCallError crashed `solaar show`.
Fix: in Device.feature_request, after resolving the index, compare the
type of the stored inverse entry against the type of the requested
feature. Mismatched types mean the device actually has the Centurion
variant, not the HID++ 2.0 feature — return None instead of issuing a
mis-targeted request.
CenturionFeatureSet bulk query only returns as many features as fit in
a single ~60-byte Centurion frame (~13-14 features per response). The
single-call implementation silently truncated devices with more
features, which may explain missing RGB/audio features on the G522.
Loop the query with increasing start_index, stopping on empty batch,
short batch, or 16-batch safety bound (224 feature max).
Also log the final discovered count at INFO level so we can see it in
field test logs.
Device.__init__ creates self.features as an empty dict ({}) when the
reported _protocol is <2.0, reserving FeaturesArray for HID++ 2.0.
The protocol floor in the protocol property only helps code paths that
access the property (e.g. feature_request's `if self.protocol >= 2.0`).
But __init__ reads the raw self._protocol attribute, so a wired G522
(reports 1.1) ends up with self.features = {}.
When solaar show later triggers `self.features._check()` inside
feature_request, the dict has no _check method → AttributeError →
crashes `solaar show` for the wired G522.
Fix: use FeaturesArray unconditionally when the device is Centurion.
The protocol version reported by these dongles is cosmetic — all
Centurion devices speak HID++ 2.0 features.
Three cleanup fixes after field testing confirmed the brute-force probe
finds device_addr=0x23 on the G522 (no special addresses at 0x00/0xFF):
1. Short-circuit the probe on first hit instead of sweeping all 256. The
diagnostic full-sweep served its purpose — only 0x23 responds.
2. Drop per-candidate read timeout from 20ms to 5ms. USB round-trip is
<1ms, so 5ms is 5x margin. Worst case: 1.3s. Typical: 180ms.
3. Short-circuit FeaturesArray.__getitem__ for Centurion devices. All
parent + sub-device features are enumerated upfront by
_check_centurion(). If a 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. Eliminates cycling
{0002}..{000F} error log spam during settings init and speeds up
check_feature_settings() by ~225 round trips per device.
For initial field testing, sweep all 256 candidates instead of stopping
on first hit. Logs every address that responds (sent_addr, response_addr,
first 8 bytes) at INFO level so we can discover if 0x00, 0xFF, or other
addresses have special behavior.
The first responding address is still used as the device_addr. Revert to
short-circuit once we've confirmed there are no special addresses worth
trying first.
Write one candidate, read briefly (20ms), move to next. Stops on first
hit. Avoids overwhelming the dongle's RX buffer with 256 back-to-back
frames where the response could get lost.
Worst case (addr=0xFF): 256 × 20ms ≈ 5s.
Typical G522 (addr=0x23): 36 × 20ms ≈ 0.7s.
The previous probe sent a single all-zeros frame and waited for the
dongle to respond — but the dongle silently drops frames with the
wrong device_addr, producing no response.
Now send a valid ROOT.GetProtocolVersion request for every possible
device_addr (0x00–0xFF). The dongle ignores the 255 wrong addresses
and responds only to the correct one. The response carries the real
address at byte[1]. 256 writes complete in under 100ms on USB; the
read phase (3 x 500ms) catches the single response.
This discovers the address during synchronous init, before the
listener starts, eliminating the need for deferred init when the
headset is already powered on.
When a pending (deferred-init) CenturionReceiver has no discovered
features, accessing receiver.firmware triggers get_firmware_centurion
which calls feature_request. With an empty feature list, feature_request
raises FeatureNotSupported — but passes a positional arg to KwException
which only accepts **kwargs, producing a TypeError.
Two fixes:
- Guard firmware property with `not self._pending` so it skips fetch
when features haven't been discovered yet
- Fix FeatureNotSupported raise to use keyword argument
Three interrelated fixes for G522 LIGHTSPEED headset support:
1. Deferred init for silent 0x50 dongles: When the probe fails and
feature discovery returns nothing (dongle silently drops all frames
with device_addr=0x00), return a "pending" CenturionReceiver instead
of falling through to the broken create_device direct-device path.
The listener thread starts reading; when the first unsolicited frame
arrives, _unwrap_centurion_frame learns device_addr, the notification
handler detects the pending state, and re-runs feature discovery with
the correct address — finding the bridge and creating the child device.
2. Centurion protocol version floor: The G522 dongle reports protocol
1.1 (major=1, minor=1), which routes all protocol < 2.0 gates into
HID++ 1.0 code paths (battery register reads, etc.) that crash with
INVALID_SUB_ID_COMMAND. Centurion devices always use HID++ 2.0
features, so the protocol property now returns 2.0 as a floor for
any device with centurion=True.
3. CI segfault fix: Mock probe_centurion_device_addr in the two
Centurion device tests so fake handles never reach the real hidapi
C extension on macOS.
Temporary diagnostics for field-testing the probe fix. Each step of
the probe loop logs attempt number, bytes received, and the first 4
bytes of the RX frame so a `solaar -dd` trace shows exactly what the
device returned (or didn't).
Also adds a WARNING log in Device.battery() when it falls through to
the HID++ 1.0 path on a Centurion device. If that warning ever fires
in the wild, it means the probe silently failed and the next step will
be the register read that returns INVALID_SUB_ID_COMMAND — a direct
breadcrumb from cause to crash.
Revert this commit once we've confirmed the probe works reliably on
reporter hardware.
The 0x50 variant requires a device-specific address byte at frame[1]
on every TX frame. Until now we left state.device_addr=None and sent
0x00 as a placeholder, relying on the device to either be permissive
enough to respond, or to send an unsolicited frame early enough for
_unwrap_centurion_frame to learn the address passively.
Strict firmware silently drops device_addr=0x00 requests, which breaks
dongle feature discovery before it can start: _discover_dongle_features
times out, has_bridge is False, create_centurion_receiver falls through
to create_device, Device.__init__ leaves _protocol=None, and a later
read_battery() dispatches HID++ 1.0 read_register(BATTERY_CHARGE) that
the dongle rejects with INVALID_SUB_ID_COMMAND.
Port strain08's fix from LGSTrayEx (commits 1439b27a + c6d21972): right
after registering a 0x50 handle, write a 64-byte all-zero frame with
just the report ID set. That elicits an error/unsolicited response
whose byte[1] is the real device address. Read up to 3 x 500ms until
a matching frame arrives, then store the address on the handle state
so subsequent TX frames carry it correctly.
On timeout the probe logs a warning and leaves device_addr=None, so
behavior falls back to the current 0x00-placeholder path (no regression
for devices where the probe isn't needed). The passive learn-on-first-RX
in _unwrap_centurion_frame is preserved as a second line of defense.
The G522 uses a Centurion protocol variant with report ID 0x50 that adds
a device address byte at frame position [1], shifting all CPL fields by
+1 compared to the PRO X 2's 0x51 variant. This commit adds transport-
layer support for both variants while consolidating per-handle centurion
state into a single CenturionHandleState dataclass.
Key changes:
- Consolidate _centurion_handles (set) and _centurion_protocol_versions
(dict) into a single dict[int, CenturionHandleState] keyed by handle
- Add _unwrap_centurion_frame() helper that auto-detects 0x50 vs 0x51
from raw frame bytes and learns the device address on first RX
- Add _centurion_frame_header() to build the correct TX header per variant
- Detect both report IDs in udev report descriptor parsing
- Adjust bridge fragment chunk sizes for 0x50's extra header byte
- Propagate full CenturionHandleState when opening per-thread handles
Wire format verified against G522 diagnostic logs from LGSTrayEx#15.
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.
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.