diff --git a/flux_led/aiodevice.py b/flux_led/aiodevice.py index 5573b937..7ae30d1b 100644 --- a/flux_led/aiodevice.py +++ b/flux_led/aiodevice.py @@ -602,6 +602,18 @@ async def async_set_device_config( ) if isinstance(self._protocol, ALL_IC_PROTOCOLS): await self._async_device_config_resync() + elif ( + operating_mode is not None + and device_config.operating_modes + and operating_mode_num is not None + ): + # Non-IC devices (e.g. 0x25 Controller RGB/WW/CW) have no + # strip-config response to resync from, and the device may + # not echo the new operating mode back in its next state + # response. Cache the requested value optimistically so + # callers see what they asked for until a fresh state + # message arrives. + self._pending_operating_mode_num = operating_mode_num async def async_unpair_remotes(self) -> None: """Unpair 2.4ghz remotes.""" diff --git a/flux_led/base_device.py b/flux_led/base_device.py index 8ed70bf9..cadb6134 100644 --- a/flux_led/base_device.py +++ b/flux_led/base_device.py @@ -244,6 +244,10 @@ def __init__( self._power_state_transition_complete_time: float = 0 self._last_effect_brightness: int = 100 self._device_config: LEDENETAddressableDeviceConfiguration | None = None + # Pending operating-mode number applied optimistically after a + # non-IC ``async_set_device_config`` call. Cleared on the next state + # response so the device's reported value remains authoritative. + self._pending_operating_mode_num: int | None = None self._last_message: dict[str, bytes] = {} self._unavailable_reason: str | None = None @@ -524,6 +528,10 @@ def operating_mode(self) -> str | None: return None if self._device_config: return self._device_config.operating_mode + if self._pending_operating_mode_num is not None: + return device_config.num_to_operating_mode.get( + self._pending_operating_mode_num + ) assert self.raw_state is not None return device_config.num_to_operating_mode.get(self.raw_state.mode & 0x0F) @@ -532,6 +540,8 @@ def operating_mode_num(self) -> int | None: """Return the strip mode as a string.""" if not self.model_data.device_config.operating_modes: return None + if self._pending_operating_mode_num is not None: + return self._pending_operating_mode_num assert self.raw_state is not None return self.raw_state.mode & 0x0F @@ -795,6 +805,10 @@ def _process_valid_state_response(self, rx: bytes) -> bool: self._protocol.named_raw_state(rx) ) _LOGGER.debug("%s: State: %s", self.ipaddr, raw_state) + # A fresh state response from the device is authoritative; drop any + # optimistic operating-mode override stored after a recent + # ``async_set_device_config`` call. + self._pending_operating_mode_num = None if raw_state != self.raw_state: _LOGGER.debug( diff --git a/tests/test_aio.py b/tests/test_aio.py index e749d4bc..4296b29c 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -329,6 +329,90 @@ def _updated_callback(*args, **kwargs): assert transport.mock_calls[0][1][0] == b"b\x02\x0fs" +@pytest.mark.asyncio +async def test_pending_operating_mode_after_set_device_config(mock_aio_protocol): + """Optimistic operating_mode after async_set_device_config on 0x25.""" + light = AIOWifiLedBulb("192.168.1.166") + + def _updated_callback(*args, **kwargs): + pass + + task = asyncio.create_task(light.async_setup(_updated_callback)) + await mock_aio_protocol() + # raw_state.mode = 0x05 -> RGBWW per MULTI_MODE_NUM_TO_MODE + light._aio_protocol.data_received( + b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" + ) + await task + assert light.model_num == 0x25 + assert light.operating_mode == COLOR_MODE_RGBWW + assert light.operating_mode_num == 5 + + # Switch to RGB. Device may not echo the change in its next state + # response, so cache the requested value locally until then. + await light.async_set_device_config(operating_mode=COLOR_MODE_RGB) + assert light.operating_mode == COLOR_MODE_RGB + assert light.operating_mode_num == 3 + + # A fresh state response from the device is authoritative. + light._aio_protocol.data_received( + b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" + ) + await asyncio.sleep(0) + assert light.operating_mode == COLOR_MODE_RGBWW + assert light.operating_mode_num == 5 + + +@pytest.mark.asyncio +async def test_pending_operating_mode_handles_stuck_dim(mock_aio_protocol): + """Issue #390: 0x25 sometimes reports mode=0x21 (low nibble = DIM).""" + light = AIOWifiLedBulb("192.168.1.166") + + def _updated_callback(*args, **kwargs): + pass + + task = asyncio.create_task(light.async_setup(_updated_callback)) + await mock_aio_protocol() + # Reproduces the raw state reported by the issue author: mode=0x21. + light._aio_protocol.data_received( + b"\x81\x25\x24\x61\x21\x13\x26\x26\x26\x00\x01\x00\x00\xd2" + ) + await task + assert light.model_num == 0x25 + # Low nibble of 0x21 is 1 -> DIM. + assert light.operating_mode == "DIM" + + # Caller asks the controller to switch to RGBWW; until the device + # echoes back, the property surfaces the requested value rather than + # the stale DIM reading. + await light.async_set_device_config(operating_mode=COLOR_MODE_RGBWW) + assert light.operating_mode == COLOR_MODE_RGBWW + assert light.operating_mode_num == 5 + + +@pytest.mark.asyncio +async def test_pending_operating_mode_not_set_when_omitted(mock_aio_protocol): + """Calling async_set_device_config without operating_mode is a no-op.""" + light = AIOWifiLedBulb("192.168.1.166") + + def _updated_callback(*args, **kwargs): + pass + + task = asyncio.create_task(light.async_setup(_updated_callback)) + await mock_aio_protocol() + light._aio_protocol.data_received( + b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" + ) + await task + assert light.operating_mode == COLOR_MODE_RGBWW + + # Re-applying the existing operating mode should leave the pending + # cache empty because no caller-requested value was supplied. + await light.async_set_device_config() + assert light._pending_operating_mode_num is None + assert light.operating_mode == COLOR_MODE_RGBWW + + @pytest.mark.asyncio async def test_extract_from_outer_message(mock_aio_protocol): """Test we can can extract a message wrapped with an outer message."""