From 61adaad5e01a63814ff227e5bcfb6c38ccb516b2 Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Sun, 31 May 2026 17:53:30 +0000 Subject: [PATCH] fix: cache operating_mode optimistically after async_set_device_config For non-IC controllers like 0x25 (Controller RGB/WW/CW) there is no strip-config response to resync from, so async_set_device_config returns without touching local state. If the device does not echo the new mode back in its next state response, callers see the previous value until the device decides to update raw_state.mode -- if it ever does. Cache the requested operating_mode_num locally after a non-IC async_set_device_config so the property reflects what was just requested. The cache is cleared on the next state response, keeping the device's reported value authoritative once it arrives. Refs #390 --- flux_led/aiodevice.py | 12 ++++++ flux_led/base_device.py | 14 +++++++ tests/test_aio.py | 84 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+) 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."""