Skip to content

Commit

Permalink
Add device-level capabilities tests (#166)
Browse files Browse the repository at this point in the history
* Simplified PRESET_ECO capability parsing

* Use in array syntax instead of multiple ORs

* Add testcases for device capabilities

* Tweak capability error reporting
  • Loading branch information
mill1000 authored Aug 16, 2024
1 parent 3d188ea commit e614ee5
Show file tree
Hide file tree
Showing 3 changed files with 166 additions and 29 deletions.
26 changes: 11 additions & 15 deletions msmart/device/AC/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,13 +476,13 @@ def get_value(w) -> Callable[[int], bool]: return lambda v: v == w
reader("fan_custom", get_value(1)),
],
CapabilityId.FILTER_REMIND: [
reader("filter_notice", lambda v: v == 1 or v == 2 or v == 4),
reader("filter_clean", lambda v: v == 3 or v == 4),
reader("filter_notice", lambda v: v in [1, 2, 4]),
reader("filter_clean", lambda v: v in [3, 4]),
],
CapabilityId.HUMIDITY:
[
reader("humidity_auto_set", lambda v: v == 1 or v == 2),
reader("humidity_manual_set", lambda v: v == 2 or v == 3),
reader("humidity_auto_set", lambda v: v in [1, 2]),
reader("humidity_manual_set", lambda v: v in [2, 3]),
],
CapabilityId.MODES: [
reader("heat_mode", lambda v: v in [1, 2, 4, 6, 7, 9]),
Expand All @@ -495,13 +495,10 @@ def get_value(w) -> Callable[[int], bool]: return lambda v: v == w
reader("energy_setting", lambda v: v in [3, 5]),
reader("energy_bcd", lambda v: v in [2, 3]),
],
CapabilityId.PRESET_ECO: [
reader("eco_mode", get_value(1)),
reader("eco_mode_2", get_value(2)),
],
CapabilityId.PRESET_ECO: reader("eco_mode", lambda v: v in [1, 2]),
CapabilityId.PRESET_FREEZE_PROTECTION: reader("freeze_protection", get_value(1)),
CapabilityId.PRESET_TURBO: [
reader("turbo_heat", lambda v: v == 1 or v == 3),
reader("turbo_heat", lambda v: v in [1, 3]),
reader("turbo_cool", lambda v: v < 2),
],
CapabilityId.RATE_SELECT: [
Expand All @@ -514,7 +511,7 @@ def get_value(w) -> Callable[[int], bool]: return lambda v: v == w
CapabilityId.SWING_LR_ANGLE: reader("swing_horizontal_angle", get_value(1)),
CapabilityId.SWING_UD_ANGLE: reader("swing_vertical_angle", get_value(1)),
CapabilityId.SWING_MODES: [
reader("swing_horizontal", lambda v: v == 1 or v == 3),
reader("swing_horizontal", lambda v: v in [1, 3]),
reader("swing_vertical", lambda v: v < 2),
],
# CapabilityId.TEMPERATURES too complex to be handled here
Expand Down Expand Up @@ -546,7 +543,7 @@ def get_value(w) -> Callable[[int], bool]: return lambda v: v == w
capability_id = CapabilityId(raw_id)
except ValueError:
_LOGGER.warning(
"Unknown capability. ID: 0x%04X, Size: %d.", raw_id, size)
"Unknown capability ID: 0x%04X, Size: %d.", raw_id, size)
# Advanced to next capability
caps = caps[3+size:]
continue
Expand Down Expand Up @@ -587,11 +584,11 @@ def apply(d, v): return {d.name: d.read(v)}
elif capability_id == CapabilityId._UNKNOWN:
# Supress warnings from unknown capability
_LOGGER.debug(
"Ignored unknown capability. ID: 0x%04X, Size: %d.", capability_id, size)
"Ignored unknown capability ID: 0x%04X, Size: %d.", capability_id, size)

else:
_LOGGER.info(
"Unsupported capability. ID: 0x%04X, Size: %d.", capability_id, size)
"Unsupported capability %r, Size: %d.", capability_id, size)

# Advanced to next capability
caps = caps[3+size:]
Expand Down Expand Up @@ -699,8 +696,7 @@ def auto_mode(self) -> bool:

@property
def eco_mode(self) -> bool:
return (self._capabilities.get("eco_mode", False)
or self._capabilities.get("eco_mode_2", False))
return self._capabilities.get("eco_mode", False)

@property
def turbo_mode(self) -> bool:
Expand Down
21 changes: 9 additions & 12 deletions msmart/device/AC/test_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,15 +272,12 @@ def _build_capability_response(cap, value) -> CapabilitiesResponse:
# e.g. eco_mode -> X == 1, eco_mode2 -> X == 2
resp = _build_capability_response(CapabilityId.PRESET_ECO, 0)
self.assertEqual(resp._capabilities["eco_mode"], False)
self.assertEqual(resp._capabilities["eco_mode_2"], False)

resp = _build_capability_response(CapabilityId.PRESET_ECO, 1)
self.assertEqual(resp._capabilities["eco_mode"], True)
self.assertEqual(resp._capabilities["eco_mode_2"], False)

resp = _build_capability_response(CapabilityId.PRESET_ECO, 2)
self.assertEqual(resp._capabilities["eco_mode"], False)
self.assertEqual(resp._capabilities["eco_mode_2"], True)
self.assertEqual(resp._capabilities["eco_mode"], True)

# Test PRESET_TURBO capability which uses 2 custom parsers.
# e.g. turbo_heat -> X == 1 or X == 3, turbo_cool -> X < 2
Expand Down Expand Up @@ -311,7 +308,7 @@ def test_capabilities(self) -> None:
resp = cast(CapabilitiesResponse, resp)

EXPECTED_RAW_CAPABILITIES = {
"eco_mode": True, "eco_mode_2": False,
"eco_mode": True,
"freeze_protection": True, "heat_mode": True,
"cool_mode": True, "dry_mode": True,
"auto_mode": True,
Expand Down Expand Up @@ -354,10 +351,10 @@ def test_capabilities_2(self) -> None:

# Check debug message is generated for ID 0x0040
self.assertRegex("\n".join(log.output),
"Ignored unknown capability. ID: 0x0040")
"Ignored unknown capability ID: 0x0040")

EXPECTED_RAW_CAPABILITIES = {
"eco_mode": True, "eco_mode_2": False, "breezeless": False,
"eco_mode": True, "breezeless": False,
"heat_mode": True, "cool_mode": True, "dry_mode": True,
"auto_mode": True, "swing_horizontal": True, "swing_vertical": True,
"energy_stats": False, "energy_setting": False, "energy_bcd": False,
Expand Down Expand Up @@ -400,7 +397,7 @@ def test_capabilities_3(self) -> None:
resp = cast(CapabilitiesResponse, resp)

EXPECTED_RAW_CAPABILITIES = {
"eco_mode": False, "eco_mode_2": True, "heat_mode": False,
"eco_mode": True, "heat_mode": False,
"cool_mode": True, "dry_mode": True, "auto_mode": True,
"swing_horizontal": False, "swing_vertical": False,
"filter_notice": True, "filter_clean": False, "turbo_heat": False,
Expand Down Expand Up @@ -439,7 +436,7 @@ def test_capabilities_4(self) -> None:
resp = cast(CapabilitiesResponse, resp)

EXPECTED_RAW_CAPABILITIES = {
"eco_mode": False, "eco_mode_2": True, "freeze_protection": False,
"eco_mode": True, "freeze_protection": False,
"heat_mode": False, "cool_mode": True, "dry_mode": True, "auto_mode": True,
"swing_horizontal": False, "swing_vertical": True, "filter_notice": True,
"filter_clean": False, "turbo_heat": False, "turbo_cool": True,
Expand Down Expand Up @@ -485,10 +482,10 @@ def test_additional_capabilities(self) -> None:

# Check debug message is generated for ID 0x0040
self.assertRegex("\n".join(log.output),
"Ignored unknown capability. ID: 0x0040")
"Ignored unknown capability ID: 0x0040")

EXPECTED_RAW_CAPABILITIES = {
"eco_mode": True, "eco_mode_2": False,
"eco_mode": True,
"breeze_control": True,
"heat_mode": True, "cool_mode": True, "dry_mode": True, "auto_mode": True,
"swing_horizontal": True, "swing_vertical": True,
Expand Down Expand Up @@ -532,7 +529,7 @@ def test_additional_capabilities(self) -> None:
resp.merge(additional_resp)

EXPECTED_MERGED_RAW_CAPABILITIES = {
"eco_mode": True, "eco_mode_2": False,
"eco_mode": True,
"breeze_control": True,
"heat_mode": True, "cool_mode": True, "dry_mode": True, "auto_mode": True,
"swing_horizontal": True, "swing_vertical": True,
Expand Down
148 changes: 146 additions & 2 deletions msmart/device/AC/test_device.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import logging
import unittest

from .command import (EnergyUsageResponse, HumidityResponse,
PropertiesResponse, Response, StateResponse)
from .command import (CapabilitiesResponse, EnergyUsageResponse,
HumidityResponse, PropertiesResponse, Response,
StateResponse)
from .device import AirConditioner as AC
from .device import PropertyId

Expand Down Expand Up @@ -314,6 +315,149 @@ def test_humidity_response(self) -> None:
self.assertEqual(device.indoor_humidity, humidity)


class TestCapabilities(unittest.TestCase):
"""Test parsing of CapabilitiesResponse into device capabilities."""

def test_general_capabilities(self) -> None:
"""Test general device capabilities."""
# Device with numerous supported features
# https://github.com/mill1000/midea-msmart/issues/150#issuecomment-2276158338
CAPABILITIES_PAYLOAD_0 = bytes.fromhex(
"b50a12020101430001011402010115020101160201001a020101100201011f020103250207203c203c203c05400001000100")
CAPABILITIES_PAYLOAD_1 = bytes.fromhex(
"b5051e020101130201012202010019020100390001010000")

# Create a dummy device and process the response
device = AC(0, 0, 0)

# Parse capability payloads
with memoryview(CAPABILITIES_PAYLOAD_0) as payload0, memoryview(CAPABILITIES_PAYLOAD_1) as payload1:
resp0 = CapabilitiesResponse(payload0)
resp1 = CapabilitiesResponse(payload1)

resp0.merge(resp1)
device._update_capabilities(resp0)

self.assertCountEqual(device.supported_operation_modes, [AC.OperationalMode.AUTO,
AC.OperationalMode.COOL,
AC.OperationalMode.DRY,
AC.OperationalMode.FAN_ONLY,
AC.OperationalMode.HEAT,
AC.OperationalMode.SMART_DRY])

self.assertCountEqual(device.supported_swing_modes, [AC.SwingMode.OFF,
AC.SwingMode.BOTH,
AC.SwingMode.HORIZONTAL,
AC.SwingMode.VERTICAL])

self.assertEqual(device.supports_custom_fan_speed, True)
self.assertCountEqual(device.supported_fan_speeds, [AC.FanSpeed.SILENT,
AC.FanSpeed.LOW,
AC.FanSpeed.MEDIUM,
AC.FanSpeed.HIGH,
AC.FanSpeed.MAX, # Supports custom
AC.FanSpeed.AUTO,
])

self.assertEqual(device.supports_humidity, True)
self.assertEqual(device.supports_target_humidity, True)

self.assertEqual(device.supports_purifier, True)
self.assertEqual(device.supports_self_clean, True)

self.assertEqual(device.supports_eco_mode, True)
self.assertEqual(device.supports_freeze_protection_mode, True)
self.assertEqual(device.supports_turbo_mode, True)

def test_rate_select(self) -> None:
"""Test rate select device capability."""
# https://github.com/mill1000/midea-msmart/issues/148#issuecomment-2273549806
CAPABILITIES_PAYLOAD_0 = bytes.fromhex(
"b50a1202010114020101150201001e020101170201021a02010110020101250207203c203c203c0024020101480001010101")
CAPABILITIES_PAYLOAD_1 = bytes.fromhex(
"b5071f0201002c020101160201043900010151000101e3000101130201010002")

# Create a dummy device and process the response
device = AC(0, 0, 0)

# Parse capability payloads
with memoryview(CAPABILITIES_PAYLOAD_0) as payload0, memoryview(CAPABILITIES_PAYLOAD_1) as payload1:
resp0 = CapabilitiesResponse(payload0)
resp1 = CapabilitiesResponse(payload1)

resp0.merge(resp1)
device._update_capabilities(resp0)

self.assertCountEqual(device.supported_rate_selects, [AC.RateSelect.OFF,
AC.RateSelect.GEAR_75,
AC.RateSelect.GEAR_50
])

# TODO find device with 5 levels of rate select

def test_breeze_modes(self) -> None:
"""Test breeze mode capabilities."""
# "Modern" breezeless device with "breeze control" i.e. breeze away, breeze mild and breezeless.
# https://github.com/mill1000/midea-msmart/issues/150#issuecomment-2276158338
CAPABILITIES_PAYLOAD_0 = bytes.fromhex(
"b50a12020101430001011402010115020101160201001a020101100201011f020103250207203c203c203c05400001000100")
CAPABILITIES_PAYLOAD_1 = bytes.fromhex(
"b5051e020101130201012202010019020100390001010000")

# Create a dummy device and process the response
device = AC(0, 0, 0)

# Parse capability payloads
with memoryview(CAPABILITIES_PAYLOAD_0) as payload0, memoryview(CAPABILITIES_PAYLOAD_1) as payload1:
resp0 = CapabilitiesResponse(payload0)
resp1 = CapabilitiesResponse(payload1)

resp0.merge(resp1)
device._update_capabilities(resp0)

self.assertEqual(device.supports_breeze_away, True)
self.assertEqual(device.supports_breeze_mild, True)
self.assertEqual(device.supports_breezeless, True)

# Device with only breeze away
# https://github.com/mill1000/midea-msmart/issues/150#issuecomment-2259796473
CAPABILITIES_PAYLOAD_0 = bytes.fromhex(
"b50912020101180001001402010115020101160201001a020101100201011f020103250207203c203c203c050100")
CAPABILITIES_PAYLOAD_1 = bytes.fromhex(
"b5091e0201011302010122020100190201003900010142000101090001010a000101300001010000")

# Parse capability payloads
with memoryview(CAPABILITIES_PAYLOAD_0) as payload0, memoryview(CAPABILITIES_PAYLOAD_1) as payload1:
resp0 = CapabilitiesResponse(payload0)
resp1 = CapabilitiesResponse(payload1)

resp0.merge(resp1)
device._update_capabilities(resp0)

self.assertEqual(device.supports_breeze_away, True)
self.assertEqual(device.supports_breeze_mild, False)
self.assertEqual(device.supports_breezeless, False)

# "Legacy" breezeless device with only breezeless.
# https://github.com/mill1000/midea-ac-py/issues/186#issuecomment-2249023972
CAPABILITIES_PAYLOAD_0 = bytes.fromhex(
"b50912020101180001011402010115020101160201001a020101100201011f020103250207203c203c203c050100")
CAPABILITIES_PAYLOAD_1 = bytes.fromhex(
"b5041e0201011302010122020100190201000000")

# Parse capability payloads
with memoryview(CAPABILITIES_PAYLOAD_0) as payload0, memoryview(CAPABILITIES_PAYLOAD_1) as payload1:
resp0 = CapabilitiesResponse(payload0)
resp1 = CapabilitiesResponse(payload1)

resp0.merge(resp1)
device._update_capabilities(resp0)

self.assertEqual(device.supports_breeze_away, False)
self.assertEqual(device.supports_breeze_mild, False)
self.assertEqual(device.supports_breezeless, True)


class TestSetState(unittest.TestCase):
"""Test setting device state."""

Expand Down

0 comments on commit e614ee5

Please sign in to comment.