diff --git a/src/libdeye/const.py b/src/libdeye/const.py index f2101ee..a092897 100644 --- a/src/libdeye/const.py +++ b/src/libdeye/const.py @@ -43,6 +43,8 @@ class DeyeProductConfig(TypedDict): water_pump: bool single_property_fog_commands: bool requires_power_in_fog_partial_updates: bool + requires_mode_in_fog_power_on_updates: bool + omit_set_humidity_in_fog_auto_updates: bool class DeyeProductPartialConfig(TypedDict, total=False): @@ -57,6 +59,8 @@ class DeyeProductPartialConfig(TypedDict, total=False): water_pump: bool single_property_fog_commands: bool requires_power_in_fog_partial_updates: bool + requires_mode_in_fog_power_on_updates: bool + omit_set_humidity_in_fog_auto_updates: bool PRODUCT_FEATURE_CONFIG: dict[str, DeyeProductPartialConfig] = { @@ -215,6 +219,8 @@ class DeyeProductPartialConfig(TypedDict, total=False): "oscillating": False, "water_pump": False, "requires_power_in_fog_partial_updates": True, + "requires_mode_in_fog_power_on_updates": True, + "omit_set_humidity_in_fog_auto_updates": True, }, "17ab051af38611e89d4c00163e0c1b21": { # W20A3 "mode": [ @@ -486,6 +492,8 @@ def get_product_feature_config(product_id: str) -> DeyeProductConfig: "water_pump": True, "single_property_fog_commands": False, "requires_power_in_fog_partial_updates": False, + "requires_mode_in_fog_power_on_updates": False, + "omit_set_humidity_in_fog_auto_updates": False, } try: return default | PRODUCT_FEATURE_CONFIG[product_id] diff --git a/src/libdeye/mqtt_client.py b/src/libdeye/mqtt_client.py index 46f9b11..fbab33c 100644 --- a/src/libdeye/mqtt_client.py +++ b/src/libdeye/mqtt_client.py @@ -11,7 +11,11 @@ import paho.mqtt.client as mqtt from .cloud_api import DeyeApiResponseFogPlatformDeviceProperties, DeyeCloudApi -from .const import QUERY_DEVICE_STATE_COMMAND_CLASSIC, get_product_feature_config +from .const import ( + QUERY_DEVICE_STATE_COMMAND_CLASSIC, + DeyeDeviceMode, + get_product_feature_config, +) from .device_command import DeyeDeviceCommand from .device_state import DeyeDeviceState @@ -308,6 +312,10 @@ async def publish_command( properties_to_publish = ( dict(properties) if properties is not None else command.to_json() ) + powering_on = properties_to_publish.get("Power") == 1 + if powering_on and feature_config["requires_mode_in_fog_power_on_updates"]: + properties_to_publish["Mode"] = int(command.mode) + if ( properties is not None and properties_to_publish @@ -317,6 +325,14 @@ async def publish_command( ): properties_to_publish["Power"] = 1 + if ( + feature_config["omit_set_humidity_in_fog_auto_updates"] + and command.mode == DeyeDeviceMode.AUTO_MODE + ): + properties_to_publish.pop("SetHumidity", None) + if not properties_to_publish: + return + if feature_config["single_property_fog_commands"]: if "Power" in properties_to_publish: await self._cloud_api.set_fog_platform_device_properties( diff --git a/tests/test_const.py b/tests/test_const.py index ff6010d..4a29c8b 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -13,8 +13,15 @@ def test_get_product_feature_config() -> None: u20a3_config = get_product_feature_config("20eae2ea268511e8829100163e0f811e") assert u20a3_config["requires_power_in_fog_partial_updates"] is True + assert u20a3_config["requires_mode_in_fog_power_on_updates"] is False + assert u20a3_config["omit_set_humidity_in_fog_auto_updates"] is False assert u20a3_config["single_property_fog_commands"] is False u20air_config = get_product_feature_config("363b686a31ee11efb7203b3cd9717242") assert u20air_config["requires_power_in_fog_partial_updates"] is False assert u20air_config["single_property_fog_commands"] is True + + v58a3_config = get_product_feature_config("2b770cba268611e89d4c00163e0c1b21") + assert v58a3_config["requires_power_in_fog_partial_updates"] is True + assert v58a3_config["requires_mode_in_fog_power_on_updates"] is True + assert v58a3_config["omit_set_humidity_in_fog_auto_updates"] is True diff --git a/tests/test_mqtt_client.py b/tests/test_mqtt_client.py index 8c5393a..d16a39b 100644 --- a/tests/test_mqtt_client.py +++ b/tests/test_mqtt_client.py @@ -16,7 +16,7 @@ DeyeApiResponseFogPlatformMqttTopics, DeyeCloudApi, ) -from libdeye.const import QUERY_DEVICE_STATE_COMMAND_CLASSIC +from libdeye.const import QUERY_DEVICE_STATE_COMMAND_CLASSIC, DeyeDeviceMode from libdeye.device_command import DeyeDeviceCommand from libdeye.device_state import DeyeDeviceState from libdeye.mqtt_client import ( @@ -556,6 +556,135 @@ async def test_publish_command_includes_power_for_u20a3_partial_updates( ) assert properties == {"SetHumidity": 70} + @pytest.mark.asyncio + async def test_publish_command_reasserts_mode_for_v58a3_power_on( + self, fog_client: DeyeFogMqttClient + ) -> None: + """Test V58A3 wakes in auto mode without publishing target humidity.""" + product_id = "2b770cba268611e89d4c00163e0c1b21" + device_id = "device456" + command = DeyeDeviceCommand( + power_switch=True, + mode=DeyeDeviceMode.AUTO_MODE, + target_humidity=25, + ) + properties = {"Power": 1, "SetHumidity": 25} + + await fog_client.publish_command( + product_id, + device_id, + command, + properties=properties, + ) + + cast( + MagicMock, fog_client._cloud_api + ).set_fog_platform_device_properties.assert_awaited_once_with( + device_id, + {"Power": 1, "Mode": int(DeyeDeviceMode.AUTO_MODE)}, + ) + assert properties == {"Power": 1, "SetHumidity": 25} + + @pytest.mark.asyncio + async def test_publish_command_preserves_v58a3_power_for_auto_partial_updates( + self, fog_client: DeyeFogMqttClient + ) -> None: + """Test V58A3 auto updates preserve power before omitting humidity.""" + product_id = "2b770cba268611e89d4c00163e0c1b21" + device_id = "device456" + command = DeyeDeviceCommand( + power_switch=True, + mode=DeyeDeviceMode.AUTO_MODE, + target_humidity=25, + ) + + await fog_client.publish_command( + product_id, + device_id, + command, + properties={"SetHumidity": 25}, + ) + + cast( + MagicMock, fog_client._cloud_api + ).set_fog_platform_device_properties.assert_awaited_once_with( + device_id, {"Power": 1} + ) + + @pytest.mark.asyncio + async def test_publish_command_omits_v58a3_auto_humidity_while_powered_off( + self, fog_client: DeyeFogMqttClient + ) -> None: + """Test V58A3 does not publish an empty auto humidity update.""" + product_id = "2b770cba268611e89d4c00163e0c1b21" + command = DeyeDeviceCommand( + power_switch=False, + mode=DeyeDeviceMode.AUTO_MODE, + target_humidity=25, + ) + + await fog_client.publish_command( + product_id, + "device456", + command, + properties={"SetHumidity": 25}, + ) + + cast( + MagicMock, fog_client._cloud_api + ).set_fog_platform_device_properties.assert_not_awaited() + + @pytest.mark.asyncio + async def test_publish_command_preserves_v58a3_power_for_manual_updates( + self, fog_client: DeyeFogMqttClient + ) -> None: + """Test V58A3 manual updates include the current powered-on state.""" + product_id = "2b770cba268611e89d4c00163e0c1b21" + command = DeyeDeviceCommand( + power_switch=True, + mode=DeyeDeviceMode.MANUAL_MODE, + target_humidity=70, + ) + + await fog_client.publish_command( + product_id, + "device456", + command, + properties={"SetHumidity": 70}, + ) + + cast( + MagicMock, fog_client._cloud_api + ).set_fog_platform_device_properties.assert_awaited_once_with( + "device456", {"SetHumidity": 70, "Power": 1} + ) + + @pytest.mark.asyncio + async def test_publish_command_does_not_apply_v58a3_quirks_to_other_products( + self, fog_client: DeyeFogMqttClient + ) -> None: + """Test generic Fog products keep their power-on auto properties unchanged.""" + product_id = "product123" + device_id = "device456" + command = DeyeDeviceCommand( + power_switch=True, + mode=DeyeDeviceMode.AUTO_MODE, + target_humidity=25, + ) + + await fog_client.publish_command( + product_id, + device_id, + command, + properties={"Power": 1, "SetHumidity": 25}, + ) + + cast( + MagicMock, fog_client._cloud_api + ).set_fog_platform_device_properties.assert_awaited_once_with( + device_id, {"Power": 1, "SetHumidity": 25} + ) + @pytest.mark.asyncio async def test_publish_command_splits_properties_for_u20air( self, fog_client: DeyeFogMqttClient