headset RGB: LED Control as a claim switch + keyboard-style restructure

Rework headset RGB lighting so it mirrors the keyboard/mouse model
instead of its own ad-hoc shape.

LED Control (0x0620 HostMode) becomes a boolean toggle: whether Solaar
holds the headset's live-coloring claim. Off releases the LEDs so
another app (e.g. OpenRGB) can drive them; on lets Solaar drive.

0x0620 per-zone painting and the 0x0621 onboard effect are both live
LED control, so both are gated on the claim — UI rows grey out and
wire writes are skipped (value still persisted) when the claim isn't
held, mirroring the keyboard's RGBEffectSetting under rgb_control.

0x0621 HeadsetOnboardEffect is now the primary lighting setting, the
headset analog of keyboard 0x8071 zone effects. Its build reads the
cluster's supported-effect set so the picker offers only those; effect
id 0 is labelled "Static" to match every other Solaar device. The
redundant HeadsetLEDsPrimary (0x0620 single-colour host push) is
removed — that job is exactly the 0x0621 Static effect.

HeadsetPerZoneLighting is the per-key-style overlay: gated on the
claim AND the onboard effect being Static, since per-zone painting
overlays a Static cluster effect (the analog of keyboard per-key
needing rgb_control + zone Static).

The 0x0622 signature effects (startup/shutdown/passive colours) are
the only stored settings here and stay ungated — editable whether or
not Solaar holds the claim.

On re-claim HeadsetLEDControl.write reasserts the dominant layer:
per-zone painting when the onboard effect is Static, else the 0x0621
effect itself.
This commit is contained in:
Ken Sanislo 2026-05-15 18:37:01 -07:00 committed by Peter F. Patel-Schneider
parent 936991e0b4
commit 4a7edd75ce
2 changed files with 148 additions and 136 deletions

View File

@ -2330,13 +2330,31 @@ def _headset_setting_by_name(device, name):
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)
"""The headset's base color — the 0x0621 onboard Fixed-effect color.
Per-zone 'No change' cells resolve against this. Returns `default` when
the onboard-effect setting is absent or not currently on Fixed."""
s = _headset_setting_by_name(device, HeadsetOnboardEffect.name)
value = getattr(s, "_value", None) if s is not None else None
if value is not None and int(getattr(value, "ID", -1)) == 0:
return int(getattr(value, "color1", default))
return default
def _headset_cluster_effect_is_fixed(device):
"""True when the 0x0621 onboard effect is Fixed (the Static analog), or
when the device has no onboard-effect setting. A non-Fixed cluster
animation masks the per-zone buffer, so per-zone writes are suppressed
while one runs."""
s = _headset_setting_by_name(device, HeadsetOnboardEffect.name)
if s is None:
return default
return True
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
if value is None:
persister = getattr(device, "persister", None)
value = persister.get(HeadsetOnboardEffect.name) if persister else None
if value is None:
return True
return int(getattr(value, "ID", 0)) == 0
def _headset_per_zone_overrides(device):
@ -2360,35 +2378,48 @@ def _headset_per_zone_overrides(device):
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`.
"""
ID = 0x01 # matches hidpp20.LEDEffects[0x01] = Static
index = 0x01
def _headset_led_control_on(device):
"""True when the headset LED Control is on (Solaar drives the LEDs).
When off, the firmware owns the LEDs and host color writes are
suppressed the value is still persisted so it re-applies on switch-on.
Reads setting._value first, then the persister; accepts a bool or a
legacy int 0/1 from the old ChoicesValidator era."""
s = _headset_setting_by_name(device, HeadsetLEDControl.name)
v = getattr(s, "_value", None) if s is not None else None
if v is None:
persister = getattr(device, "persister", None)
v = persister.get(HeadsetLEDControl.name) if persister else None
if v is None:
return True # unknown — don't suppress
return bool(v)
class HeadsetLEDControl(settings.Setting):
"""Switch headset LED control between device and Solaar.
"""Whether Solaar holds the headset's live-coloring claim.
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.
Mirrors the `RGBControl` pattern for keyboards and mice. On = Solaar
may drive the LEDs the 0x0621 onboard effect and 0x0620 per-zone
painting are both live LED control; off = Solaar releases the LEDs so
another app (e.g. OpenRGB) can drive them. The 0x0622 signature
effects are stored settings (startup/shutdown colors), not live
coloring, and stay editable either way.
"""
name = "headset_led_control"
label = _("LED Control")
description = _("Switch control of LED zones between device and Solaar")
description = _("Allow Solaar to control the headset LED zones.")
feature = _F.HEADSET_RGB_HOSTMODE
rw_options = {"read_fnid": 0x70, "write_fnid": 0x80}
choices_universe = common.NamedInts(Device=0, Solaar=1)
validator_class = settings_validator.ChoicesValidator
validator_options = {"choices": choices_universe}
# Two-state — render as a Gtk.Switch. Wire byte: 1 = Solaar (host) control,
# 0 = Device (firmware) control.
validator_class = settings_validator.BooleanValidator
validator_options = {"true_value": 1, "false_value": 0}
def _pre_read(self, cached, key=None):
# Migrate legacy int values (0/1 from the old ChoicesValidator) to bool.
super()._pre_read(cached, key)
if isinstance(self._value, int) and not isinstance(self._value, bool):
self._value = self._value != 0
@classmethod
def build(cls, device):
@ -2402,105 +2433,24 @@ class HeadsetLEDControl(settings.Setting):
return super().build(device)
def write(self, value, save=True):
# After switching to Solaar control, the firmware drops whatever
# colors we'd programmed — so reassert the saved Primary + per-zone
# overrides immediately. Otherwise the LEDs stay on whatever
# device-driven effect was last shown until the user edits a color.
# On re-claim the firmware drops our colors; reassert the dominant
# layer — per-zone when the onboard effect is Static, else the effect.
result = super().write(value, save)
if result is not None and int(value) == 1 and self._device.online:
primary = _headset_primary_color(self._device)
zones = headset_rgb.discover_zones(self._device)
if zones:
zone_map = {int(z): primary for z in zones}
zone_map.update(_headset_per_zone_overrides(self._device) or {})
headset_rgb.write_zone_map(self._device, zone_map)
if result is not None and value and self._device.online:
if _headset_cluster_effect_is_fixed(self._device):
primary = _headset_primary_color(self._device)
zones = headset_rgb.discover_zones(self._device)
if zones:
zone_map = {int(z): primary for z in zones}
zone_map.update(_headset_per_zone_overrides(self._device) or {})
headset_rgb.write_zone_map(self._device, zone_map)
else:
onboard = next((s for s in self._device.settings if s.name == "headset-onboard-effect"), None)
if onboard is not None and onboard._value is not None:
onboard.write(onboard._value, save=False)
return result
class HeadsetLEDsPrimary(settings.Setting):
"""Primary headset LED color, rendered as a GTK color picker.
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_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
persist = True
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.HeteroValidator(
data_class=hidpp20.LEDEffectSetting,
options=[_HeadsetStaticEffectOption()],
readable=False,
)
return cls(device, rw, validator)
def read(self, cached=True):
# 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
self._value = hidpp20.LEDEffectSetting(ID=common.NamedInt(0x01, _("Static")), color=0xFFFFFF)
return self._value
def write(self, value, save=True):
color = getattr(value, "color", None)
if color is None:
return None
device = self._device
if not device.online:
return None
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.
@ -2559,19 +2509,24 @@ class HeadsetPerZoneLighting(settings.Settings):
if not device.online:
return None
self.update(map_, save)
# Gate the wire on both conditions, like keyboard per-key (needs
# rgb_control on + zone Static): LED Control on, cluster effect Fixed.
if not _headset_led_control_on(device) or not _headset_cluster_effect_is_fixed(device):
return map_ # value stored, skip the wire
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
headset_rgb.write_zone_map(device, zone_map)
return map_
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
if not _headset_led_control_on(device) or not _headset_cluster_effect_is_fixed(device):
return result # value stored, skip the wire
try:
v = int(value)
except (TypeError, ValueError):
@ -2808,11 +2763,12 @@ yaml.add_representer(_HeadsetOnboardEffect, _HeadsetOnboardEffect.to_yaml)
class HeadsetOnboardEffect(settings.Setting):
"""The firmware RGB effect a headset runs autonomously on its primary
lighting cluster (HEADSET_RGB_ONBOARD_EFFECTS, 0x0621). Build reads the
cluster's supported-effect set so the picker offers only those. Like the
signature/boot effects this is firmware-driven and not gated on host LED
control. Multi-cluster devices (none seen yet) drive only cluster 0."""
"""The RGB effect the headset shows on its primary lighting cluster
(HEADSET_RGB_ONBOARD_EFFECTS, 0x0621). Build reads the cluster's
supported-effect set so the picker offers only those. This is live LED
control, like per-zone painting gated on Solaar holding the LED-control
claim: writes are skipped and the row greys out when the claim is
released. Multi-cluster devices (none seen yet) drive only cluster 0."""
name = "headset-onboard-effect"
label = _("Onboard Effect")
@ -2821,7 +2777,7 @@ class HeadsetOnboardEffect(settings.Setting):
_CLUSTER = 0
_ALL_EFFECTS = (
("Fixed", 0),
("Static", 0),
("Color Cycle", 1),
("Color Wave", 2),
("Breathing", 3),
@ -2852,7 +2808,11 @@ class HeadsetOnboardEffect(settings.Setting):
return reply[1:10] # strip clusterIndex -> [effectId_u16, 7 param bytes]
def write(self, device, data_bytes):
# data_bytes is [effectId_u16, 7 param bytes]; prepend clusterIndex
# data_bytes is [effectId_u16, 7 param bytes]; prepend clusterIndex.
# The onboard effect is live LED control — skip the wire when Solaar
# doesn't hold the claim; the value is still persisted.
if not _headset_led_control_on(device):
return data_bytes
reply = device.feature_request(self.feature, 0x30, bytes([self._cluster]) + bytes(data_bytes))
return data_bytes if reply is not None else None
@ -4463,12 +4423,11 @@ SETTINGS: list[settings.Setting] = [
HeadsetActiveEQPreset,
HeadsetAdvancedEQ,
HeadsetLEDControl,
HeadsetLEDsPrimary,
HeadsetOnboardEffect,
HeadsetPerZoneLighting,
HeadsetSignatureStartupEffect,
HeadsetSignatureShutdownEffect,
HeadsetSignaturePassiveEffect,
HeadsetOnboardEffect,
*_LOGIVOICE_SETTINGS,
]

View File

@ -816,6 +816,13 @@ _icons_allowables = {v: k for k, v in _allowables_icons.items()}
# the zone index (rgb_zone_1, rgb_zone_2, ...).
_SW_CONTROL_DEPENDENT_NAMES = ("rgb_idle_timeout", "rgb_idle_effect", "rgb_sleep_timeout")
_SW_CONTROL_DEPENDENT_PREFIXES = ("rgb_zone_",)
# headset_led_control = whether Solaar holds the live-coloring claim (off lets
# another app drive the LEDs). The 0x0620 per-zone painting and the 0x0621
# onboard effect are both live LED control, so both need the claim; per-zone
# additionally needs the onboard effect on Static (the per-key analog of
# needs-rgb_control + zone-Static). The 0x0622 signature effects are stored
# settings (startup/shutdown colors) and stay ungated.
_HEADSET_LED_DEPENDENT_NAMES = ("headset_per_zone_lighting", "headset-onboard-effect")
def _sw_control_blocked(device):
@ -839,6 +846,43 @@ def _sw_control_blocked(device):
return value not in (True, 3)
def _headset_led_blocked(device):
"""True when the headset's LED Control is off (Device/firmware mode).
Reads setting._value first, then the persister; accepts the current bool
or a legacy int 0/1 from the old ChoicesValidator persister entries."""
persister = getattr(device, "persister", None)
if persister is None:
return False
value = None
for s in getattr(device, "settings", []) or []:
if s.name == "headset_led_control":
value = s._value
break
if value is None:
value = persister.get("headset_led_control")
if value is None:
return False
return value not in (True, 1)
def _cluster_effect_blocks_perzone(device):
"""True when the headset's 0x0621 onboard effect is not Fixed — a
non-Fixed cluster animation masks the per-zone buffer. Mirrors
`_zone_effect_blocks_perkey`. False when the device has no
onboard-effect setting (nothing to mask against)."""
persister = getattr(device, "persister", None)
value = None
for s in getattr(device, "settings", []) or []:
if s.name == "headset-onboard-effect":
value = s._value if s._value is not None else (persister.get(s.name) if persister else None)
break
else:
return False
if value is None:
return False
return int(getattr(value, "ID", 0)) != 0
def _zone_effect_blocks_perkey(device):
"""True when any zone effect's saved ID is not Static (0x01) — zone
animations mask the per-key buffer regardless of SW control state."""
@ -877,6 +921,11 @@ def _gate_blocks(device, name):
return _sw_control_blocked(device)
if name == "per-key-lighting":
return _sw_control_blocked(device) or _zone_effect_blocks_perkey(device)
if name in _HEADSET_LED_DEPENDENT_NAMES:
if _headset_led_blocked(device):
return True
# Per-zone painting additionally needs the onboard effect on Static.
return name == "headset_per_zone_lighting" and _cluster_effect_blocks_perzone(device)
return False
@ -895,6 +944,7 @@ def _apply_rgb_gates(device):
name in _SW_CONTROL_DEPENDENT_NAMES
or any(name.startswith(p) for p in _SW_CONTROL_DEPENDENT_PREFIXES)
or name == "per-key-lighting"
or name in _HEADSET_LED_DEPENDENT_NAMES
):
_set_row_sensitive(device, name, not _gate_blocks(device, name))
@ -940,10 +990,12 @@ def _change_click(button, sbox):
perkey, has_paint = rgb_power.perkey_has_paint(device)
if has_paint:
_write_async(perkey, perkey._value, None)
# The lock icon on rgb_control, any zone, or per-key itself can change
# whether per-key is functional — re-evaluate the gate.
# The lock icon on rgb_control, any zone, per-key, or headset_led_control
# can change whether a dependent row is functional — re-evaluate the gate.
name = sbox.setting.name
if name == "rgb_control" or name == "per-key-lighting" or name.startswith("rgb_zone_"):
if name in ("rgb_control", "per-key-lighting", "headset_led_control", "headset-onboard-effect") or name.startswith(
"rgb_zone_"
):
_apply_rgb_gates(sbox.setting._device)
return True
@ -1047,8 +1099,9 @@ def _update_setting_item(sbox, value, is_online=True, sensitive=True, null_okay=
logger.warning("%s: error setting control value (%s): %s", sbox.setting.name, sbox.setting._device, repr(e))
sbox._control.set_sensitive(sensitive is True and can_function)
_change_icon(sensitive, sbox._change_icon)
# rgb_control and rgb_zone_* state gate per-key sensitivity.
if name == "rgb_control" or name.startswith("rgb_zone_"):
# rgb_control / rgb_zone_* gate per-key; headset_led_control and the
# headset-onboard-effect gate the per-zone row — re-evaluate on a change.
if name in ("rgb_control", "headset_led_control", "headset-onboard-effect") or name.startswith("rgb_zone_"):
_apply_rgb_gates(sbox.setting._device)