V2 setCustomEQ wire format is pcap-verified: request payload is
[dir, slot, pad=0] + N × [freq_hi, freq_lo, filter, gain_hi, gain_lo].
Gain is offset-binary against the [gain_min, gain_max] / gain_steps
grid from getEQInfos. Freq and filter type aren't user-editable today;
they're sourced from the build-time cache.
Changes:
- rw_class.write: injects [dir, slot=active, pad=0] before band bytes
and re-queries the active slot each call (matches the read path).
- validator.prepare_write: encodes the band payload from the dict the
UI hands us, converting int dB → raw u16 against the gain grid.
- Setting.write: overrides RangeFieldSetting.write to treat empty-bytes
replies as success (HID++ set ops ACK with b""), only None as failure.
- Drop the read-only override and stale docstring.
G522 advertises 6 RO + 10 custom slots in getEQInfos but the firmware
only honors slot 0; LGHUB never touches the rest either. Probing all 16
generated 15 NOT_SUPPORTED log lines per device boot. Probe slot 0,
then slot 1 as a canary — if it fails, skip the remaining 14 redundant
probes. Behavior is unchanged on hypothetical multi-slot firmware:
slot 1 success triggers the full 0..total-1 probe.
The V2 parser was using stride [filter, gain_hi, gain_lo, freq_hi,
freq_lo] with a 3-byte header. Pcap traces of LGHUB ↔ G522 LIGHTSPEED
show the actual wire is [freq_hi, freq_lo, filter, gain_hi, gain_lo]
with a 1-byte header (just dir_echo; the centurion bridge omits the
slot_echo the protocol spec describes for getCustomEQ).
With the old stride, slot 0's 10-band response on G522 parsed as 9
bands: the highest band (19 kHz, +2.75 dB on the active preset) was
silently swallowed because byte alignment landed on what looked like a
freq=0 end-sentinel. The displayed bands were also shifted — 20 Hz was
missing from the front because the parser started one full band late.
See ~/ghub/solaar_0x020D_slot_investigation.md for the RE notes.
HeadsetActiveEQPreset.build runs before HeadsetAdvancedEQ.build and probes
each EQ slot via getCustomEQ, which needs the step_db from getEQInfos to
decode gain values. Without the cache, the first pass decoded gains as
all 0.0dB and logged a "no cached getEQInfos — gain values will be wrong"
warning. Cache the dict on device._advanced_eq_info as soon as we have it
so every later caller sees the right step_db.
HID++ 0x0108 is not a single integer: V3 carries two uint8 timer slots,
V4+ carries three. The old code decoded the response as one big-endian
integer and wrote the user's value into byte[0] with the other slots
zeroed — which the firmware rejects, so writes silently never round-tripped.
_AutoSleepRangeValidator now decodes only the user-facing slot (byte[0]
on V<3 and V4+, byte[1] on V3 per LGHUB) and builds the wire payload by
reading the current bytes and mutating only that slot. Range becomes the
real uint8 0–255 (not the arbitrary 240), and the description drops the
"minutes" claim since per-slot units are device-specific.
See ~/ghub/solaar_0x0108_autosleep_impl_guide.md for the RE notes.
The notification handler matched address==0x00/0x10, which only works when
the firmware echoes swid 0. When the host sends a SetState write with a
non-zero swid, the device echoes that swid in the low nibble (e.g. 0x14
for fn=1 swid=4), so the toggle stopped tracking host-driven changes.
Mask off the swid and match on the function index only.
Wire the per-key painter UI to the G522's per-zone lighting setting and
ship a layout file matching the eight-LED earcup arrangement.
- lib/solaar/ui/perkey/layouts/headset_g522.py (new): 2x5 layout, four
LEDs per earcup in a 2x2 grid with a gap column between, viewed from
outside. Zone IDs used as labels (Logitech firmware numbering).
- lib/solaar/ui/perkey/layouts/__init__.py: register layout against
feature 0x0620 (HEADSET_RGB_HOSTMODE) via name-substring match on
"G522".
- lib/logitech_receiver/settings_templates.py: switch
HeadsetPerZoneLighting from ChoicesMapValidator (limited to the
COLORSPLUS named-color palette) to MapRangeValidator (any 24-bit
RGB). Set editor_class so the painter UI takes over from the old
MapChoice dropdowns. Drops choices_universe; matches the
PerKeyLighting pattern.
LED positions per tester's reference:
Left earcup Right earcup
| 8 | 7 | | 6 | 5 |
| 4 | 3 | | 2 | 1 |
The 0x0601 mic-mute feature was being treated as if it followed the
typical BooleanValidator default of fn 0 GetState / fn 1 SetState, but
that's not how this feature works: fn 0 and fn 1 are state-change
events, fn 2 is the actual SetState. That's a feature-level wire
convention, not a G522 firmware quirk — the previous PID-gated
override implied the standard defaults were correct for other
headsets, which we have no evidence of. Set the rw_options at the
class level so every device using this feature gets the right fnids.
If a future device emerges that genuinely uses the BooleanValidator
defaults for 0x0601 we can add a per-PID override at that point —
default to what we've actually verified.
G522 emits state-change events on the mic-mute feature whenever the
physical mute switch is pressed or after a successful host-driven
SetState (fn 2) write. Both events carry the new state in byte 0;
two function indices observed:
fn 0 swid 0 (address=0x00) — physical mute switch press
fn 1 swid 0 (address=0x10) — echo after host write
Add a handler in _process_feature_notification that pushes the new
state to the UI via device.setting_callback so the HeadsetMicMute
toggle tracks both paths. Without this, pressing the physical mute
button left Solaar's UI showing the stale value until manual refresh.
Centurion-bridged sub-device feature notifications resolve correctly
to SupportedFeature.HEADSET_MIC_MUTE thanks to the get_feature 0x100
offset fix landed earlier.
Three findings from G HUB pcap analysis (~/ghub/g522/*.pcapng):
1. Mic-mute on G522 actually works — we had the wrong fnids. The
firmware uses fn 0x10 (function 1) for state-change events / reads
and fn 0x20 (function 2) for SetState; the standard fn 0x10 SetState
path returns 0x0A NOT_SUPPORTED. Replace the previous "suppress
entirely on G522" quirk with a per-PID rw_options override that
uses the right fnids. Other headsets keep the standard pattern.
2. setRGBClusterEffect (0x0621 fn 0x30) takes effect_id 0x0000 paired
with RGB bytes to mean "Static color" — *not* "Off / Disabled" as
our cluster-info parse had guessed. Add a structured decoder for
getRGBClusterInfo (fn 0x10) that emits a human-readable summary
alongside the raw hex, with effect_id 0x0000 labelled "Static".
Records turn out to be 4 bytes each (effect_id LE u16, slot_idx
LE u16) — most other HID++ fields are BE but this one is LE per
the captured factory-default bytes.
3. Sub-feature 0x0623 is present on G522 but unmapped (it appeared
as `unknown:0623` in the feature-table dump). Add a placeholder
SupportedFeature entry and probe the first 8 functions read-side
so the next bring-up captures whatever responds.
Five fixes from analysis of the latest user log:
A. HeadsetMicMute.build() now matches device.product_id as the hex
string ("0B18", "0B19") rather than ints. product_id is set as a
str by hidapi_impl.f"{pid:04X}", so the int comparison was always
False and the suppression never fired. The G522 was still being
asked to write mic-mute on each connect and erroring out 0x0A.
B. RGB-effects probe sub-device feature dump tolerates "unknown:HHHH"
string features. _format_feature now detects that shape explicitly
(and renders 0xHHHH from the suffix) instead of letting int(feat)
raise ValueError mid-iteration and abort the whole table dump.
C. HeadsetActiveEQPreset.write replaces the bare `_value = None` cache
invalidation with a synchronous read(cached=False) so _value is a
real dict before returning. Prevents a UI band-click crash with
'NoneType' object is not subscriptable when a user clicks an EQ
band before the panel re-reads after a preset switch.
D. New probe_advanced_eq_slots() iterates every advertised slot via
getCustomEQ at build time, logs which respond, and caches a list
of (slot, name, bands) on device._advanced_eq_working_slots. The
HeadsetActiveEQPreset selector builds choices only from working
slots and returns None from build() if ≤1 slot responds — G522's
firmware advertises 16 slots but only honors slot 0, so the user
no longer sees a 16-option dropdown they can't actually use.
HeadsetAdvancedEQ.build now reuses the same probe (cached) rather
than the old probe_all_presets path. The legacy alias is kept in
the hidpp20 facade for any external callers still on it.
E. (capture only — no parse change yet) get_advanced_eq_params and
get_advanced_eq_defaults now log raw=<hex> on success too. The
two functions decode the same slot to wildly different bands on
G522 (likely a different header size between getCustomEQ's reply
and getEQDefaults's reply); raw bytes will let us pin down the
exact framing difference and adjust the parser in a follow-up.
Fix path will land alongside docs/features.md notes documenting
the per-call format variation, since other 0x020D headsets may
diverge differently from G522.
Choice setting that lists all 16 EQ slots — 6 read-only factory
presets and 10 user-custom slots — by their getEQFriendlyName,
with "(factory)" tagging the read-only ones. Selecting calls
setActiveEQ to switch which slot drives live audio. Activation
works for any slot regardless of writability; the read-only
distinction matters only for band edits (not implemented yet).
Placed directly above HeadsetAdvancedEQ in SETTINGS so the picker
sits above the band-display panel that reflects whichever slot is
currently active.
After a successful write, drop the AdvancedParaEQ panel's cached
_value so the next read pulls the new active slot's bands. The
visible band update happens on the next refresh of that panel —
auto-redraw on selector change would need UI-side plumbing we
don't currently expose; manual refresh / panel reopen works for
read-only verification.
read_prefix was hardcoded to b'\\x00\\x00' (direction=0, slot=0), so
every panel refresh re-read slot 0's bands regardless of which slot
the device considered active — the panel would silently show the
wrong EQ if the user had switched away from slot 0 (via G HUB on
another host, an onboard button, etc.).
Replace the static prefix with a custom rw_class that calls
getActiveEQ before each read and feeds the resulting slot into the
read_prefix. Direction stays hardcoded to 0 (playback); mic-side
EQ isn't exposed yet.
parse_v2_bands now takes the full getEQInfos dict (needs gain_min,
gain_max, and gain_steps for the offset-binary gain decode) instead of
just step_db. The validator was still passing step_db, which would
hit AttributeError on .get when refreshing the EQ panel value.
Cache the four gain bounds on the validator at build time and
reconstruct the info dict at validate_read time.
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.