Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
=============
Expand Down
5 changes: 3 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/libdeye/cloud_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down
11 changes: 8 additions & 3 deletions src/libdeye/device_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions tests/test_device_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
27 changes: 27 additions & 0 deletions tests/test_mqtt_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading