Commit Graph

2988 Commits

Author SHA1 Message Date
Ken Sanislo be047fd949 HeadsetAdvancedEQ: enable slot-0 band writes
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.
2026-05-10 17:26:33 -07:00
Ken Sanislo 6b61e0ef19 AdvancedParaEQ: use slot 1 as a multi-slot-capable canary
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.
2026-05-10 17:24:22 -07:00
Ken Sanislo 7c73c88867 AdvancedParaEQ: correct V2 band-stride and header length
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.
2026-05-10 17:21:09 -07:00
Ken Sanislo 298380b8d2 HeadsetAutoSleep: restore 'minutes' in description
Prior empirical testing confirmed byte[0] (the user-facing slot) is in
minutes; only the other two slots (preserved via RMW) have uncertain units.
2026-05-10 16:54:28 -07:00
Ken Sanislo 593223d6eb AdvancedParaEQ: cache getEQInfos result on the device
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.
2026-05-10 16:52:13 -07:00
Ken Sanislo e06c4ce93a HeadsetAutoSleep: read-modify-write a single uint8 timer slot
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.
2026-05-10 16:48:28 -07:00
Ken Sanislo 5357ef955d HEADSET_MIC_MUTE: match on function index only, ignore swid nibble
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.
2026-05-10 16:48:23 -07:00
Ken Sanislo f3af38862d Add G522 headset layout to per-key painter
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 |
2026-05-10 15:58:15 -07:00
Ken Sanislo e34bcbe0b8 HeadsetMicMute: apply correct fnids feature-wide, not per-device
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 02d3cc177c HEADSET_MIC_MUTE: handle device state-change notifications
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 56bce3fc1b G522 mic-mute fix + cluster-info decode + 0x0623 probe
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 2e07b112ff G522 bug batch from log df178225
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo cb798438fa Add HeadsetActiveEQPreset selector
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo b8344734e5 HeadsetAdvancedEQ: refresh-read tracks the active slot
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 7e0b2e5666 Fix HeadsetAdvancedEQ.validate_read after parse_v2_bands signature change
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo c7820c2fca LogiVoice: rewrite Parameters field tables and decode GetInfo bounds
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo d1e2a08d8f RGB effects probe: dump sub-device feature table
_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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 59e3dcb736 AdvancedParaEQ V2: handle G522 wire layout
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 9bb0fc0205 Hide mic-mute toggle on G522; strip trailing whitespace in get_feature comment
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo f6a6315a7d Update hidpp20.py
We probably should add a comment here so this makes sense later on. Otherwise, this looks good.
2026-05-10 15:39:17 -07:00
Schmiddy f1864fd211 fix(centurion): route sub-device notifications via sub_inverse in get_feature
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 2e039cf63a Add read-only corpus probe for 0x0621 / 0x0622 RGB effects
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo e5e4a4bdd0 Reassert headset colors on switch to Solaar; enable LogiVoice state toggles
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo e77a330f2f Fix LogiVoice parameters display and stale _absent cache
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo e5bba1043c Refactor G522 RGB to match keyboard/mouse LED Control pattern
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo b6d4d65da8 Add LogiVoice read-only support and corpus probe
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 3ea47b09ed Audit diagnostic logging added during G522 bring-up
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 76b2ee8270 Remove references to internal RE docs and lghub_agent from code comments 2026-05-10 15:39:17 -07:00
Ken Sanislo a1089cd8c5 AdvancedParaEQ V2: correct stride + real Hz labels
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 0520ab40ba AdvancedParaEQ: probe all factory + custom presets at build time
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 41db76bc81 AdvancedParaEQ: add V2 wire-format support; keep V0/V1
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 1bcee309d3 Fall back to FrameEnd 0x01; log _absent cache skips
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo c45449b212 Add diagnostic logging for EQ setting build failures
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo c8a52ecd3c Reject bridge responses for wrong sub-device function
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo e09f0de107 HeadsetRGBColor: persist colors via FrameEnd 0x02, 0x01 for off
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 309ec2baf1 HeadsetRGBColor: FrameEnd byte 0 must be 0x01, not 0x00
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo f1c8f22fbc Log GetHostModeState before/after write for RGB diagnostic
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo a56d02a6fb HeadsetEcoMode: skip same-value writes to avoid G522 NACK 0x0B
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 10b1d8baff Add diagnostic logs for HeadsetMicGain fallback + bridge TX payloads
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 299dc4daab HeadsetMicGain: query device-reported gain bounds from GetInfo
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo fa205436f1 Record feature versions in Centurion Phase A (parent/direct) too
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo ef439d87fc Record Centurion sub-device feature versions + version-gate AutoSleep
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo a11d402c96 Log bridge sub-device error responses at INFO, not DEBUG
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 143c8093f2 Don't misreport empty-ACK writes as failed (Setting.write)
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 6e84325d67 HeadsetRGBColor: reorder host mode before zone query, parse tight response
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo bde3c3bc86 Add read-only HeadsetAdvancedEQ (0x020D) display
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo b90bbf8af3 HeadsetRGBColor: use shared special_keys.COLORS palette
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo 059874c210 Add INFO/WARNING logging to HeadsetRGBColor for field diagnostics
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo ad3d761118 Add basic headset RGB support (HeadsetRGBHostMode + HeadsetRGBColor)
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.
2026-05-10 15:39:17 -07:00
Ken Sanislo cfc69431d1 Use per-index queries for Centurion sub-device feature enumeration
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.
2026-05-10 15:39:17 -07:00