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.
This commit is contained in:
Ken Sanislo 2026-04-19 14:16:49 -07:00
parent f6ab8e986e
commit 2a3b8bbeaa
3 changed files with 420 additions and 137 deletions

View File

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

View File

@ -40,6 +40,7 @@ from __future__ import annotations
import logging
import struct
from typing import Iterable
from .hidpp20_constants import SupportedFeature

View File

@ -33,6 +33,7 @@ from . import descriptors
from . import desktop_notifications
from . import diversion
from . import exceptions
from . import headset_rgb
from . import hidpp20
from . import hidpp20_constants
from . import logivoice
@ -1979,172 +1980,241 @@ class HeadsetAdvancedEQ(settings.RangeFieldSetting):
return None
class HeadsetRGBHostMode(settings.Setting):
"""Toggle host control of headset RGB lighting.
_NO_CHANGE_COLOR = int(special_keys.COLORSPLUS["No change"])
When enabled, solaar drives the LEDs via HeadsetRGBColor. When disabled,
firmware-driven onboard/signature effects resume.
def _headset_setting_by_name(device, name):
for s in getattr(device, "settings", None) or []:
if getattr(s, "name", None) == name:
return s
return None
def _headset_primary_color(device, default=0xFFFFFF):
"""Resolve the currently-saved Primary color, or `default` if absent."""
s = _headset_setting_by_name(device, HeadsetLEDsPrimary.name)
if s is None:
return default
value = getattr(s, "_value", None)
color = getattr(value, "color", None) if value is not None else None
return int(color) if color is not None else default
def _headset_per_zone_overrides(device):
"""Return `{zone_id: color_int}` for zones with explicit (non-'No change')
colors set via the Per-zone Lighting setting, or `None` if the setting
isn't built/present."""
s = _headset_setting_by_name(device, HeadsetPerZoneLighting.name)
if s is None:
return None
value = getattr(s, "_value", None)
if not isinstance(value, dict):
return None
overrides = {}
for zone, color in value.items():
try:
color_int = int(color)
except (TypeError, ValueError):
continue
if color_int != _NO_CHANGE_COLOR:
overrides[int(zone)] = color_int
return overrides
class _HeadsetStaticEffectOption:
"""Minimal stand-in for `hidpp20.LEDEffectInfo`.
`HeteroValidator` only inspects `.ID` and `.index` on its `options`
list; we don't need the full device-query machinery here because the
headset wire protocol is handled by `headset_rgb.write_zone_map`.
"""
name = "headset-rgb-hostmode"
label = _("Headset RGB Host Control")
description = _("Enable software control of headset RGB lighting.")
ID = 0x01 # matches hidpp20.LEDEffects[0x01] = Static
index = 0x01
class HeadsetLEDControl(settings.Setting):
"""Switch headset LED control between device and Solaar.
Mirrors the `LEDControl` / `RGBControl` pattern used for keyboards and
mice. When set to Solaar, the `LEDs Primary` and `Per-zone Lighting`
settings drive the LEDs; when set to Device, firmware-driven onboard
and signature effects resume.
"""
name = "headset_led_control"
label = _("LED Control")
description = _("Switch control of LED zones between device and Solaar")
feature = _F.HEADSET_RGB_HOSTMODE
rw_options = {"read_fnid": 0x70, "write_fnid": 0x80}
validator_class = settings_validator.BooleanValidator
choices_universe = common.NamedInts(Device=0, Solaar=1)
validator_class = settings_validator.ChoicesValidator
validator_options = {"choices": choices_universe}
class HeadsetRGBColor(settings.Setting):
"""Pick a color from the shared `special_keys.COLORS` palette and apply it
to all headset RGB zones via HeadsetRGBHostmode (0x0620).
class HeadsetLEDsPrimary(settings.Setting):
"""Primary headset LED color, rendered as a GTK color picker.
On write: enables host mode, queries zone IDs via GetRGBZoneInfo, sets
them all to the chosen RGB color via SetRgbZonesSingleValue, commits via
FrameEnd. Picking `black` effectively turns the LEDs off; fully disabling
host control is done through the separate headset-rgb-hostmode toggle.
Mirrors the `LEDZoneSetting` / `RGBEffectSetting` shape: a
`HeteroValidator` with a single "Static" effect whose only visible
field is the color. Write applies the chosen color across all zones
discovered at build time, then re-applies any per-zone overrides on
top so they aren't clobbered.
Read support is deliberately disabled the feature exposes no "get
current color" function, so we rely on the persister.
"""
name = "headset-rgb-color"
label = _("Headset RGB Color")
description = _("Set headset LED color (all zones, from the shared color palette).")
name = "headset_leds_primary"
label = _("LEDs") + " " + _("Primary")
description = _(
"Set the primary color applied to every headset LED zone.\n" "LED Control needs to be set to Solaar to be effective."
)
feature = _F.HEADSET_RGB_HOSTMODE
choices_universe = special_keys.COLORS
validator_class = settings_validator.ChoicesValidator
persist = True
# Write-only: we don't read back the current color (0x0620 doesn't expose it).
rw_options = {"read_fnid": None, "write_fnid": None}
# HeteroKeyControl renders exactly these fields; ID is hidden
# (`label=None`) but kept so setup_visibles can key off it.
color_field = {"name": hidpp20.LEDParam.color, "kind": settings.Kind.COLOR, "label": _("Color")}
possible_fields = [
{
"name": "ID",
"kind": settings.Kind.CHOICE,
"label": None,
"choices": [common.NamedInt(0x01, _("Static"))],
},
color_field,
]
# HeteroKeyControl.setup_visibles looks up fields_map[effect_id][1] to
# decide which fields to show — we only expose the color.
fields_map = {0x01: [common.NamedInt(0x01, _("Static")), {hidpp20.LEDParam.color: 0}]}
@classmethod
def build(cls, device):
zones = headset_rgb.discover_zones(device)
if not zones:
return None
rw = settings.FeatureRW(cls.feature)
validator = settings_validator.ChoicesValidator(choices=cls.choices_universe)
validator = settings_validator.HeteroValidator(
data_class=hidpp20.LEDEffectSetting,
options=[_HeadsetStaticEffectOption()],
readable=False,
)
return cls(device, rw, validator)
def read(self, cached=True):
# Write-only; return persisted/cached value if we have one.
if cached and getattr(self, "_value", None) is not None:
# Feature 0x0620 doesn't expose a "current primary color" read —
# pull from the persister via _pre_read, fall back to white so
# the picker opens on a sane starting color.
self._pre_read(cached)
if self._value is not None:
return self._value
return None
self._value = hidpp20.LEDEffectSetting(ID=common.NamedInt(0x01, _("Static")), color=0xFFFFFF)
return self._value
def write(self, value, save=True):
if value is None:
color = getattr(value, "color", None)
if color is None:
return None
try:
color_int = int(value)
except (TypeError, ValueError):
return None
if color_int not in special_keys.COLORS:
return None
name = str(special_keys.COLORS[color_int])
r = (color_int >> 16) & 0xFF
g = (color_int >> 8) & 0xFF
b = color_int & 0xFF
device = self._device
if not device.online:
logger.info("HeadsetRGBColor: device offline, skipping write of %s", name)
return None
try:
# Order per protocol doc: claim host mode FIRST, then enumerate zones,
# then set colors, then commit. GetRGBZoneInfo returns zero counts if
# we query before claiming control.
resp = device.feature_request(_F.HEADSET_RGB_HOSTMODE, 0x80, b"\x01")
logger.info("HeadsetRGBColor: SetHostModeState(1) resp=%s", resp.hex() if resp else resp)
zone_ids = self._zone_ids(device)
if not zone_ids:
logger.warning("HeadsetRGBColor: no zones available; cannot set color %s", name)
zones = headset_rgb.discover_zones(device)
if not zones:
return None
primary = int(color)
zone_map = {int(z): primary for z in zones}
# Re-apply any non-"No change" per-zone overrides on top of the
# fresh Primary baseline so the user's explicit zone choices stick
# when they change the bulk color.
overrides = _headset_per_zone_overrides(device) or {}
zone_map.update(overrides)
if headset_rgb.write_zone_map(device, zone_map):
self.update(value, save)
return value
return None
class HeadsetPerZoneLighting(settings.Settings):
"""Per-zone LED color overrides.
Mirrors `PerKeyLighting`'s `Settings` + `ChoicesMapValidator` style —
one dropdown per discovered zone with the `COLORSPLUS` palette. The
"No change" entry means "inherit the current `LEDs Primary` color",
so users can paint individual zones without losing the base setup.
"""
name = "headset_per_zone_lighting"
label = _("Per-zone Lighting")
description = _(
"Override individual zone colors. 'No change' inherits the LEDs Primary color.\n"
"LED Control needs to be set to Solaar to be effective."
)
feature = _F.HEADSET_RGB_HOSTMODE
persist = True
choices_universe = special_keys.COLORSPLUS
class rw_class(settings.FeatureRWMap):
pass
class validator_class(settings_validator.ChoicesMapValidator):
@classmethod
def build(cls, setting_class, device):
zones = headset_rgb.discover_zones(device)
if not zones:
return None
logger.info(
"HeadsetRGBColor: setting color %s RGB=(%02X,%02X,%02X) on %d zone(s): %s",
name,
r,
g,
b,
len(zone_ids),
[f"0x{z:02X}" for z in zone_ids],
)
# SetRgbZonesSingleValue: [R, G, B, count, zone_ids...]
payload = bytes([r, g, b, len(zone_ids)]) + bytes(zone_ids)
resp = device.feature_request(_F.HEADSET_RGB_HOSTMODE, 0x50, payload)
logger.info(
"HeadsetRGBColor: SetRgbZonesSingleValue payload=%s resp=%s",
payload.hex(),
resp.hex() if resp else resp,
)
# FrameEnd: commit the frame.
# Byte 0 is frame_type: 0x01 = transient commit, 0x02 = persistent.
# G522 firmware rejects 0x02 with LOGITECH_INTERNAL (0x05) — the
# persistent commit path may require additional state (e.g. onboard
# profile activation) that isn't mapped yet. Stick with 0x01 so the
# LEDs refresh visually; revisit persistence once the preconditions
# are known.
resp = device.feature_request(_F.HEADSET_RGB_HOSTMODE, 0x60, bytes([0x01, 0x00, 0x00, 0x00]))
frame_type = 0x01
logger.info(
"HeadsetRGBColor: FrameEnd frame_type=0x%02X resp=%s",
frame_type,
resp.hex() if resp else resp,
)
except Exception as e:
logger.warning("HeadsetRGBColor write failed for %s: %s", name, e)
choices_map = {
common.NamedInt(int(z), _("Zone") + " " + str(int(z))): setting_class.choices_universe for z in zones
}
return cls(choices_map) if choices_map else None
def read(self, cached=True):
self._pre_read(cached)
if cached and self._value is not None:
return self._value
# Device doesn't expose current per-zone state; default every
# zone to "No change" so the primary color shows through.
reply_map = {int(key): _NO_CHANGE_COLOR for key in self._validator.choices}
self._value = reply_map
return reply_map
def _resolve_zone_map(self, map_, primary):
"""Substitute 'No change' entries with the primary color."""
resolved = {}
for key, value in map_.items():
try:
v = int(value)
except (TypeError, ValueError):
continue
resolved[int(key)] = primary if v == _NO_CHANGE_COLOR else v
return resolved
def write(self, map_, save=True):
device = self._device
if not device.online:
return None
self.update(value, save)
return value
self.update(map_, save)
primary = _headset_primary_color(device)
zone_map = self._resolve_zone_map(map_, primary)
if not zone_map:
return None
if headset_rgb.write_zone_map(device, zone_map):
return map_
return None
@staticmethod
def _zone_ids(device):
"""Query GetRGBZoneInfo (function 1) and return list of zone IDs.
Caller must have enabled host mode first querying before SetHostModeState
has been observed to return all zeros on the G522.
"""
cached = getattr(device, "_headset_rgb_zone_ids", None)
if cached:
return cached
def write_key_value(self, key, value, save=True):
result = super().write_key_value(int(key), value, save)
device = self._device
if not device.online:
return result
try:
resp = device.feature_request(_F.HEADSET_RGB_HOSTMODE, 0x10)
except Exception as e:
logger.warning("HeadsetRGBColor: GetRGBZoneInfo raised %s", e)
resp = None
if not resp or len(resp) < 1:
logger.warning(
"HeadsetRGBColor: GetRGBZoneInfo returned %s; NOT caching so we retry next write",
resp,
)
return []
zone_count = resp[0]
# Tight format observed on G522: [count, zone_ids...] with no reserved gap.
# The protocol doc shows 3-byte + 1-byte reserved gaps before zone IDs, but
# the G522 sub-device packs them immediately after the count.
# Don't filter zone_id==0 — on some devices 0 is a valid zone ID. We trust
# the device to report sane zone IDs and let the device reject nonsense.
tight = list(resp[1 : 1 + zone_count]) if 1 <= zone_count <= len(resp) - 1 else []
if tight and len(tight) == zone_count:
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"HeadsetRGBColor: discovered %d zone(s) %s (tight format)",
len(tight),
[f"0x{z:02X}" for z in tight],
)
device._headset_rgb_zone_ids = tight
return tight
# Try the doc's format (with reserved gap) as fallback
gap = list(resp[5 : 5 + zone_count]) if len(resp) >= 5 + zone_count else []
if gap and len(gap) == zone_count:
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"HeadsetRGBColor: discovered %d zone(s) %s (doc format)",
len(gap),
[f"0x{z:02X}" for z in gap],
)
device._headset_rgb_zone_ids = gap
return gap
# Neither format parsed cleanly; don't cache so next write retries.
logger.warning(
"HeadsetRGBColor: GetRGBZoneInfo ambiguous count=%d resp=%s; not caching",
zone_count,
resp.hex(),
)
return []
v = int(value)
except (TypeError, ValueError):
return result
effective = _headset_primary_color(device) if v == _NO_CHANGE_COLOR else v
headset_rgb.write_zone_map(device, {int(key): int(effective)})
return result
# ----------------------------------------------------------------------------
@ -2822,8 +2892,9 @@ SETTINGS: list[settings.Setting] = [
HeadsetAutoSleep,
HeadsetOnboardEQ,
HeadsetAdvancedEQ,
HeadsetRGBHostMode,
HeadsetRGBColor,
HeadsetLEDControl,
HeadsetLEDsPrimary,
HeadsetPerZoneLighting,
*_LOGIVOICE_SETTINGS,
]