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): def _headset_primary_color(device, default=0xFFFFFF):
"""Resolve the currently-saved Primary color, or `default` if absent.""" """The headset's base color — the 0x0621 onboard Fixed-effect color.
s = _headset_setting_by_name(device, HeadsetLEDsPrimary.name) 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: if s is None:
return default return True
value = getattr(s, "_value", None) value = getattr(s, "_value", None)
color = getattr(value, "color", None) if value is not None else None if value is None:
return int(color) if color is not None else default 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): def _headset_per_zone_overrides(device):
@ -2360,35 +2378,48 @@ def _headset_per_zone_overrides(device):
return overrides return overrides
class _HeadsetStaticEffectOption: def _headset_led_control_on(device):
"""Minimal stand-in for `hidpp20.LEDEffectInfo`. """True when the headset LED Control is on (Solaar drives the LEDs).
When off, the firmware owns the LEDs and host color writes are
`HeteroValidator` only inspects `.ID` and `.index` on its `options` suppressed the value is still persisted so it re-applies on switch-on.
list; we don't need the full device-query machinery here because the Reads setting._value first, then the persister; accepts a bool or a
headset wire protocol is handled by `headset_rgb.write_zone_map`. 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
ID = 0x01 # matches hidpp20.LEDEffects[0x01] = Static if v is None:
index = 0x01 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): 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 Mirrors the `RGBControl` pattern for keyboards and mice. On = Solaar
mice. When set to Solaar, the `LEDs Primary` and `Per-zone Lighting` may drive the LEDs the 0x0621 onboard effect and 0x0620 per-zone
settings drive the LEDs; when set to Device, firmware-driven onboard painting are both live LED control; off = Solaar releases the LEDs so
and signature effects resume. 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" name = "headset_led_control"
label = _("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 feature = _F.HEADSET_RGB_HOSTMODE
rw_options = {"read_fnid": 0x70, "write_fnid": 0x80} rw_options = {"read_fnid": 0x70, "write_fnid": 0x80}
choices_universe = common.NamedInts(Device=0, Solaar=1) # Two-state — render as a Gtk.Switch. Wire byte: 1 = Solaar (host) control,
validator_class = settings_validator.ChoicesValidator # 0 = Device (firmware) control.
validator_options = {"choices": choices_universe} 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 @classmethod
def build(cls, device): def build(cls, device):
@ -2402,105 +2433,24 @@ class HeadsetLEDControl(settings.Setting):
return super().build(device) return super().build(device)
def write(self, value, save=True): def write(self, value, save=True):
# After switching to Solaar control, the firmware drops whatever # On re-claim the firmware drops our colors; reassert the dominant
# colors we'd programmed — so reassert the saved Primary + per-zone # layer — per-zone when the onboard effect is Static, else the effect.
# overrides immediately. Otherwise the LEDs stay on whatever
# device-driven effect was last shown until the user edits a color.
result = super().write(value, save) result = super().write(value, save)
if result is not None and int(value) == 1 and self._device.online: if result is not None and value and self._device.online:
primary = _headset_primary_color(self._device) if _headset_cluster_effect_is_fixed(self._device):
zones = headset_rgb.discover_zones(self._device) primary = _headset_primary_color(self._device)
if zones: zones = headset_rgb.discover_zones(self._device)
zone_map = {int(z): primary for z in zones} if zones:
zone_map.update(_headset_per_zone_overrides(self._device) or {}) zone_map = {int(z): primary for z in zones}
headset_rgb.write_zone_map(self._device, zone_map) 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 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): class HeadsetPerZoneLighting(settings.Settings):
"""Per-zone LED color overrides. """Per-zone LED color overrides.
@ -2559,19 +2509,24 @@ class HeadsetPerZoneLighting(settings.Settings):
if not device.online: if not device.online:
return None return None
self.update(map_, save) 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) primary = _headset_primary_color(device)
zone_map = self._resolve_zone_map(map_, primary) zone_map = self._resolve_zone_map(map_, primary)
if not zone_map: if not zone_map:
return None
if headset_rgb.write_zone_map(device, zone_map):
return map_ return map_
return None headset_rgb.write_zone_map(device, zone_map)
return map_
def write_key_value(self, key, value, save=True): def write_key_value(self, key, value, save=True):
result = super().write_key_value(int(key), value, save) result = super().write_key_value(int(key), value, save)
device = self._device device = self._device
if not device.online: if not device.online:
return result 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: try:
v = int(value) v = int(value)
except (TypeError, ValueError): except (TypeError, ValueError):
@ -2808,11 +2763,12 @@ yaml.add_representer(_HeadsetOnboardEffect, _HeadsetOnboardEffect.to_yaml)
class HeadsetOnboardEffect(settings.Setting): class HeadsetOnboardEffect(settings.Setting):
"""The firmware RGB effect a headset runs autonomously on its primary """The RGB effect the headset shows on its primary lighting cluster
lighting cluster (HEADSET_RGB_ONBOARD_EFFECTS, 0x0621). Build reads the (HEADSET_RGB_ONBOARD_EFFECTS, 0x0621). Build reads the cluster's
cluster's supported-effect set so the picker offers only those. Like the supported-effect set so the picker offers only those. This is live LED
signature/boot effects this is firmware-driven and not gated on host LED control, like per-zone painting gated on Solaar holding the LED-control
control. Multi-cluster devices (none seen yet) drive only cluster 0.""" 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" name = "headset-onboard-effect"
label = _("Onboard Effect") label = _("Onboard Effect")
@ -2821,7 +2777,7 @@ class HeadsetOnboardEffect(settings.Setting):
_CLUSTER = 0 _CLUSTER = 0
_ALL_EFFECTS = ( _ALL_EFFECTS = (
("Fixed", 0), ("Static", 0),
("Color Cycle", 1), ("Color Cycle", 1),
("Color Wave", 2), ("Color Wave", 2),
("Breathing", 3), ("Breathing", 3),
@ -2852,7 +2808,11 @@ class HeadsetOnboardEffect(settings.Setting):
return reply[1:10] # strip clusterIndex -> [effectId_u16, 7 param bytes] return reply[1:10] # strip clusterIndex -> [effectId_u16, 7 param bytes]
def write(self, device, data_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)) reply = device.feature_request(self.feature, 0x30, bytes([self._cluster]) + bytes(data_bytes))
return data_bytes if reply is not None else None return data_bytes if reply is not None else None
@ -4463,12 +4423,11 @@ SETTINGS: list[settings.Setting] = [
HeadsetActiveEQPreset, HeadsetActiveEQPreset,
HeadsetAdvancedEQ, HeadsetAdvancedEQ,
HeadsetLEDControl, HeadsetLEDControl,
HeadsetLEDsPrimary, HeadsetOnboardEffect,
HeadsetPerZoneLighting, HeadsetPerZoneLighting,
HeadsetSignatureStartupEffect, HeadsetSignatureStartupEffect,
HeadsetSignatureShutdownEffect, HeadsetSignatureShutdownEffect,
HeadsetSignaturePassiveEffect, HeadsetSignaturePassiveEffect,
HeadsetOnboardEffect,
*_LOGIVOICE_SETTINGS, *_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, ...). # 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_NAMES = ("rgb_idle_timeout", "rgb_idle_effect", "rgb_sleep_timeout")
_SW_CONTROL_DEPENDENT_PREFIXES = ("rgb_zone_",) _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): def _sw_control_blocked(device):
@ -839,6 +846,43 @@ def _sw_control_blocked(device):
return value not in (True, 3) 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): def _zone_effect_blocks_perkey(device):
"""True when any zone effect's saved ID is not Static (0x01) — zone """True when any zone effect's saved ID is not Static (0x01) — zone
animations mask the per-key buffer regardless of SW control state.""" 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) return _sw_control_blocked(device)
if name == "per-key-lighting": if name == "per-key-lighting":
return _sw_control_blocked(device) or _zone_effect_blocks_perkey(device) 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 return False
@ -895,6 +944,7 @@ def _apply_rgb_gates(device):
name in _SW_CONTROL_DEPENDENT_NAMES name in _SW_CONTROL_DEPENDENT_NAMES
or any(name.startswith(p) for p in _SW_CONTROL_DEPENDENT_PREFIXES) or any(name.startswith(p) for p in _SW_CONTROL_DEPENDENT_PREFIXES)
or name == "per-key-lighting" or name == "per-key-lighting"
or name in _HEADSET_LED_DEPENDENT_NAMES
): ):
_set_row_sensitive(device, name, not _gate_blocks(device, name)) _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) perkey, has_paint = rgb_power.perkey_has_paint(device)
if has_paint: if has_paint:
_write_async(perkey, perkey._value, None) _write_async(perkey, perkey._value, None)
# The lock icon on rgb_control, any zone, or per-key itself can change # The lock icon on rgb_control, any zone, per-key, or headset_led_control
# whether per-key is functional — re-evaluate the gate. # can change whether a dependent row is functional — re-evaluate the gate.
name = sbox.setting.name 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) _apply_rgb_gates(sbox.setting._device)
return True 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)) 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) sbox._control.set_sensitive(sensitive is True and can_function)
_change_icon(sensitive, sbox._change_icon) _change_icon(sensitive, sbox._change_icon)
# rgb_control and rgb_zone_* state gate per-key sensitivity. # rgb_control / rgb_zone_* gate per-key; headset_led_control and the
if name == "rgb_control" or name.startswith("rgb_zone_"): # 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) _apply_rgb_gates(sbox.setting._device)