Skip to content

Commit e5c19b4

Browse files
author
Tom Lasswell
committed
fix: Resolve RGBIC segment race condition on area-targeted turn_off (#16)
Add asyncio.sleep(0) at the start of segment async_turn_off() to yield to the event loop, ensuring the main entity's PowerCommand sets the _pending_power_off flag before any segment checks it.
1 parent 001d6b2 commit e5c19b4

File tree

5 files changed

+197
-1
lines changed

5 files changed

+197
-1
lines changed

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.2.10",
19+
"version": "2026.2.11",
2020
"zeroconf": []
2121
}

custom_components/govee/platforms/grouped_segment.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from __future__ import annotations
77

8+
import asyncio
89
import logging
910
from typing import Any
1011

@@ -131,6 +132,11 @@ async def async_turn_off(self, **kwargs: Any) -> None:
131132
is already off — prevents race conditions in area-targeted turn_off
132133
that cause firmware glitches on RGBIC devices (issue #16).
133134
"""
135+
# Yield to the event loop so that a concurrent PowerCommand (from the
136+
# main light entity in an area-targeted turn_off) gets a chance to set
137+
# the _pending_power_off flag before we check it.
138+
await asyncio.sleep(0)
139+
134140
device_state = self.coordinator.get_state(self._device_id)
135141
device_already_off = device_state is not None and not device_state.power_state
136142
power_off_pending = self.coordinator.is_power_off_pending(self._device_id)

custom_components/govee/platforms/segment.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from __future__ import annotations
88

9+
import asyncio
910
import logging
1011
from typing import Any
1112

@@ -131,6 +132,11 @@ async def async_turn_off(self, **kwargs: Any) -> None:
131132
is already off — prevents race conditions in area-targeted turn_off
132133
that cause firmware glitches on RGBIC devices (issue #16).
133134
"""
135+
# Yield to the event loop so that a concurrent PowerCommand (from the
136+
# main light entity in an area-targeted turn_off) gets a chance to set
137+
# the _pending_power_off flag before we check it.
138+
await asyncio.sleep(0)
139+
134140
device_state = self.coordinator.get_state(self._device_id)
135141
device_already_off = device_state is not None and not device_state.power_state
136142
power_off_pending = self.coordinator.is_power_off_pending(self._device_id)

tests/test_grouped_segment.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66

77
from __future__ import annotations
88

9+
import asyncio
910
from unittest.mock import AsyncMock, MagicMock, patch
1011

1112
import pytest
1213

1314
from custom_components.govee.models import (
1415
GoveeDeviceState,
16+
PowerCommand,
1517
RGBColor,
1618
SegmentColorCommand,
1719
)
@@ -181,3 +183,93 @@ async def test_different_segment_counts(self):
181183
for segment_count in [1, 4, 8, 16]:
182184
entity = _make_grouped_segment_entity(segment_count=segment_count)
183185
assert entity._segment_indices == tuple(range(segment_count))
186+
187+
@pytest.mark.asyncio
188+
async def test_turn_off_yields_before_flag_check(self):
189+
"""asyncio.sleep(0) is called before checking the power-off flag."""
190+
entity = _make_grouped_segment_entity(power_state=True, power_off_pending=False)
191+
192+
call_order: list[str] = []
193+
original_sleep = asyncio.sleep
194+
195+
async def tracking_sleep(delay: float, *args: object) -> None:
196+
if delay == 0:
197+
call_order.append("sleep_0")
198+
await original_sleep(delay)
199+
200+
entity.coordinator.is_power_off_pending = MagicMock(
201+
side_effect=lambda _: (call_order.append("flag_check"), False)[1]
202+
)
203+
204+
with patch("asyncio.sleep", side_effect=tracking_sleep):
205+
await entity.async_turn_off()
206+
207+
assert call_order == ["sleep_0", "flag_check"]
208+
209+
@pytest.mark.asyncio
210+
async def test_concurrent_turn_off_with_main_entity(self):
211+
"""Concurrent area turn_off: grouped segment defers to main entity's PowerCommand.
212+
213+
Simulates asyncio.gather(main_turn_off, grouped_segment_turn_off) and verifies
214+
the grouped segment skips its SegmentColorCommand because the main entity sets
215+
the power-off flag first.
216+
"""
217+
coordinator = MagicMock()
218+
coordinator.last_update_success = True
219+
220+
state = GoveeDeviceState.create_empty("AA:BB:CC:DD:EE:FF:00:11")
221+
state.power_state = True
222+
coordinator.get_state = MagicMock(return_value=state)
223+
224+
# Track commands sent and implement real pending-power-off logic
225+
pending_power_off: set[str] = set()
226+
commands_sent: list[object] = []
227+
228+
# Use an event so the mock API call holds the flag until the
229+
# segment has had a chance to check it (mirrors real API latency).
230+
api_done = asyncio.Event()
231+
232+
async def mock_control(device_id: str, command: object) -> bool:
233+
is_power_off = isinstance(command, PowerCommand) and not command.power_on
234+
if is_power_off:
235+
pending_power_off.add(device_id)
236+
commands_sent.append(command)
237+
if is_power_off:
238+
await api_done.wait()
239+
pending_power_off.discard(device_id)
240+
return True
241+
242+
coordinator.async_control_device = mock_control
243+
coordinator.is_power_off_pending = lambda did: did in pending_power_off
244+
245+
# Build grouped segment entity
246+
with patch.object(GoveeGroupedSegmentEntity, "__init__", lambda self, *a, **kw: None):
247+
entity = GoveeGroupedSegmentEntity.__new__(GoveeGroupedSegmentEntity)
248+
entity.coordinator = coordinator
249+
entity._device_id = "AA:BB:CC:DD:EE:FF:00:11"
250+
entity._segment_indices = tuple(range(8))
251+
entity._is_on = True
252+
entity._brightness = 255
253+
entity._rgb_color = (255, 255, 255)
254+
entity.async_write_ha_state = MagicMock()
255+
256+
# Simulate main entity turn_off (sends PowerCommand directly)
257+
async def main_turn_off() -> None:
258+
await coordinator.async_control_device(
259+
"AA:BB:CC:DD:EE:FF:00:11",
260+
PowerCommand(power_on=False),
261+
)
262+
263+
async def run_both() -> None:
264+
await asyncio.gather(main_turn_off(), entity.async_turn_off())
265+
266+
# Let both coroutines run, then release the API mock
267+
task = asyncio.create_task(run_both())
268+
await asyncio.sleep(0) # let gather start both coroutines
269+
await asyncio.sleep(0) # let segment's sleep(0) yield
270+
api_done.set()
271+
await task
272+
273+
# Only PowerCommand should have been sent, not SegmentColorCommand
274+
assert len(commands_sent) == 1
275+
assert isinstance(commands_sent[0], PowerCommand)

tests/test_segment.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77

88
from __future__ import annotations
99

10+
import asyncio
1011
from unittest.mock import AsyncMock, MagicMock, patch
1112

1213
import pytest
1314

1415
from custom_components.govee.models import (
1516
GoveeDeviceState,
17+
PowerCommand,
1618
RGBColor,
1719
SegmentColorCommand,
1820
)
@@ -138,3 +140,93 @@ async def test_turn_off_when_no_state_exists(self):
138140

139141
# When state is None, device_already_off is False, so command should be sent
140142
entity.coordinator.async_control_device.assert_called_once()
143+
144+
@pytest.mark.asyncio
145+
async def test_turn_off_yields_before_flag_check(self):
146+
"""asyncio.sleep(0) is called before checking the power-off flag."""
147+
entity = _make_segment_entity(power_state=True, power_off_pending=False)
148+
149+
call_order: list[str] = []
150+
original_sleep = asyncio.sleep
151+
152+
async def tracking_sleep(delay: float, *args: object) -> None:
153+
if delay == 0:
154+
call_order.append("sleep_0")
155+
await original_sleep(delay)
156+
157+
entity.coordinator.is_power_off_pending = MagicMock(
158+
side_effect=lambda _: (call_order.append("flag_check"), False)[1]
159+
)
160+
161+
with patch("asyncio.sleep", side_effect=tracking_sleep):
162+
await entity.async_turn_off()
163+
164+
assert call_order == ["sleep_0", "flag_check"]
165+
166+
@pytest.mark.asyncio
167+
async def test_concurrent_turn_off_with_main_entity(self):
168+
"""Concurrent area turn_off: segment defers to main entity's PowerCommand.
169+
170+
Simulates asyncio.gather(main_turn_off, segment_turn_off) and verifies
171+
the segment skips its SegmentColorCommand because the main entity sets
172+
the power-off flag first.
173+
"""
174+
coordinator = MagicMock()
175+
coordinator.last_update_success = True
176+
177+
state = GoveeDeviceState.create_empty("AA:BB:CC:DD:EE:FF:00:11")
178+
state.power_state = True
179+
coordinator.get_state = MagicMock(return_value=state)
180+
181+
# Track commands sent and implement real pending-power-off logic
182+
pending_power_off: set[str] = set()
183+
commands_sent: list[object] = []
184+
185+
# Use an event so the mock API call holds the flag until the
186+
# segment has had a chance to check it (mirrors real API latency).
187+
api_done = asyncio.Event()
188+
189+
async def mock_control(device_id: str, command: object) -> bool:
190+
is_power_off = isinstance(command, PowerCommand) and not command.power_on
191+
if is_power_off:
192+
pending_power_off.add(device_id)
193+
commands_sent.append(command)
194+
if is_power_off:
195+
await api_done.wait()
196+
pending_power_off.discard(device_id)
197+
return True
198+
199+
coordinator.async_control_device = mock_control
200+
coordinator.is_power_off_pending = lambda did: did in pending_power_off
201+
202+
# Build segment entity
203+
with patch.object(GoveeSegmentEntity, "__init__", lambda self, *a, **kw: None):
204+
segment = GoveeSegmentEntity.__new__(GoveeSegmentEntity)
205+
segment.coordinator = coordinator
206+
segment._device_id = "AA:BB:CC:DD:EE:FF:00:11"
207+
segment._segment_index = 0
208+
segment._is_on = True
209+
segment._brightness = 255
210+
segment._rgb_color = (255, 255, 255)
211+
segment.async_write_ha_state = MagicMock()
212+
213+
# Simulate main entity turn_off (sends PowerCommand directly)
214+
async def main_turn_off() -> None:
215+
await coordinator.async_control_device(
216+
"AA:BB:CC:DD:EE:FF:00:11",
217+
PowerCommand(power_on=False),
218+
)
219+
220+
async def run_both() -> None:
221+
await asyncio.gather(main_turn_off(), segment.async_turn_off())
222+
223+
# Let both coroutines run, then release the API mock
224+
task = asyncio.create_task(run_both())
225+
await asyncio.sleep(0) # let gather start both coroutines
226+
await asyncio.sleep(0) # let segment's sleep(0) yield
227+
api_done.set()
228+
await task
229+
230+
# Only PowerCommand should have been sent, not SegmentColorCommand
231+
assert len(commands_sent) == 1
232+
assert isinstance(commands_sent[0], PowerCommand)

0 commit comments

Comments
 (0)