headset/RGB: default-deny allowlist for NVconfig-saved color settings

NVconfig-saved colors (0x8071 RGBEffects boot effects, 0x0622 HeadsetRGB
signature effects) persist to device storage, so an unvalidated control
can durably misconfigure a device. Gate them behind a per-model
allowlist: every field is hidden and every slot suppressed unless the
exact model is known-good. SOLAAR_EXPERIMENTAL=true bypasses the masking
for testers. Non-persistent effect parameters (zone effects, LED
directions) keep their default-allow blocklist — unchanged.

device_quirks.py is rewritten around two per-feature allowlists with
their own accessors, replacing the flat blocklist QUIRKS dict.

Centurion device identification: _get_ids_centurion now derives modelId
from the firmware-stable model_id byte (G522 0x33, G325 0x45) instead of
productId, which is shared across the headset family and varies by
firmware (G522 0x0508 -> 0x0509) — it could never key a quirk reliably.

Approved models: G502 X PLUS and G515 TKL for the 0x8071 boot effects,
G522 for the 0x0622 signature effects (startup primary only, shutdown
both colors, no speed, passive slot suppressed pending RE).
This commit is contained in:
Ken Sanislo 2026-05-17 14:37:10 -07:00 committed by Peter F. Patel-Schneider
parent d37573122f
commit e557c30ab2
3 changed files with 113 additions and 49 deletions

View File

@ -315,7 +315,10 @@ class Device:
hw_info = _hidpp20.get_hardware_info_centurion(self)
if hw_info:
model_id, hw_revision, product_id = hw_info
self._modelId = f"{product_id:04X}"
# modelId is the stable per-model disambiguator (G522 0x33, G325
# 0x45). productId is shared across the headset family and varies
# by firmware (G522 0x0508 -> 0x0509), so it must NOT key modelId.
self._modelId = f"{model_id:02X}"
self._tid_map = {"usbid": f"{product_id:04X}"}
@property

View File

@ -1,47 +1,91 @@
"""Per-device-model quirks.
"""Per-device-model quirks for RGB lighting.
Keyed by ``device.modelId``, which Logitech composes by concatenating every
transport-specific PID (btid + btleid + wpid + usbid) for a single physical
model. That makes one entry cover the same device regardless of how it is
currently connected no transport-aliasing gotchas.
Keyed by ``device.modelId``. For normal HID++ devices that is the string
Logitech composes by concatenating every transport PID (btid + btleid + wpid
+ usbid) one entry covers the model on any transport. For Centurion
headsets it is the firmware-stable model byte (G522 ``"33"``, G325 ``"45"``);
see ``device._get_ids_centurion``.
Quirks are hand-curated. Devices do not self-report behaviors like "this
firmware ignores the bytes I wrote", so each entry is observation-derived.
Keep entries narrow and document the observation in a comment.
Two postures, by feature class:
* **Effect parameters that do NOT persist** (zone effects, LED directions)
default-ALLOW. Those are validated and low-harm (a wrong value is cosmetic
and transient). They use blocklists elsewhere, e.g. ``LedDirectionBlocklist``
in ``hidpp20.py``. Nothing of that kind lives here.
* **NVconfig-saved colors** (0x8071 RGBEffects boot effects, 0x0622 HeadsetRGB
signature effects) default-DENY. Those writes persist to non-volatile
storage, so an unvalidated control can durably misconfigure a device. Every
field is hidden and every slot suppressed unless the exact model is listed
here as known-good.
Setting ``SOLAAR_EXPERIMENTAL`` truthy bypasses the allowlists entirely for
testers / reverse-engineering on devices not yet validated.
Entries are hand-curated; document the observation in a comment.
"""
from __future__ import annotations
# Quirk keys are named after the doc-canonical feature/function path so a
# grep for the HID++ feature name (e.g. "RGBEffects", "NvConfig") lands here.
#
# Default-allow: each quirk lists what is KNOWN to be broken or ignored on
# that device model. Unlisted models / unlisted entries get the full UI.
#
# rgb_effects_nvconfig_colors_inert
# Feature 0x8071 RGBEffects, Function 3 NvConfig — cap-id → set of
# color slot names ({"color1", "color2"}) whose bytes the firmware
# accepts but does not visibly apply. UI hides color pickers for
# slots in the set.
QUIRKS: dict[str, dict[str, object]] = {
# G502 X PLUS — RGBEffects NvConfig startup effect (cap 0x0001): color
# bytes are entirely ignored; only the enabled flag is honored. Shutdown
# cap (0x0040) is not supported and is suppressed via build()-time probe.
"4099C0950000": {
"rgb_effects_nvconfig_colors_inert": {
0x0001: {"color1", "color2"},
},
import os
_ALL_NVCONFIG_FIELDS = {"color1", "color2", "speed"}
def _experimental() -> bool:
"""True when SOLAAR_EXPERIMENTAL is set truthy — bypasses allowlist masking."""
return os.environ.get("SOLAAR_EXPERIMENTAL", "").strip().lower() in ("1", "true", "yes", "on")
# Feature 0x8071 RGBEffects, Function 3 NvConfig — persistent boot/shutdown
# effects. Default-DENY allowlist: modelId -> cap_id -> set of color fields
# the firmware is KNOWN to honor. A listed cap shows the setting (its On/Off
# toggle plus the listed color pickers); an empty set shows the toggle only.
# An unlisted cap or unlisted model suppresses the setting entirely.
RGB_EFFECTS_NVCONFIG_ALLOWED: dict[str, dict[int, set[str]]] = {
# G502 X PLUS — startup (0x0001): color bytes are inert, only the enabled
# flag is honored, so no color fields are allowed (toggle only). Shutdown
# (0x0040) is not supported and is suppressed by build()-time probe anyway.
"4099C0950000": {0x0001: set()},
# G515 LIGHTSPEED TKL — startup and shutdown both honor both colors.
"B38940B4C355": {0x0001: {"color1", "color2"}, 0x0040: {"color1", "color2"}},
}
# Feature 0x0622 HeadsetRGB signature effects — persistent startup / shutdown
# / passive effects. Default-DENY allowlist: modelId -> effect_id -> set of
# fields the firmware is KNOWN to honor. An unlisted effect_id or model
# suppresses that signature-effect setting entirely.
HEADSET_SIGNATURE_EFFECTS_ALLOWED: dict[str, dict[int, set[str]]] = {
# G522 LIGHTSPEED (Centurion model byte 0x33). Verified against user
# reports: startup honors only the primary color (secondary unused, speed
# has no effect); shutdown honors both colors (speed has no effect). The
# passive slot (effect_id 2) is omitted — its behavior is not understood,
# so the whole passive setting is suppressed.
"33": {
0: {"color1"},
1: {"color1", "color2"},
},
}
def get(device, key, default=None):
"""Look up a quirk by key for the device's model.
def rgb_effects_nvconfig_allowed_fields(device, cap_id: int) -> set[str] | None:
"""Color fields to expose for an 0x8071 NvConfig boot effect.
Returns ``default`` when either the model has no quirks entry or the
entry lacks the requested key.
Returns the allowed field set (possibly empty On/Off toggle only), or
``None`` to suppress the setting entirely.
"""
model_id = getattr(device, "modelId", None)
if not model_id:
return default
return QUIRKS.get(model_id, {}).get(key, default)
if _experimental():
return set(_ALL_NVCONFIG_FIELDS)
model_id = getattr(device, "modelId", None) or ""
return RGB_EFFECTS_NVCONFIG_ALLOWED.get(model_id, {}).get(cap_id)
def headset_signature_allowed_fields(device, effect_id: int) -> set[str] | None:
"""Fields to expose for an 0x0622 signature effect slot.
Returns the allowed field set, or ``None`` to suppress the slot entirely.
"""
if _experimental():
return set(_ALL_NVCONFIG_FIELDS)
model_id = getattr(device, "modelId", None) or ""
return HEADSET_SIGNATURE_EFFECTS_ALLOWED.get(model_id, {}).get(effect_id)

View File

@ -2635,6 +2635,15 @@ class _HeadsetSignatureEffectSetting(settings.Setting):
return None
if state is None or len(state) < 3:
return None
# NVconfig-saved colors are default-DENY: signature effects persist to
# device storage, so a slot is shown only on models explicitly known-
# good. allowed is None on an unlisted model/slot (suppress the whole
# setting), else the set of fields the firmware honors. The G522
# passive slot is deliberately unlisted — its behavior is unknown.
# SOLAAR_EXPERIMENTAL unmasks everything.
allowed = device_quirks.headset_signature_allowed_fields(device, cls.effect_id)
if allowed is None:
return None
# Log getSignatureEffectsInfo (fn 0) once per device — its byte layout
# isn't pinned down, so slot discovery uses per-slot probing for now.
if not getattr(device, "_headset_sig_info_logged", False):
@ -2651,7 +2660,7 @@ class _HeadsetSignatureEffectSetting(settings.Setting):
# speed stay visible in both states so toggling Off keeps them.
id_field = {"name": "ID", "kind": settings.Kind.TOGGLE, "label": None, "on_value": 1, "off_value": 2}
setting.possible_fields = [id_field, cls._COLOR1_FIELD, cls._COLOR2_FIELD, cls._SPEED_FIELD]
visible = {"color1": 1, "color2": 1, "speed": 1}
visible = {f: 1 for f in ("color1", "color2", "speed") if f in allowed}
setting.fields_map = {
int(cls._ENABLED_CHOICES["On"]): (cls._ENABLED_CHOICES["On"], visible),
int(cls._ENABLED_CHOICES["Off"]): (cls._ENABLED_CHOICES["Off"], visible),
@ -3642,6 +3651,23 @@ class _RgbBootEffectSetting(settings.Setting):
return None # device rejects this cap — gate the setting off
if reply is None or len(reply) < 10:
return None
# Register the firmware shutdown trigger on cap 0x0040 devices so the
# animation plays on Solaar exit. This depends only on the device
# having the cap (probe above), not on the UI control being shown —
# so it runs before the allowlist gate below. rgb_power.cleanup fires
# mode 0 at its end when _rgb_has_shutdown_cap is set. See
# solaar_shutdown_effect_trigger_spec.md.
if cls.cap_id == 0x0040:
device._rgb_has_shutdown_cap = True
if rgb_power.cleanup not in device.cleanups:
device.cleanups.append(rgb_power.cleanup)
# NVconfig-saved colors are default-DENY: the boot-effect setting is
# shown only on models explicitly known-good. allowed is None on an
# unlisted model/cap (suppress), or a (possibly empty) set of color
# fields the firmware honors. SOLAAR_EXPERIMENTAL unmasks everything.
allowed = device_quirks.rgb_effects_nvconfig_allowed_fields(device, cls.cap_id)
if allowed is None:
return None
rw = cls.rw_class(cls.feature, cls.cap_id)
validator = settings_validator.HeteroValidator(data_class=_RgbBootEffect, options=None)
setting = cls(device, rw, validator)
@ -3654,24 +3680,15 @@ class _RgbBootEffectSetting(settings.Setting):
# them and writes still carry their values — the firmware may store
# bytes it doesn't visibly use. fields_map controls UI visibility.
setting.possible_fields = [id_field, cls._COLOR1_FIELD, cls._COLOR2_FIELD]
# Default-allow: show both color pickers unless this device model is
# known to ignore one or both. Most 0x8071 devices honor the bytes.
inert = device_quirks.get(device, "rgb_effects_nvconfig_colors_inert", {}).get(cls.cap_id, set())
visible = {n: o for n, o in (("color1", 1), ("color2", 4)) if n not in inert}
# An empty allowed set shows the On/Off toggle only (cap works, colors
# don't); a non-empty set adds the listed color pickers.
visible = {n: o for n, o in (("color1", 1), ("color2", 4)) if n in allowed}
# Both On/Off map to the same visible field set so colors stay editable
# when the effect is Off (pre-stages them for next enable).
setting.fields_map = {
int(cls._ENABLED_CHOICES["On"]): (cls._ENABLED_CHOICES["On"], visible),
int(cls._ENABLED_CHOICES["Off"]): (cls._ENABLED_CHOICES["Off"], visible),
}
# Register the firmware shutdown trigger on cap 0x0040 devices so the
# animation plays on Solaar exit. rgb_power.cleanup fires mode 0 at
# its end when _rgb_has_shutdown_cap is set. See
# solaar_shutdown_effect_trigger_spec.md.
if cls.cap_id == 0x0040:
device._rgb_has_shutdown_cap = True
if rgb_power.cleanup not in device.cleanups:
device.cleanups.append(rgb_power.cleanup)
return setting