Solaar/lib/logitech_receiver/device_quirks.py

93 lines
4.1 KiB
Python

"""Per-device-model quirks for RGB lighting.
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 ``"32"``, G325 ``"44"``);
see ``device._get_ids_centurion``.
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
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 0x32, from DeviceInfo func 0;
# confirmed against multiple diagnostic logs). 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.
"32": {
0: {"color1"},
1: {"color1", "color2"},
},
}
def rgb_effects_nvconfig_allowed_fields(device, cap_id: int) -> set[str] | None:
"""Color fields to expose for an 0x8071 NvConfig boot effect.
Returns the allowed field set (possibly empty — On/Off toggle only), or
``None`` to suppress the setting entirely.
"""
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)