From bee6d3ed56f360bcf3d55bcc4265d85779e5d14a Mon Sep 17 00:00:00 2001 From: sususweet Date: Sun, 21 Sep 2025 14:23:27 +0800 Subject: [PATCH 1/6] feat: add new devices. --- README.rst | 3 ++- src/libdeye/const.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 8ca4387..f88f0f7 100644 --- a/README.rst +++ b/README.rst @@ -58,7 +58,8 @@ Supported devices: * DY-8138C * DY-8158C * DY-8158T - +* DY-Y16A3 +* DY-SC60Y For devices not in the above list, consider `adding your own definitions here `_. diff --git a/src/libdeye/const.py b/src/libdeye/const.py index f87e0b3..7712acb 100644 --- a/src/libdeye/const.py +++ b/src/libdeye/const.py @@ -421,6 +421,36 @@ class DeyeProductPartialConfig(TypedDict, total=False): "oscillating": False, "water_pump": False, }, + "0c44950cc8b811efaf1d0242ac480009": { # Y16A3 + "mode": [ + DeyeDeviceMode.MANUAL_MODE, + DeyeDeviceMode.CLOTHES_DRYER_MODE, + DeyeDeviceMode.AIR_PURIFIER_MODE, + DeyeDeviceMode.AUTO_MODE, + DeyeDeviceMode.SLEEP_MODE, + ], + "fan_speed": [ + DeyeFanSpeed.LOW, + DeyeFanSpeed.HIGH, + ], + "oscillating": False, + "water_pump": False, + }, + "a83dfb084b4211f08c060242ac480009": { # SC60Y + "mode": [ + DeyeDeviceMode.MANUAL_MODE, + DeyeDeviceMode.CLOTHES_DRYER_MODE, + DeyeDeviceMode.AIR_PURIFIER_MODE, + DeyeDeviceMode.AUTO_MODE, + DeyeDeviceMode.SLEEP_MODE, + ], + "fan_speed": [ + DeyeFanSpeed.LOW, + DeyeFanSpeed.HIGH, + ], + "oscillating": False, + "water_pump": False, + }, } From 4e277ab1c5ee03f44cb8bb8088a2e6e9c73b69aa Mon Sep 17 00:00:00 2001 From: sususweet Date: Wed, 20 May 2026 11:53:54 +0800 Subject: [PATCH 2/6] fix: Send only changed Fog device properties via separated HA state. Fix #57. Fog platform property updates now diff desired state against reported device state instead of sending the full command payload. HA coordinator keeps reported_state (from MQTT/poll) and an independent mutable state copy for entity writes, so in-place UI updates no longer invalidate the diff baseline. Add DeyeDeviceState.copy() and DeyeDeviceCommand.to_json_diff(). DeyeFogMqttClient.publish_command() accepts an optional properties dict; HA builds the diff and syncs reported_state after publish. --- src/libdeye/const.py | 2 ++ src/libdeye/device_command.py | 19 +++++++++++++- src/libdeye/device_state.py | 6 +++++ src/libdeye/mqtt_client.py | 20 +++++++++++--- tests/test_device_command.py | 49 +++++++++++++++++++++++++++++++++++ tests/test_device_state.py | 10 +++++++ tests/test_mqtt_client.py | 32 ++++++++++++++++++----- 7 files changed, 127 insertions(+), 11 deletions(-) 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..ebccc43 100644 --- a/tests/test_device_command.py +++ b/tests/test_device_command.py @@ -149,6 +149,55 @@ 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 libdeye.cloud_api import DeyeApiResponseFogPlatformDeviceProperties + from libdeye.device_state import DeyeDeviceState + from typing import cast + + 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}) From 848065350aaa18434b4cb2c7b69ba985e8cd726b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 03:55:59 +0000 Subject: [PATCH 3/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_device_command.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_device_command.py b/tests/test_device_command.py index ebccc43..7d21b0e 100644 --- a/tests/test_device_command.py +++ b/tests/test_device_command.py @@ -167,9 +167,10 @@ def test_deye_device_command_to_json_diff() -> None: 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 - from typing import cast state = DeyeDeviceState( cast( From e1a6d7204da20e57d784dbf71d74dcb4b7b0fade Mon Sep 17 00:00:00 2001 From: sususweet Date: Wed, 20 May 2026 11:59:38 +0800 Subject: [PATCH 4/6] refactor: type checking. --- src/libdeye/device_command.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/libdeye/device_command.py b/src/libdeye/device_command.py index 4f75ec3..120b056 100644 --- a/src/libdeye/device_command.py +++ b/src/libdeye/device_command.py @@ -1,17 +1,11 @@ """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""" From 68f33f491873a1feb7a0fa3900b7bc57a5a18552 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 04:00:15 +0000 Subject: [PATCH 5/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/libdeye/device_command.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libdeye/device_command.py b/src/libdeye/device_command.py index 120b056..452cde7 100644 --- a/src/libdeye/device_command.py +++ b/src/libdeye/device_command.py @@ -1,11 +1,13 @@ """Utilities for device command parsing""" from enum import IntFlag, auto + from .const import ( DeyeDeviceMode, DeyeFanSpeed, ) + class DeyeDeviceCommand: """A class to store the command to control the device""" From e6a2378ca2e5f37c26c97ce64107f7ec5e7d860c Mon Sep 17 00:00:00 2001 From: sususweet Date: Wed, 20 May 2026 12:05:38 +0800 Subject: [PATCH 6/6] Revert "refactor: type checking." This reverts commit e1a6d7204da20e57d784dbf71d74dcb4b7b0fade. --- src/libdeye/device_command.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/libdeye/device_command.py b/src/libdeye/device_command.py index 120b056..4f75ec3 100644 --- a/src/libdeye/device_command.py +++ b/src/libdeye/device_command.py @@ -1,11 +1,17 @@ """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"""