Skip to content

Commit 89a6e20

Browse files
author
Tom Lasswell
committed
fix: Preserve pre-scene color across API polls so "None" restores it (#18)
Two bugs caused selecting "None" in the scene dropdown to set the light to black instead of restoring the previous color: 1. last_color/last_color_temp_kelvin were lost during API poll cycles because _fetch_device_state() didn't preserve them from existing state. 2. async_clear_scene() fell back to None instead of last_color when rejecting RGBColor(0,0,0) returned by the API during scenes. Also preserves last_scene_id/last_scene_name needed by music mode disable.
1 parent 069f699 commit 89a6e20

5 files changed

Lines changed: 123 additions & 3 deletions

File tree

custom_components/govee/coordinator.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,17 @@ async def _fetch_device_state(
477477
# DIY scenes also persist across power cycles
478478
if existing_state.active_diy_scene:
479479
state.active_diy_scene = existing_state.active_diy_scene
480+
# Preserve restore-target fields across API polls.
481+
# These are "memory" fields — always preserved regardless of power state.
482+
if existing_state.last_color is not None:
483+
state.last_color = existing_state.last_color
484+
if existing_state.last_color_temp_kelvin is not None:
485+
state.last_color_temp_kelvin = existing_state.last_color_temp_kelvin
486+
if existing_state.last_scene_id is not None:
487+
state.last_scene_id = existing_state.last_scene_id
488+
if existing_state.last_scene_name is not None:
489+
state.last_scene_name = existing_state.last_scene_name
490+
480491
self._preserve_optimistic_field(
481492
existing_state, state, device_id, "dreamview_enabled", "DreamView"
482493
)
@@ -961,7 +972,11 @@ async def async_clear_scene(self, device_id: str) -> None:
961972
self.clear_diy_scene(device_id)
962973
return
963974

975+
# Resolve the color to restore. Skip RGBColor(0,0,0) — the API returns
976+
# colorRgb=0 when a scene is running, which is not a meaningful restore target.
964977
color = state.color or state.last_color
978+
if color and color.as_packed_int == 0:
979+
color = state.last_color
965980
color_temp = state.color_temp_kelvin or state.last_color_temp_kelvin
966981

967982
success = False

custom_components/govee/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,6 @@
1616
"cryptography>=41.0.0"
1717
],
1818
"ssdp": [],
19-
"version": "2026.3.2",
19+
"version": "2026.3.4",
2020
"zeroconf": []
2121
}

custom_components/govee/models/state.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,9 @@ def apply_optimistic_scene(
248248
self.source = "optimistic"
249249
# Save current color/color_temp before clearing so we can restore on scene clear.
250250
# Only save when a value exists so scene A → scene B → clear restores pre-A color.
251-
if self.color is not None:
251+
# Skip RGBColor(0,0,0) — the API returns colorRgb=0 when a scene is running,
252+
# which is not a meaningful color to restore.
253+
if self.color is not None and self.color.as_packed_int != 0:
252254
self.last_color = self.color
253255
self.last_color_temp_kelvin = None
254256
elif self.color_temp_kelvin is not None:
@@ -273,7 +275,7 @@ def apply_optimistic_diy_scene(self, scene_id: str) -> None:
273275
self.active_diy_scene = scene_id
274276
self.source = "optimistic"
275277
# Save current color/color_temp before clearing (same logic as regular scenes)
276-
if self.color is not None:
278+
if self.color is not None and self.color.as_packed_int != 0:
277279
self.last_color = self.color
278280
self.last_color_temp_kelvin = None
279281
elif self.color_temp_kelvin is not None:

tests/test_coordinator.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,3 +1010,88 @@ def test_clear_scene_clears_both_scene_types(self):
10101010
assert state.active_scene is None
10111011
assert state.active_scene_name is None
10121012
assert state.active_diy_scene is None
1013+
1014+
1015+
class TestStatePreservationAcrossApiPoll:
1016+
"""Test that restore-target fields survive API poll cycles."""
1017+
1018+
def test_last_color_preserved_across_api_poll(self):
1019+
"""Test last_color is preserved when API returns a fresh state."""
1020+
existing = GoveeDeviceState.create_empty("test_id")
1021+
existing.color = RGBColor(255, 0, 0)
1022+
existing.apply_optimistic_scene("scene_1", "Sunset")
1023+
assert existing.last_color == RGBColor(255, 0, 0)
1024+
1025+
# Simulate API poll returning a fresh state (no last_color)
1026+
new_state = GoveeDeviceState.create_empty("test_id")
1027+
new_state.power_state = True
1028+
1029+
# Mimic coordinator preservation logic
1030+
if existing.last_color is not None:
1031+
new_state.last_color = existing.last_color
1032+
1033+
assert new_state.last_color == RGBColor(255, 0, 0)
1034+
1035+
def test_last_color_temp_preserved_across_api_poll(self):
1036+
"""Test last_color_temp_kelvin is preserved when API returns a fresh state."""
1037+
existing = GoveeDeviceState.create_empty("test_id")
1038+
existing.color_temp_kelvin = 4500
1039+
existing.apply_optimistic_scene("scene_1", "Sunset")
1040+
assert existing.last_color_temp_kelvin == 4500
1041+
1042+
new_state = GoveeDeviceState.create_empty("test_id")
1043+
new_state.power_state = True
1044+
1045+
if existing.last_color_temp_kelvin is not None:
1046+
new_state.last_color_temp_kelvin = existing.last_color_temp_kelvin
1047+
1048+
assert new_state.last_color_temp_kelvin == 4500
1049+
1050+
def test_last_scene_preserved_across_api_poll(self):
1051+
"""Test last_scene_id and last_scene_name survive API poll."""
1052+
existing = GoveeDeviceState.create_empty("test_id")
1053+
existing.apply_optimistic_scene("scene_42", "Aurora")
1054+
assert existing.last_scene_id == "scene_42"
1055+
assert existing.last_scene_name == "Aurora"
1056+
1057+
new_state = GoveeDeviceState.create_empty("test_id")
1058+
1059+
if existing.last_scene_id is not None:
1060+
new_state.last_scene_id = existing.last_scene_id
1061+
if existing.last_scene_name is not None:
1062+
new_state.last_scene_name = existing.last_scene_name
1063+
1064+
assert new_state.last_scene_id == "scene_42"
1065+
assert new_state.last_scene_name == "Aurora"
1066+
1067+
def test_full_flow_color_scene_poll_clear(self):
1068+
"""End-to-end: set red → scene → API poll (colorRgb=0) → clear → red resolved."""
1069+
# Step 1: User sets red
1070+
state = GoveeDeviceState.create_empty("test_id")
1071+
state.color = RGBColor(255, 0, 0)
1072+
state.power_state = True
1073+
1074+
# Step 2: User activates scene — saves red as last_color
1075+
state.apply_optimistic_scene("scene_1", "Party")
1076+
assert state.last_color == RGBColor(255, 0, 0)
1077+
assert state.color is None
1078+
1079+
# Step 3: API poll returns fresh state with colorRgb=0 (scene running)
1080+
api_state = GoveeDeviceState.create_empty("test_id")
1081+
api_state.power_state = True
1082+
api_state.color = RGBColor(0, 0, 0) # API returns black during scene
1083+
1084+
# Coordinator preserves memory fields
1085+
if state.active_scene:
1086+
api_state.active_scene = state.active_scene
1087+
if state.active_scene_name:
1088+
api_state.active_scene_name = state.active_scene_name
1089+
if state.last_color is not None:
1090+
api_state.last_color = state.last_color
1091+
1092+
# Step 4: Resolve color for clear_scene — reject black, fall back to last_color
1093+
color = api_state.color or api_state.last_color
1094+
if color and color.as_packed_int == 0:
1095+
color = api_state.last_color
1096+
1097+
assert color == RGBColor(255, 0, 0)

tests/test_models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,24 @@ def test_diy_scene_chain_preserves_first_color(self):
780780
state.apply_optimistic_diy_scene("2")
781781
assert state.last_color == RGBColor(128, 128, 0)
782782

783+
def test_scene_does_not_save_black(self):
784+
"""Test that RGBColor(0,0,0) is not saved as last_color.
785+
786+
The API returns colorRgb=0 when a scene is running, which is not a
787+
meaningful color to restore.
788+
"""
789+
state = GoveeDeviceState.create_empty("test_id")
790+
state.color = RGBColor(0, 0, 0)
791+
state.apply_optimistic_scene("123", "Sunrise")
792+
assert state.last_color is None
793+
794+
def test_diy_scene_does_not_save_black(self):
795+
"""Test that RGBColor(0,0,0) is not saved as last_color for DIY scenes."""
796+
state = GoveeDeviceState.create_empty("test_id")
797+
state.color = RGBColor(0, 0, 0)
798+
state.apply_optimistic_diy_scene("456")
799+
assert state.last_color is None
800+
783801

784802
# ==============================================================================
785803
# Command Tests

0 commit comments

Comments
 (0)