Skip to content
2 changes: 2 additions & 0 deletions src/libdeye/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 18 additions & 1 deletion src/libdeye/device_command.py
Original file line number Diff line number Diff line change
@@ -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"""
Expand Down Expand Up @@ -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),
Expand All @@ -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"""
Expand Down
6 changes: 6 additions & 0 deletions src/libdeye/device_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
20 changes: 16 additions & 4 deletions src/libdeye/mqtt_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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(
Expand Down
50 changes: 50 additions & 0 deletions tests/test_device_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions tests/test_device_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
32 changes: 26 additions & 6 deletions tests/test_mqtt_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})
Expand Down
Loading