Solaar/tests/logitech_receiver/test_rgb_power.py

675 lines
24 KiB
Python

## 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_with_locked_sensitivity():
# False (locked) still counts as paint — only IGNORE opts out.
pk = _FakePerKey({1: 0xFF0000})
dev = _FakeDevice([pk], _FakePersister()) # sensitivity defaults to False
_, has_paint = rgb_power.perkey_has_paint(dev)
assert has_paint is True
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, no IGNORE flag — treat as paint present.
pk = _FakePerKey({1: 0xFF0000})
dev = _FakeDevice([pk], persister=None)
_, has_paint = rgb_power.perkey_has_paint(dev)
assert has_paint is True
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._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._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()