From 151ec30ffca031602f0fa0ab4cc33265209fdfc6 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Tue, 28 Nov 2017 11:28:27 +0100 Subject: [PATCH] Xiaomi Air Conditioner Companion support (#129) * First draft of the Xiaomi Mi Home Air Conditioner Companion supported. Fixes #76. * Unused imports removed. * Unit tests added. Example response added. Refactoring. * Unneeded method removed. * Method naming improved. * Naming improved. * Unit tests fixed. * Unused import removed. --- miio/__init__.py | 1 + miio/airconditioningcompanion.py | 111 ++++++++++++++++++++ miio/tests/test_airconditioningcompanion.py | 52 +++++++++ 3 files changed, 164 insertions(+) create mode 100644 miio/airconditioningcompanion.py create mode 100644 miio/tests/test_airconditioningcompanion.py diff --git a/miio/__init__.py b/miio/__init__.py index 8350244d1..a551ebc1a 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -16,6 +16,7 @@ from miio.fan import Fan from miio.wifispeaker import WifiSpeaker from miio.airqualitymonitor import AirQualityMonitor +from miio.airconditioningcompanion import AirConditioningCompanion from miio.yeelight import Yeelight from miio.device import Device, DeviceException from miio.discovery import Discovery diff --git a/miio/airconditioningcompanion.py b/miio/airconditioningcompanion.py new file mode 100644 index 000000000..fbc91ee40 --- /dev/null +++ b/miio/airconditioningcompanion.py @@ -0,0 +1,111 @@ +from .device import Device +import enum +from typing import Optional + + +class OperationMode(enum.Enum): + Heating = 0 + Cooling = 1 + Auto = 2 + + +class FanSpeed(enum.Enum): + Low = 0 + Medium = 1 + High = 2 + Auto = 3 + + +STORAGE_SLOT_ID = 30 + + +class AirConditioningCompanionStatus: + """Container for status reports of the Xiaomi AC Companion.""" + + def __init__(self, data): + # Device model: lumi.acpartner.v2 + # + # Response of "get_model_and_state": + # ['010500978022222102', '010201190280222221', '2'] + self.data = data + + @property + def air_condition_power(self) -> str: + """Current power state of the air conditioner.""" + return str(self.data[2]) + + @property + def air_condition_model(self) -> str: + """Model of the air conditioner.""" + return str(self.data[0][0:2] + self.data[0][8:16]) + + @property + def power(self) -> str: + """Current power state.""" + return 'on' if (self.data[1][2:3] == 1) else 'off' + + @property + def is_on(self) -> bool: + """True if the device is turned on.""" + return self.power == 'on' + + @property + def temperature(self) -> int: + """Current temperature.""" + return int(self.data[1][6:8], 16) + + @property + def swing_mode(self) -> bool: + """True if swing mode is enabled.""" + return self.data[1][5:6] == '0' + + @property + def fan_speed(self) -> Optional[FanSpeed]: + """Current fan speed.""" + speed = int(self.data[1][4:5]) + if speed is not None: + return FanSpeed(speed) + + return None + + @property + def mode(self) -> Optional[OperationMode]: + """Current operation mode.""" + mode = int(self.data[1][3:4]) + if mode is not None: + return OperationMode(mode) + + return None + + +class AirConditioningCompanion(Device): + """Main class representing Xiaomi Air Conditioning Companion.""" + + def status(self) -> AirConditioningCompanionStatus: + """Return device status.""" + status = self.send("get_model_and_state", []) + return AirConditioningCompanionStatus(status) + + def learn(self): + """Learn an infrared command.""" + return self.send("start_ir_learn", [STORAGE_SLOT_ID]) + + def learn_result(self): + """Read the learned command.""" + return self.send("get_ir_learn_result", []) + + def learn_stop(self): + """Stop learning of a infrared command.""" + return self.send("end_ir_learn", [STORAGE_SLOT_ID]) + + def send_ir_code(self, command: str): + """Play a captured command. + + :param str command: Command to execute""" + return self.send("send_ir_code", [str(command)]) + + def send_command(self, command: str): + """Send a command to the air conditioner. + + :param str command: Command to execute""" + return self.send("send_cmd", [str(command)]) diff --git a/miio/tests/test_airconditioningcompanion.py b/miio/tests/test_airconditioningcompanion.py new file mode 100644 index 000000000..245b35c44 --- /dev/null +++ b/miio/tests/test_airconditioningcompanion.py @@ -0,0 +1,52 @@ +from unittest import TestCase +from miio import AirConditioningCompanion +from miio.airconditioningcompanion import OperationMode, FanSpeed +import pytest + + +class DummyAirConditioningCompanion(AirConditioningCompanion): + def __init__(self, *args, **kwargs): + self.state = ['010500978022222102', '010201190280222221', '2'] + + self.return_values = { + 'get_model_and_state': self._get_state + } + self.start_state = self.state.copy() + + def send(self, command: str, parameters=None, retry_count=3): + """Overridden send() to return values from `self.return_values`.""" + return self.return_values[command](parameters) + + def _reset_state(self): + """Revert back to the original state.""" + self.state = self.start_state.copy() + + def _get_state(self, props): + """Return the requested data""" + return self.state + + +@pytest.fixture(scope="class") +def airconditioningcompanion(request): + request.cls.device = DummyAirConditioningCompanion() + # TODO add ability to test on a real device + + +@pytest.mark.usefixtures("airconditioningcompanion") +class TestAirConditioningCompanion(TestCase): + def is_on(self): + return self.device.status().is_on + + def state(self): + return self.device.status() + + def test_status(self): + self.device._reset_state() + + assert self.is_on() is False + assert self.state().air_condition_power == '2' + assert self.state().air_condition_model == '0180222221' + assert self.state().temperature == 25 + assert self.state().swing_mode is False + assert self.state().fan_speed == FanSpeed.Low + assert self.state().mode == OperationMode.Auto