-
-
Notifications
You must be signed in to change notification settings - Fork 565
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
Changes from 3 commits
53e1be7
506d6e4
3f50068
2d5dd6d
53a3443
0678fae
06c0c5a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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,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: | ||
|
@@ -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 | ||
|
||
|
@@ -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 | ||
|
@@ -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 | ||
|
@@ -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]) | ||
return OperationMode(mode) | ||
except TypeError: | ||
return None | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
@@ -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"), | ||
|
@@ -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)) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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,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))) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
@@ -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']))) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
There was a problem hiding this comment.
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]
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point!