headset 0x0621: fix onboard effect param encoding from binary decode

Breathing rendered all LEDs off because wire byte 3 (CE[6]) was
hardcoded 0 — it is the intensity field (Breathing.Params field 3,
value/100). Encode it, expose the intensity slider, and seed a non-zero
default so picking the effect does not send an off frame.

DualColor wire byte 6 is intensity, not "speed" — the firmware-lighting
decode shows no speed/period field for DualColor. Rename throughout.

Custom (effect 5) is a stored card-reference effect, not a parametric
one; it cannot be set via setRGBClusterEffect. Drop it from the picker.

Also seed sane per-effect defaults (the picker rebuilds the effect from
zeroed widgets, so an unseeded pick sends an all-zero frame the firmware
rejects) and clamp the period slider to 1000-20000 ms, matching the
keyboard/mouse RGB effects.
This commit is contained in:
Ken Sanislo 2026-05-17 02:21:36 -07:00 committed by Peter F. Patel-Schneider
parent 4a7edd75ce
commit 5aae38f929
1 changed files with 52 additions and 18 deletions

View File

@ -2687,15 +2687,41 @@ class _HeadsetOnboardEffect:
leading clusterIndex). intensity is a 0-100 percent; saturation is a raw leading clusterIndex). intensity is a 0-100 percent; saturation is a raw
0-255 byte (same as the keyboard RGB effects).""" 0-255 byte (same as the keyboard RGB effects)."""
# Per-effect default parameter values, applied to any field the effect
# uses that would otherwise be 0. Picking an effect in the UI rebuilds
# this object from the (initially zeroed) field widgets, so without
# seeding the picker emits an all-zero frame: the firmware rejects
# ColorCycle/ColorWave outright and renders Breathing/DualColor/Static
# as black or zero-intensity. intensity 0 in particular reads as "LEDs
# off". Defaults confirmed against the LGHUB binary decode of 0x0621.
_DEFAULTS = {
0: {"color1": 0xFFFFFF}, # Static / Fixed
1: {"intensity": 100, "saturation": 255, "period": 5000}, # Color Cycle
2: {"intensity": 100, "saturation": 255, "period": 5000}, # Color Wave
3: {"color1": 0xFFFFFF, "intensity": 100, "period": 5000}, # Breathing
4: {"color1": 0xFFFFFF, "color2": 0x0000FF, "intensity": 100}, # Dual Color
}
# speed is accepted only to load configs persisted before the 0x0621
# decode (DualColor byte 6 was mislabelled "speed"; it is intensity).
def __init__(self, ID=0, color1=0, color2=0, intensity=0, saturation=0, period=0, speed=0, direction=0): def __init__(self, ID=0, color1=0, color2=0, intensity=0, saturation=0, period=0, speed=0, direction=0):
self.ID = int(ID) self.ID = int(ID)
self.intensity = max(0, min(100, int(intensity))) self.intensity = max(0, min(100, int(intensity)))
self.saturation = max(0, min(255, int(saturation))) self.saturation = max(0, min(255, int(saturation)))
self.period = max(0, min(0xFFFF, int(period))) self.period = max(0, min(0xFFFF, int(period)))
self.speed = max(0, min(0xFF, int(speed)))
self.direction = max(0, min(3, int(direction))) self.direction = max(0, min(3, int(direction)))
for k, v in (("color1", color1), ("color2", color2)): for k, v in (("color1", color1), ("color2", color2)):
setattr(self, k, common.ColorInt(int(v) & 0xFFFFFF)) setattr(self, k, common.ColorInt(int(v) & 0xFFFFFF))
# Seed any field the selected effect uses that arrived as 0 so the
# picker never sends an all-zero (rejected / black) frame. An effect
# actually active on the device reports non-zero values, so reads
# via from_bytes are unaffected.
for field, default in self._DEFAULTS.get(self.ID, {}).items():
if not getattr(self, field):
if field.startswith("color"):
setattr(self, field, common.ColorInt(default & 0xFFFFFF))
else:
setattr(self, field, default)
@classmethod @classmethod
def from_bytes(cls, data, options=None): def from_bytes(cls, data, options=None):
@ -2712,13 +2738,14 @@ class _HeadsetOnboardEffect:
kw["saturation"] = p[3] kw["saturation"] = p[3]
if eid == 2: if eid == 2:
kw["direction"] = p[4] kw["direction"] = p[4]
elif eid == 3: # Breathing elif eid == 3: # Breathing: R, G, B, intensity, period u16 BE
kw["color1"] = (p[0] << 16) | (p[1] << 8) | p[2] kw["color1"] = (p[0] << 16) | (p[1] << 8) | p[2]
kw["intensity"] = p[3]
kw["period"] = (p[4] << 8) | p[5] kw["period"] = (p[4] << 8) | p[5]
elif eid == 4: # DualColor elif eid == 4: # DualColor: R1, G1, B1, R2, G2, B2, intensity
kw["color1"] = (p[0] << 16) | (p[1] << 8) | p[2] kw["color1"] = (p[0] << 16) | (p[1] << 8) | p[2]
kw["color2"] = (p[3] << 16) | (p[4] << 8) | p[5] kw["color2"] = (p[3] << 16) | (p[4] << 8) | p[5]
kw["speed"] = p[6] kw["intensity"] = p[6]
return cls(**kw) return cls(**kw)
def to_bytes(self, options=None): def to_bytes(self, options=None):
@ -2733,14 +2760,14 @@ class _HeadsetOnboardEffect:
p[3] = self.saturation p[3] = self.saturation
if eid == 2: if eid == 2:
p[4] = self.direction p[4] = self.direction
elif eid == 3: # Breathing: R, G, B, CE[6]=0, period u16 BE elif eid == 3: # Breathing: R, G, B, intensity, period u16 BE, pad
p[0], p[1], p[2] = (c1 >> 16) & 0xFF, (c1 >> 8) & 0xFF, c1 & 0xFF p[0], p[1], p[2] = (c1 >> 16) & 0xFF, (c1 >> 8) & 0xFF, c1 & 0xFF
p[3] = self.intensity
p[4], p[5] = (self.period >> 8) & 0xFF, self.period & 0xFF p[4], p[5] = (self.period >> 8) & 0xFF, self.period & 0xFF
elif eid == 4: # DualColor: R1,G1,B1, R2,G2,B2, speed elif eid == 4: # DualColor: R1,G1,B1, R2,G2,B2, intensity
p[0], p[1], p[2] = (c1 >> 16) & 0xFF, (c1 >> 8) & 0xFF, c1 & 0xFF p[0], p[1], p[2] = (c1 >> 16) & 0xFF, (c1 >> 8) & 0xFF, c1 & 0xFF
p[3], p[4], p[5] = (c2 >> 16) & 0xFF, (c2 >> 8) & 0xFF, c2 & 0xFF p[3], p[4], p[5] = (c2 >> 16) & 0xFF, (c2 >> 8) & 0xFF, c2 & 0xFF
p[6] = self.speed p[6] = self.intensity
# eid 5 Custom: no inline parameters
return bytes([(eid >> 8) & 0xFF, eid & 0xFF]) + bytes(p) return bytes([(eid >> 8) & 0xFF, eid & 0xFF]) + bytes(p)
def __eq__(self, other): def __eq__(self, other):
@ -2776,21 +2803,21 @@ class HeadsetOnboardEffect(settings.Setting):
feature = _F.HEADSET_RGB_ONBOARD_EFFECTS feature = _F.HEADSET_RGB_ONBOARD_EFFECTS
_CLUSTER = 0 _CLUSTER = 0
# Custom (5) is intentionally absent — it is a stored card-reference
# effect, not a parametric one; it cannot be set via setRGBClusterEffect.
_ALL_EFFECTS = ( _ALL_EFFECTS = (
("Static", 0), ("Static", 0),
("Color Cycle", 1), ("Color Cycle", 1),
("Color Wave", 2), ("Color Wave", 2),
("Breathing", 3), ("Breathing", 3),
("Dual Color", 4), ("Dual Color", 4),
("Custom", 5),
) )
_EFFECT_FIELDS = { _EFFECT_FIELDS = {
0: ("color1",), 0: ("color1",),
1: ("intensity", "period", "saturation"), 1: ("intensity", "period", "saturation"),
2: ("intensity", "period", "saturation", "direction"), 2: ("intensity", "period", "saturation", "direction"),
3: ("color1", "period"), 3: ("color1", "intensity", "period"),
4: ("color1", "color2", "speed"), 4: ("color1", "color2", "intensity"),
5: (),
} }
_DIRECTIONS = common.NamedInts(**{"Horizontal": 0, "Vertical": 1, "Reverse Horizontal": 2, "Reverse Vertical": 3}) _DIRECTIONS = common.NamedInts(**{"Horizontal": 0, "Vertical": 1, "Reverse Horizontal": 2, "Reverse Vertical": 3})
@ -2831,12 +2858,13 @@ class HeadsetOnboardEffect(settings.Setting):
if off + 2 > len(info): if off + 2 > len(info):
break break
eid = (info[off] << 8) | info[off + 1] eid = (info[off] << 8) | info[off + 1]
if 0 <= eid <= 5 and eid not in supported: if 0 <= eid <= 4 and eid not in supported:
supported.append(eid) supported.append(eid)
if not supported: if not supported:
# Unparseable reply — offer all six; the firmware rejects any it # Unparseable reply — offer the five parametric effects; the
# doesn't support. See the 0x0621 fallback note in protocol RE. # firmware rejects any it doesn't support. Custom (5) is never
supported = [0, 1, 2, 3, 4, 5] # offered here. See the 0x0621 fallback note in protocol RE.
supported = [0, 1, 2, 3, 4]
rw = cls.rw_class(cls.feature, cls._CLUSTER) rw = cls.rw_class(cls.feature, cls._CLUSTER)
validator = settings_validator.HeteroValidator(data_class=_HeadsetOnboardEffect, options=None) validator = settings_validator.HeteroValidator(data_class=_HeadsetOnboardEffect, options=None)
setting = cls(device, rw, validator) setting = cls(device, rw, validator)
@ -2848,8 +2876,14 @@ class HeadsetOnboardEffect(settings.Setting):
{"name": "color2", "kind": settings.Kind.COLOR, "label": _("Secondary")}, {"name": "color2", "kind": settings.Kind.COLOR, "label": _("Secondary")},
{"name": "intensity", "kind": settings.Kind.RANGE, "label": _("Intensity"), "min": 0, "max": 100}, {"name": "intensity", "kind": settings.Kind.RANGE, "label": _("Intensity"), "min": 0, "max": 100},
{"name": "saturation", "kind": settings.Kind.RANGE, "label": _("Saturation"), "min": 0, "max": 255}, {"name": "saturation", "kind": settings.Kind.RANGE, "label": _("Saturation"), "min": 0, "max": 255},
{"name": "period", "kind": settings.Kind.RANGE, "label": _("Period"), "min": 0, "max": 0xFFFF}, {
{"name": "speed", "kind": settings.Kind.RANGE, "label": _("Speed"), "min": 0, "max": 0xFF}, "name": "period",
"kind": settings.Kind.RANGE,
"label": _("Period"),
"min": 1000,
"max": 20000,
"display_seconds": True,
},
{"name": "direction", "kind": settings.Kind.CHOICE, "label": _("Direction"), "choices": cls._DIRECTIONS}, {"name": "direction", "kind": settings.Kind.CHOICE, "label": _("Direction"), "choices": cls._DIRECTIONS},
] ]
setting.fields_map = {eid: (id_choices[eid], {field: 1 for field in cls._EFFECT_FIELDS[eid]}) for eid in supported} setting.fields_map = {eid: (id_choices[eid], {field: 1 for field in cls._EFFECT_FIELDS[eid]}) for eid in supported}