From e557c30ab20c701de6f7cdbf796eff5927c1a33c Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Sun, 17 May 2026 14:37:10 -0700 Subject: [PATCH] headset/RGB: default-deny allowlist for NVconfig-saved color settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- lib/logitech_receiver/device.py | 5 +- lib/logitech_receiver/device_quirks.py | 114 ++++++++++++++------ lib/logitech_receiver/settings_templates.py | 43 +++++--- 3 files changed, 113 insertions(+), 49 deletions(-) diff --git a/lib/logitech_receiver/device.py b/lib/logitech_receiver/device.py index 63376e75..e400c17e 100644 --- a/lib/logitech_receiver/device.py +++ b/lib/logitech_receiver/device.py @@ -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 diff --git a/lib/logitech_receiver/device_quirks.py b/lib/logitech_receiver/device_quirks.py index 978e0505..30cb2018 100644 --- a/lib/logitech_receiver/device_quirks.py +++ b/lib/logitech_receiver/device_quirks.py @@ -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) diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index bc3e1adf..5eff7952 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -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