Add RGB lighting persistence and software LED power management for G515
Software-managed LED persistence and power management for devices that expose RGBEffects (0x8071) — primarily G515 LIGHTSPEED TKL, but the same infrastructure works on any 0x8071 device that supports SW takeover. Core mechanism: RGBControl toggle drives a Set SWControl(mode=3, flags) handshake. While SW control is held, the host owns the LED pipeline: zone effects, per-key paint, idle/sleep transitions, and the NvConfig boot/exit animations. On release, the firmware resumes its onboard profile. Major pieces: - rgb_power.py — new module hosting the software RGB power manager: ACTIVE / DIMMING / IDLE / SLEEPING state machine driven by firmware onUserActivity events, smooth 5-second dim ramp (zone or per-key), idle Static color snap, software sleep timer, wake handler that re-pushes saved state. Includes the cleanup hook that runs on device close (and, optionally, fires the cap 0x0040 shutdown trigger). - RGBControl (settings_templates) — switch-style render via BooleanValidator (true_value=3 / false_value=0) plus a full _claim_sw_control / _release_sw_control pair: profile-management mode, SetSWControl, per-key flag reset, manager start, cleanup registration, and a post-claim repaint pass so the device immediately reflects Solaar's saved zone + per-key state. - RGBEffectSetting — zone-effect Setting subclass for 0x8071. Handles per-key/zone coexistence: per-key paint dominates only when zone is Static and the user has explicitly opted in via the lock icon; under animations or before opt-in, the zone wire push is the visible layer. - RGBIdleEffect / RGBIdleTimeout / RGBSleepTimeout — Solaar-managed idle behavior. Choice list: "No change" → Dim → Static (snap to color) → device-specific animations. Static idle substitutes the idle color for unset per-key cells via effective_zone_base_color's state-aware lookup. - RgbStartupAnimation / RgbShutdownAnimation — toggle-and-color rows for RGBEffects NvConfig caps 0x0001 and 0x0040, exposed only on devices that answer the probe. Shutdown trigger fires SetRgbPowerMode(0) at cleanup time so the firmware plays the configured animation on exit. - PerKeyLighting — per-key painter improvements: explicit-opt-in dominance over zone effects, BUSY retry, FrameEnd suppression on per-cell failure, single-shot prep sequence (SetEffectByIndex into the out-of-range slot) on mice with firmware effect cards. - device_quirks.py — small per-model quirks table keyed by device.modelId (stable across USB/BLE/wireless). Currently used to mark RGBEffects NvConfig color slots as inert on devices where the firmware accepts but ignores the bytes (G502 X PLUS startup colors). - config_panel.py — HeteroKeyControl gains a TOGGLE field kind that renders as a Gtk.Switch (used by the boot-effect rows). Visual gate greys out RGB settings whose prerequisites aren't met: rgb_zone_* / rgb_idle_* / rgb_sleep_timeout require LED Control = Solaar; per-key additionally requires every zone effect to be Static. Visual-only — doesn't touch the persister's user lock-icon state. - tests/test_rgb_power.py — coverage for the power manager state machine, dim ramp, idle effects, wake path, and per-key/zone coexistence. Closes pwr-Solaar/Solaar#3149.
This commit is contained in:
parent
09cb2dddb9
commit
3df2a30f30
|
|
@ -940,12 +940,16 @@ class Device:
|
|||
pass
|
||||
|
||||
def close(self):
|
||||
handle, self.handle = self.handle, None
|
||||
if self in Device.instances:
|
||||
Device.instances.remove(self)
|
||||
# Run device.cleanups before clearing self.handle — cleanup callbacks
|
||||
# typically need to issue final feature_request() writes (e.g. release
|
||||
# SW control, restore device-side state) and feature_request() relies
|
||||
# on self.handle being set.
|
||||
if hasattr(self, "cleanups"):
|
||||
for cleanup in self.cleanups:
|
||||
cleanup(self)
|
||||
handle, self.handle = self.handle, None
|
||||
if self in Device.instances:
|
||||
Device.instances.remove(self)
|
||||
return handle and self.low_level.close(handle)
|
||||
|
||||
def __index__(self):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
"""Per-device-model quirks.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
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"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get(device, key, default=None):
|
||||
"""Look up a quirk by key for the device's model.
|
||||
|
||||
Returns ``default`` when either the model has no quirks entry or the
|
||||
entry lacks the requested key.
|
||||
"""
|
||||
model_id = getattr(device, "modelId", None)
|
||||
if not model_id:
|
||||
return default
|
||||
return QUIRKS.get(model_id, {}).get(key, default)
|
||||
|
|
@ -1128,22 +1128,37 @@ class LEDParam:
|
|||
ramp = "ramp"
|
||||
form = "form"
|
||||
saturation = "saturation"
|
||||
direction = "direction"
|
||||
|
||||
|
||||
class LedRampChoice(IntEnum):
|
||||
DEFAULT = 0
|
||||
YES = 1
|
||||
NO = 2
|
||||
# NamedInts (not IntEnum) so the GTK ComboBoxText shows readable labels.
|
||||
LedRampChoice = common.NamedInts(Default=0, Yes=1, No=2)
|
||||
|
||||
LedFormChoices = common.NamedInts(
|
||||
Default=0,
|
||||
Sine=1,
|
||||
Square=2,
|
||||
Triangle=3,
|
||||
Sawtooth=4,
|
||||
Shark_fin=5,
|
||||
Exponential=6,
|
||||
)
|
||||
|
||||
class LedFormChoices(IntEnum):
|
||||
DEFAULT = 0
|
||||
SINE = 1
|
||||
SQUARE = 2
|
||||
TRIANGLE = 3
|
||||
SAWTOOTH = 4
|
||||
SHARKFIN = 5
|
||||
EXPONENTIAL = 6
|
||||
LedDirectionChoices = common.NamedInts()
|
||||
LedDirectionChoices[0] = _("Cycle")
|
||||
LedDirectionChoices[1] = _("Right")
|
||||
LedDirectionChoices[2] = _("Down")
|
||||
LedDirectionChoices[3] = _("Center Out")
|
||||
LedDirectionChoices[4] = _("In")
|
||||
LedDirectionChoices[5] = _("Out")
|
||||
LedDirectionChoices[6] = _("Left")
|
||||
LedDirectionChoices[7] = _("Up")
|
||||
LedDirectionChoices[8] = _("Center In")
|
||||
|
||||
# Direction values to hide on devices whose LED grid can't render them.
|
||||
LedDirectionBlocklist = {
|
||||
"40B4": {4, 5}, # G515 LS TKL — no edge-radiating wave geometry
|
||||
}
|
||||
|
||||
|
||||
LEDParamSize = {
|
||||
|
|
@ -1154,26 +1169,62 @@ LEDParamSize = {
|
|||
LEDParam.ramp: 1,
|
||||
LEDParam.form: 1,
|
||||
LEDParam.saturation: 1,
|
||||
LEDParam.direction: 1,
|
||||
}
|
||||
# not implemented from x8070 Wave=4, Stars=5, Press=6, Audio=7
|
||||
# not implemented from x8071 Custom=12, Kitt=13, HSVPulsing=20,
|
||||
# WaveC=22, RippleC=23, SignatureActive=24, SignaturePassive=25
|
||||
# Entry: [NamedInt, params, defaults, ranges] — trailing dicts optional.
|
||||
# ranges overrides a field's global min/max, e.g. period: (2, 200).
|
||||
LEDEffects = {
|
||||
0x00: [NamedInt(0x00, _("Disabled")), {}],
|
||||
0x01: [NamedInt(0x01, _("Static")), {LEDParam.color: 0, LEDParam.ramp: 3}],
|
||||
0x02: [NamedInt(0x02, _("Pulse")), {LEDParam.color: 0, LEDParam.speed: 3}],
|
||||
0x03: [NamedInt(0x03, _("Cycle")), {LEDParam.period: 5, LEDParam.intensity: 7}],
|
||||
0x03: [
|
||||
NamedInt(0x03, _("Cycle")),
|
||||
{LEDParam.period: 5, LEDParam.intensity: 7},
|
||||
{LEDParam.period: 5000, LEDParam.intensity: 100},
|
||||
],
|
||||
# No probe device enumerates base Wave; assume the 0x16 layout so the
|
||||
# UI matches what 0x16-capable hardware shows.
|
||||
0x04: [
|
||||
NamedInt(0x04, _("Wave")),
|
||||
{LEDParam.period: 6, LEDParam.direction: 9},
|
||||
{LEDParam.period: 5000},
|
||||
],
|
||||
0x08: [NamedInt(0x08, _("Boot")), {}],
|
||||
0x09: [NamedInt(0x09, _("Demo")), {}],
|
||||
0x0A: [
|
||||
NamedInt(0x0A, _("Breathe")),
|
||||
{LEDParam.color: 0, LEDParam.period: 3, LEDParam.form: 5, LEDParam.intensity: 6},
|
||||
{LEDParam.period: 5000, LEDParam.intensity: 100},
|
||||
],
|
||||
0x0B: [
|
||||
NamedInt(0x0B, _("Ripple")),
|
||||
{LEDParam.color: 0, LEDParam.period: 4},
|
||||
{LEDParam.period: 20},
|
||||
{LEDParam.period: (2, 200)},
|
||||
],
|
||||
0x0B: [NamedInt(0x0B, _("Ripple")), {LEDParam.color: 0, LEDParam.period: 4}],
|
||||
0x0E: [NamedInt(0x0E, _("Decomposition")), {LEDParam.period: 6, LEDParam.intensity: 8}],
|
||||
0x0F: [NamedInt(0x0F, _("Signature1")), {LEDParam.period: 5, LEDParam.intensity: 7}],
|
||||
0x10: [NamedInt(0x10, _("Signature2")), {LEDParam.period: 5, LEDParam.intensity: 7}],
|
||||
0x15: [NamedInt(0x15, _("CycleS")), {LEDParam.saturation: 1, LEDParam.period: 6, LEDParam.intensity: 8}],
|
||||
0x15: [
|
||||
NamedInt(0x15, _("Cycle")),
|
||||
{LEDParam.saturation: 1, LEDParam.period: 6, LEDParam.intensity: 8},
|
||||
{LEDParam.saturation: 255, LEDParam.period: 5000, LEDParam.intensity: 100},
|
||||
],
|
||||
0x16: [
|
||||
NamedInt(0x16, _("Wave")),
|
||||
{LEDParam.saturation: 1, LEDParam.period: 6, LEDParam.intensity: 8, LEDParam.direction: 9},
|
||||
{LEDParam.saturation: 255, LEDParam.period: 5000, LEDParam.intensity: 100},
|
||||
],
|
||||
# Saturation derivative of Ripple 0x0B; pcap layout: color @ 0-2,
|
||||
# saturation @ 3, period @ 6-7.
|
||||
0x17: [
|
||||
NamedInt(0x17, _("Ripple")),
|
||||
{LEDParam.color: 0, LEDParam.saturation: 3, LEDParam.period: 6},
|
||||
{LEDParam.saturation: 255, LEDParam.period: 20},
|
||||
{LEDParam.period: (2, 200)},
|
||||
],
|
||||
# Synthetic — host-side dim ramp, no wire effect.
|
||||
0x80: [NamedInt(0x80, _("Dim")), {LEDParam.intensity: 0}],
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ from . import diversion
|
|||
from . import hidpp10
|
||||
from . import hidpp10_constants
|
||||
from . import hidpp20
|
||||
from . import rgb_power
|
||||
from . import settings_templates
|
||||
from .common import Alert
|
||||
from .common import BatteryStatus
|
||||
|
|
@ -427,6 +428,14 @@ def _process_feature_notification(device: Device, notification: HIDPPNotificatio
|
|||
brightness = struct.unpack("!H", device.feature_request(SupportedFeature.BRIGHTNESS_CONTROL, 0x10)[:2])[0]
|
||||
device.setting_callback(device, settings_templates.BrightnessControl, [brightness])
|
||||
|
||||
elif feature == SupportedFeature.RGB_EFFECTS:
|
||||
fn = notification.address >> 4
|
||||
if fn == 1: # onUserActivity: type=0 is IDLE, type!=0 is ACTIVE
|
||||
activity_type = notification.data[0] if notification.data else 0xFF
|
||||
rgb_power.on_user_activity(device, activity_type)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: RGB_EFFECTS notification addr=%02x: %s", device, notification.address, notification)
|
||||
|
||||
diversion.process_notification(device, notification, feature)
|
||||
return True
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,975 @@
|
|||
## Copyright (C) 2012-2013 Daniel Pavel
|
||||
## Copyright (C) 2014-2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/
|
||||
##
|
||||
## This program is free software; you can redistribute it and/or modify
|
||||
## it under the terms of the GNU General Public License as published by
|
||||
## the Free Software Foundation; either version 2 of the License, or
|
||||
## (at your option) any later version.
|
||||
##
|
||||
## This program is distributed in the hope that it will be useful,
|
||||
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
## GNU General Public License for more details.
|
||||
##
|
||||
## You should have received a copy of the GNU General Public License along
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
"""Software-driven RGB power management for devices that hand off LED
|
||||
control to the host (RGB_EFFECTS / 0x8071).
|
||||
|
||||
Handles the firmware onUserActivity events, the two-stage idle effect
|
||||
(smooth dim ramp or animation), and the software sleep timer that fires
|
||||
after idle_timeout has elapsed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from time import sleep
|
||||
|
||||
from . import exceptions
|
||||
from . import hidpp20_constants
|
||||
from . import settings
|
||||
from . import special_keys
|
||||
from .hidpp20_constants import SupportedFeature
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from gi.repository import GLib
|
||||
|
||||
_has_glib = True
|
||||
except ImportError:
|
||||
_has_glib = False
|
||||
|
||||
|
||||
# SetSWControl flag bits for RGB_EFFECTS (0x8071).
|
||||
FLAG_EFFECT = 0x01
|
||||
FLAG_POWER = 0x02
|
||||
FLAG_NVCONFIG = 0x04
|
||||
|
||||
# SetSWControl payloads: [subfn=set, mode=3, flags]
|
||||
SW_ACTIVE = bytes([0x01, 0x03, FLAG_NVCONFIG]) # firmware monitors idle
|
||||
SW_IDLE = bytes([0x01, 0x03, FLAG_POWER]) # firmware monitors activity
|
||||
SW_RELEASE = bytes([0x01, 0x00, 0x00])
|
||||
|
||||
|
||||
_managers = {} # keyed by id(device)
|
||||
|
||||
|
||||
def get_manager(device):
|
||||
"""Return the active RGBPowerManager for `device`, or None."""
|
||||
return _managers.get(id(device))
|
||||
|
||||
|
||||
def on_user_activity(device, activity_type):
|
||||
"""Dispatch firmware onUserActivity events to the device's power manager."""
|
||||
mgr = _managers.get(id(device))
|
||||
if mgr:
|
||||
mgr.on_user_activity(activity_type)
|
||||
|
||||
|
||||
def translate_color_for_display(color, state, dim_pct, dim_step, dim_steps):
|
||||
"""Map a saved (undimmed) color to the display color for `state`.
|
||||
Returns None for SLEEPING."""
|
||||
if state == RGBPowerManager.ACTIVE:
|
||||
return color
|
||||
if state == RGBPowerManager.SLEEPING:
|
||||
return None
|
||||
target = RGBPowerManager._compute_dim_color(color, dim_pct)
|
||||
if state == RGBPowerManager.IDLE:
|
||||
return target
|
||||
# DIMMING — interpolate from saved toward dimmed target by ramp progress.
|
||||
t = (dim_step / dim_steps) if dim_steps else 1.0
|
||||
return RGBPowerManager._interpolate_color(color, target, t)
|
||||
|
||||
|
||||
def translate_for_device(device, color):
|
||||
"""Translate `color` through the device's RGBPowerManager state, or
|
||||
return it unchanged when no manager is registered. None signals SLEEPING."""
|
||||
mgr = _managers.get(id(device))
|
||||
if mgr is None:
|
||||
return color
|
||||
return mgr.translate_color(color)
|
||||
|
||||
|
||||
_EFFECT_STATIC = 0x01
|
||||
|
||||
|
||||
def perkey_has_paint(device):
|
||||
"""Return ``(perkey_setting, has_paint)``. has_paint is True when the
|
||||
per-key buffer has at least one real color, a usable zone set, and the
|
||||
user has *explicitly enabled* per-key via the lock icon (sensitivity
|
||||
is True). Default-sensitivity (False) and ignore both yield False so
|
||||
zone effects remain the primary mechanism on devices where the user
|
||||
hasn't opted in to per-key dominance."""
|
||||
perkey = None
|
||||
for s in getattr(device, "settings", []) or []:
|
||||
if s.name == "per-key-lighting":
|
||||
perkey = s
|
||||
break
|
||||
if perkey is None:
|
||||
return None, False
|
||||
validator = getattr(perkey, "_validator", None)
|
||||
choices = getattr(validator, "choices", None)
|
||||
if not choices:
|
||||
return perkey, False
|
||||
# Apply path runs rgb_zone_ before per-key, so _value may still be None
|
||||
# when this gate is consulted — fall back to the persister.
|
||||
value = getattr(perkey, "_value", None)
|
||||
persister = getattr(device, "persister", None)
|
||||
if value is None and persister is not None:
|
||||
value = persister.get("per-key-lighting")
|
||||
if not value:
|
||||
return perkey, False
|
||||
no_change = special_keys.COLORSPLUS["No change"]
|
||||
if not any(c != no_change and isinstance(c, int) and c >= 0 for c in value.values()):
|
||||
return perkey, False
|
||||
if persister is None or persister.get_sensitivity("per-key-lighting") is not True:
|
||||
return perkey, False
|
||||
return perkey, True
|
||||
|
||||
|
||||
def zone_effect_is_static(device):
|
||||
"""True when the persisted zone effect is Static, or when no
|
||||
rgb_zone_* setting exists at all (per-key-only hardware)."""
|
||||
has_zone = False
|
||||
persister = getattr(device, "persister", None)
|
||||
for s in getattr(device, "settings", []) or []:
|
||||
if s.name.startswith("rgb_zone_"):
|
||||
has_zone = True
|
||||
value = getattr(s, "_value", None)
|
||||
if value is None and persister is not None:
|
||||
value = persister.get(s.name)
|
||||
if value is not None and int(getattr(value, "ID", 0) or 0) == _EFFECT_STATIC:
|
||||
return True
|
||||
return not has_zone
|
||||
|
||||
|
||||
def zone_effect_is_ignored(device):
|
||||
"""True when every rgb_zone_* setting on `device` is marked
|
||||
SENSITIVITY_IGNORE in the persister."""
|
||||
persister = getattr(device, "persister", None)
|
||||
if persister is None:
|
||||
return False
|
||||
zones = [s for s in getattr(device, "settings", []) or [] if s.name.startswith("rgb_zone_")]
|
||||
if not zones:
|
||||
return False
|
||||
return all(persister.get_sensitivity(s.name) == settings.SENSITIVITY_IGNORE for s in zones)
|
||||
|
||||
|
||||
def effective_zone_base_color(device):
|
||||
"""Color to use for per-key unset cells: 0 (off/black) when the zone
|
||||
effect is ignored (or unavailable), the persisted zone color otherwise.
|
||||
Reads through the persister so we still get the saved color even before
|
||||
apply has populated _value.
|
||||
|
||||
During an idle-Static transition the saved color is substituted with
|
||||
the idle effect's color so unset cells track the idle primary. Reverts
|
||||
on wake when state returns to ACTIVE."""
|
||||
if zone_effect_is_ignored(device):
|
||||
return 0
|
||||
mgr = _managers.get(id(device))
|
||||
if mgr is not None and mgr._state == RGBPowerManager.IDLE and mgr._idle_effect_id() == 0x01:
|
||||
return int(getattr(mgr._idle_effect, "color", 0) or 0)
|
||||
persister = getattr(device, "persister", None)
|
||||
for s in getattr(device, "settings", []) or []:
|
||||
if not s.name.startswith("rgb_zone_"):
|
||||
continue
|
||||
value = getattr(s, "_value", None)
|
||||
if value is None and persister is not None:
|
||||
value = persister.get(s.name)
|
||||
if value is not None:
|
||||
color = getattr(value, "color", None)
|
||||
if isinstance(color, int):
|
||||
return int(color)
|
||||
return 0
|
||||
|
||||
|
||||
_RETRY_BUSY_BACKOFF_MS = (30, 60, 90)
|
||||
|
||||
|
||||
def feature_request_acked(device, feature, function, data=b"", retries=3):
|
||||
"""feature_request with BUSY/timeout retries. Returns reply bytes
|
||||
on ACK, None on hard failure (logged WARNING)."""
|
||||
busy_attempt = 0
|
||||
max_busy = len(_RETRY_BUSY_BACKOFF_MS)
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
reply = device.feature_request(feature, function, data)
|
||||
except exceptions.FeatureCallError as e:
|
||||
if getattr(e, "error", None) == hidpp20_constants.ErrorCode.BUSY and busy_attempt < max_busy:
|
||||
delay_ms = _RETRY_BUSY_BACKOFF_MS[busy_attempt]
|
||||
busy_attempt += 1
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
"%s: feature 0x%04x fn 0x%02x BUSY, retry %d/%d after %dms",
|
||||
device,
|
||||
int(feature),
|
||||
function,
|
||||
busy_attempt,
|
||||
max_busy,
|
||||
delay_ms,
|
||||
)
|
||||
sleep(delay_ms / 1000.0)
|
||||
continue
|
||||
logger.warning("%s: feature 0x%04x fn 0x%02x rejected: %s", device, int(feature), function, e)
|
||||
return None
|
||||
if reply is not None:
|
||||
if (attempt > 0 or busy_attempt > 0) and logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
"%s: feature 0x%04x fn 0x%02x succeeded after %d timeout retries, %d BUSY retries",
|
||||
device,
|
||||
int(feature),
|
||||
function,
|
||||
attempt,
|
||||
busy_attempt,
|
||||
)
|
||||
return reply
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
"%s: feature 0x%04x fn 0x%02x timed out (attempt %d/%d)",
|
||||
device,
|
||||
int(feature),
|
||||
function,
|
||||
attempt + 1,
|
||||
retries + 1,
|
||||
)
|
||||
logger.warning("%s: feature 0x%04x fn 0x%02x no ACK after %d attempts", device, int(feature), function, retries + 1)
|
||||
return None
|
||||
|
||||
|
||||
def _probe_tmpl_bytes(device):
|
||||
"""GetEffectSpecificInfo page 1: returns (tmpl_0, tmpl_1) or
|
||||
(None, None) if the device has no firmware effect cards."""
|
||||
try:
|
||||
reply = device.feature_request(SupportedFeature.RGB_EFFECTS, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x01)
|
||||
except exceptions.FeatureCallError:
|
||||
return (None, None)
|
||||
if reply is None or len(reply) < 12:
|
||||
return (None, None)
|
||||
return (reply[10], reply[11])
|
||||
|
||||
|
||||
def push_artanis_perkey_prep(device):
|
||||
"""Disable the firmware effects engine on mice with firmware effect cards.
|
||||
Returns True if the call ACKed."""
|
||||
infos = getattr(device, "led_effects", None)
|
||||
if not infos or not infos.zones:
|
||||
return False
|
||||
num_effects = len(infos.zones[0].effects)
|
||||
# SetEffectByIndex: cluster + effectIdx + 10 param bytes + persist.
|
||||
# Shipping with call 2 only — sufficient on tested hardware (G502 X PLUS).
|
||||
# Call 1 (TMPL-handshake) left commented for reactivation if broader
|
||||
# testing turns up a device that needs it; uncomment the _probe_tmpl_bytes
|
||||
# use and the call1 block together.
|
||||
# tmpl_0, tmpl_1 = _probe_tmpl_bytes(device)
|
||||
# if tmpl_0 is None:
|
||||
# return False
|
||||
# call1 = b"\xff\x02" + b"\x00" * 6 + bytes([tmpl_0, tmpl_1]) + b"\x00\x00" + b"\x01"
|
||||
# if feature_request_acked(device, SupportedFeature.RGB_EFFECTS, 0x10, call1) is None:
|
||||
# return False
|
||||
call2 = b"\xff" + bytes([num_effects]) + b"\x00" * 10 + b"\x01"
|
||||
return feature_request_acked(device, SupportedFeature.RGB_EFFECTS, 0x10, call2) is not None
|
||||
|
||||
|
||||
def start(device):
|
||||
"""Begin software RGB power management for `device`. No-op without GLib."""
|
||||
if not _has_glib:
|
||||
return
|
||||
key = id(device)
|
||||
if key not in _managers:
|
||||
mgr = RGBPowerManager(device)
|
||||
_managers[key] = mgr
|
||||
mgr.start()
|
||||
else:
|
||||
mgr = _managers[key]
|
||||
mgr.reset()
|
||||
# Push persisted settings into the manager. Settings marked ignore via the
|
||||
# lock icon are skipped so the manager keeps its built-in default.
|
||||
from . import hidpp20
|
||||
|
||||
persister = getattr(device, "persister", None)
|
||||
|
||||
def _ignored(name):
|
||||
return persister is not None and persister.get_sensitivity(name) == settings.SENSITIVITY_IGNORE
|
||||
|
||||
for s in device.settings:
|
||||
if _ignored(s.name):
|
||||
continue
|
||||
if s.name == "rgb_idle_timeout":
|
||||
val = s._value if s._value is not None else 60
|
||||
mgr.set_idle_timeout(int(val))
|
||||
elif s.name == "rgb_sleep_timeout":
|
||||
val = s._value if s._value is not None else 300
|
||||
mgr.set_sleep_timeout(int(val))
|
||||
elif s.name == "rgb_idle_effect":
|
||||
val = s._value if s._value is not None else hidpp20.LEDEffectSetting(ID=0x80, intensity=50)
|
||||
mgr.set_idle_effect(val)
|
||||
|
||||
|
||||
def stop(device):
|
||||
"""End software RGB power management for `device`."""
|
||||
key = id(device)
|
||||
mgr = _managers.pop(key, None)
|
||||
if mgr:
|
||||
mgr.stop()
|
||||
|
||||
|
||||
def cleanup(device):
|
||||
"""device.cleanups handler — restore firmware control on device close.
|
||||
|
||||
On devices that support NvConfig cap 0x0040 (shutdown effect), also fires
|
||||
SetRgbPowerMode(0) as the final step so the firmware plays the configured
|
||||
shutdown animation during the active→off transition. If the cap is
|
||||
disabled, the firmware powers down LEDs silently. Matches LGHUB exit.
|
||||
See solaar_shutdown_effect_trigger_spec.md.
|
||||
"""
|
||||
stop(device)
|
||||
try:
|
||||
device.feature_request(SupportedFeature.RGB_EFFECTS, 0x50, SW_RELEASE)
|
||||
if device.features and SupportedFeature.PROFILE_MANAGEMENT in device.features:
|
||||
device.feature_request(SupportedFeature.PROFILE_MANAGEMENT, 0x60, b"\x03")
|
||||
elif device.features and SupportedFeature.ONBOARD_PROFILES in device.features:
|
||||
device.feature_request(SupportedFeature.ONBOARD_PROFILES, 0x10, b"\x01")
|
||||
except Exception:
|
||||
pass # Device may already be offline
|
||||
if getattr(device, "_rgb_has_shutdown_cap", False):
|
||||
try:
|
||||
# SetRgbPowerMode(set=1, mode=0) — firmware off transition.
|
||||
# no_reply: device goes offline; don't block waiting for an ACK.
|
||||
device.feature_request(SupportedFeature.RGB_EFFECTS, 0x90, b"\x01\x00", no_reply=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class RGBPowerManager:
|
||||
"""Two-stage idle handler driven by firmware onUserActivity events.
|
||||
|
||||
State machine: ACTIVE → DIMMING → IDLE → SLEEPING.
|
||||
Stage 1 (idle) runs a host-side dim ramp or hands off to a firmware
|
||||
animation. Stage 2 (sleep) is a software timer that fires
|
||||
sleep_timeout - idle_timeout after IDLE.
|
||||
"""
|
||||
|
||||
ACTIVE = 0
|
||||
DIMMING = 1
|
||||
IDLE = 2
|
||||
SLEEPING = 3
|
||||
|
||||
_DIM_INTERVAL_MS = 200
|
||||
_DIM_STEPS = 25 # ~5s dim ramp
|
||||
|
||||
def __init__(self, device):
|
||||
self._device = device
|
||||
self._state = self.ACTIVE
|
||||
self._idle_timeout = 60
|
||||
self._sleep_timeout = 300
|
||||
# LEDEffectSetting with ID in {0x00 Disabled, 0x80 Dim, 0x0A
|
||||
# Breathe, 0x0B Ripple}. Populated by start() from the persister.
|
||||
self._idle_effect = None
|
||||
self._sleep_timer_id = None
|
||||
self._dim_timer_id = None
|
||||
self._dim_step = 0
|
||||
self._dim_zones = []
|
||||
self._dim_perkey = None
|
||||
|
||||
def start(self):
|
||||
self._state = self.ACTIVE
|
||||
self._read_firmware_timers()
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
"%s: RGB power manager started (firmware idle=%ds, sleep=%ds)",
|
||||
self._device,
|
||||
self._idle_timeout,
|
||||
self._sleep_timeout,
|
||||
)
|
||||
|
||||
def stop(self):
|
||||
self._cancel_dim_timer()
|
||||
self._cancel_sleep_timer()
|
||||
if self._state != self.ACTIVE:
|
||||
try:
|
||||
self._wake()
|
||||
except Exception:
|
||||
pass # Best effort during shutdown
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: RGB power manager stopped", self._device)
|
||||
|
||||
def reset(self):
|
||||
"""Reset to ACTIVE on device reconnect. Re-reads firmware timers so
|
||||
externally-updated values (other tool wrote NV between sessions)
|
||||
are picked up even when our settings are ignored."""
|
||||
self._cancel_dim_timer()
|
||||
self._cancel_sleep_timer()
|
||||
self._state = self.ACTIVE
|
||||
self._read_firmware_timers()
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: RGB power manager reset to ACTIVE", self._device)
|
||||
|
||||
def set_idle_timeout(self, seconds):
|
||||
self._idle_timeout = seconds
|
||||
self._cancel_sleep_timer()
|
||||
if seconds == 0 and self._state in (self.DIMMING, self.IDLE):
|
||||
self._wake()
|
||||
self._write_firmware_idle_timeout(seconds)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: RGB idle timeout set to %ss", self._device, seconds)
|
||||
|
||||
def set_sleep_timeout(self, seconds):
|
||||
"""0 disables sleep."""
|
||||
self._sleep_timeout = seconds
|
||||
self._cancel_sleep_timer()
|
||||
if seconds == 0 and self._state == self.SLEEPING:
|
||||
self._wake()
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: RGB sleep timeout set to %ss", self._device, seconds)
|
||||
|
||||
def set_idle_effect(self, effect):
|
||||
"""`effect` is an LEDEffectSetting. Wake immediately if the user
|
||||
switched to Disabled while we're mid-idle."""
|
||||
self._idle_effect = effect
|
||||
if self._idle_effect_id() == 0x00 and self._state in (self.DIMMING, self.IDLE):
|
||||
self._wake()
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
"%s: RGB idle effect set to ID=0x%02X (period=%s, intensity=%s)",
|
||||
self._device,
|
||||
self._idle_effect_id(),
|
||||
getattr(self._idle_effect, "period", None),
|
||||
getattr(self._idle_effect, "intensity", None),
|
||||
)
|
||||
|
||||
def _idle_effect_id(self):
|
||||
"""Return the ID of the current idle effect, or 0 if unset."""
|
||||
return int(getattr(self._idle_effect, "ID", 0) or 0)
|
||||
|
||||
# --- Firmware activity events ---
|
||||
|
||||
def on_user_activity(self, activity_type):
|
||||
"""Handle firmware onUserActivity event from RGB_EFFECTS (0x8071).
|
||||
|
||||
activity_type=0: IDLE — user stopped typing, firmware idle timer expired.
|
||||
activity_type!=0: ACTIVE — user resumed typing after being idle.
|
||||
|
||||
The firmware sends a burst of ~8 events with exponential backoff.
|
||||
Only the first event matters; subsequent events for the same transition are ignored.
|
||||
"""
|
||||
if not self._device.online:
|
||||
return
|
||||
|
||||
if activity_type == 0:
|
||||
# IDLE event — firmware detected inactivity at idle_timeout
|
||||
if self._state != self.ACTIVE:
|
||||
return # Already idle/dimming/sleeping, ignore burst
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: firmware IDLE event — starting idle sequence", self._device)
|
||||
# Switch to flags=3 so firmware monitors for activity during dim/idle
|
||||
try:
|
||||
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x50, SW_IDLE)
|
||||
except Exception:
|
||||
pass
|
||||
idle_enabled = self._idle_effect_id() != 0 and self._idle_timeout > 0 and not self._is_ignored("rgb_idle_effect")
|
||||
if idle_enabled:
|
||||
self._start_idle_effect()
|
||||
else:
|
||||
self._state = self.IDLE
|
||||
# Sleep is host-driven only — schedule whenever _sleep_timeout > 0,
|
||||
# regardless of the setting's ignore flag (which only blocks pushing
|
||||
# the user value to firmware, see start()).
|
||||
sleep_enabled = self._sleep_timeout > 0
|
||||
if sleep_enabled:
|
||||
delay = max(self._sleep_timeout - self._idle_timeout, 0)
|
||||
if delay == 0:
|
||||
self._start_sleep()
|
||||
else:
|
||||
self._sleep_timer_id = GLib.timeout_add_seconds(delay, self._sleep_timer_fired)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: sleep timer scheduled in %ds", self._device, delay)
|
||||
else:
|
||||
# ACTIVE event — user resumed typing
|
||||
if self._state == self.ACTIVE:
|
||||
return # Already active, ignore burst
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: firmware ACTIVE event — waking", self._device)
|
||||
self._cancel_sleep_timer()
|
||||
self._wake()
|
||||
|
||||
def _sleep_timer_fired(self):
|
||||
"""GLib callback — software sleep timer expired after IDLE."""
|
||||
self._sleep_timer_id = None
|
||||
if self._state in (self.IDLE, self.DIMMING) and self._device.online:
|
||||
self._start_sleep()
|
||||
return False # One-shot timer
|
||||
|
||||
def _cancel_sleep_timer(self):
|
||||
if self._sleep_timer_id is not None:
|
||||
GLib.source_remove(self._sleep_timer_id)
|
||||
self._sleep_timer_id = None
|
||||
|
||||
def _read_firmware_timers(self):
|
||||
"""Read idle/sleep timeouts from firmware as the manager's defaults."""
|
||||
try:
|
||||
resp = self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x70, b"\x00")
|
||||
if resp and len(resp) >= 7:
|
||||
idle_s = (resp[3] << 8) | resp[4]
|
||||
sleep_s = (resp[5] << 8) | resp[6]
|
||||
if idle_s > 0:
|
||||
self._idle_timeout = idle_s
|
||||
if sleep_s > 0:
|
||||
self._sleep_timeout = sleep_s
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
"%s: firmware timers: idle=%ds, sleep=%ds",
|
||||
self._device,
|
||||
idle_s,
|
||||
sleep_s,
|
||||
)
|
||||
except Exception as e:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: could not read firmware timers, using defaults: %s", self._device, e)
|
||||
|
||||
def _write_firmware_idle_timeout(self, seconds):
|
||||
"""Push idle/sleep timeouts back to firmware so it fires IDLE on time."""
|
||||
try:
|
||||
idle_hi = (seconds >> 8) & 0xFF
|
||||
idle_lo = seconds & 0xFF
|
||||
sleep_hi = (self._sleep_timeout >> 8) & 0xFF
|
||||
sleep_lo = self._sleep_timeout & 0xFF
|
||||
payload = bytes([0x01, 0x00, 0x00, idle_hi, idle_lo, sleep_hi, sleep_lo])
|
||||
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x70, payload)
|
||||
except Exception as e:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: could not write firmware idle timeout: %s", self._device, e)
|
||||
|
||||
# --- Idle effect ---
|
||||
|
||||
def _start_idle_effect(self):
|
||||
idle_id = self._idle_effect_id()
|
||||
if idle_id == 0x80: # Dim
|
||||
dim_pct = int(getattr(self._idle_effect, "intensity", 50) or 50)
|
||||
self._start_dim_ramp(dim_pct)
|
||||
elif idle_id == 0x01: # Static — snap to idle color
|
||||
self._start_static_idle()
|
||||
elif idle_id != 0x00:
|
||||
self._apply_animation(idle_id)
|
||||
|
||||
def _start_static_idle(self):
|
||||
"""Snap to the idle effect's color exactly as if the user had set
|
||||
the active Static zone color to it. Per-key paint continues to
|
||||
display; unset cells repaint to the idle primary color via
|
||||
effective_zone_base_color's IDLE-state substitution. No animation
|
||||
— instant transition. Wake reverts via _restore_colors()."""
|
||||
idle_color = int(getattr(self._idle_effect, "color", 0) or 0)
|
||||
infos = getattr(self._device, "led_effects", None)
|
||||
if not infos or not infos.zones:
|
||||
self._state = self.IDLE
|
||||
return
|
||||
self._state = self.IDLE
|
||||
perkey_setting, has_paint = perkey_has_paint(self._device)
|
||||
perkey_dominates = has_paint and zone_effect_is_static(self._device)
|
||||
if perkey_dominates and perkey_setting is not None:
|
||||
# Per-key is the visible layer — repaint unset cells with the
|
||||
# idle color (effective_zone_base_color now returns it because
|
||||
# state == IDLE and idle effect ID == Static).
|
||||
try:
|
||||
if perkey_setting._fill_unset_zones_with_base_color():
|
||||
perkey_setting._send_with_retry(0x70, b"\x00") # FrameEnd
|
||||
except Exception as e:
|
||||
if logger.isEnabledFor(logging.WARNING):
|
||||
logger.warning("%s: static idle per-key repaint failed: %s", self._device, e)
|
||||
return
|
||||
# Zone is the visible layer — push Static at idle.color to each zone.
|
||||
for zone in infos.zones:
|
||||
if 0x01 in (e.ID for e in zone.effects):
|
||||
try:
|
||||
self._push_static_effect(zone, idle_color)
|
||||
except Exception as e:
|
||||
if logger.isEnabledFor(logging.WARNING):
|
||||
logger.warning("%s: static idle zone push failed: %s", self._device, e)
|
||||
|
||||
def _start_dim_ramp(self, dim_pct):
|
||||
"""Smooth ~5s dim ramp. Dims the per-key buffer when it's the visible
|
||||
layer (any real per-key paint), otherwise the zone effect."""
|
||||
infos = getattr(self._device, "led_effects", None)
|
||||
if not infos or not infos.zones:
|
||||
self._state = self.IDLE
|
||||
return
|
||||
|
||||
perkey_setting, has_paint = perkey_has_paint(self._device)
|
||||
# Per-key only dominates when zone is Static. Under animations, the
|
||||
# firmware engine owns the visible layer — dim the zone instead.
|
||||
perkey_active = has_paint and zone_effect_is_static(self._device)
|
||||
|
||||
self._dim_perkey = None
|
||||
if perkey_active:
|
||||
self._dim_zones = []
|
||||
self._dim_perkey = self._build_full_perkey_dim_map(perkey_setting, dim_pct)
|
||||
if self._dim_perkey:
|
||||
# Push base color to unset cells first so they don't start from stale.
|
||||
self._init_unset_perkey_zones(perkey_setting)
|
||||
else:
|
||||
self._dim_zones = []
|
||||
for zone in infos.zones:
|
||||
if 0x01 in (e.ID for e in zone.effects):
|
||||
start_color = self._get_zone_color(zone)
|
||||
target_color = self._compute_dim_color(start_color, dim_pct)
|
||||
self._dim_zones.append((zone, start_color, target_color))
|
||||
|
||||
if not self._dim_zones and not self._dim_perkey:
|
||||
self._state = self.IDLE
|
||||
return
|
||||
self._dim_step = 0
|
||||
self._state = self.DIMMING
|
||||
self._dim_timer_id = GLib.timeout_add(self._DIM_INTERVAL_MS, self._dim_ramp_step)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
n_zones = len(self._dim_zones)
|
||||
n_perkey = len(self._dim_perkey) if self._dim_perkey else 0
|
||||
logger.debug(
|
||||
"%s: starting dim ramp to %d%% brightness (%d zones, %d per-key%s)",
|
||||
self._device,
|
||||
dim_pct,
|
||||
n_zones,
|
||||
n_perkey,
|
||||
", per-key masking zones" if perkey_active else "",
|
||||
)
|
||||
|
||||
def _dim_ramp_step(self):
|
||||
if self._state != self.DIMMING or not self._device.online:
|
||||
self._dim_timer_id = None
|
||||
return False
|
||||
self._dim_step += 1
|
||||
t = self._dim_step / self._DIM_STEPS
|
||||
for zone, start_color, target_color in self._dim_zones:
|
||||
try:
|
||||
self._push_static_effect(zone, self._interpolate_color(start_color, target_color, t))
|
||||
except Exception as e:
|
||||
if logger.isEnabledFor(logging.WARNING):
|
||||
logger.warning("%s: dim ramp step failed for zone %s: %s", self._device, zone.index, e)
|
||||
if self._dim_perkey:
|
||||
try:
|
||||
self._push_perkey_dimmed(t)
|
||||
except Exception as e:
|
||||
if logger.isEnabledFor(logging.WARNING):
|
||||
logger.warning("%s: dim ramp step failed for per-key: %s", self._device, e)
|
||||
if self._dim_step >= self._DIM_STEPS:
|
||||
self._state = self.IDLE
|
||||
self._dim_timer_id = None
|
||||
return False
|
||||
return True
|
||||
|
||||
def _push_static_effect(self, zone, color):
|
||||
"""Non-persistent Static effect, one zone."""
|
||||
static_effect = next((e for e in zone.effects if e.ID == 0x01), None)
|
||||
if static_effect is None:
|
||||
return
|
||||
r = (color >> 16) & 0xFF
|
||||
g = (color >> 8) & 0xFF
|
||||
b = color & 0xFF
|
||||
params = bytes([r, g, b, 0, 0, 0, 0, 0, 0, 0])
|
||||
payload = bytes([zone.index, static_effect.index]) + params + b"\x01"
|
||||
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x10, payload)
|
||||
|
||||
def _push_perkey_dimmed(self, t):
|
||||
"""Push interpolated per-key colors for one dim ramp step.
|
||||
|
||||
Groups keys by their interpolated color and uses SetRgbZonesSingleValue
|
||||
(0x8081 function 6) for efficient bulk writes — up to 13 zone IDs per
|
||||
HID message when multiple keys share the same dimmed color.
|
||||
"""
|
||||
# Build color -> [zone_ids] map for this interpolation step
|
||||
color_groups = {}
|
||||
for zone_id, (start_color, target_color) in self._dim_perkey.items():
|
||||
color = self._interpolate_color(start_color, target_color, t)
|
||||
if color not in color_groups:
|
||||
color_groups[color] = []
|
||||
color_groups[color].append(zone_id)
|
||||
|
||||
feat = SupportedFeature.PER_KEY_LIGHTING_V2
|
||||
for color, zone_ids in color_groups.items():
|
||||
r = (color >> 16) & 0xFF
|
||||
g = (color >> 8) & 0xFF
|
||||
b = color & 0xFF
|
||||
# Function 6: SetRgbZonesSingleValue — color(3) + zone_ids (up to 13 per report)
|
||||
while zone_ids:
|
||||
batch = zone_ids[:13]
|
||||
zone_ids = zone_ids[13:]
|
||||
data = bytes([r, g, b]) + bytes(batch)
|
||||
self._device.feature_request(feat, 0x60, data)
|
||||
# Commit the frame
|
||||
self._device.feature_request(feat, 0x70, b"\x00\x00\x00\x00\x00")
|
||||
|
||||
def _apply_animation(self, effect_id):
|
||||
"""Hand off to a firmware animation. Generic over any effect in
|
||||
hidpp20.LEDEffects: builds the 10-byte param block from the
|
||||
effect's param map, sourcing color from the zone and other
|
||||
params from the persisted _idle_effect."""
|
||||
from . import hidpp20
|
||||
|
||||
infos = getattr(self._device, "led_effects", None)
|
||||
if not infos or not infos.zones:
|
||||
self._state = self.IDLE
|
||||
return
|
||||
entry = hidpp20.LEDEffects.get(effect_id)
|
||||
if entry is None:
|
||||
self._state = self.IDLE
|
||||
return
|
||||
param_map = entry[1]
|
||||
for zone in infos.zones:
|
||||
effect_info = next((e for e in zone.effects if e.ID == effect_id), None)
|
||||
if effect_info is None:
|
||||
continue
|
||||
color = self._get_zone_color(zone)
|
||||
params = bytearray(10)
|
||||
if hidpp20.LEDParam.color in param_map:
|
||||
offset = param_map[hidpp20.LEDParam.color]
|
||||
params[offset] = (color >> 16) & 0xFF
|
||||
params[offset + 1] = (color >> 8) & 0xFF
|
||||
params[offset + 2] = color & 0xFF
|
||||
for pname, poff in param_map.items():
|
||||
if pname == hidpp20.LEDParam.color:
|
||||
continue
|
||||
psize = hidpp20.LEDParamSize.get(pname, 1)
|
||||
user_val = getattr(self._idle_effect, str(pname), None)
|
||||
if user_val is None:
|
||||
user_val = effect_info.period or 3000 if pname == hidpp20.LEDParam.period else 0
|
||||
params[poff : poff + psize] = int(user_val).to_bytes(psize, "big")
|
||||
if effect_id == 0x01:
|
||||
params[3] = 0x02 # Static fixed-color marker
|
||||
payload = bytes([zone.index, effect_info.index]) + bytes(params) + b"\x01"
|
||||
try:
|
||||
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x10, payload)
|
||||
except Exception as exc:
|
||||
if logger.isEnabledFor(logging.WARNING):
|
||||
logger.warning(
|
||||
"%s: failed to apply animation 0x%02x to zone %d: %s",
|
||||
self._device,
|
||||
effect_id,
|
||||
zone.index,
|
||||
exc,
|
||||
)
|
||||
self._state = self.IDLE
|
||||
|
||||
# --- Sleep ---
|
||||
|
||||
def _start_sleep(self):
|
||||
"""Enter firmware-managed sleep. Firmware fades from current level."""
|
||||
self._cancel_dim_timer()
|
||||
try:
|
||||
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x80, b"\x01\x03\x00")
|
||||
self._state = self.SLEEPING
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: RGB entering sleep (firmware power-down)", self._device)
|
||||
except Exception as e:
|
||||
if logger.isEnabledFor(logging.WARNING):
|
||||
logger.warning("%s: failed to enter RGB sleep: %s", self._device, e)
|
||||
|
||||
# --- Wake ---
|
||||
|
||||
def _wake(self):
|
||||
"""Restore full lighting from any non-ACTIVE state."""
|
||||
if self._state == self.ACTIVE:
|
||||
return
|
||||
prev_state = self._state
|
||||
self._cancel_dim_timer()
|
||||
self._cancel_sleep_timer()
|
||||
# State must be ACTIVE before _restore_colors() — the paint paths
|
||||
# translate through it, and writes would otherwise go at the old dim.
|
||||
self._state = self.ACTIVE
|
||||
try:
|
||||
if prev_state == self.SLEEPING:
|
||||
self._set_power_mode_with_retry(1)
|
||||
# Re-claim full LED pipeline control
|
||||
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x50, SW_ACTIVE)
|
||||
# Firmware engine has re-engaged during sleep — re-arm per-key
|
||||
# one-shots so the next write re-fires the prep + double-send.
|
||||
for s in self._device.settings:
|
||||
if s.name == "per-key-lighting":
|
||||
s._frame_settled = False
|
||||
s._prep_pushed = False
|
||||
break
|
||||
self._restore_colors()
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
state_names = {self.DIMMING: "dimming", self.IDLE: "idle", self.SLEEPING: "sleep"}
|
||||
logger.debug("%s: RGB woken from %s", self._device, state_names.get(prev_state, "unknown"))
|
||||
except Exception as e:
|
||||
if logger.isEnabledFor(logging.WARNING):
|
||||
logger.warning("%s: failed to wake RGB LEDs: %s", self._device, e)
|
||||
|
||||
def _cancel_dim_timer(self):
|
||||
if self._dim_timer_id is not None:
|
||||
GLib.source_remove(self._dim_timer_id)
|
||||
self._dim_timer_id = None
|
||||
|
||||
def _get_zone_color(self, zone):
|
||||
location = int(zone.location)
|
||||
setting_name = f"rgb_zone_{location}"
|
||||
for s in self._device.settings:
|
||||
if s.name == setting_name and s._value is not None:
|
||||
return getattr(s._value, "color", 0xFFFFFF)
|
||||
return 0xFFFFFF
|
||||
|
||||
def _get_zone_base_color(self):
|
||||
"""Color used as the base for unset per-key cells. Black when the
|
||||
zone effect is marked ignore, the saved zone color otherwise."""
|
||||
return effective_zone_base_color(self._device)
|
||||
|
||||
@staticmethod
|
||||
def _has_real_perkey_colors(perkey_setting):
|
||||
if not perkey_setting._value:
|
||||
return False
|
||||
no_change = special_keys.COLORSPLUS["No change"]
|
||||
return any(color != no_change and isinstance(color, int) and color >= 0 for color in perkey_setting._value.values())
|
||||
|
||||
def _build_full_perkey_dim_map(self, perkey_setting, dim_pct):
|
||||
"""{zone_id: (start, target)} for every zone — user-set keys from
|
||||
their color, unset from the zone base."""
|
||||
no_change = special_keys.COLORSPLUS["No change"]
|
||||
zone_base = self._get_zone_base_color()
|
||||
user_colors = {int(k): c for k, c in perkey_setting._value.items() if c != no_change and isinstance(c, int) and c >= 0}
|
||||
return {
|
||||
int(k): (start, self._compute_dim_color(start, dim_pct))
|
||||
for k in perkey_setting._validator.choices
|
||||
for start in (user_colors.get(int(k), zone_base),)
|
||||
}
|
||||
|
||||
def _init_unset_perkey_zones(self, perkey_setting):
|
||||
"""Push the zone base color to per-key cells the user hasn't painted —
|
||||
avoids the white-default flash when per-key takes over the buffer."""
|
||||
no_change = special_keys.COLORSPLUS["No change"]
|
||||
zone_base = self._get_zone_base_color()
|
||||
r = (zone_base >> 16) & 0xFF
|
||||
g = (zone_base >> 8) & 0xFF
|
||||
b = zone_base & 0xFF
|
||||
|
||||
user_set = {int(k) for k, c in perkey_setting._value.items() if c != no_change and isinstance(c, int) and c >= 0}
|
||||
unset_zones = [int(k) for k in perkey_setting._validator.choices if int(k) not in user_set]
|
||||
if not unset_zones:
|
||||
return
|
||||
|
||||
feat = SupportedFeature.PER_KEY_LIGHTING_V2
|
||||
remaining = list(unset_zones)
|
||||
try:
|
||||
while remaining:
|
||||
batch = remaining[:13]
|
||||
remaining = remaining[13:]
|
||||
self._device.feature_request(feat, 0x60, bytes([r, g, b]) + bytes(batch))
|
||||
self._device.feature_request(feat, 0x70, b"\x00\x00\x00\x00\x00")
|
||||
except exceptions.FeatureCallError as e:
|
||||
logger.warning("%s: per-key zone init failed (device busy?): %s", self._device, e)
|
||||
|
||||
@staticmethod
|
||||
def _compute_dim_color(color, dim_pct):
|
||||
r = ((color >> 16) & 0xFF) * dim_pct // 100
|
||||
g = ((color >> 8) & 0xFF) * dim_pct // 100
|
||||
b = (color & 0xFF) * dim_pct // 100
|
||||
return (r << 16) | (g << 8) | b
|
||||
|
||||
@staticmethod
|
||||
def _interpolate_color(start, target, t):
|
||||
r_s, g_s, b_s = (start >> 16) & 0xFF, (start >> 8) & 0xFF, start & 0xFF
|
||||
r_t, g_t, b_t = (target >> 16) & 0xFF, (target >> 8) & 0xFF, target & 0xFF
|
||||
r = int(r_s + (r_t - r_s) * t)
|
||||
g = int(g_s + (g_t - g_s) * t)
|
||||
b = int(b_s + (b_t - b_s) * t)
|
||||
return (r << 16) | (g << 8) | b
|
||||
|
||||
def _current_dim_pct(self):
|
||||
"""100 unless we're in Dim mode — animations run at firmware brightness."""
|
||||
if self._idle_effect_id() != 0x80:
|
||||
return 100
|
||||
return int(getattr(self._idle_effect, "intensity", 50) or 50)
|
||||
|
||||
def translate_color(self, color):
|
||||
"""Map a saved (undimmed) per-key color to what should be displayed
|
||||
on the device right now, given the current power-management state.
|
||||
Returns None to signal SLEEPING — caller should persist and skip the
|
||||
wire write; _restore_colors on wake will re-push the saved value."""
|
||||
# Static idle is a color swap, not a brightness change — user-painted
|
||||
# cells render their saved color unchanged, and the unset-cell
|
||||
# substitution happens upstream via effective_zone_base_color.
|
||||
if self._state == self.IDLE and self._idle_effect_id() == 0x01:
|
||||
return color
|
||||
return translate_color_for_display(color, self._state, self._current_dim_pct(), self._dim_step, self._DIM_STEPS)
|
||||
|
||||
def notify_perkey_changed(self, zone_id, new_color):
|
||||
"""Resync a per-key zone's dim ramp entry to a user-repainted color."""
|
||||
if self._state != self.DIMMING or not self._dim_perkey or zone_id not in self._dim_perkey:
|
||||
return
|
||||
self._dim_perkey[zone_id] = (
|
||||
new_color,
|
||||
self._compute_dim_color(new_color, self._current_dim_pct()),
|
||||
)
|
||||
|
||||
def notify_perkey_bulk_changed(self, color_map):
|
||||
"""Bulk notify_perkey_changed, skipping 'No change' entries."""
|
||||
if self._state != self.DIMMING or not self._dim_perkey:
|
||||
return
|
||||
no_change = special_keys.COLORSPLUS["No change"]
|
||||
for zone_id, color in color_map.items():
|
||||
if color == no_change or not isinstance(color, int) or color < 0:
|
||||
continue
|
||||
self.notify_perkey_changed(int(zone_id), int(color))
|
||||
|
||||
def notify_zone_changed(self, cluster_index, new_color):
|
||||
"""Resync a zone-effect dim ramp entry to a user-repainted color."""
|
||||
if self._state != self.DIMMING or not self._dim_zones:
|
||||
return
|
||||
dim_pct = self._current_dim_pct()
|
||||
for i, (zone, _start, _target) in enumerate(self._dim_zones):
|
||||
if int(zone.index) == int(cluster_index):
|
||||
self._dim_zones[i] = (zone, new_color, self._compute_dim_color(new_color, dim_pct))
|
||||
return
|
||||
|
||||
def _set_power_mode_with_retry(self, mode):
|
||||
"""First command after wake may fail; retry."""
|
||||
params = bytes([0x01, mode, 0x00])
|
||||
for attempt in range(3):
|
||||
try:
|
||||
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x80, params)
|
||||
return
|
||||
except Exception:
|
||||
if attempt == 2:
|
||||
raise
|
||||
import time as _time
|
||||
|
||||
_time.sleep(0.1)
|
||||
|
||||
def _is_ignored(self, setting_name):
|
||||
"""True if marked ignore via the lock icon."""
|
||||
persister = getattr(self._device, "persister", None)
|
||||
if persister is None:
|
||||
return False
|
||||
return persister.get_sensitivity(setting_name) == settings.SENSITIVITY_IGNORE
|
||||
|
||||
def _restore_colors(self):
|
||||
"""Re-push lighting state after waking. Per-key dominates only when
|
||||
zone is Static — under animations, the zone wire push goes through
|
||||
and per-key is skipped."""
|
||||
_perkey_setting, has_paint = perkey_has_paint(self._device)
|
||||
zone_static = zone_effect_is_static(self._device)
|
||||
perkey_dominates = has_paint and zone_static
|
||||
for s in self._device.settings:
|
||||
if s._value is None:
|
||||
continue
|
||||
if self._is_ignored(s.name):
|
||||
continue
|
||||
if s.name == "per-key-lighting":
|
||||
if not self._has_real_perkey_colors(s):
|
||||
continue
|
||||
if not zone_static:
|
||||
continue # firmware animation owns the visible layer
|
||||
elif s.name.startswith("rgb_zone_"):
|
||||
if perkey_dominates:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
try:
|
||||
s.write(s._value, save=False)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: restored %s after wake", self._device, s.name)
|
||||
except Exception as e:
|
||||
if logger.isEnabledFor(logging.WARNING):
|
||||
logger.warning("%s: failed to restore %s: %s", self._device, s.name, e)
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -584,7 +584,8 @@ class HeteroValidator(Validator):
|
|||
return cls(**kwargs)
|
||||
|
||||
def __init__(self, data_class=None, options=None, readable=True):
|
||||
assert data_class is not None and options is not None
|
||||
# options=None for purely host-side settings — data_class handles bytes[0] as the ID.
|
||||
assert data_class is not None
|
||||
self.data_class = data_class
|
||||
self.options = options
|
||||
self.readable = readable
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import gi
|
|||
|
||||
from logitech_receiver import hidpp20
|
||||
from logitech_receiver import settings
|
||||
from logitech_receiver import settings_templates
|
||||
|
||||
from solaar.i18n import _
|
||||
from solaar.i18n import ngettext
|
||||
|
|
@ -144,6 +145,13 @@ class SliderControl(Gtk.Scale, Control):
|
|||
self.set_round_digits(0)
|
||||
self.set_digits(0)
|
||||
self.set_increments(1, 5)
|
||||
# Halving tick marks are an intensity-slider feature only.
|
||||
if self.sbox.setting.name == "brightness_control":
|
||||
validator = getattr(self.sbox.setting, "_validator", None)
|
||||
steps = getattr(validator, "steps", 0) if validator is not None else 0
|
||||
if steps:
|
||||
for mark in settings_templates.halving_marks(validator.max_value, steps):
|
||||
self.add_mark(mark, Gtk.PositionType.BOTTOM, None)
|
||||
self.connect(GtkSignal.VALUE_CHANGED.value, self.changed)
|
||||
|
||||
def set_value(self, value):
|
||||
|
|
@ -609,6 +617,26 @@ class GraphicEQControl(MultipleControl):
|
|||
|
||||
|
||||
# control with an ID key that determines what else to show
|
||||
class _HeteroToggleSwitch(Gtk.Switch):
|
||||
"""Gtk.Switch with int-valued get/set_value for HeteroKeyControl.
|
||||
|
||||
Maps switch True/False to the field's wire on/off integer values so the
|
||||
surrounding control machinery (changed handler, get_value, set_value) can
|
||||
stay int-based like every other field kind.
|
||||
"""
|
||||
|
||||
def __init__(self, on_value: int = 1, off_value: int = 2, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._on = int(on_value)
|
||||
self._off = int(off_value)
|
||||
|
||||
def get_value(self) -> int:
|
||||
return self._on if self.get_state() else self._off
|
||||
|
||||
def set_value(self, value) -> None:
|
||||
self.set_state(int(value) == self._on)
|
||||
|
||||
|
||||
class HeteroKeyControl(Gtk.HBox, Control):
|
||||
def __init__(self, sbox, delegate=None):
|
||||
super().__init__(homogeneous=False, spacing=6)
|
||||
|
|
@ -629,6 +657,17 @@ class HeteroKeyControl(Gtk.HBox, Control):
|
|||
item_box.set_active(0)
|
||||
item_box.connect(GtkSignal.CHANGED.value, self.changed)
|
||||
self.pack_start(item_box, False, False, 0)
|
||||
elif item["kind"] == settings.Kind.TOGGLE:
|
||||
# Right-align like standard TOGGLE settings — pack_end so the
|
||||
# switch hugs the right edge while other fields stay left.
|
||||
item_box = _HeteroToggleSwitch(
|
||||
on_value=item.get("on_value", 1),
|
||||
off_value=item.get("off_value", 2),
|
||||
halign=Gtk.Align.CENTER,
|
||||
valign=Gtk.Align.CENTER,
|
||||
)
|
||||
item_box.connect(GtkSignal.NOTIFY_ACTIVE.value, self.changed)
|
||||
self.pack_end(item_box, False, False, 0)
|
||||
elif item["kind"] == settings.Kind.COLOR:
|
||||
item_box = Gtk.ColorButton()
|
||||
item_box.connect(GtkSignal.COLOR_SET.value, self.changed)
|
||||
|
|
@ -639,6 +678,15 @@ class HeteroKeyControl(Gtk.HBox, Control):
|
|||
item_box.set_round_digits(0)
|
||||
item_box.set_digits(0)
|
||||
item_box.set_increments(1, 5)
|
||||
# Halving tick marks are an intensity-slider feature only.
|
||||
if item.get("halving") and str(item.get("name")) == str(hidpp20.LEDParam.intensity):
|
||||
steps = getattr(sbox.setting._device, "_brightness_steps", 0) or settings_templates.auto_step_count(
|
||||
item["max"]
|
||||
)
|
||||
for mark in settings_templates.halving_marks(item["max"], steps):
|
||||
item_box.add_mark(mark, Gtk.PositionType.BOTTOM, None)
|
||||
if item.get("display_seconds", False):
|
||||
item_box.connect("format-value", lambda _s, v: f"{int(v) / 1000:.2f}s")
|
||||
item_box.connect(GtkSignal.VALUE_CHANGED.value, self.changed)
|
||||
self.pack_start(item_box, True, True, 0)
|
||||
item_box.set_visible(False)
|
||||
|
|
@ -655,11 +703,14 @@ class HeteroKeyControl(Gtk.HBox, Control):
|
|||
result[str(k)] = (r << 16) | (g << 8) | b
|
||||
else:
|
||||
result[str(k)] = box.get_value()
|
||||
result = hidpp20.LEDEffectSetting(**result)
|
||||
data_class = getattr(self.sbox.setting._validator, "data_class", hidpp20.LEDEffectSetting)
|
||||
result = data_class(**result)
|
||||
return result
|
||||
|
||||
def set_value(self, value):
|
||||
self.set_sensitive(False)
|
||||
id_ = value.ID if value is not None else 0
|
||||
self._apply_id_ranges(id_)
|
||||
if value is not None:
|
||||
for k, v in value.__dict__.items():
|
||||
if k in self._items:
|
||||
|
|
@ -673,7 +724,7 @@ class HeteroKeyControl(Gtk.HBox, Control):
|
|||
box.set_value(v)
|
||||
else:
|
||||
self.sbox._failed.set_visible(True)
|
||||
self.setup_visibles(value.ID if value is not None else 0)
|
||||
self.setup_visibles(id_)
|
||||
|
||||
def setup_visibles(self, id_):
|
||||
fields = self.sbox.setting.fields_map[id_][1] if id_ in self.sbox.setting.fields_map else {}
|
||||
|
|
@ -683,15 +734,61 @@ class HeteroKeyControl(Gtk.HBox, Control):
|
|||
lblbox.set_visible(visible)
|
||||
box.set_visible(visible)
|
||||
|
||||
def changed(self, control):
|
||||
def changed(self, control, *_args):
|
||||
# *_args swallows the extra GParamSpec passed by Gtk.Switch's
|
||||
# "notify::active" signal — other field signals pass just (widget,).
|
||||
if self.get_sensitive() and control.get_sensitive():
|
||||
if "ID" in self._items and control == self._items["ID"][1]:
|
||||
self.setup_visibles(int(self._items["ID"][1].get_value()))
|
||||
new_id = int(self._items["ID"][1].get_value())
|
||||
self.setup_visibles(new_id)
|
||||
self._apply_id_ranges(new_id)
|
||||
self._apply_id_defaults(new_id)
|
||||
if hasattr(control, "_timer"):
|
||||
control._timer.cancel()
|
||||
control._timer = Timer(0.3, lambda: GLib.idle_add(self._write, control))
|
||||
control._timer.start()
|
||||
|
||||
def _apply_id_ranges(self, id_):
|
||||
"""Reset every RANGE widget to its field's global min/max, then apply
|
||||
per-effect overrides from fields_map[id_][3]. Reset-first ensures
|
||||
switching from an override (e.g. Ripple 2-200) to an effect without
|
||||
one restores the global range instead of inheriting the narrow one."""
|
||||
fields_map = getattr(self.sbox.setting, "fields_map", None)
|
||||
entry = fields_map.get(id_) if fields_map else None
|
||||
ranges = entry[3] if entry and len(entry) > 3 else {}
|
||||
for field in self.sbox.setting.possible_fields:
|
||||
if field.get("kind") != settings.Kind.RANGE:
|
||||
continue
|
||||
name = str(field["name"])
|
||||
if name not in self._items:
|
||||
continue
|
||||
_, box = self._items[name]
|
||||
lo, hi = ranges.get(field["name"], (field.get("min", 0), field.get("max", 0)))
|
||||
box.set_range(lo, hi)
|
||||
|
||||
def _apply_id_defaults(self, id_):
|
||||
"""Apply fields_map[id_][2] defaults to RANGE widgets sitting at min."""
|
||||
fields_map = getattr(self.sbox.setting, "fields_map", None)
|
||||
if not fields_map or id_ not in fields_map:
|
||||
return
|
||||
entry = fields_map[id_]
|
||||
if len(entry) < 3:
|
||||
return
|
||||
defaults = entry[2]
|
||||
ranges = entry[3] if len(entry) > 3 else {}
|
||||
field_by_name = {str(f["name"]): f for f in self.sbox.setting.possible_fields}
|
||||
for param_name, default_value in defaults.items():
|
||||
name = str(param_name)
|
||||
if name not in self._items:
|
||||
continue
|
||||
field = field_by_name.get(name)
|
||||
if field is None or field.get("kind") != settings.Kind.RANGE:
|
||||
continue
|
||||
_, box = self._items[name]
|
||||
effective_min = ranges[param_name][0] if param_name in ranges else field.get("min", 0)
|
||||
if box.get_value() == effective_min:
|
||||
box.set_value(default_value)
|
||||
|
||||
def _write(self, control):
|
||||
control._timer.cancel()
|
||||
delattr(control, "_timer")
|
||||
|
|
@ -711,6 +808,81 @@ _icons_allowables = {v: k for k, v in _allowables_icons.items()}
|
|||
|
||||
|
||||
# clicking on the lock icon changes from changeable to unchangeable to ignore
|
||||
# Settings whose operation depends on LED Control being set to Solaar.
|
||||
# Zone settings (rgb_zone_*) are matched by prefix because their name carries
|
||||
# the zone index (rgb_zone_1, rgb_zone_2, ...).
|
||||
_SW_CONTROL_DEPENDENT_NAMES = ("rgb_idle_timeout", "rgb_idle_effect", "rgb_sleep_timeout")
|
||||
_SW_CONTROL_DEPENDENT_PREFIXES = ("rgb_zone_",)
|
||||
|
||||
|
||||
def _sw_control_blocked(device):
|
||||
"""True when LED Control is not Solaar. Reads from setting._value first,
|
||||
then the persister, so the gate is right at panel load before live reads
|
||||
have populated. Accepts either the current bool (BooleanValidator) or the
|
||||
legacy int 3/0 (older ChoicesValidator persister entries)."""
|
||||
persister = getattr(device, "persister", None)
|
||||
if persister is None:
|
||||
return False
|
||||
value = None
|
||||
for s in getattr(device, "settings", []) or []:
|
||||
if s.name == "rgb_control":
|
||||
value = s._value
|
||||
break
|
||||
if value is None:
|
||||
value = persister.get("rgb_control")
|
||||
if value is None:
|
||||
return False
|
||||
# Solaar = bool True or legacy int 3; everything else (False, 0, …) blocks.
|
||||
return value not in (True, 3)
|
||||
|
||||
|
||||
def _zone_effect_blocks_perkey(device):
|
||||
"""True when any zone effect's saved ID is not Static (0x01) — zone
|
||||
animations mask the per-key buffer regardless of SW control state."""
|
||||
persister = getattr(device, "persister", None)
|
||||
if persister is None:
|
||||
return False
|
||||
for s in getattr(device, "settings", []) or []:
|
||||
if not s.name.startswith("rgb_zone_"):
|
||||
continue
|
||||
v = s._value if s._value is not None else persister.get(s.name)
|
||||
if v is None:
|
||||
continue
|
||||
if int(getattr(v, "ID", 0) or 0) != 0x01:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _set_row_sensitive(device, name, can_function):
|
||||
"""Apply sensitivity to a single setting's control row. Combines the
|
||||
user's lock-icon opt-in (persister sensitivity) with the can-function
|
||||
gate so neither alone can override the other."""
|
||||
device_id = (device.receiver.path if device.receiver else device.path, device.number)
|
||||
sbox = _items.get((device_id[0], device_id[1], name))
|
||||
if sbox is None or not hasattr(sbox, "_control"):
|
||||
return
|
||||
persister = getattr(device, "persister", None)
|
||||
user_allowed = persister.get_sensitivity(name) if persister else True
|
||||
sbox._control.set_sensitive(user_allowed is True and can_function)
|
||||
|
||||
|
||||
def _apply_rgb_gates(device):
|
||||
"""Grey out RGB settings whose prerequisites aren't met. Visual-only:
|
||||
leaves persister _sensitive flags (user lock-icon opt-ins) intact.
|
||||
|
||||
- rgb_zone_* and rgb_idle_*/rgb_sleep_timeout need LED Control = Solaar
|
||||
(rgb_control == 3).
|
||||
- per-key-lighting needs LED Control = Solaar AND every zone effect on
|
||||
Static (0x01), because non-Static zone animations mask per-key writes.
|
||||
"""
|
||||
sw_blocked = _sw_control_blocked(device)
|
||||
for s in getattr(device, "settings", []) or []:
|
||||
if s.name in _SW_CONTROL_DEPENDENT_NAMES or any(s.name.startswith(p) for p in _SW_CONTROL_DEPENDENT_PREFIXES):
|
||||
_set_row_sensitive(device, s.name, not sw_blocked)
|
||||
perkey_blocked = sw_blocked or _zone_effect_blocks_perkey(device)
|
||||
_set_row_sensitive(device, "per-key-lighting", not perkey_blocked)
|
||||
|
||||
|
||||
def _change_click(button, sbox):
|
||||
icon = button.get_children()[0]
|
||||
icon_name, _ = icon.get_icon_name()
|
||||
|
|
@ -728,6 +900,35 @@ def _change_click(button, sbox):
|
|||
_write_async(setting, persisted, sbox)
|
||||
else:
|
||||
_read_async(setting, True, sbox, bool(sbox.setting._device.online), sbox._control.get_sensitive())
|
||||
elif new_allowed == settings.SENSITIVITY_IGNORE and sbox.setting.name == "per-key-lighting":
|
||||
# User just opted out of per-key lighting. The firmware effect engine
|
||||
# is currently in its OOR "direct mode" slot showing the per-key buffer
|
||||
# (entered when the prep sequence wrote effectIdx=numEffects via
|
||||
# SetEffectByIndex on 0x8071). Writing a regular in-range effectIdx
|
||||
# with persist=1 displaces that slot and the saved zone effect becomes
|
||||
# the visible layer again. See LOGITECH_HIDPP2_PROTOCOL.md
|
||||
# "Per-key prep sequence" and 0x8071 SetEffectByIndex persist=1
|
||||
# requirement.
|
||||
device = sbox.setting._device
|
||||
for s in device.settings:
|
||||
if s.name.startswith("rgb_zone_") and s._value is not None:
|
||||
_write_async(s, s._value, None)
|
||||
break # one zone-effect write is enough to flip the engine
|
||||
if sbox.setting.name.startswith("rgb_zone_"):
|
||||
# Toggling zone-effect sensitivity changes the effective base color
|
||||
# for per-key unset cells (zone color ↔ black). When per-key is
|
||||
# opted-in, repaint it so the unset cells pick up the new base.
|
||||
from logitech_receiver import rgb_power
|
||||
|
||||
device = sbox.setting._device
|
||||
perkey, has_paint = rgb_power.perkey_has_paint(device)
|
||||
if has_paint:
|
||||
_write_async(perkey, perkey._value, None)
|
||||
# The lock icon on rgb_control, any zone, or per-key itself can change
|
||||
# whether per-key is functional — re-evaluate the gate.
|
||||
name = sbox.setting.name
|
||||
if name == "rgb_control" or name == "per-key-lighting" or name.startswith("rgb_zone_"):
|
||||
_apply_rgb_gates(sbox.setting._device)
|
||||
return True
|
||||
|
||||
|
||||
|
|
@ -828,6 +1029,10 @@ def _update_setting_item(sbox, value, is_online=True, sensitive=True, null_okay=
|
|||
logger.warning("%s: error setting control value (%s): %s", sbox.setting.name, sbox.setting._device, repr(e))
|
||||
sbox._control.set_sensitive(sensitive is True)
|
||||
_change_icon(sensitive, sbox._change_icon)
|
||||
# rgb_control and rgb_zone_* state gate per-key sensitivity.
|
||||
name = sbox.setting.name
|
||||
if name == "rgb_control" or name.startswith("rgb_zone_"):
|
||||
_apply_rgb_gates(sbox.setting._device)
|
||||
|
||||
|
||||
def _disable_listbox_highlight_bg(lb):
|
||||
|
|
@ -887,6 +1092,7 @@ def update(device, is_online=None):
|
|||
sensitive = device.persister.get_sensitivity(s.name) if device.persister else True
|
||||
_read_async(s, False, sbox, is_online, sensitive)
|
||||
|
||||
_apply_rgb_gates(device)
|
||||
_box.set_visible(True)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -137,15 +137,15 @@ class _SettingSink:
|
|||
persister[self._palette_key()] = {"active": int(active), "previous": int(previous)}
|
||||
|
||||
def zone_base_color(self) -> int | None:
|
||||
"""Color used to render per-key unset cells in the editor. Matches
|
||||
rgb_power.effective_zone_base_color: black when zone is ignored,
|
||||
the saved zone color otherwise."""
|
||||
device = getattr(self._setting, "_device", None)
|
||||
if device is None or not getattr(device, "settings", None):
|
||||
return None
|
||||
for s in device.settings:
|
||||
if s.name.startswith("rgb_zone_") and s._value is not None:
|
||||
color = getattr(s._value, "color", None)
|
||||
if isinstance(color, int):
|
||||
return int(color)
|
||||
if device is None:
|
||||
return None
|
||||
from logitech_receiver import rgb_power
|
||||
|
||||
return int(rgb_power.effective_zone_base_color(device))
|
||||
|
||||
def _notify(self) -> None:
|
||||
snapshot = self.current
|
||||
|
|
|
|||
|
|
@ -387,6 +387,8 @@ class Device:
|
|||
wpid: Optional[str] = "0000"
|
||||
setting_callback: Any = None
|
||||
centurion: bool = False
|
||||
path = None
|
||||
cleanups = None
|
||||
sliding = profiles = _backlight = _keys = _remap_keys = _led_effects = _gestures = None
|
||||
_gestures_lock = threading.Lock()
|
||||
number = "d1"
|
||||
|
|
@ -409,6 +411,7 @@ class Device:
|
|||
self.persister = configuration._DeviceEntry()
|
||||
self.features = hidpp20.FeaturesArray(self)
|
||||
self.settings = []
|
||||
self.cleanups = []
|
||||
self.receiver = []
|
||||
if self.feature is not None:
|
||||
self.features = hidpp20.FeaturesArray(self)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,679 @@
|
|||
## Copyright (C) Solaar contributors
|
||||
##
|
||||
## This program is free software; you can redistribute it and/or modify
|
||||
## it under the terms of the GNU General Public License as published by
|
||||
## the Free Software Foundation; either version 2 of the License, or
|
||||
## (at your option) any later version.
|
||||
|
||||
"""Tests for the display-state-aware color translation in rgb_power.
|
||||
|
||||
These cover the pure math (`translate_color_for_display`) and the small
|
||||
manager hooks that PerKeyLighting calls into (`translate_color`,
|
||||
`notify_perkey_changed`) without requiring a GLib main loop.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from logitech_receiver import rgb_power
|
||||
|
||||
M = rgb_power.RGBPowerManager
|
||||
|
||||
|
||||
# --- translate_color_for_display (pure function) ----------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"color",
|
||||
[0x000000, 0x123456, 0xFF0000, 0xFFFFFF],
|
||||
)
|
||||
def test_translate_active_is_identity(color):
|
||||
assert rgb_power.translate_color_for_display(color, M.ACTIVE, 50, 0, 25) == color
|
||||
|
||||
|
||||
def test_translate_idle_50pct():
|
||||
# 255 * 50 // 100 == 127
|
||||
assert rgb_power.translate_color_for_display(0xFFFFFF, M.IDLE, 50, 0, 25) == 0x7F7F7F
|
||||
|
||||
|
||||
def test_translate_idle_25pct_on_ff8800():
|
||||
# 0xFF * 25 // 100 = 63 (0x3F); 0x88 * 25 // 100 = 34 (0x22); 0 stays 0
|
||||
assert rgb_power.translate_color_for_display(0xFF8800, M.IDLE, 25, 0, 25) == 0x3F2200
|
||||
|
||||
|
||||
def test_translate_dimming_start_is_saved_color():
|
||||
# t = 0/25 = 0 → interpolation returns the start (saved) color
|
||||
assert rgb_power.translate_color_for_display(0xABCDEF, M.DIMMING, 50, 0, 25) == 0xABCDEF
|
||||
|
||||
|
||||
def test_translate_dimming_end_equals_idle():
|
||||
# t = 25/25 = 1 → interpolation returns the target (fully dimmed)
|
||||
idle = rgb_power.translate_color_for_display(0xFFFFFF, M.IDLE, 50, 0, 25)
|
||||
dimming_end = rgb_power.translate_color_for_display(0xFFFFFF, M.DIMMING, 50, 25, 25)
|
||||
assert dimming_end == idle
|
||||
|
||||
|
||||
def test_translate_dimming_midramp_between_full_and_dim():
|
||||
# At t=12/25 (≈0.48), white interpolates between 0xFF and 0x7F (50% target).
|
||||
# Expected r = 255 + (127 - 255) * 12/25 = 255 - 61.44 → int(193.56) = 193 (0xC1)
|
||||
assert rgb_power.translate_color_for_display(0xFFFFFF, M.DIMMING, 50, 12, 25) == 0xC1C1C1
|
||||
|
||||
|
||||
def test_translate_sleeping_returns_none():
|
||||
assert rgb_power.translate_color_for_display(0xFFFFFF, M.SLEEPING, 50, 0, 25) is None
|
||||
|
||||
|
||||
# --- translate_for_device (manager lookup) ----------------------------------
|
||||
|
||||
|
||||
def test_translate_for_device_no_manager_is_identity():
|
||||
# An arbitrary object with no manager registered → returns input unchanged.
|
||||
fake = object()
|
||||
assert rgb_power.translate_for_device(fake, 0xABCDEF) == 0xABCDEF
|
||||
|
||||
|
||||
def _dim(intensity):
|
||||
"""Shortcut: build a Dim-mode LEDEffectSetting for tests that used to
|
||||
pass a bare dim-percent int as `_idle_effect`."""
|
||||
from logitech_receiver import hidpp20
|
||||
|
||||
return hidpp20.LEDEffectSetting(ID=0x80, intensity=intensity)
|
||||
|
||||
|
||||
def _install_manager(monkeypatch, state, idle_effect=None, dim_step=0):
|
||||
"""Build an RGBPowerManager with state injected and register it for a
|
||||
fake device id. Cleanup happens via monkeypatch's _managers swap.
|
||||
|
||||
`idle_effect` defaults to Dim 50%. Pass a bare int (legacy) and it
|
||||
will be wrapped as a Dim-mode LEDEffectSetting; pass an
|
||||
LEDEffectSetting directly to use as-is.
|
||||
"""
|
||||
from logitech_receiver import hidpp20
|
||||
|
||||
fake_device = object()
|
||||
|
||||
class _Dev:
|
||||
pass
|
||||
|
||||
mgr = M.__new__(M) # bypass __init__ (no GLib needed)
|
||||
mgr._device = _Dev()
|
||||
mgr._state = state
|
||||
if idle_effect is None:
|
||||
mgr._idle_effect = _dim(50)
|
||||
elif isinstance(idle_effect, int):
|
||||
mgr._idle_effect = _dim(idle_effect)
|
||||
else:
|
||||
mgr._idle_effect = idle_effect
|
||||
mgr._dim_step = dim_step
|
||||
mgr._dim_perkey = None
|
||||
# Avoid circular import surprise — make sure the LEDEffectSetting is
|
||||
# imported here so type checks in helpers don't crash.
|
||||
_ = hidpp20.LEDEffectSetting
|
||||
|
||||
saved_managers = dict(rgb_power._managers)
|
||||
monkeypatch.setattr(rgb_power, "_managers", {id(fake_device): mgr})
|
||||
yield_data = (fake_device, mgr, saved_managers)
|
||||
return yield_data
|
||||
|
||||
|
||||
def test_translate_for_device_idle_routes_through_manager(monkeypatch):
|
||||
fake_device, mgr, _ = _install_manager(monkeypatch, M.IDLE, idle_effect=50)
|
||||
assert rgb_power.translate_for_device(fake_device, 0xFFFFFF) == 0x7F7F7F
|
||||
|
||||
|
||||
def test_translate_for_device_sleeping_returns_none(monkeypatch):
|
||||
fake_device, _, _ = _install_manager(monkeypatch, M.SLEEPING)
|
||||
assert rgb_power.translate_for_device(fake_device, 0xFFFFFF) is None
|
||||
|
||||
|
||||
def test_current_dim_pct_falls_back_to_100_for_non_dim_effects():
|
||||
from logitech_receiver import hidpp20
|
||||
|
||||
mgr = M.__new__(M)
|
||||
# Dim is the only host-side idle effect — it's the only one Solaar can
|
||||
# render itself (by interpolating colors toward a dimmer target on a
|
||||
# Static zone color or in the per-key buffer). Disabled does nothing.
|
||||
# Breathe and Ripple hand off to the firmware effect engine, which
|
||||
# runs the animation at its own brightness; Solaar applies no dim
|
||||
# translation in those cases, so _current_dim_pct returns 100.
|
||||
for fw_or_disabled in (0x00, 0x0A, 0x0B):
|
||||
mgr._idle_effect = hidpp20.LEDEffectSetting(ID=fw_or_disabled)
|
||||
assert mgr._current_dim_pct() == 100
|
||||
# Dim mode — intensity carries the dim percentage.
|
||||
for dim_pct in (25, 50, 75):
|
||||
mgr._idle_effect = hidpp20.LEDEffectSetting(ID=0x80, intensity=dim_pct)
|
||||
assert mgr._current_dim_pct() == dim_pct
|
||||
|
||||
|
||||
# --- notify_perkey_changed --------------------------------------------------
|
||||
|
||||
|
||||
def test_notify_perkey_changed_updates_dim_map_during_dimming():
|
||||
mgr = M.__new__(M)
|
||||
mgr._state = M.DIMMING
|
||||
mgr._idle_effect = _dim(50)
|
||||
mgr._dim_perkey = {5: (0xFFFFFF, 0x7F7F7F)}
|
||||
mgr.notify_perkey_changed(5, 0x00FF00)
|
||||
start, target = mgr._dim_perkey[5]
|
||||
assert start == 0x00FF00
|
||||
# target = dim(0x00FF00, 50) = (0, 0xFF*50//100, 0) = (0, 0x7F, 0)
|
||||
assert target == 0x007F00
|
||||
|
||||
|
||||
def test_notify_perkey_changed_noop_when_not_dimming():
|
||||
mgr = M.__new__(M)
|
||||
mgr._state = M.IDLE
|
||||
mgr._idle_effect = _dim(50)
|
||||
mgr._dim_perkey = {5: (0xFFFFFF, 0x7F7F7F)}
|
||||
mgr.notify_perkey_changed(5, 0x00FF00)
|
||||
assert mgr._dim_perkey[5] == (0xFFFFFF, 0x7F7F7F)
|
||||
|
||||
|
||||
def test_notify_perkey_changed_noop_for_unknown_zone():
|
||||
mgr = M.__new__(M)
|
||||
mgr._state = M.DIMMING
|
||||
mgr._idle_effect = _dim(50)
|
||||
mgr._dim_perkey = {5: (0xFFFFFF, 0x7F7F7F)}
|
||||
mgr.notify_perkey_changed(99, 0x00FF00) # not in _dim_perkey
|
||||
assert mgr._dim_perkey == {5: (0xFFFFFF, 0x7F7F7F)}
|
||||
|
||||
|
||||
def test_notify_perkey_bulk_changed_skips_no_change():
|
||||
from logitech_receiver import special_keys
|
||||
|
||||
no_change = special_keys.COLORSPLUS["No change"]
|
||||
mgr = M.__new__(M)
|
||||
mgr._state = M.DIMMING
|
||||
mgr._idle_effect = _dim(50)
|
||||
mgr._dim_perkey = {5: (0xFFFFFF, 0x7F7F7F), 7: (0x000000, 0x000000)}
|
||||
mgr.notify_perkey_bulk_changed({5: 0x00FF00, 7: no_change})
|
||||
# Zone 5 updated, zone 7 (No change) left alone.
|
||||
assert mgr._dim_perkey[5][0] == 0x00FF00
|
||||
assert mgr._dim_perkey[7] == (0x000000, 0x000000)
|
||||
|
||||
|
||||
# --- notify_zone_changed (zone-effect dim ramp) -----------------------------
|
||||
|
||||
|
||||
class _FakeZone:
|
||||
"""Minimal stand-in for hidpp20.LEDZoneInfo — only `.index` is consulted
|
||||
by RGBPowerManager.notify_zone_changed."""
|
||||
|
||||
def __init__(self, index):
|
||||
self.index = index
|
||||
|
||||
|
||||
def test_notify_zone_changed_updates_matching_cluster_during_dimming():
|
||||
mgr = M.__new__(M)
|
||||
mgr._state = M.DIMMING
|
||||
mgr._idle_effect = _dim(50)
|
||||
zone_a = _FakeZone(0)
|
||||
zone_b = _FakeZone(1)
|
||||
mgr._dim_zones = [
|
||||
(zone_a, 0xFFFFFF, 0x7F7F7F),
|
||||
(zone_b, 0xFF0000, 0x7F0000),
|
||||
]
|
||||
mgr.notify_zone_changed(1, 0x00FF00)
|
||||
# zone_a unchanged
|
||||
assert mgr._dim_zones[0] == (zone_a, 0xFFFFFF, 0x7F7F7F)
|
||||
# zone_b updated: new start + recomputed target at 50%
|
||||
zone, start, target = mgr._dim_zones[1]
|
||||
assert zone is zone_b
|
||||
assert start == 0x00FF00
|
||||
# _compute_dim_color(0x00FF00, 50) = (0, 0xFF*50//100, 0) = (0, 0x7F, 0)
|
||||
assert target == 0x007F00
|
||||
|
||||
|
||||
def test_notify_zone_changed_noop_when_not_dimming():
|
||||
mgr = M.__new__(M)
|
||||
mgr._state = M.IDLE
|
||||
mgr._idle_effect = _dim(50)
|
||||
zone = _FakeZone(0)
|
||||
mgr._dim_zones = [(zone, 0xFFFFFF, 0x7F7F7F)]
|
||||
mgr.notify_zone_changed(0, 0x00FF00)
|
||||
assert mgr._dim_zones[0] == (zone, 0xFFFFFF, 0x7F7F7F)
|
||||
|
||||
|
||||
def test_notify_zone_changed_noop_for_unknown_cluster():
|
||||
mgr = M.__new__(M)
|
||||
mgr._state = M.DIMMING
|
||||
mgr._idle_effect = _dim(50)
|
||||
zone = _FakeZone(0)
|
||||
mgr._dim_zones = [(zone, 0xFFFFFF, 0x7F7F7F)]
|
||||
mgr.notify_zone_changed(99, 0x00FF00) # cluster 99 not in _dim_zones
|
||||
assert mgr._dim_zones[0] == (zone, 0xFFFFFF, 0x7F7F7F)
|
||||
|
||||
|
||||
def test_notify_zone_changed_noop_when_dim_zones_empty():
|
||||
mgr = M.__new__(M)
|
||||
mgr._state = M.DIMMING
|
||||
mgr._idle_effect = _dim(50)
|
||||
mgr._dim_zones = [] # e.g. per-key active, zone path skipped
|
||||
mgr.notify_zone_changed(0, 0x00FF00) # should not raise
|
||||
assert mgr._dim_zones == []
|
||||
|
||||
|
||||
# --- perkey_has_paint / zone_effect_is_static (module-level predicates) -----
|
||||
|
||||
|
||||
class _FakePerKey:
|
||||
"""Minimal stand-in for PerKeyLighting: holds a _value map and a
|
||||
_validator with .choices that perkey_has_paint inspects."""
|
||||
|
||||
name = "per-key-lighting"
|
||||
|
||||
def __init__(self, value, choices=(1, 2, 3)):
|
||||
self._value = value
|
||||
|
||||
class _V:
|
||||
pass
|
||||
|
||||
self._validator = _V()
|
||||
self._validator.choices = list(choices)
|
||||
|
||||
|
||||
class _FakeZoneSetting:
|
||||
"""Minimal stand-in for an RGBEffectSetting child instance: holds a
|
||||
name starting with "rgb_zone_" and a _value with an .ID attribute."""
|
||||
|
||||
def __init__(self, name, value):
|
||||
self.name = name
|
||||
self._value = value
|
||||
|
||||
|
||||
class _ValueWithID:
|
||||
"""Stand-in for hidpp20.LEDEffectSetting — only .ID is consulted by
|
||||
zone_effect_is_static."""
|
||||
|
||||
def __init__(self, ID):
|
||||
self.ID = ID
|
||||
|
||||
|
||||
class _FakePersister:
|
||||
def __init__(self, sensitivities=None):
|
||||
self._s = sensitivities or {}
|
||||
|
||||
def get_sensitivity(self, name):
|
||||
return self._s.get(name, False)
|
||||
|
||||
|
||||
class _FakeDevice:
|
||||
def __init__(self, settings_list, persister=None):
|
||||
self.settings = settings_list
|
||||
self.persister = persister
|
||||
|
||||
|
||||
def test_perkey_has_paint_with_real_colors():
|
||||
pk = _FakePerKey({1: 0xFF0000, 2: -1, 3: -1}) # one real color, rest "No change"
|
||||
dev = _FakeDevice([pk], _FakePersister({"per-key-lighting": True}))
|
||||
found, has_paint = rgb_power.perkey_has_paint(dev)
|
||||
assert found is pk
|
||||
assert has_paint is True
|
||||
|
||||
|
||||
def test_perkey_has_paint_requires_explicit_opt_in():
|
||||
# Paint exists but sensitivity is the default (False) — has_paint must
|
||||
# be False so zone effects remain primary on devices where the user
|
||||
# hasn't opted in to per-key dominance (e.g. G502X mouse out of the box).
|
||||
pk = _FakePerKey({1: 0xFF0000})
|
||||
dev = _FakeDevice([pk], _FakePersister()) # sensitivity defaults to False
|
||||
_, has_paint = rgb_power.perkey_has_paint(dev)
|
||||
assert has_paint is False
|
||||
|
||||
|
||||
def test_perkey_has_paint_only_no_change():
|
||||
pk = _FakePerKey({1: -1, 2: -1, 3: -1}) # all "No change"
|
||||
dev = _FakeDevice([pk])
|
||||
found, has_paint = rgb_power.perkey_has_paint(dev)
|
||||
assert found is pk
|
||||
assert has_paint is False
|
||||
|
||||
|
||||
def test_perkey_has_paint_no_perkey_setting():
|
||||
dev = _FakeDevice([])
|
||||
found, has_paint = rgb_power.perkey_has_paint(dev)
|
||||
assert found is None
|
||||
assert has_paint is False
|
||||
|
||||
|
||||
def test_perkey_has_paint_validator_choices_empty():
|
||||
pk = _FakePerKey({1: 0xFF0000}, choices=())
|
||||
dev = _FakeDevice([pk])
|
||||
found, has_paint = rgb_power.perkey_has_paint(dev)
|
||||
assert found is pk
|
||||
assert has_paint is False
|
||||
|
||||
|
||||
def test_perkey_has_paint_user_ignores_perkey():
|
||||
from logitech_receiver import settings as _settings
|
||||
|
||||
pk = _FakePerKey({1: 0xFF0000})
|
||||
dev = _FakeDevice([pk], _FakePersister({"per-key-lighting": _settings.SENSITIVITY_IGNORE}))
|
||||
found, has_paint = rgb_power.perkey_has_paint(dev)
|
||||
assert found is pk
|
||||
assert has_paint is False
|
||||
|
||||
|
||||
def test_perkey_has_paint_with_no_persister():
|
||||
# No persister means no place to record the user opting in, so per-key
|
||||
# dominance never engages — zone effects remain primary.
|
||||
pk = _FakePerKey({1: 0xFF0000})
|
||||
dev = _FakeDevice([pk], persister=None)
|
||||
_, has_paint = rgb_power.perkey_has_paint(dev)
|
||||
assert has_paint is False
|
||||
|
||||
|
||||
def test_zone_effect_is_static_true_for_static():
|
||||
z = _FakeZoneSetting("rgb_zone_1", _ValueWithID(0x01))
|
||||
assert rgb_power.zone_effect_is_static(_FakeDevice([z])) is True
|
||||
|
||||
|
||||
def test_zone_effect_is_static_false_for_animated():
|
||||
for eff_id in (0x02, 0x03, 0x0A, 0x0B, 0x0E, 0x15):
|
||||
z = _FakeZoneSetting("rgb_zone_1", _ValueWithID(eff_id))
|
||||
assert rgb_power.zone_effect_is_static(_FakeDevice([z])) is False, eff_id
|
||||
|
||||
|
||||
def test_zone_effect_is_static_false_for_disabled():
|
||||
# Disabled (0x00) means the user wants no zone effect — including
|
||||
# suppressing the per-key paint, since per-key push would re-light
|
||||
# the device.
|
||||
z = _FakeZoneSetting("rgb_zone_1", _ValueWithID(0x00))
|
||||
assert rgb_power.zone_effect_is_static(_FakeDevice([z])) is False
|
||||
|
||||
|
||||
def test_zone_effect_is_static_true_when_no_zone_setting():
|
||||
# Devices that only enumerate PER_KEY_LIGHTING_V2 (no RGB_EFFECTS) have
|
||||
# no zone-effect setting at all — per-key paint should be free to drive.
|
||||
assert rgb_power.zone_effect_is_static(_FakeDevice([])) is True
|
||||
|
||||
|
||||
def test_zone_effect_is_static_true_when_any_zone_is_static():
|
||||
# Multi-zone device with one Static and one Cycle zone — at least one
|
||||
# Static slot is enough for per-key to overlay on.
|
||||
a = _FakeZoneSetting("rgb_zone_1", _ValueWithID(0x01)) # Static
|
||||
b = _FakeZoneSetting("rgb_zone_2", _ValueWithID(0x03)) # Cycle
|
||||
assert rgb_power.zone_effect_is_static(_FakeDevice([a, b])) is True
|
||||
|
||||
|
||||
# --- RGBEffectSetting.write divert / push --------------------------------
|
||||
|
||||
|
||||
def _make_rgb_effect_setting(device, current_value):
|
||||
"""Build a partial RGBEffectSetting bound to `device` without running the
|
||||
setup classmethod (which requires a real led_effects descriptor). Only
|
||||
the fields touched by RGBEffectSetting.write are populated."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from logitech_receiver import settings_templates
|
||||
|
||||
s = settings_templates.RGBEffectSetting.__new__(settings_templates.RGBEffectSetting)
|
||||
s._device = device
|
||||
s._value = current_value
|
||||
s._rw = MagicMock()
|
||||
s._rw.prefix = b"\x01"
|
||||
s._validator = MagicMock()
|
||||
s._validator.needs_current_value = False
|
||||
# update() persists onto _value; mimic with a simple assignment.
|
||||
s.update = lambda v, save=True: setattr(s, "_value", v)
|
||||
return s
|
||||
|
||||
|
||||
def test_rgb_effect_write_static_to_static_repaints_unset_zones(monkeypatch):
|
||||
"""User tweaks the Static base color while per-key is the visible
|
||||
layer: persist, repaint per-key's unset zones, send FrameEnd. No
|
||||
SetEffectByIndex (would reclaim the engine and overwrite per-key).
|
||||
Also notifies any in-flight dim ramp so unset cells' start_color
|
||||
tracks the new base."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from logitech_receiver import hidpp20
|
||||
|
||||
perkey = MagicMock()
|
||||
perkey._fill_unset_zones_with_base_color.return_value = True
|
||||
perkey._send_with_retry.return_value = True
|
||||
perkey._unset_zone_ids.return_value = [5, 7, 9]
|
||||
device = MagicMock()
|
||||
device.online = True
|
||||
|
||||
mgr = MagicMock()
|
||||
monkeypatch.setattr(rgb_power, "perkey_has_paint", lambda d: (perkey, True))
|
||||
monkeypatch.setattr(rgb_power, "get_manager", lambda d: mgr)
|
||||
|
||||
old = hidpp20.LEDEffectSetting(ID=1, color=0xFF0000)
|
||||
new = hidpp20.LEDEffectSetting(ID=1, color=0x00FF00)
|
||||
s = _make_rgb_effect_setting(device, old)
|
||||
|
||||
result = s.write(new)
|
||||
|
||||
assert result is new
|
||||
assert s._value is new
|
||||
perkey._fill_unset_zones_with_base_color.assert_called_once()
|
||||
perkey._send_with_retry.assert_called_once_with(0x70, b"\x00")
|
||||
s._rw.write.assert_not_called()
|
||||
# No follow-up per-key push — we were already in Static.
|
||||
perkey.write.assert_not_called()
|
||||
# In-flight dim ramp gets notified that unset cells now interpolate
|
||||
# from the new base (0x00FF00) rather than the old.
|
||||
mgr.notify_perkey_bulk_changed.assert_called_once_with({5: 0x00FF00, 7: 0x00FF00, 9: 0x00FF00})
|
||||
|
||||
|
||||
def test_rgb_effect_write_static_to_static_no_notify_when_no_unset_zones(monkeypatch):
|
||||
"""If every per-key cell is user-painted, _unset_zone_ids returns
|
||||
[] and the dim-ramp notification is skipped (nothing to update)."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from logitech_receiver import hidpp20
|
||||
|
||||
perkey = MagicMock()
|
||||
perkey._fill_unset_zones_with_base_color.return_value = True
|
||||
perkey._send_with_retry.return_value = True
|
||||
perkey._unset_zone_ids.return_value = []
|
||||
device = MagicMock()
|
||||
device.online = True
|
||||
|
||||
mgr = MagicMock()
|
||||
monkeypatch.setattr(rgb_power, "perkey_has_paint", lambda d: (perkey, True))
|
||||
monkeypatch.setattr(rgb_power, "get_manager", lambda d: mgr)
|
||||
|
||||
old = hidpp20.LEDEffectSetting(ID=1, color=0xFF0000)
|
||||
new = hidpp20.LEDEffectSetting(ID=1, color=0x00FF00)
|
||||
s = _make_rgb_effect_setting(device, old)
|
||||
|
||||
s.write(new)
|
||||
|
||||
mgr.notify_perkey_bulk_changed.assert_not_called()
|
||||
|
||||
|
||||
def test_rgb_effect_write_static_to_static_unchanged_is_noop(monkeypatch):
|
||||
"""Same value persisted: no repaint, no FrameEnd, no wire write."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from logitech_receiver import hidpp20
|
||||
|
||||
perkey = MagicMock()
|
||||
device = MagicMock()
|
||||
device.online = True
|
||||
monkeypatch.setattr(rgb_power, "perkey_has_paint", lambda d: (perkey, True))
|
||||
|
||||
same = hidpp20.LEDEffectSetting(ID=1, color=0xFF0000)
|
||||
s = _make_rgb_effect_setting(device, same)
|
||||
|
||||
s.write(same)
|
||||
|
||||
perkey._fill_unset_zones_with_base_color.assert_not_called()
|
||||
perkey._send_with_retry.assert_not_called()
|
||||
s._rw.write.assert_not_called()
|
||||
|
||||
|
||||
def test_rgb_effect_write_static_to_animation_pushes_wire(monkeypatch):
|
||||
"""User switches the zone effect dropdown from Static to an animation
|
||||
while per-key has paint: push the new value to wire so the animation
|
||||
starts. Per-key is a sub-mode of Static — animations take over the
|
||||
visible layer when selected."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from logitech_receiver import hidpp20
|
||||
|
||||
perkey = MagicMock()
|
||||
perkey._value = {1: 0xFF0000}
|
||||
device = MagicMock()
|
||||
device.online = True
|
||||
monkeypatch.setattr(rgb_power, "perkey_has_paint", lambda d: (perkey, True))
|
||||
monkeypatch.setattr(rgb_power, "translate_for_device", lambda d, c: c)
|
||||
monkeypatch.setattr(rgb_power, "get_manager", lambda d: None)
|
||||
|
||||
old = hidpp20.LEDEffectSetting(ID=0x01, color=0xFF0000)
|
||||
new = hidpp20.LEDEffectSetting(ID=0x0A, color=0x00FF00)
|
||||
s = _make_rgb_effect_setting(device, old)
|
||||
s._validator.prepare_write.return_value = b"prepared"
|
||||
s._rw.write.return_value = b"ack"
|
||||
|
||||
s.write(new)
|
||||
|
||||
s._rw.write.assert_called_once()
|
||||
perkey._fill_unset_zones_with_base_color.assert_not_called()
|
||||
|
||||
|
||||
def test_rgb_effect_write_repaints_perkey_unset_on_color_change(monkeypatch):
|
||||
"""When per-key has paint and the zone's color changes, repaint the
|
||||
per-key unset cells with the new base so the visible result tracks
|
||||
the user's color choice."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from logitech_receiver import hidpp20
|
||||
|
||||
perkey = MagicMock()
|
||||
perkey._value = {1: 0xFF0000, 2: -1}
|
||||
perkey._fill_unset_zones_with_base_color.return_value = True
|
||||
perkey._unset_zone_ids.return_value = [2]
|
||||
device = MagicMock()
|
||||
device.online = True
|
||||
monkeypatch.setattr(rgb_power, "perkey_has_paint", lambda d: (perkey, True))
|
||||
monkeypatch.setattr(rgb_power, "translate_for_device", lambda d, c: c)
|
||||
monkeypatch.setattr(rgb_power, "get_manager", lambda d: None)
|
||||
|
||||
old = hidpp20.LEDEffectSetting(ID=0x01, color=0xFF0000)
|
||||
new = hidpp20.LEDEffectSetting(ID=0x01, color=0x00FF00)
|
||||
s = _make_rgb_effect_setting(device, old)
|
||||
s._validator.prepare_write.return_value = b"prepared"
|
||||
s._rw.write.return_value = b"ack"
|
||||
|
||||
s.write(new)
|
||||
|
||||
s._rw.write.assert_not_called()
|
||||
perkey._fill_unset_zones_with_base_color.assert_called_once()
|
||||
perkey._send_with_retry.assert_called_once_with(0x70, b"\x00")
|
||||
|
||||
|
||||
def test_rgb_effect_write_apply_path_suppressed_when_perkey_has_paint(monkeypatch):
|
||||
"""save=False is the apply_all_settings path. When per-key has paint and
|
||||
is opted in, per-key fully owns the visible layer — the zone wire push
|
||||
is suppressed here too, not just on the save path."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from logitech_receiver import hidpp20
|
||||
|
||||
perkey = MagicMock()
|
||||
device = MagicMock()
|
||||
device.online = True
|
||||
monkeypatch.setattr(rgb_power, "perkey_has_paint", lambda d: (perkey, True))
|
||||
monkeypatch.setattr(rgb_power, "translate_for_device", lambda d, c: c)
|
||||
monkeypatch.setattr(rgb_power, "get_manager", lambda d: None)
|
||||
|
||||
new = hidpp20.LEDEffectSetting(ID=0x01, color=0x00FF00)
|
||||
s = _make_rgb_effect_setting(device, None)
|
||||
s._validator.prepare_write.return_value = b"prepared"
|
||||
s._rw.write.return_value = b"ack"
|
||||
|
||||
s.write(new, save=False)
|
||||
|
||||
s._rw.write.assert_not_called()
|
||||
# Apply path doesn't repaint either — that's reserved for explicit user
|
||||
# color changes via save=True.
|
||||
perkey._fill_unset_zones_with_base_color.assert_not_called()
|
||||
|
||||
|
||||
def test_rgb_effect_write_inactive_perkey_falls_through(monkeypatch):
|
||||
"""No per-key paint (or per-key feature absent): existing
|
||||
translate-through-power-state wire path runs unchanged."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from logitech_receiver import hidpp20
|
||||
|
||||
perkey = MagicMock()
|
||||
device = MagicMock()
|
||||
device.online = True
|
||||
monkeypatch.setattr(rgb_power, "perkey_has_paint", lambda d: (perkey, False))
|
||||
monkeypatch.setattr(rgb_power, "translate_for_device", lambda d, c: c)
|
||||
monkeypatch.setattr(rgb_power, "get_manager", lambda d: None)
|
||||
|
||||
new = hidpp20.LEDEffectSetting(ID=0x01, color=0x00FF00)
|
||||
s = _make_rgb_effect_setting(device, None)
|
||||
s._validator.prepare_write.return_value = b"prepared"
|
||||
s._rw.write.return_value = b"ack"
|
||||
|
||||
s.write(new)
|
||||
|
||||
s._rw.write.assert_called_once()
|
||||
perkey._fill_unset_zones_with_base_color.assert_not_called()
|
||||
perkey.write.assert_not_called()
|
||||
|
||||
|
||||
# --- PerKeyLighting.write defers to firmware animations -------------------
|
||||
|
||||
|
||||
def test_perkey_write_skipped_when_zone_is_animation(monkeypatch):
|
||||
"""Per-key is a sub-mode of Static. When the saved zone effect is an
|
||||
animation (Breathe etc.), the firmware engine owns the visible layer
|
||||
and per-key writes do not go to the wire."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from logitech_receiver import settings_templates
|
||||
|
||||
breathe_zone = _FakeZoneSetting("rgb_zone_1", _ValueWithID(0x0A))
|
||||
|
||||
s = settings_templates.PerKeyLighting.__new__(settings_templates.PerKeyLighting)
|
||||
device = MagicMock()
|
||||
device.online = True
|
||||
device.settings = [breathe_zone]
|
||||
s._device = device
|
||||
s._value = {}
|
||||
s._has_rgb_effects = True
|
||||
s._ensure_sw_control = MagicMock()
|
||||
s._send_with_retry = MagicMock(return_value=True)
|
||||
s._fill_unset_zones_with_base_color = MagicMock(return_value=True)
|
||||
monkeypatch.setattr(rgb_power, "translate_for_device", lambda d, c: c)
|
||||
monkeypatch.setattr(rgb_power, "get_manager", lambda d: None)
|
||||
s.update = lambda m, save=True: None
|
||||
|
||||
s.write({1: 0xFF0000})
|
||||
|
||||
s._send_with_retry.assert_not_called()
|
||||
|
||||
|
||||
def test_perkey_write_key_value_skipped_when_zone_is_animation(monkeypatch):
|
||||
"""Same: write_key_value defers to firmware animations."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from logitech_receiver import settings_templates
|
||||
|
||||
breathe_zone = _FakeZoneSetting("rgb_zone_1", _ValueWithID(0x0A))
|
||||
|
||||
s = settings_templates.PerKeyLighting.__new__(settings_templates.PerKeyLighting)
|
||||
device = MagicMock()
|
||||
device.online = True
|
||||
device.settings = [breathe_zone]
|
||||
s._device = device
|
||||
s._value = {}
|
||||
s._has_rgb_effects = True
|
||||
s._ensure_sw_control = MagicMock()
|
||||
s._send_with_retry = MagicMock(return_value=True)
|
||||
s._send_zone_color = MagicMock(return_value=True)
|
||||
s._fill_unset_zones_with_base_color = MagicMock(return_value=True)
|
||||
monkeypatch.setattr(rgb_power, "translate_for_device", lambda d, c: c)
|
||||
monkeypatch.setattr(rgb_power, "get_manager", lambda d: None)
|
||||
s.update_key_value = lambda k, v, save=True: None
|
||||
|
||||
s.write_key_value(7, 0xFF0000)
|
||||
|
||||
s._send_zone_color.assert_not_called()
|
||||
|
|
@ -297,7 +297,27 @@ simple_tests = [
|
|||
Setup(
|
||||
FeatureTest(settings_templates.RGBControl, False, True),
|
||||
fake_hidpp.Response("0000", 0x0450),
|
||||
fake_hidpp.Response("010100", 0x0450, "0101"),
|
||||
fake_hidpp.Response("010304", 0x0450, "010304"),
|
||||
fake_hidpp.Response("00003C012C", 0x0470, "00"), # GetRgbPowerModeConfig: idle=60s, sleep=300s
|
||||
),
|
||||
Setup( # RGBIdleEffect — software-only, no feature requests for read/write
|
||||
# The setting is a HeteroValidator carrying a LEDEffectSetting.
|
||||
# Default (read with empty persister) = Dim 50%.
|
||||
FeatureTest(
|
||||
settings_templates.RGBIdleEffect,
|
||||
hidpp20.LEDEffectSetting(ID=0x80, intensity=50),
|
||||
hidpp20.LEDEffectSetting(ID=0x80, intensity=75),
|
||||
0,
|
||||
),
|
||||
fake_hidpp.Response("00", 0xFFFF), # placeholder — no device requests needed
|
||||
),
|
||||
Setup( # RGBIdleTimeout — software-only, no feature requests for read/write
|
||||
FeatureTest(settings_templates.RGBIdleTimeout, 60, 300, 0),
|
||||
fake_hidpp.Response("00", 0xFFFF), # placeholder — no device requests needed
|
||||
),
|
||||
Setup( # RGBSleepTimeout — software-only, no feature requests for read/write
|
||||
FeatureTest(settings_templates.RGBSleepTimeout, 300, 600, 0),
|
||||
fake_hidpp.Response("00", 0xFFFF), # placeholder — no device requests needed
|
||||
),
|
||||
Setup(
|
||||
FeatureTest(
|
||||
|
|
@ -911,3 +931,90 @@ def test_check_feature_setting(test, mocker):
|
|||
setting = settings_templates.check_feature_setting(device, tst.sclass.name)
|
||||
|
||||
assert setting
|
||||
|
||||
|
||||
# --- RGBIdleEffect._pre_read legacy bare-int migration ---------------------
|
||||
# Solaar versions before the HeteroValidator refactor stored
|
||||
# `rgb_idle_effect` as a bare int (0 / 25 / 50 / 75 / 0x0A / 0x0B).
|
||||
# On first read after upgrade, RGBIdleEffect._pre_read should map
|
||||
# each to the equivalent LEDEffectSetting and write the upgraded form
|
||||
# back to the persister so subsequent reads return it directly.
|
||||
|
||||
|
||||
def _idle_setting_with_persisted(value):
|
||||
"""Build a minimally-scaffolded RGBIdleEffect with `value` already
|
||||
in the persister and `_value` unread. Returns (setting, device).
|
||||
Avoids RGBIdleEffect.build's led_effects probe (which needs a
|
||||
full device fixture); _pre_read is fully exercised regardless.
|
||||
"""
|
||||
from solaar import configuration
|
||||
|
||||
class _Dev:
|
||||
pass
|
||||
|
||||
device = _Dev()
|
||||
device.persister = configuration._DeviceEntry()
|
||||
if value is not None:
|
||||
device.persister[settings_templates.RGBIdleEffect.name] = value
|
||||
setting = settings_templates.RGBIdleEffect.__new__(settings_templates.RGBIdleEffect)
|
||||
setting._device = device
|
||||
setting._value = None
|
||||
return setting, device
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"legacy, expected_id, expected_attrs",
|
||||
[
|
||||
(0, 0x00, {}), # Disabled
|
||||
(25, 0x80, {"intensity": 25}), # Dim 25%
|
||||
(50, 0x80, {"intensity": 50}), # Dim 50%
|
||||
(75, 0x80, {"intensity": 75}), # Dim 75%
|
||||
(0x0A, 0x0A, {"period": 3000, "intensity": 100}), # Breathe
|
||||
(0x0B, 0x0B, {"period": 3000}), # Ripple
|
||||
],
|
||||
)
|
||||
def test_RGBIdleEffect_legacy_int_migration(legacy, expected_id, expected_attrs):
|
||||
setting, device = _idle_setting_with_persisted(legacy)
|
||||
|
||||
setting._pre_read(cached=True)
|
||||
|
||||
assert isinstance(setting._value, hidpp20.LEDEffectSetting)
|
||||
assert int(setting._value.ID) == expected_id
|
||||
for attr, val in expected_attrs.items():
|
||||
assert getattr(setting._value, attr) == val
|
||||
# Persister was rewritten so subsequent reads return the migrated form.
|
||||
persisted = device.persister[setting.name]
|
||||
assert isinstance(persisted, hidpp20.LEDEffectSetting)
|
||||
assert int(persisted.ID) == expected_id
|
||||
|
||||
|
||||
def test_RGBIdleEffect_already_migrated_is_unchanged():
|
||||
"""A persisted LEDEffectSetting passes through _pre_read untouched —
|
||||
no double-migration on subsequent reads."""
|
||||
pre_migrated = hidpp20.LEDEffectSetting(ID=0x80, intensity=42)
|
||||
setting, _ = _idle_setting_with_persisted(pre_migrated)
|
||||
|
||||
setting._pre_read(cached=True)
|
||||
|
||||
assert setting._value is pre_migrated # same instance, no rewrap
|
||||
|
||||
|
||||
def test_RGBIdleEffect_none_value_passes_through():
|
||||
"""Fresh install / nothing in persister: _pre_read leaves _value as
|
||||
None instead of crashing in the migration branch."""
|
||||
setting, _ = _idle_setting_with_persisted(None)
|
||||
|
||||
setting._pre_read(cached=True)
|
||||
|
||||
assert setting._value is None
|
||||
|
||||
|
||||
def test_RGBIdleEffect_unrecognized_int_passes_through():
|
||||
"""An int outside the legacy mapping (corrupt config, future value)
|
||||
falls through _pre_read without touching _value or the persister."""
|
||||
setting, device = _idle_setting_with_persisted(999)
|
||||
|
||||
setting._pre_read(cached=True)
|
||||
|
||||
assert setting._value == 999
|
||||
assert device.persister[setting.name] == 999
|
||||
|
|
|
|||
Loading…
Reference in New Issue