Skip to content

Commit

Permalink
Add PayloadDecodeException and DeviceInfoUnavailableException (#685)
Browse files Browse the repository at this point in the history
* Add two new exceptions: PayloadDecodeException and DeviceInfoUnavailableException

Enables better control of error cases for downstream users:

* PayloadDecodeException gets raised if the payload cannot be decoded even after all quirks are tried
* DeviceInfoUnavailable gets raised by Device.info() if decoding the miIO.info payload fails

Related: home-assistant/core#34225

* add tests
  • Loading branch information
rytilahti authored Apr 30, 2020
1 parent 95bdf27 commit b368507
Show file tree
Hide file tree
Showing 5 changed files with 98 additions and 36 deletions.
18 changes: 12 additions & 6 deletions miio/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import click

from .click_common import DeviceGroupMeta, LiteralParamType, command, format_output
from .exceptions import DeviceInfoUnavailableException, PayloadDecodeException
from .miioprotocol import MiIOProtocol

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -161,7 +162,7 @@ def raw_command(self, command, parameters):
:param str command: Command to send
:param dict parameters: Parameters to send"""
return self._protocol.send(command, parameters)
return self.send(command, parameters)

@command(
default_output=format_output(
Expand All @@ -177,7 +178,12 @@ def info(self) -> DeviceInfo:
"""Get miIO protocol information from the device.
This includes information about connected wlan network,
and hardware and software versions."""
return DeviceInfo(self._protocol.send("miIO.info"))
try:
return DeviceInfo(self.send("miIO.info"))
except PayloadDecodeException as ex:
raise DeviceInfoUnavailableException(
"Unable to request miIO.info from the device"
) from ex

def update(self, url: str, md5: str):
"""Start an OTA update."""
Expand All @@ -188,23 +194,23 @@ def update(self, url: str, md5: str):
"file_md5": md5,
"proc": "dnld install",
}
return self._protocol.send("miIO.ota", payload)[0] == "ok"
return self.send("miIO.ota", payload)[0] == "ok"

def update_progress(self) -> int:
"""Return current update progress [0-100]."""
return self._protocol.send("miIO.get_ota_progress")[0]
return self.send("miIO.get_ota_progress")[0]

def update_state(self):
"""Return current update state."""
return UpdateState(self._protocol.send("miIO.get_ota_state")[0])
return UpdateState(self.send("miIO.get_ota_state")[0])

def configure_wifi(self, ssid, password, uid=0, extra_params=None):
"""Configure the wifi settings."""
if extra_params is None:
extra_params = {}
params = {"ssid": ssid, "passwd": password, "uid": uid, **extra_params}

return self._protocol.send("miIO.config_router", params)[0]
return self.send("miIO.config_router", params)[0]

def get_properties(self, properties, *, max_properties=None):
"""Request properties in slices based on given max_properties.
Expand Down
22 changes: 21 additions & 1 deletion miio/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,28 @@ class DeviceException(Exception):
"""Exception wrapping any communication errors with the device."""


class PayloadDecodeException(DeviceException):
"""Exception for failures in payload decoding.
This is raised when the json payload cannot be decoded,
indicating invalid response from a device.
"""


class DeviceInfoUnavailableException(DeviceException):
"""Exception raised when requesting miio.info fails.
This allows users to gracefully handle cases where the information unavailable.
This can happen, for instance, when the device has no cloud access.
"""


class DeviceError(DeviceException):
"""Exception communicating an error delivered by the target device."""
"""Exception communicating an error delivered by the target device.
The device given error code and message can be accessed with
`code` and `message` variables.
"""

def __init__(self, error):
self.code = error.get("code")
Expand Down
7 changes: 6 additions & 1 deletion miio/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

from miio.exceptions import PayloadDecodeException

_LOGGER = logging.getLogger(__name__)


Expand Down Expand Up @@ -193,7 +195,10 @@ def _decode(self, obj, context, path):
# log the error when decrypted bytes couldn't be loaded
# after trying all quirk adaptions
if i == len(decrypted_quirks) - 1:
_LOGGER.error("unable to parse json '%s': %s", decoded, ex)
_LOGGER.debug("Unable to parse json '%s': %s", decoded, ex)
raise PayloadDecodeException(
"Unable to parse message payload"
) from ex

return None

Expand Down
11 changes: 11 additions & 0 deletions miio/tests/test_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import pytest

from miio import Device
from miio.exceptions import DeviceInfoUnavailableException, PayloadDecodeException


@pytest.mark.parametrize("max_properties", [None, 1, 15])
Expand All @@ -16,3 +17,13 @@ def test_get_properties_splitting(mocker, max_properties):
if max_properties is None:
max_properties = len(properties)
assert send.call_count == math.ceil(len(properties) / max_properties)


def test_unavailable_device_info_raises(mocker):
send = mocker.patch("miio.Device.send", side_effect=PayloadDecodeException)
d = Device("127.0.0.1", "68ffffffffffffffffffffffffffffff")

with pytest.raises(DeviceInfoUnavailableException):
d.info()

assert send.call_count == 1
76 changes: 48 additions & 28 deletions miio/tests/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import pytest

from miio.exceptions import DeviceError, RecoverableError
from miio.exceptions import DeviceError, PayloadDecodeException, RecoverableError

from .. import Utils
from ..miioprotocol import MiIOProtocol
Expand All @@ -17,6 +17,28 @@ def proto() -> MiIOProtocol:
return MiIOProtocol()


@pytest.fixture
def token() -> bytes:
return bytes.fromhex(32 * "0")


def build_msg(data, token):
encrypted_data = Utils.encrypt(data, token)

# header
magic = binascii.unhexlify(b"2131")
length = (32 + len(encrypted_data)).to_bytes(2, byteorder="big")
unknown = binascii.unhexlify(b"00000000")
did = binascii.unhexlify(b"01234567")
epoch = binascii.unhexlify(b"00000000")

checksum = Utils.md5(
magic + length + unknown + did + epoch + token + encrypted_data
)

return magic + length + unknown + did + epoch + checksum + encrypted_data


def test_incrementing_id(proto):
old_id = proto.raw_id
proto._create_request("dummycmd", "dummy")
Expand Down Expand Up @@ -62,18 +84,16 @@ def test_device_error_handling(proto: MiIOProtocol):
proto._handle_error({"code": 1234})


def test_non_bytes_payload():
def test_non_bytes_payload(token):
payload = "hello world"
valid_token = 32 * b"0"
with pytest.raises(TypeError):
Utils.encrypt(payload, valid_token)
Utils.encrypt(payload, token)
with pytest.raises(TypeError):
Utils.decrypt(payload, valid_token)
Utils.decrypt(payload, token)


def test_encrypt():
def test_encrypt(token):
payload = b"hello world"
token = bytes.fromhex(32 * "0")

encrypted = Utils.encrypt(payload, token)
decrypted = Utils.decrypt(encrypted, token)
Expand All @@ -95,46 +115,46 @@ def test_invalid_token():
Utils.decrypt(payload, wrong_length)


def test_decode_json_payload():
token = bytes.fromhex(32 * "0")
def test_decode_json_payload(token):
ctx = {"token": token}

def build_msg(data):
encrypted_data = Utils.encrypt(data, token)

# header
magic = binascii.unhexlify(b"2131")
length = (32 + len(encrypted_data)).to_bytes(2, byteorder="big")
unknown = binascii.unhexlify(b"00000000")
did = binascii.unhexlify(b"01234567")
epoch = binascii.unhexlify(b"00000000")

checksum = Utils.md5(
magic + length + unknown + did + epoch + token + encrypted_data
)

return magic + length + unknown + did + epoch + checksum + encrypted_data

# can parse message with valid json
serialized_msg = build_msg(b'{"id": 123456}')
serialized_msg = build_msg(b'{"id": 123456}', token)
parsed_msg = Message.parse(serialized_msg, **ctx)
assert parsed_msg.data.value
assert isinstance(parsed_msg.data.value, dict)
assert parsed_msg.data.value["id"] == 123456


def test_decode_json_quirk_powerstrip(token):
ctx = {"token": token}

# can parse message with invalid json for edge case powerstrip
# when not connected to cloud
serialized_msg = build_msg(b'{"id": 123456,,"otu_stat":0}')
serialized_msg = build_msg(b'{"id": 123456,,"otu_stat":0}', token)
parsed_msg = Message.parse(serialized_msg, **ctx)
assert parsed_msg.data.value
assert isinstance(parsed_msg.data.value, dict)
assert parsed_msg.data.value["id"] == 123456
assert parsed_msg.data.value["otu_stat"] == 0


def test_decode_json_quirk_cloud(token):
ctx = {"token": token}

# can parse message with invalid json for edge case xiaomi cloud
# reply to _sync.batch_gen_room_up_url
serialized_msg = build_msg(b'{"id": 123456}\x00k')
serialized_msg = build_msg(b'{"id": 123456}\x00k', token)
parsed_msg = Message.parse(serialized_msg, **ctx)
assert parsed_msg.data.value
assert isinstance(parsed_msg.data.value, dict)
assert parsed_msg.data.value["id"] == 123456


def test_decode_json_raises_for_invalid_json(token):
ctx = {"token": token}

# make sure PayloadDecodeDexception is raised for invalid json
serialized_msg = build_msg(b'{"id": 123456,,"otu_stat":0', token)
with pytest.raises(PayloadDecodeException):
Message.parse(serialized_msg, **ctx)

0 comments on commit b368507

Please sign in to comment.