Skip to content

Commit

Permalink
AC Partner V3: Add socket support (Closes #337) (#415)
Browse files Browse the repository at this point in the history
  • Loading branch information
syssi authored Nov 18, 2018
1 parent 34c0223 commit 13bad32
Show file tree
Hide file tree
Showing 3 changed files with 195 additions and 26 deletions.
1 change: 1 addition & 0 deletions miio/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
120 changes: 97 additions & 23 deletions miio/airconditioningcompanion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -182,15 +203,15 @@ 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

@property
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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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"),
Expand Down Expand Up @@ -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))
100 changes: 97 additions & 3 deletions miio/tests/test_airconditioningcompanion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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

0 comments on commit 13bad32

Please sign in to comment.