Skip to content

Commit

Permalink
Air Conditioning Companion: Rewrite a captured command before replay (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
syssi authored May 19, 2018
1 parent 551687b commit 395fcb6
Show file tree
Hide file tree
Showing 3 changed files with 215 additions and 52 deletions.
58 changes: 45 additions & 13 deletions miio/airconditioningcompanion.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
import click

from .click_common import command, format_output, EnumType
from .device import Device
from .device import Device, DeviceException

_LOGGER = logging.getLogger(__name__)


class AirConditioningCompanionException(DeviceException):
pass


class OperationMode(enum.Enum):
Heat = 0
Cool = 1
Expand Down Expand Up @@ -99,19 +103,19 @@ def load_power(self) -> int:
return int(self.data[2])

@property
def air_condition_model(self) -> str:
def air_condition_model(self) -> bytes:
"""Model of the air conditioner."""
return self.data[0]
return bytes.fromhex(self.data[0])

@property
def model_format(self) -> int:
"""Version number of the model format."""
return int(self.air_condition_model[0:2])
return self.air_condition_model[0]

@property
def device_type(self) -> int:
"""Device type identifier."""
return int(self.air_condition_model[2:4])
return self.air_condition_model[1]

@property
def air_condition_brand(self) -> int:
Expand All @@ -120,7 +124,7 @@ def air_condition_brand(self) -> int:
Known brand ids (int) are 0182, 0097, 0037, 0202, 02782, 0197, 0192.
"""
return int(self.air_condition_model[4:8])
return int(self.air_condition_model[2:4].hex())

@property
def air_condition_remote(self) -> int:
Expand All @@ -136,7 +140,7 @@ def air_condition_remote(self) -> int:
80666661 (brand: 192)
"""
return int(self.air_condition_model[8:16])
return int(self.air_condition_model[4:8].hex())

@property
def state_format(self) -> int:
Expand All @@ -145,7 +149,7 @@ def state_format(self) -> int:
Known values (int) are: 01, 02, 03
"""
return int(self.air_condition_model[16:18])
return int(self.air_condition_model[8])

@property
def air_condition_configuration(self) -> int:
Expand Down Expand Up @@ -227,7 +231,7 @@ def __repr__(self) -> str:
"mode=%s>" % \
(self.power,
self.load_power,
self.air_condition_model,
self.air_condition_model.hex(),
self.model_format,
self.device_type,
self.air_condition_brand,
Expand Down Expand Up @@ -306,14 +310,42 @@ def learn_stop(self, slot: int=STORAGE_SLOT_ID):
return self.send("end_ir_learn", [slot])

@command(
click.argument("command", type=str),
click.argument("model", type=str),
click.argument("code", type=str),
default_output=format_output("Sending the supplied infrared command")
)
def send_ir_code(self, command: str):
def send_ir_code(self, model: str, code: str, slot: int=0):
"""Play a captured command.
:param str command: Command to execute"""
return self.send("send_ir_code", [str(command)])
:param str model: Air condition model
:param str code: Command to execute
:param int slot: Unknown internal register or slot
"""
try:
model = bytes.fromhex(model)
except ValueError:
raise AirConditioningCompanionException(
"Invalid model. A hexadecimal string must be provided")

try:
code = bytes.fromhex(code)
except ValueError:
raise AirConditioningCompanionException(
"Invalid code. A hexadecimal string must be provided")

if slot < 0 or slot > 134:
raise AirConditioningCompanionException("Invalid slot: %s" % slot)

slot = bytes([121 + slot])

# FE + 0487 + 00007145 + 9470 + 1FFF + 7F + FF + 06 + 0042 + 27 + 4E + 0025002D008500AC01...
command = code[0:1] + model[2:8] + b'\x94\x70\x1F\xFF' + \
slot + b'\xFF' + code[13:16] + b'\x27'

checksum = sum(command) & 0xFF
command = command + bytes([checksum]) + code[18:]

return self.send("send_ir_code", [command.hex().upper()])

@command(
click.argument("command", type=str),
Expand Down
99 changes: 99 additions & 0 deletions miio/tests/test_airconditioningcompanion.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
{
"test_send_ir_code_ok": [
{
"in": [
"010504870000714501",
"FE00000000000000000000000006004222680025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517"
],
"out": "FE04870000714594701FFF79FF06004227480025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517"
},
{
"in": [
"010504870000714501",
"FE00000000000000000000000006004222680025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517",
1
],
"out": "FE04870000714594701FFF7AFF06004227490025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517"
},
{
"in": [
"010504870000714501",
"FE00000000000000000000000006004222680025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517",
134
],
"out": "FE04870000714594701FFFFFFF06004227CE0025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517"
}
],
"test_send_ir_code_exception": [
{
"in": [
"010504870000714501",
"FE00000000000000000000000006004222680025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517",
-1
],
"out": "FE04870000714594701FFF79FF06004227480025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517"
},
{
"in": [
"010504870000714501",
"FE00000000000000000000000006004222680025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517",
135
],
"out": "FE04870000714594701FFF79FF06004227480025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517"
},
{
"in": [
"Y",
"FE00000000000000000000000006004222680025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517",
0
],
"out": "FE04870000714594701FFF79FF06004227480025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517"
},
{
"in": [
"010504870000714501",
"Z",
0
],
"out": "FE04870000714594701FFF79FF06004227480025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517"
}
],
"test_send_configuration_ok": [
{
"in": ["010000000001072700", {"__enum__": "Power.Off"}, {"__enum__": "OperationMode.Auto"}, 22, {"__enum__": "FanSpeed.Low"}, {"__enum__": "SwingMode.On"}, {"__enum__": "Led.Off"}],
"out": "010001072701011101004000205002112000D04000207002000000A0"
},
{
"in": ["010000000001072700", {"__enum__": "Power.On"}, {"__enum__": "OperationMode.Auto"}, 22, {"__enum__": "FanSpeed.Low"}, {"__enum__": "SwingMode.On"}, {"__enum__": "Led.Off"}],
"out": "010001072712001611001906205002102000C0190620700200000090"
},
{
"in": ["010000000001072700", {"__enum__": "Power.On"}, {"__enum__": "OperationMode.Auto"}, 22, {"__enum__": "FanSpeed.High"}, {"__enum__": "SwingMode.On"}, {"__enum__": "Led.Off"}],
"out": "010001072712201611001906205002102000C0190620700200000090"
},
{
"in": ["010000000001072700", {"__enum__": "Power.On"}, {"__enum__": "OperationMode.Auto"}, 22, {"__enum__": "FanSpeed.Low"}, {"__enum__": "SwingMode.Off"}, {"__enum__": "Led.Off"}],
"out": "010001072712011611001906205002102000C0190620700200000090"
},
{
"in": ["010000000001072700", {"__enum__": "Power.Off"}, {"__enum__": "OperationMode.Auto"}, 22, {"__enum__": "FanSpeed.Low"}, {"__enum__": "SwingMode.On"}, {"__enum__": "Led.On"}],
"out": "010001072701011101004000205002112000D04000207002000000A0"
},
{
"in": ["010000000001072700", {"__enum__": "Power.Off"}, {"__enum__": "OperationMode.Auto"}, 22, {"__enum__": "FanSpeed.Low"}, {"__enum__": "SwingMode.Off"}, {"__enum__": "Led.Off"}],
"out": "010001072701011101004000205002112000D04000207002000000A0"
},
{
"in": ["010000000001072700", {"__enum__": "Power.On"}, {"__enum__": "OperationMode.Auto"}, 23, {"__enum__": "FanSpeed.Low"}, {"__enum__": "SwingMode.On"}, {"__enum__": "Led.Off"}],
"out": "010001072712001711001907205002102000D01907207002000000A0"
},
{
"in": ["010000000001072700", {"__enum__": "Power.Off"}, {"__enum__": "OperationMode.Auto"}, 22, {"__enum__": "FanSpeed.Low"}, {"__enum__": "SwingMode.On"}, {"__enum__": "Led.Off"}],
"out": "010001072701011101004000205002112000D04000207002000000A0"
},
{
"in": ["010507950000257301", {"__enum__": "Power.On"}, {"__enum__": "OperationMode.Auto"}, 22, {"__enum__": "FanSpeed.Low"}, {"__enum__": "SwingMode.On"}, {"__enum__": "Led.Off"}],
"out": "0100002573120016A1"
}
]
}
110 changes: 71 additions & 39 deletions miio/tests/test_airconditioningcompanion.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import string
import json
import os
from unittest import TestCase

import pytest
Expand All @@ -7,22 +9,53 @@
from miio.airconditioningcompanion import (OperationMode, FanSpeed, Power,
SwingMode, Led,
AirConditioningCompanionStatus,
AirConditioningCompanionException,
STORAGE_SLOT_ID, )

STATE_ON = ['on']
STATE_OFF = ['off']

PUBLIC_ENUMS = {
'OperationMode': OperationMode,
'FanSpeed': FanSpeed,
'Power': Power,
'SwingMode': SwingMode,
'Led': Led,
}


def as_enum(d):
if "__enum__" in d:
name, member = d["__enum__"].split(".")
return getattr(PUBLIC_ENUMS[name], member)
else:
return d


with open(os.path.join(os.path.dirname(__file__),
'test_airconditioningcompanion.json')) as inp:
test_data = json.load(inp, object_hook=as_enum)


class EnumEncoder(json.JSONEncoder):
def default(self, obj):
if type(obj) in PUBLIC_ENUMS.values():
return {"__enum__": str(obj)}
return json.JSONEncoder.default(self, obj)


class DummyAirConditioningCompanion(AirConditioningCompanion):
def __init__(self, *args, **kwargs):
self.state = ['010500978022222102', '01020119A280222221', '2']
self.last_ir_played = None

self.return_values = {
'get_model_and_state': self._get_state,
'start_ir_learn': lambda x: True,
'end_ir_learn': lambda x: True,
'get_ir_learn_result': lambda x: True,
'send_ir_code': lambda x: True,
'send_cmd': self._send_cmd_input_validation,
'send_ir_code': lambda x: self._send_input_validation(x),
'send_cmd': lambda x: self._send_input_validation(x),
'set_power': lambda x: self._set_power(x),
}
self.start_state = self.state.copy()
Expand All @@ -47,8 +80,19 @@ def _set_power(self, value: str):
if value == STATE_OFF:
self.state[1] = self.state[1][:2] + '0' + self.state[1][3:]

def _send_cmd_input_validation(self, props):
return all(c in string.hexdigits for c in props[0])
@staticmethod
def _hex_input_validation(payload):
return all(c in string.hexdigits for c in payload[0])

def _send_input_validation(self, payload):
if self._hex_input_validation(payload[0]):
self.last_ir_played = payload[0]
return True

return False

def get_last_ir_played(self):
return self.last_ir_played


@pytest.fixture(scope="class")
Expand Down Expand Up @@ -86,7 +130,8 @@ def test_status(self):

assert self.is_on() is False
assert self.state().load_power == 2
assert self.state().air_condition_model == '010500978022222102'
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
Expand Down Expand Up @@ -131,42 +176,29 @@ def test_learn_stop(self):
assert self.device.learn_stop() is True

def test_send_ir_code(self):
assert self.device.send_ir_code('0000000') is True
for args in test_data['test_send_ir_code_ok']:
with self.subTest():
self.device._reset_state()
self.assertTrue(self.device.send_ir_code(*args['in']))
self.assertSequenceEqual(
self.device.get_last_ir_played(),
args['out']
)

for args in test_data['test_send_ir_code_exception']:
with pytest.raises(AirConditioningCompanionException):
self.device.send_ir_code(*args['in'])

def test_send_command(self):
assert self.device.send_command('0000000') is True

def test_send_configuration(self):
def send_configuration_known_aircondition():
return self.device.send_configuration(
'010000000001072700', # best guess
Power.On,
OperationMode.Auto,
22,
FanSpeed.Low,
SwingMode.On,
Led.Off)

def send_configuration_known_aircondition_turn_off():
return self.device.send_configuration(
'010000000001072700', # best guess
Power.Off,
OperationMode.Auto,
22,
FanSpeed.Low,
SwingMode.On,
Led.Off)

def send_configuration_unknown_aircondition():
return self.device.send_configuration(
'010507950000257301',
Power.On,
OperationMode.Auto,
22,
FanSpeed.Low,
SwingMode.On,
Led.Off)

assert send_configuration_known_aircondition() is True
assert send_configuration_known_aircondition_turn_off() is True
assert send_configuration_unknown_aircondition() is True

for args in test_data['test_send_configuration_ok']:
with self.subTest():
self.device._reset_state()
self.assertTrue(self.device.send_configuration(*args['in']))
self.assertSequenceEqual(
self.device.get_last_ir_played(),
args['out']
)

0 comments on commit 395fcb6

Please sign in to comment.