From 32e5e97e3496a711d7e722afc25993092b9c543c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 08:16:51 +0000 Subject: [PATCH] fix: treat omitted Fog Demisting/WaterTank/Fan as off Device-list and MQTT Fog payloads do not always include these diagnostic keys. Required subscript access raised KeyError and prevented ha-deye-dehumidifier 3.0.0 from loading (stackia/ha-deye-dehumidifier#134). Parse them like other optional Fog flags and default to False. Co-authored-by: Stackie Jia --- CHANGELOG.rst | 3 +++ README.rst | 5 +++-- src/libdeye/cloud_api.py | 6 +++--- src/libdeye/device_state.py | 11 ++++++++--- tests/test_client.py | 15 +++++++++++++++ tests/test_device_state.py | 32 ++++++++++++++++++++++++++++++++ tests/test_mqtt_client.py | 27 +++++++++++++++++++++++++++ 7 files changed, 91 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ba28cbe..863d923 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,9 @@ Unreleased - Construct the paho-mqtt client with ``CallbackAPIVersion.VERSION2`` and update ``on_connect`` / ``on_disconnect`` to the v2 callback signatures. +- Fog GET / device-list payloads may omit ``Demisting``, ``WaterTank``, and + ``Fan``. Parse them like other optional Fog flags and default to off + instead of raising ``KeyError``. Version 3.0.2 ============= diff --git a/README.rst b/README.rst index 4f52380..ae58e67 100644 --- a/README.rst +++ b/README.rst @@ -250,8 +250,9 @@ gates anion and oscillating. Use ``get_product_feature_config`` (``uv``, ``prompt_sound``, ``screen_display``, ``timed_off``) to decide whether a product advertises them. -Fog GET payloads may omit any of those keys; a missing or invalid value -stays ``None`` and is not posted. ``ProtocolVersion == 0`` companions +Fog GET and device-list payloads may omit any Fog key. Optional controls +stay ``None`` and are not posted. Diagnostic flags ``Demisting``, +``WaterTank``, and ``Fan`` default to off. ``ProtocolVersion == 0`` companions copy cached non-null Integers for that command's official key set (display, tone, and timer stay single-key). Sleep is ``DeyeDeviceMode.SLEEP_MODE``. ``to_json_diff`` treats omitted keys as diff --git a/src/libdeye/cloud_api.py b/src/libdeye/cloud_api.py index 7cd062b..53df6c9 100644 --- a/src/libdeye/cloud_api.py +++ b/src/libdeye/cloud_api.py @@ -115,9 +115,9 @@ class DeyeApiResponseFogPlatformDeviceProperties(TypedDict): CurrentCoilTemperature: int CurrentEnvironmentalHumidity: int CurrentExhaustTemperature: int - Demisting: int + Demisting: NotRequired[int] EnvironmentalRating: int - Fan: int + Fan: NotRequired[int] KeyLock: int Mode: int NegativeIon: int @@ -137,7 +137,7 @@ class DeyeApiResponseFogPlatformDeviceProperties(TypedDict): TimedStartupTimeRemainingHours: int TimedStartupTimeRemainingMinutes: int WaterPump: int - WaterTank: int + WaterTank: NotRequired[int] WindSpeed: int fault: dict[str, int] Sleep: NotRequired[int] diff --git a/src/libdeye/device_state.py b/src/libdeye/device_state.py index 937d26b..9098f2f 100644 --- a/src/libdeye/device_state.py +++ b/src/libdeye/device_state.py @@ -31,6 +31,11 @@ def _optional_flag(value: object) -> bool | None: return bool(parsed) +def _status_flag(value: object) -> bool: + """Parse a Fog diagnostic 0/1 flag, defaulting False when omitted.""" + return _optional_flag(value) is True + + class DeyeDeviceState: """A class to store the device state.""" @@ -116,9 +121,9 @@ def _parse_state_fog( self.power_switch = _optional_flag(state.get("Power")) self.oscillating_switch = _optional_flag(state.get("SwingingWind")) self.child_lock_switch = _optional_flag(state.get("KeyLock")) - self.defrosting = bool(state["Demisting"]) - self.water_tank_full = bool(state["WaterTank"]) - self.fan_running = bool(state["Fan"]) + self.defrosting = _status_flag(state.get("Demisting")) + self.water_tank_full = _status_flag(state.get("WaterTank")) + self.fan_running = _status_flag(state.get("Fan")) self.fan_speed = DeyeFanSpeed(state.get("WindSpeed", DeyeFanSpeed.STOPPED)) self.mode = DeyeDeviceMode(state.get("Mode", DeyeDeviceMode.SLEEP_MODE)) self.target_humidity = state.get("SetHumidity", 60) diff --git a/tests/test_client.py b/tests/test_client.py index 6a9e242..9a9469c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -269,6 +269,21 @@ def test_state_from_fog_payload_and_empty_fallback() -> None: assert fog_device.reported_state.oscillating_switch is True assert empty_device.reported_state.power_switch is False + omitted = DeyeDevice( + DeyeClient(MagicMock(spec=DeyeCloudApi)), + _info(DeyeIotPlatform.Fog, payload={"Power": 1, "Mode": 0}), + ) + empty_dict = DeyeDevice( + DeyeClient(MagicMock(spec=DeyeCloudApi)), + _info(DeyeIotPlatform.Fog, payload={}), + ) + assert omitted.reported_state.power_switch is True + assert omitted.reported_state.defrosting is False + assert omitted.reported_state.water_tank_full is False + assert omitted.reported_state.fan_running is False + assert empty_dict.reported_state.defrosting is False + assert empty_dict.reported_state.power_switch is None + @pytest.mark.asyncio async def test_apply_defaults_to_current_state_command() -> None: diff --git a/tests/test_device_state.py b/tests/test_device_state.py index a39ebc2..0b2022e 100644 --- a/tests/test_device_state.py +++ b/tests/test_device_state.py @@ -233,6 +233,38 @@ def test_deye_device_state_parse_fog_optional_controls() -> None: assert omitted.to_command().to_json()["NegativeIon"] == 0 +def test_deye_device_state_fog_omitted_diagnostic_flags() -> None: + """Demisting, WaterTank, and Fan default to off when the Fog payload omits them.""" + fog_state: dict[str, object] = { + "Power": 1, + "Mode": 0, + "WindSpeed": 1, + "SetHumidity": 45, + } + state = DeyeDeviceState(cast(DeyeApiResponseFogPlatformDeviceProperties, fog_state)) + assert state.defrosting is False + assert state.water_tank_full is False + assert state.fan_running is False + assert state.power_switch is True + + empty = DeyeDeviceState(cast(DeyeApiResponseFogPlatformDeviceProperties, {})) + assert empty.defrosting is False + assert empty.water_tank_full is False + assert empty.fan_running is False + assert empty.anion_switch is None + assert empty.child_lock_switch is None + + string_flags = DeyeDeviceState( + cast( + DeyeApiResponseFogPlatformDeviceProperties, + {**fog_state, "Demisting": "1", "WaterTank": "0", "Fan": "1"}, + ) + ) + assert string_flags.defrosting is True + assert string_flags.water_tank_full is False + assert string_flags.fan_running is True + + def test_deye_device_state_copy() -> None: """Test copy() returns an independent state.""" state = DeyeDeviceState("14118100113B00000000000000000040300000000000") diff --git a/tests/test_mqtt_client.py b/tests/test_mqtt_client.py index 0447ab8..5ba2400 100644 --- a/tests/test_mqtt_client.py +++ b/tests/test_mqtt_client.py @@ -717,6 +717,33 @@ def test_subscribe_state_change_parses_thing_property( assert received[0].timed_off_hour == 3 assert fog_client._fog_last_properties["device456"]["UV"] == 1 + def test_subscribe_state_change_omitted_diagnostic_flags( + self, fog_client: DeyeFogMqttClient + ) -> None: + """thing_property snapshots without Demisting/WaterTank/Fan still parse.""" + received: list[DeyeDeviceState] = [] + with patch.object(fog_client, "_subscribe_topic") as mock_subscribe_topic: + fog_client.subscribe_state_change( + "product123", "device456", received.append + ) + on_payload = mock_subscribe_topic.call_args[0][1] + + on_payload( + { + "device_id": "device456", + "biz_code": "device_data", + "data": { + "message_type": "thing_property", + "properties": {"Power": 1, "Mode": 0}, + }, + } + ) + assert len(received) == 1 + assert received[0].power_switch is True + assert received[0].defrosting is False + assert received[0].water_tank_full is False + assert received[0].fan_running is False + def test_subscribe_availability_change(self, fog_client: DeyeFogMqttClient) -> None: """Test subscribe_availability_change method.""" callback = MagicMock()