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) hw_info = _hidpp20.get_hardware_info_centurion(self)
if hw_info: if hw_info:
model_id, hw_revision, product_id = 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}"} self._tid_map = {"usbid": f"{product_id:04X}"}
@property @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 Keyed by ``device.modelId``. For normal HID++ devices that is the string
transport-specific PID (btid + btleid + wpid + usbid) for a single physical Logitech composes by concatenating every transport PID (btid + btleid + wpid
model. That makes one entry cover the same device regardless of how it is + usbid) one entry covers the model on any transport. For Centurion
currently connected no transport-aliasing gotchas. 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 Two postures, by feature class:
firmware ignores the bytes I wrote", so each entry is observation-derived.
Keep entries narrow and document the observation in a comment. * **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 from __future__ import annotations
# Quirk keys are named after the doc-canonical feature/function path so a import os
# grep for the HID++ feature name (e.g. "RGBEffects", "NvConfig") lands here.
# _ALL_NVCONFIG_FIELDS = {"color1", "color2", "speed"}
# 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.
# def _experimental() -> bool:
# rgb_effects_nvconfig_colors_inert """True when SOLAAR_EXPERIMENTAL is set truthy — bypasses allowlist masking."""
# Feature 0x8071 RGBEffects, Function 3 NvConfig — cap-id → set of return os.environ.get("SOLAAR_EXPERIMENTAL", "").strip().lower() in ("1", "true", "yes", "on")
# color slot names ({"color1", "color2"}) whose bytes the firmware
# accepts but does not visibly apply. UI hides color pickers for
# slots in the set. # Feature 0x8071 RGBEffects, Function 3 NvConfig — persistent boot/shutdown
QUIRKS: dict[str, dict[str, object]] = { # effects. Default-DENY allowlist: modelId -> cap_id -> set of color fields
# G502 X PLUS — RGBEffects NvConfig startup effect (cap 0x0001): color # the firmware is KNOWN to honor. A listed cap shows the setting (its On/Off
# bytes are entirely ignored; only the enabled flag is honored. Shutdown # toggle plus the listed color pickers); an empty set shows the toggle only.
# cap (0x0040) is not supported and is suppressed via build()-time probe. # An unlisted cap or unlisted model suppresses the setting entirely.
"4099C0950000": { RGB_EFFECTS_NVCONFIG_ALLOWED: dict[str, dict[int, set[str]]] = {
"rgb_effects_nvconfig_colors_inert": { # G502 X PLUS — startup (0x0001): color bytes are inert, only the enabled
0x0001: {"color1", "color2"}, # 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): def rgb_effects_nvconfig_allowed_fields(device, cap_id: int) -> set[str] | None:
"""Look up a quirk by key for the device's model. """Color fields to expose for an 0x8071 NvConfig boot effect.
Returns ``default`` when either the model has no quirks entry or the Returns the allowed field set (possibly empty On/Off toggle only), or
entry lacks the requested key. ``None`` to suppress the setting entirely.
""" """
model_id = getattr(device, "modelId", None) if _experimental():
if not model_id: return set(_ALL_NVCONFIG_FIELDS)
return default model_id = getattr(device, "modelId", None) or ""
return QUIRKS.get(model_id, {}).get(key, default) 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 return None
if state is None or len(state) < 3: if state is None or len(state) < 3:
return None 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 # Log getSignatureEffectsInfo (fn 0) once per device — its byte layout
# isn't pinned down, so slot discovery uses per-slot probing for now. # isn't pinned down, so slot discovery uses per-slot probing for now.
if not getattr(device, "_headset_sig_info_logged", False): 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. # 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} 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] 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 = { setting.fields_map = {
int(cls._ENABLED_CHOICES["On"]): (cls._ENABLED_CHOICES["On"], visible), int(cls._ENABLED_CHOICES["On"]): (cls._ENABLED_CHOICES["On"], visible),
int(cls._ENABLED_CHOICES["Off"]): (cls._ENABLED_CHOICES["Off"], 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 return None # device rejects this cap — gate the setting off
if reply is None or len(reply) < 10: if reply is None or len(reply) < 10:
return None 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) rw = cls.rw_class(cls.feature, cls.cap_id)
validator = settings_validator.HeteroValidator(data_class=_RgbBootEffect, options=None) validator = settings_validator.HeteroValidator(data_class=_RgbBootEffect, options=None)
setting = cls(device, rw, validator) setting = cls(device, rw, validator)
@ -3654,24 +3680,15 @@ class _RgbBootEffectSetting(settings.Setting):
# them and writes still carry their values — the firmware may store # them and writes still carry their values — the firmware may store
# bytes it doesn't visibly use. fields_map controls UI visibility. # bytes it doesn't visibly use. fields_map controls UI visibility.
setting.possible_fields = [id_field, cls._COLOR1_FIELD, cls._COLOR2_FIELD] setting.possible_fields = [id_field, cls._COLOR1_FIELD, cls._COLOR2_FIELD]
# Default-allow: show both color pickers unless this device model is # An empty allowed set shows the On/Off toggle only (cap works, colors
# known to ignore one or both. Most 0x8071 devices honor the bytes. # don't); a non-empty set adds the listed color pickers.
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 in allowed}
visible = {n: o for n, o in (("color1", 1), ("color2", 4)) if n not in inert}
# Both On/Off map to the same visible field set so colors stay editable # 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). # when the effect is Off (pre-stages them for next enable).
setting.fields_map = { setting.fields_map = {
int(cls._ENABLED_CHOICES["On"]): (cls._ENABLED_CHOICES["On"], visible), int(cls._ENABLED_CHOICES["On"]): (cls._ENABLED_CHOICES["On"], visible),
int(cls._ENABLED_CHOICES["Off"]): (cls._ENABLED_CHOICES["Off"], 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 return setting