diff --git a/src/libdeye/const.py b/src/libdeye/const.py index 7712acb..00b84e0 100644 --- a/src/libdeye/const.py +++ b/src/libdeye/const.py @@ -77,6 +77,8 @@ class DeyeProductPartialConfig(TypedDict, total=False): DeyeDeviceMode.AIR_PURIFIER_MODE, DeyeDeviceMode.SLEEP_MODE, ], + "min_target_humidity": 30, + "max_target_humidity": 80, "fan_speed": [], "oscillating": False, "water_pump": False, diff --git a/src/libdeye/device_command.py b/src/libdeye/device_command.py index 31af1d5..4f75ec3 100644 --- a/src/libdeye/device_command.py +++ b/src/libdeye/device_command.py @@ -1,12 +1,16 @@ """Utilities for device command parsing""" from enum import IntFlag, auto +from typing import TYPE_CHECKING from .const import ( DeyeDeviceMode, DeyeFanSpeed, ) +if TYPE_CHECKING: + from .device_state import DeyeDeviceState + class DeyeDeviceCommand: """A class to store the command to control the device""" @@ -76,7 +80,7 @@ def to_bytes(self) -> bytes: ] ) - def to_json(self) -> object: + def to_json(self) -> dict[str, int]: """Get JSON representation of this command""" return { "KeyLock": int(self.child_lock_switch), @@ -89,6 +93,19 @@ def to_json(self) -> object: "WaterPump": int(self.water_pump_switch), } + def to_json_diff( + self, baseline: "DeyeDeviceCommand | DeyeDeviceState" + ) -> dict[str, int]: + """Get JSON with only properties that differ from the baseline.""" + baseline_command = ( + baseline + if isinstance(baseline, DeyeDeviceCommand) + else baseline.to_command() + ) + command_json = self.to_json() + baseline_json = baseline_command.to_json() + return {k: v for k, v in command_json.items() if baseline_json[k] != v} + class DeyeDeviceCommandFlag(IntFlag): """Bit flags used in the command""" diff --git a/src/libdeye/device_state.py b/src/libdeye/device_state.py index d3c10e2..ea33222 100644 --- a/src/libdeye/device_state.py +++ b/src/libdeye/device_state.py @@ -99,6 +99,12 @@ def _parse_state_fog( self._coil_temperature = state.get("CurrentCoilTemperature", 27) self._exhaust_temperature = state.get("CurrentExhaustTemperature", 27) + def copy(self) -> "DeyeDeviceState": + """Return an independent copy of this state.""" + copied = DeyeDeviceState.__new__(DeyeDeviceState) + copied.__dict__.update(self.__dict__) + return copied + def to_command(self) -> DeyeDeviceCommand: """Convert to a command that can be used to let the device get into this state""" return DeyeDeviceCommand( diff --git a/src/libdeye/mqtt_client.py b/src/libdeye/mqtt_client.py index 2a88b9e..b4516f5 100644 --- a/src/libdeye/mqtt_client.py +++ b/src/libdeye/mqtt_client.py @@ -141,7 +141,11 @@ def subscribe_availability_change( @abstractmethod async def publish_command( - self, product_id: str, device_id: str, command: DeyeDeviceCommand + self, + product_id: str, + device_id: str, + command: DeyeDeviceCommand, + properties: dict[str, int] | None = None, ) -> None: """Publish commands to a device""" raise NotImplementedError @@ -196,7 +200,11 @@ def subscribe_availability_change( ) async def publish_command( - self, product_id: str, device_id: str, command: DeyeDeviceCommand | bytes + self, + product_id: str, + device_id: str, + command: DeyeDeviceCommand | bytes, + properties: dict[str, int] | None = None, ) -> None: """Publish commands to a device""" topic = f"{self._get_topic_prefix(product_id, device_id)}/command/hex" @@ -286,14 +294,18 @@ def subscribe_availability_change( ) async def publish_command( - self, product_id: str, device_id: str, command: DeyeDeviceCommand + self, + product_id: str, + device_id: str, + command: DeyeDeviceCommand, + properties: dict[str, int] | None = None, ) -> None: """ For Fog platform, commands are not published via MQTT. Instead, use the cloud API to send commands. """ await self._cloud_api.set_fog_platform_device_properties( - device_id, command.to_json() + device_id, properties if properties is not None else command.to_json() ) async def query_device_state( diff --git a/tests/test_device_command.py b/tests/test_device_command.py index 865c9a1..7d21b0e 100644 --- a/tests/test_device_command.py +++ b/tests/test_device_command.py @@ -149,6 +149,56 @@ def test_deye_device_command_to_json_all_off() -> None: assert command.to_json() == expected_json +def test_deye_device_command_to_json_diff() -> None: + """Test to_json_diff() returns only changed properties.""" + baseline = DeyeDeviceCommand( + power_switch=True, + fan_speed=DeyeFanSpeed.LOW, + target_humidity=50, + ) + command = DeyeDeviceCommand( + power_switch=True, + fan_speed=DeyeFanSpeed.HIGH, + target_humidity=50, + ) + + assert command.to_json_diff(baseline) == {"WindSpeed": int(DeyeFanSpeed.HIGH)} + + +def test_deye_device_command_to_json_diff_from_state() -> None: + """Test to_json_diff() accepts DeyeDeviceState as baseline.""" + from typing import cast + + from libdeye.cloud_api import DeyeApiResponseFogPlatformDeviceProperties + from libdeye.device_state import DeyeDeviceState + + state = DeyeDeviceState( + cast( + DeyeApiResponseFogPlatformDeviceProperties, + { + "Power": 0, + "Mode": 0, + "WindSpeed": 1, + "SetHumidity": 60, + "NegativeIon": 0, + "WaterPump": 0, + "SwingingWind": 0, + "KeyLock": 0, + "Demisting": 0, + "WaterTank": 0, + "Fan": 0, + "CurrentCoilTemperature": 25, + "CurrentExhaustTemperature": 25, + "CurrentAmbientTemperature": 25, + "CurrentEnvironmentalHumidity": 60, + }, + ) + ) + command = DeyeDeviceCommand(power_switch=True) + + assert command.to_json_diff(state) == {"Power": 1} + + def test_deye_device_command_equality() -> None: """Test equality comparison between DeyeDeviceCommand instances""" # Test equality with identical instances diff --git a/tests/test_device_state.py b/tests/test_device_state.py index afe9fc9..d077fe9 100644 --- a/tests/test_device_state.py +++ b/tests/test_device_state.py @@ -143,6 +143,16 @@ def test_deye_device_state_parse_fog() -> None: assert state.environment_humidity == 55 +def test_deye_device_state_copy() -> None: + """Test copy() returns an independent state.""" + state = DeyeDeviceState("14118100113B00000000000000000040300000000000") + copied = state.copy() + + assert copied == state + copied.power_switch = not state.power_switch + assert copied.power_switch != state.power_switch + + def test_deye_device_state_to_command_preserves_values() -> None: """Test that to_command() preserves all the values from the state""" state = DeyeDeviceState("14118100113B00000000000000000040300000000000") diff --git a/tests/test_mqtt_client.py b/tests/test_mqtt_client.py index 2d6d263..2a1e015 100644 --- a/tests/test_mqtt_client.py +++ b/tests/test_mqtt_client.py @@ -62,7 +62,11 @@ def subscribe_availability_change( return lambda: None async def publish_command( - self, product_id: str, device_id: str, command: DeyeDeviceCommand + self, + product_id: str, + device_id: str, + command: DeyeDeviceCommand, + properties: dict[str, int] | None = None, ) -> None: """Mock implementation of publish_command.""" pass @@ -495,17 +499,33 @@ def test_subscribe_availability_change(self, fog_client: DeyeFogMqttClient) -> N @pytest.mark.asyncio async def test_publish_command(self, fog_client: DeyeFogMqttClient) -> None: """Test publish_command method.""" - # Setup product_id = "product123" device_id = "device456" - command = MagicMock(spec=DeyeDeviceCommand) - command.to_json.return_value = {"Power": 1} + command = DeyeDeviceCommand(power_switch=True) - # Test publish_command await fog_client.publish_command(product_id, device_id, command) assert cast( MagicMock, fog_client._cloud_api - ).set_fog_platform_device_properties.called + ).set_fog_platform_device_properties.call_args[0] == ( + device_id, + command.to_json(), + ) + + @pytest.mark.asyncio + async def test_publish_command_with_properties( + self, fog_client: DeyeFogMqttClient + ) -> None: + """Test publish_command can send explicit property updates.""" + product_id = "product123" + device_id = "device456" + command = DeyeDeviceCommand(power_switch=True) + + await fog_client.publish_command( + product_id, + device_id, + command, + properties={"Power": 1}, + ) assert cast( MagicMock, fog_client._cloud_api ).set_fog_platform_device_properties.call_args[0] == (device_id, {"Power": 1})