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:
parent
d37573122f
commit
e557c30ab2
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue