diff --git a/src/libdeye/cli.py b/src/libdeye/cli.py index 03ab083..3797e83 100755 --- a/src/libdeye/cli.py +++ b/src/libdeye/cli.py @@ -212,8 +212,15 @@ async def set_device_state( if child_lock is not None: command.child_lock_switch = child_lock - # Send the command - await mqtt_client.publish_command(device_info["product_id"], device_id, command) + properties = ( + command.to_json_diff(state) if platform == DeyeIotPlatform.Fog else None + ) + await mqtt_client.publish_command( + device_info["product_id"], + device_id, + command, + properties=properties, + ) print(f"Command sent to device {device_info['device_name']} ({device_id})") diff --git a/src/libdeye/const.py b/src/libdeye/const.py index 00b84e0..0458ff9 100644 --- a/src/libdeye/const.py +++ b/src/libdeye/const.py @@ -41,6 +41,7 @@ class DeyeProductConfig(TypedDict): anion: bool oscillating: bool water_pump: bool + single_property_fog_commands: bool class DeyeProductPartialConfig(TypedDict, total=False): @@ -53,6 +54,7 @@ class DeyeProductPartialConfig(TypedDict, total=False): anion: bool oscillating: bool water_pump: bool + single_property_fog_commands: bool PRODUCT_FEATURE_CONFIG: dict[str, DeyeProductPartialConfig] = { @@ -204,6 +206,7 @@ class DeyeProductPartialConfig(TypedDict, total=False): ], "oscillating": False, "water_pump": False, + "single_property_fog_commands": True, }, "2b770cba268611e89d4c00163e0c1b21": { # V58A3 "oscillating": False, @@ -477,6 +480,7 @@ def get_product_feature_config(product_id: str) -> DeyeProductConfig: "anion": True, "oscillating": True, "water_pump": True, + "single_property_fog_commands": False, } try: return default | PRODUCT_FEATURE_CONFIG[product_id] diff --git a/src/libdeye/mqtt_client.py b/src/libdeye/mqtt_client.py index b4516f5..4d5886f 100644 --- a/src/libdeye/mqtt_client.py +++ b/src/libdeye/mqtt_client.py @@ -11,7 +11,7 @@ import paho.mqtt.client as mqtt from .cloud_api import DeyeApiResponseFogPlatformDeviceProperties, DeyeCloudApi -from .const import QUERY_DEVICE_STATE_COMMAND_CLASSIC +from .const import QUERY_DEVICE_STATE_COMMAND_CLASSIC, get_product_feature_config from .device_command import DeyeDeviceCommand from .device_state import DeyeDeviceState @@ -304,8 +304,24 @@ async def publish_command( For Fog platform, commands are not published via MQTT. Instead, use the cloud API to send commands. """ + properties_to_publish = ( + properties if properties is not None else command.to_json() + ) + if get_product_feature_config(product_id)["single_property_fog_commands"]: + if "Power" in properties_to_publish: + await self._cloud_api.set_fog_platform_device_properties( + device_id, {"Power": properties_to_publish["Power"]} + ) + for name, value in properties_to_publish.items(): + if name == "Power": + continue + await self._cloud_api.set_fog_platform_device_properties( + device_id, {name: value} + ) + return + await self._cloud_api.set_fog_platform_device_properties( - device_id, properties if properties is not None else command.to_json() + device_id, properties_to_publish ) async def query_device_state( diff --git a/tests/test_cli.py b/tests/test_cli.py index bf3d5d8..cd82b9c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -30,6 +30,7 @@ from libdeye.device_state import DeyeDeviceState from libdeye.mqtt_client import ( DeyeClassicMqttClient, + DeyeFogMqttClient, ) @@ -320,11 +321,51 @@ async def test_set_device_state() -> None: assert mock_command.child_lock_switch is False mock_mqtt_client.publish_command.assert_called_once_with( - "test_product_id", "test_device_id", mock_command + "test_product_id", + "test_device_id", + mock_command, + properties=None, ) mock_mqtt_client.disconnect.assert_called_once() +@pytest.mark.asyncio +async def test_set_fog_device_state_publishes_only_changed_properties() -> None: + """Test Fog commands publish only properties changed by the CLI invocation.""" + mock_api = AsyncMock(spec=DeyeCloudApi) + mock_api.get_device_list.return_value = [ + { + "device_name": "Test Device", + "device_id": "test_device_id", + "product_id": "test_product_id", + "platform": DeyeIotPlatform.Fog.value, + } + ] + + mock_mqtt_client = AsyncMock(spec=DeyeFogMqttClient) + mock_state = MagicMock(spec=DeyeDeviceState) + mock_command = MagicMock() + mock_state.to_command.return_value = mock_command + mock_command.to_json_diff.return_value = {"SetHumidity": 30} + mock_mqtt_client.query_device_state.return_value = mock_state + + with patch("libdeye.cli.DeyeFogMqttClient", return_value=mock_mqtt_client): + await set_device_state( + mock_api, + "test_device_id", + target_humidity=30, + ) + + mock_command.to_json_diff.assert_called_once_with(mock_state) + mock_mqtt_client.publish_command.assert_awaited_once_with( + "test_product_id", + "test_device_id", + mock_command, + properties={"SetHumidity": 30}, + ) + mock_mqtt_client.disconnect.assert_called_once() + + @pytest.mark.asyncio async def test_monitor_device() -> None: """Test monitoring device.""" diff --git a/tests/test_mqtt_client.py b/tests/test_mqtt_client.py index 2a1e015..590716f 100644 --- a/tests/test_mqtt_client.py +++ b/tests/test_mqtt_client.py @@ -3,7 +3,7 @@ import asyncio import json from typing import Any, Callable, cast -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch import paho.mqtt.client as mqtt import pytest @@ -530,6 +530,31 @@ async def test_publish_command_with_properties( MagicMock, fog_client._cloud_api ).set_fog_platform_device_properties.call_args[0] == (device_id, {"Power": 1}) + @pytest.mark.asyncio + async def test_publish_command_splits_properties_for_u20air( + self, fog_client: DeyeFogMqttClient + ) -> None: + """Test U20Air property updates are sent one per API request.""" + product_id = "363b686a31ee11efb7203b3cd9717242" + device_id = "device456" + command = DeyeDeviceCommand(power_switch=True, target_humidity=70) + + await fog_client.publish_command( + product_id, + device_id, + command, + properties={"SetHumidity": 70, "Power": 1}, + ) + + cast( + MagicMock, fog_client._cloud_api + ).set_fog_platform_device_properties.assert_has_awaits( + [ + call(device_id, {"Power": 1}), + call(device_id, {"SetHumidity": 70}), + ] + ) + @pytest.mark.asyncio async def test_query_device_state(self, fog_client: DeyeFogMqttClient) -> None: """Test query_device_state method."""