Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AC Partner V3: Add socket support (Closes #337) #415

Merged
merged 7 commits into from
Nov 18, 2018
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
117 changes: 94 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,43 @@ 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'],
'socket_power': 'on' }
"""
self.data = data

@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 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.data['model_and_state'][0])

@property
def model_format(self) -> int:
Expand Down Expand Up @@ -153,17 +172,17 @@ def state_format(self) -> int:

@property
def air_condition_configuration(self) -> int:
return self.data[1][2:10]
return self.data['model_and_state'][1][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.data['model_and_state'][1][2:3]) == Power.On.value else 'off'

@property
def led(self) -> Optional[bool]:
"""Current LED state."""
state = self.data[1][8:9]
state = self.data['model_and_state'][1][8:9]
if state == Led.On.value:
return True

Expand All @@ -182,15 +201,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.data['model_and_state'][1][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.data['model_and_state'][1][5:6])
return SwingMode(mode)
except TypeError:
return None
Expand All @@ -199,7 +218,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.data['model_and_state'][1][4:5])
return FanSpeed(speed)
except TypeError:
return None
Expand All @@ -208,7 +227,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.data['model_and_state'][1][3:4])
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering all of these access the same structure, would it make sense to have a variable for it? Like self.state = self.data['model_and_state'][1]?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point!

return OperationMode(mode)
except TypeError:
return None
Expand Down Expand Up @@ -250,7 +269,17 @@ 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
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This defaults to v2 if an unknown model is given? Maybe add a warning logging for that case.


@command(
default_output=format_output(
Expand All @@ -268,7 +297,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 +436,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))
95 changes: 92 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,7 +128,7 @@ 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)))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (120 > 100 characters)


assert self.is_on() is False
assert self.state().load_power == 2
Expand Down Expand Up @@ -202,3 +204,90 @@ def test_send_configuration(self):
self.device.get_last_ir_played(),
args['out']
)


class DummyAirConditioningCompanionV3(AirConditioningCompanionV3):
def __init__(self, *args, **kwargs):
self.state = ['010500978022222102', '01020119A280222221', '2']
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'])))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (188 > 100 characters)


assert self.is_on() is False
assert self.state().power_socket == 'on'
assert self.state().load_power == 2
assert self.state().air_condition_model == \
bytes.fromhex('010500978022222102')
assert self.state().model_format == 1
assert self.state().device_type == 5
assert self.state().air_condition_brand == 97
assert self.state().air_condition_remote == 80222221
assert self.state().state_format == 2
assert self.state().air_condition_configuration == '020119A2'
assert self.state().target_temperature == 25
assert self.state().swing_mode == SwingMode.Off
assert self.state().fan_speed == FanSpeed.Low
assert self.state().mode == OperationMode.Auto
assert self.state().led is False