Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions flux_led/aiodevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
14 changes: 14 additions & 0 deletions flux_led/base_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand All @@ -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

Expand Down Expand Up @@ -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(
Expand Down
84 changes: 84 additions & 0 deletions tests/test_aio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down