diff --git a/miio/__init__.py b/miio/__init__.py index 729253362..1b4247011 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -1,5 +1,6 @@ # flake8: noqa from miio.airconditioningcompanion import AirConditioningCompanion +from miio.airconditioningcompanion import AirConditioningCompanionV3 from miio.airfresh import AirFresh from miio.airhumidifier import AirHumidifier from miio.airpurifier import AirPurifier diff --git a/miio/airconditioningcompanion.py b/miio/airconditioningcompanion.py index 7012cca09..db246f946 100644 --- a/miio/airconditioningcompanion.py +++ b/miio/airconditioningcompanion.py @@ -9,6 +9,11 @@ _LOGGER = logging.getLogger(__name__) +MODEL_ACPARTNER_V1 = 'lumi.acpartner.v1' +MODEL_ACPARTNER_V2 = 'lumi.acpartner.v2' +MODEL_ACPARTNER_V3 = 'lumi.acpartner.v3' + +MODELS_SUPPORTED = [MODEL_ACPARTNER_V1, MODEL_ACPARTNER_V2, MODEL_ACPARTNER_V3] class AirConditioningCompanionException(DeviceException): pass @@ -83,29 +88,45 @@ 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'] - # - # AC turned on by set_power=on: - # ['010507950000257301', '011001160100002573', '807'] - # - # AC turned off by set_power=off: - # ['010507950000257301', '010001160100002573', '6'] - # ... - # ['010507950000257301', '010001160100002573', '1'] + """ + Device model: lumi.acpartner.v2 + + Response of "get_model_and_state": + ['010500978022222102', '010201190280222221', '2'] + + AC turned on by set_power=on: + ['010507950000257301', '011001160100002573', '807'] + + AC turned off by set_power=off: + ['010507950000257301', '010001160100002573', '6'] + ... + ['010507950000257301', '010001160100002573', '1'] + + Example data payload: + { 'model_and_state': ['010500978022222102', '010201190280222221', '2'], + 'power_socket': 'on' } + """ self.data = data + self.model = data['model_and_state'][0] + self.state = data['model_and_state'][1] @property def load_power(self) -> int: """Current power load of the air conditioner.""" - return int(self.data[2]) + return int(self.data['model_and_state'][2]) + + @property + def power_socket(self) -> Optional[str]: + """Current socket power state.""" + if "power_socket" in self.data and self.data["power_socket"] is not None: + return self.data["power_socket"] + + return None @property def air_condition_model(self) -> bytes: """Model of the air conditioner.""" - return bytes.fromhex(self.data[0]) + return bytes.fromhex(self.model) @property def model_format(self) -> int: @@ -153,17 +174,17 @@ def state_format(self) -> int: @property def air_condition_configuration(self) -> int: - return self.data[1][2:10] + return self.state[2:10] @property def power(self) -> str: """Current power state.""" - return 'on' if int(self.data[1][2:3]) == Power.On.value else 'off' + return 'on' if int(self.state[2:3]) == Power.On.value else 'off' @property def led(self) -> Optional[bool]: """Current LED state.""" - state = self.data[1][8:9] + state = self.state[8:9] if state == Led.On.value: return True @@ -182,7 +203,7 @@ def is_on(self) -> bool: def target_temperature(self) -> Optional[int]: """Target temperature.""" try: - return int(self.data[1][6:8], 16) + return int(self.state[6:8], 16) except TypeError: return None @@ -190,7 +211,7 @@ def target_temperature(self) -> Optional[int]: def swing_mode(self) -> Optional[SwingMode]: """Current swing mode.""" try: - mode = int(self.data[1][5:6]) + mode = int(self.state[5:6]) return SwingMode(mode) except TypeError: return None @@ -199,7 +220,7 @@ def swing_mode(self) -> Optional[SwingMode]: def fan_speed(self) -> Optional[FanSpeed]: """Current fan speed.""" try: - speed = int(self.data[1][4:5]) + speed = int(self.state[4:5]) return FanSpeed(speed) except TypeError: return None @@ -208,7 +229,7 @@ def fan_speed(self) -> Optional[FanSpeed]: def mode(self) -> Optional[OperationMode]: """Current operation mode.""" try: - mode = int(self.data[1][3:4]) + mode = int(self.state[3:4]) return OperationMode(mode) except TypeError: return None @@ -250,7 +271,18 @@ def __json__(self): class AirConditioningCompanion(Device): - """Main class representing Xiaomi Air Conditioning Companion.""" + """Main class representing Xiaomi Air Conditioning Companion V1 and V2.""" + + def __init__(self, ip: str = None, token: str = None, start_id: int = 0, + debug: int = 0, lazy_discover: bool = True, + model: str = MODEL_ACPARTNER_V2) -> None: + super().__init__(ip, token, start_id, debug, lazy_discover) + + if model in MODELS_SUPPORTED: + self.model = model + else: + self.model = MODEL_ACPARTNER_V2 + _LOGGER.error("Device model %s unsupported. Falling back to %s.", model, self.model) @command( default_output=format_output( @@ -268,7 +300,7 @@ class AirConditioningCompanion(Device): def status(self) -> AirConditioningCompanionStatus: """Return device status.""" status = self.send("get_model_and_state") - return AirConditioningCompanionStatus(status) + return AirConditioningCompanionStatus(dict(model_and_state=status)) @command( default_output=format_output("Powering the air condition on"), @@ -407,3 +439,45 @@ def send_configuration(self, model: str, power: Power, configuration = configuration + suffix return self.send_command(configuration) + + +class AirConditioningCompanionV3(AirConditioningCompanion): + def __init__(self, ip: str = None, token: str = None, start_id: int = 0, + debug: int = 0, lazy_discover: bool = True) -> None: + super().__init__(ip, token, start_id, debug, lazy_discover, + model=MODEL_ACPARTNER_V3) + + @command( + default_output=format_output("Powering socket on"), + ) + def socket_on(self): + """Socket power on.""" + return self.send("toggle_plug", ["on"]) + + @command( + default_output=format_output("Powering socket off"), + ) + def socket_off(self): + """Socket power off.""" + return self.send("toggle_plug", ["off"]) + + @command( + default_output=format_output( + "", + "Power: {result.power}\n" + "Power socket: {result.power_socket}\n" + "Load power: {result.load_power}\n" + "Air Condition model: {result.air_condition_model}\n" + "LED: {result.led}\n" + "Target temperature: {result.target_temperature} °C\n" + "Swing mode: {result.swing_mode}\n" + "Fan speed: {result.fan_speed}\n" + "Mode: {result.mode}\n" + ) + ) + def status(self) -> AirConditioningCompanionStatus: + """Return device status.""" + status = self.send("get_model_and_state") + power_socket = self.send("get_device_prop", ["lumi.0", "plug_state"]) + return AirConditioningCompanionStatus(dict( + model_and_state=status, power_socket=power_socket)) diff --git a/miio/tests/test_airconditioningcompanion.py b/miio/tests/test_airconditioningcompanion.py index eea8cdbd8..f5c6d0173 100644 --- a/miio/tests/test_airconditioningcompanion.py +++ b/miio/tests/test_airconditioningcompanion.py @@ -5,12 +5,14 @@ import pytest -from miio import AirConditioningCompanion +from miio import AirConditioningCompanion, AirConditioningCompanionV3 from miio.airconditioningcompanion import (OperationMode, FanSpeed, Power, SwingMode, Led, AirConditioningCompanionStatus, AirConditioningCompanionException, - STORAGE_SLOT_ID, ) + STORAGE_SLOT_ID, + MODEL_ACPARTNER_V3, + ) STATE_ON = ['on'] STATE_OFF = ['off'] @@ -126,9 +128,11 @@ def test_off(self): def test_status(self): self.device._reset_state() - assert repr(self.state()) == repr(AirConditioningCompanionStatus(self.device.start_state)) + assert repr(self.state()) == repr(AirConditioningCompanionStatus(dict( + model_and_state=self.device.start_state))) assert self.is_on() is False + assert self.state().power_socket is None assert self.state().load_power == 2 assert self.state().air_condition_model == \ bytes.fromhex('010500978022222102') @@ -202,3 +206,93 @@ def test_send_configuration(self): self.device.get_last_ir_played(), args['out'] ) + + +class DummyAirConditioningCompanionV3(AirConditioningCompanionV3): + def __init__(self, *args, **kwargs): + self.state = ['010507950000257301', '011001160100002573', '807'] + self.device_prop = {'lumi.0': {'plug_state': 'on'}} + self.model = MODEL_ACPARTNER_V3 + self.last_ir_played = None + + self.return_values = { + 'get_model_and_state': self._get_state, + 'get_device_prop': self._get_device_prop, + 'toggle_plug': self._toggle_plug, + } + self.start_state = self.state.copy() + self.start_device_prop = self.device_prop.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 + + def _get_device_prop(self, props): + """Return the requested data""" + return self.device_prop[props[0]][props[1]] + + def _toggle_plug(self, props): + """Toggle the lumi.0 plug state""" + self.device_prop['lumi.0']['plug_state'] = props.pop() + + +@pytest.fixture(scope="class") +def airconditioningcompanionv3(request): + request.cls.device = DummyAirConditioningCompanionV3() + # TODO add ability to test on a real device + + +@pytest.mark.usefixtures("airconditioningcompanionv3") +class TestAirConditioningCompanionV3(TestCase): + def state(self): + return self.device.status() + + def is_on(self): + return self.device.status().is_on + + def test_socket_on(self): + self.device.socket_off() # ensure off + assert self.state().power_socket == 'off' + + self.device.socket_on() + assert self.state().power_socket == 'on' + + def test_socket_off(self): + self.device.socket_on() # ensure on + assert self.state().power_socket == 'on' + + self.device.socket_off() + assert self.state().power_socket == 'off' + + def test_status(self): + self.device._reset_state() + + assert repr(self.state()) == repr(AirConditioningCompanionStatus(dict( + model_and_state=self.device.start_state, + power_socket=self.device.start_device_prop['lumi.0']['plug_state']) + )) + + assert self.is_on() is True + assert self.state().power_socket == 'on' + assert self.state().load_power == 807 + assert self.state().air_condition_model == \ + bytes.fromhex('010507950000257301') + assert self.state().model_format == 1 + assert self.state().device_type == 5 + assert self.state().air_condition_brand == 795 + assert self.state().air_condition_remote == 2573 + assert self.state().state_format == 1 + assert self.state().air_condition_configuration == '10011601' + assert self.state().target_temperature == 22 + assert self.state().swing_mode == SwingMode.Off + assert self.state().fan_speed == FanSpeed.Low + assert self.state().mode == OperationMode.Heat + assert self.state().led is True