diff --git a/CHANGELOG.md b/CHANGELOG.md index ca11d7a8e..053bc52ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,65 @@ # Change Log +## [0.4.3](https://github.com/rytilahti/python-miio/tree/0.4.3) + +This is a bugfix release which provides improved compatibility. + +[Full Changelog](https://github.com/rytilahti/python-miio/compare/0.4.2...0.4.3) + +**Closed issues:** + +- unsupported device zhimi airmonitor v1 [\#393](https://github.com/rytilahti/python-miio/issues/393) +- Unsupported device found: chuangmi.ir.v2 [\#392](https://github.com/rytilahti/python-miio/issues/392) +- TypeError: not all arguments converted during string formatting [\#385](https://github.com/rytilahti/python-miio/issues/385) +- Status not worked for AirHumidifier CA1 [\#383](https://github.com/rytilahti/python-miio/issues/383) +- Xiaomi Rice Cooker Normal5: get\_prop only works if "all" properties are requested [\#380](https://github.com/rytilahti/python-miio/issues/380) +- python-construct-2.9.45 [\#374](https://github.com/rytilahti/python-miio/issues/374) + +**Merged pull requests:** + +- Update commands in manual [\#398](https://github.com/rytilahti/python-miio/pull/398) ([olskar](https://github.com/olskar)) +- Add cli interface for yeelight devices [\#397](https://github.com/rytilahti/python-miio/pull/397) ([rytilahti](https://github.com/rytilahti)) +- Add last\_clean\_details to return information from the last clean [\#395](https://github.com/rytilahti/python-miio/pull/395) ([rytilahti](https://github.com/rytilahti)) +- Add discovery of the Xiaomi Air Quality Monitor \(PM2.5\) \(Closes: \#393\) [\#394](https://github.com/rytilahti/python-miio/pull/394) ([syssi](https://github.com/syssi)) +- Add miiocli support for the Air Humidifier CA1 [\#391](https://github.com/rytilahti/python-miio/pull/391) ([syssi](https://github.com/syssi)) +- Add property LED to the Xiaomi Air Fresh [\#390](https://github.com/rytilahti/python-miio/pull/390) ([syssi](https://github.com/syssi)) +- Fix Cooker Normal5: get\_prop only works if "all" properties are requested \(Closes: \#380\) [\#389](https://github.com/rytilahti/python-miio/pull/389) ([syssi](https://github.com/syssi)) +- Improve the support of the Air Humidifier CA1 \(Closes: \#383\) [\#388](https://github.com/rytilahti/python-miio/pull/388) ([syssi](https://github.com/syssi)) + + +## [0.4.2](https://github.com/rytilahti/python-miio/tree/0.4.2) + +This release removes the version pinning for "construct" library as its API has been stabilized and we don't want to force our downstreams for our version choices. +Another notable change is dropping the "mirobo" package which has been deprecated for a very long time, and everyone using it should have had converted to use "miio" already. +Furthermore the client tools work now with click's version 7+. + +This release also changes the behavior of vacuum's `got_error` property to signal properly if an error has occured. The previous behavior was based on checking the state instead of the error number, which changed after an error to 'idle' after a short while. + +[Full Changelog](https://github.com/rytilahti/python-miio/compare/0.4.1...0.4.2) + +**Fixed bugs:** + +- Zoned cleanup start and stops imediately [\#355](https://github.com/rytilahti/python-miio/issues/355) + +**Closed issues:** + +- STATE not supported: Updating, state\_code: 14 [\#381](https://github.com/rytilahti/python-miio/issues/381) +- cant get it to work with xiaomi robot vacuum cleaner s50 [\#378](https://github.com/rytilahti/python-miio/issues/378) +- airfresh problem [\#377](https://github.com/rytilahti/python-miio/issues/377) +- get device token is 000000000000000000 [\#366](https://github.com/rytilahti/python-miio/issues/366) +- Rockrobo firmware 3.3.9\_003254 [\#358](https://github.com/rytilahti/python-miio/issues/358) +- No response from the device on Xiaomi Roborock v2 [\#349](https://github.com/rytilahti/python-miio/issues/349) +- Information : Xiaomi Aqara Smart Camera Hack [\#347](https://github.com/rytilahti/python-miio/issues/347) + +**Merged pull requests:** + +- Fix click7 compatibility [\#387](https://github.com/rytilahti/python-miio/pull/387) ([rytilahti](https://github.com/rytilahti)) +- Expand documentation for token from Android backup [\#382](https://github.com/rytilahti/python-miio/pull/382) ([sgtio](https://github.com/sgtio)) +- vacuum's got\_error: compare against error code, not against the state [\#379](https://github.com/rytilahti/python-miio/pull/379) ([rytilahti](https://github.com/rytilahti)) +- Add tqdm to requirements list [\#369](https://github.com/rytilahti/python-miio/pull/369) ([pluehne](https://github.com/pluehne)) +- Improve repr format [\#368](https://github.com/rytilahti/python-miio/pull/368) ([syssi](https://github.com/syssi)) + + ## [0.4.1](https://github.com/rytilahti/python-miio/tree/0.4.1) This release provides support for some new devices, improved support of existing devices and various fixes. diff --git a/MANIFEST.in b/MANIFEST.in index eab730a63..cda9b38ba 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,10 @@ -include miio/data/* +include *.md +include *.txt +include .flake8.ini +include LICENSE +include tox.ini +recursive-include docs *.py +recursive-include docs *.rst +recursive-include docs Makefile +recursive-include miio *.json +recursive-include miio *.py diff --git a/docs/discovery.rst b/docs/discovery.rst index cdf03c040..ee17c7bb0 100644 --- a/docs/discovery.rst +++ b/docs/discovery.rst @@ -104,14 +104,38 @@ or database from the Mi Home app. The procedure is briefly described below, but you may find the following links also useful: -- https://github.com/jghaanstra/com.xiaomi-miio/blob/master/docs/obtain\_token\_mirobot\_new.md +- https://github.com/jghaanstra/com.xiaomi-miio/blob/master/docs/obtain_token.md - https://github.com/homeassistantchina/custom_components/blob/master/doc/chuang_mi_ir_remote.md Android ~~~~~~~ -To do a backup of an Android app you need to have the developer mode active, -and your device has to be accessible with ``adb``. +Start by installing the newest version of the Mi Home app from Google Play and +setting up your account. When the app asks you which server you want to use, +it's important to pick one that is also available in older versions of Mi +Home (we'll see why a bit later). U.S or china servers are OK, but the european +server is not supported by the old app. Then, set up your Xiaomi device with the +Mi Home app. + +After the setup is completed, and the device has been connected to the Wi-Fi +network of your choice, it is necessary to downgrade the Mi Home app to some +version equal or below 5.0.19. As explained `here `_ +and `here `_, newer versions +of the app do not download the token into the local database, which means that +we can't retrieve the token from the backup. You can find older versions of the +Mi Home app in `apkmirror `_. + +Download, install and start up the older version of the Mi Home app. When the +app asks which server should be used, pick the same one you used with the newer +version of the app. Then, log into your account. + +After this point, you are ready to perform the backup and extract the token. +Please note that it's possible that your device does not show under the old app. +As long as you picked the same server, it should be OK, and the token should +have been downloaded and stored into the database. + +To do a backup of an Android app you need to have the developer mode active, and +your device has to be accessible with ``adb``. .. TODO:: Add a link how to check and enable the developer mode. diff --git a/docs/vacuum.rst b/docs/vacuum.rst index aba629c8e..794692a78 100644 --- a/docs/vacuum.rst +++ b/docs/vacuum.rst @@ -118,7 +118,7 @@ Cleaning history :: - $ mirobo cleaning_history + $ mirobo cleaning-history Total clean count: 43 Clean #0: 2017-03-05 19:09:40-2017-03-05 19:09:50 (complete: False, unknown: 0) Area cleaned: 0.0 m² @@ -147,13 +147,13 @@ There are two ways to install install sound packs: :: - mirobo install_sound my_sounds.pkg + mirobo install-sound my_sounds.pkg 2. Install from an URL, in which case you need to pass the md5 hash of the file as a second parameter. :: - mirobo install_sound http://10.10.20.1:8000/my_sounds.pkg b50cfea27e52ebd5f46038ac7b9330c8 + mirobo install-sound http://10.10.20.1:8000/my_sounds.pkg b50cfea27e52ebd5f46038ac7b9330c8 `--sid` can be used to select the sound ID (SID) for the new file, using an existing SID will overwrite the old. @@ -186,7 +186,7 @@ and updating from an URL requires you to pass the md5 hash of the file. :: - mirobo update_firmware v11_003094.pkg + mirobo update-firmware v11_003094.pkg DND functionality @@ -215,14 +215,14 @@ To enable: :: - mirobo carpet_mode 1 (or any other true-value, such as 'true') + mirobo carpet-mode 1 (or any other true-value, such as 'true') To disable: :: - mirobo carpet_mode 0 + mirobo carpet-mode 0 Raw commands @@ -234,13 +234,13 @@ It is also possible to run raw commands, which can be useful :: - mirobo raw_command app_start + mirobo raw-command app_start or with parameters (same as above dnd on): :: - mirobo raw_command set_dnd_timer '[22,0,6,0]' + mirobo raw-command set_dnd_timer '[22,0,6,0]' The input is passed as it is to the device as the `params` value, so it is also possible to pass dicts. diff --git a/miio/airfresh.py b/miio/airfresh.py index ce7adf22b..8007cd3fe 100644 --- a/miio/airfresh.py +++ b/miio/airfresh.py @@ -80,6 +80,11 @@ def mode(self) -> OperationMode: """Current operation mode.""" return OperationMode(self.data["mode"]) + @property + def led(self) -> bool: + """Return True if LED is on.""" + return self.data["led"] == "on" + @property def led_brightness(self) -> Optional[LedBrightness]: """Brightness of the LED.""" @@ -137,6 +142,7 @@ def __repr__(self) -> str: "humidity=%s%%, " \ "co2=%s, " \ "mode=%s, " \ + "led=%s, " \ "led_brightness=%s, " \ "buzzer=%s, " \ "child_lock=%s, " \ @@ -152,6 +158,7 @@ def __repr__(self) -> str: self.humidity, self.co2, self.mode, + self.led, self.led_brightness, self.buzzer, self.child_lock, @@ -179,6 +186,7 @@ class AirFresh(Device): "Humidity: {result.humidity} %\n" "CO2: {result.co2} %\n" "Mode: {result.mode.value}\n" + "LED: {result.led}\n" "LED brightness: {result.led_brightness}\n" "Buzzer: {result.buzzer}\n" "Child lock: {result.child_lock}\n" @@ -237,6 +245,20 @@ def set_mode(self, mode: OperationMode): """Set mode.""" return self.send("set_mode", [mode.value]) + @command( + click.argument("led", type=bool), + default_output=format_output( + lambda led: "Turning on LED" + if led else "Turning off LED" + ) + ) + def set_led(self, led: bool): + """Turn led on/off.""" + if led: + return self.send("set_led", ['on']) + else: + return self.send("set_led", ['off']) + @command( click.argument("brightness", type=EnumType(LedBrightness, False)), default_output=format_output( diff --git a/miio/airhumidifier.py b/miio/airhumidifier.py index 72aa136e7..031d30c04 100644 --- a/miio/airhumidifier.py +++ b/miio/airhumidifier.py @@ -10,6 +10,27 @@ _LOGGER = logging.getLogger(__name__) +MODEL_HUMIDIFIER_V1 = 'zhimi.humidifier.v1' +MODEL_HUMIDIFIER_CA1 = 'zhimi.humidifier.ca1' + +AVAILABLE_PROPERTIES_COMMON = [ + 'power', + 'mode', + 'temp_dec', + 'humidity', + 'buzzer', + 'led_b', + 'child_lock', + 'limit_hum', + 'use_time', + 'hw_version', +] + +AVAILABLE_PROPERTIES = { + MODEL_HUMIDIFIER_V1: AVAILABLE_PROPERTIES_COMMON + ['trans_level', 'button_pressed'], + MODEL_HUMIDIFIER_CA1: AVAILABLE_PROPERTIES_COMMON + ['speed', 'depth', 'dry'], +} + class AirHumidifierException(DeviceException): pass @@ -96,13 +117,15 @@ def target_humidity(self) -> int: return self.data["limit_hum"] @property - def trans_level(self) -> int: + def trans_level(self) -> Optional[int]: """ The meaning of the property is unknown. The property is used to determine the strong mode is enabled on old firmware. """ - return self.data["trans_level"] + if "trans_level" in self.data and self.data["trans_level"] is not None: + return self.data["trans_level"] + return None @property def strong_mode_enabled(self) -> bool: @@ -133,12 +156,16 @@ def firmware_version_minor(self) -> int: @property def speed(self) -> Optional[int]: """Current fan speed.""" - return self.data["speed"] + if "speed" in self.data and self.data["speed"] is not None: + return self.data["speed"] + return None @property def depth(self) -> Optional[int]: """The remaining amount of water in percent.""" - return self.data["depth"] + if "depth" in self.data and self.data["depth"] is not None: + return self.data["depth"] + return None @property def dry(self) -> Optional[bool]: @@ -147,7 +174,7 @@ def dry(self) -> Optional[bool]: Return True if dry mode is on if available. """ - if self.data["dry"] is not None: + if "dry" in self.data and self.data["dry"] is not None: return self.data["dry"] == "on" return None @@ -164,7 +191,9 @@ def hardware_version(self) -> Optional[str]: @property def button_pressed(self) -> Optional[str]: """Last pressed button.""" - return self.data["button_pressed"] + if "button_pressed" in self.data and self.data["button_pressed"] is not None: + return self.data["button_pressed"] + return None def __repr__(self) -> str: s = " None: + debug: int = 0, lazy_discover: bool = True, + model: str = MODEL_HUMIDIFIER_V1) -> None: super().__init__(ip, token, start_id, debug, lazy_discover) + if model in AVAILABLE_PROPERTIES: + self.model = model + else: + self.model = MODEL_HUMIDIFIER_V1 + self.device_info = None @command( @@ -244,20 +279,26 @@ def status(self) -> AirHumidifierStatus: if self.device_info is None: self.device_info = self.info() - properties = ['power', 'mode', 'temp_dec', 'humidity', 'buzzer', - 'led_b', 'child_lock', 'limit_hum', 'trans_level', - 'speed', 'depth', 'dry', 'use_time', 'button_pressed', - 'hw_version', ] + properties = AVAILABLE_PROPERTIES[self.model] - values = self.send( - "get_prop", - properties - ) + # A single request is limited to 16 properties. Therefore the + # properties are divided into multiple requests + _props_per_request = 15 + + # The CA1 is limited to a single property per request + if self.model == MODEL_HUMIDIFIER_CA1: + _props_per_request = 1 + + _props = properties.copy() + values = [] + while _props: + values.extend(self.send("get_prop", _props[:_props_per_request])) + _props[:] = _props[_props_per_request:] properties_count = len(properties) values_count = len(values) if properties_count != values_count: - _LOGGER.debug( + _LOGGER.error( "Count (%s) of requested properties does not match the " "count (%s) of received values.", properties_count, values_count) @@ -349,3 +390,10 @@ def set_dry(self, dry: bool): return self.send("set_dry", ["on"]) else: return self.send("set_dry", ["off"]) + + +class AirHumidifierCA1(AirHumidifier): + 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_HUMIDIFIER_CA1) diff --git a/miio/click_common.py b/miio/click_common.py index 55ddf828d..2a930cc54 100644 --- a/miio/click_common.py +++ b/miio/click_common.py @@ -6,7 +6,7 @@ import sys if sys.version_info < (3, 5): print("To use this script you need python 3.5 or newer, got %s" % - sys.version_info) + (sys.version_info,)) sys.exit(1) import click import ipaddress diff --git a/miio/cooker.py b/miio/cooker.py index d4db721da..bdef7a4e4 100644 --- a/miio/cooker.py +++ b/miio/cooker.py @@ -16,8 +16,8 @@ MODEL_PRESSURE2 = 'chunmi.cooker.press2' MODEL_NORMAL1 = 'chunmi.cooker.normal1' MODEL_NORMAL2 = 'chunmi.cooker.normal2' -MODEL_NORMAL4 = 'chunmi.cooker.normal3' -MODEL_NORMAL3 = 'chunmi.cooker.normal4' +MODEL_NORMAL3 = 'chunmi.cooker.normal3' +MODEL_NORMAL4 = 'chunmi.cooker.normal4' MODEL_NORMAL5 = 'chunmi.cooker.normal5' MODEL_PRESSURE = [MODEL_PRESSURE1, MODEL_PRESSURE2] @@ -726,7 +726,13 @@ def status(self) -> CookerStatus: """Retrieve properties.""" properties = ['func', 'menu', 'stage', 'temp', 't_func', 't_precook', 't_cook', 'setting', 'delay', 'version', 'favorite', 'custom'] - values = self.send("get_prop", properties) + + """ + Some cookers doesn't support a list of properties here. Therefore "all" properties + are requested. If the property count or order changes the property list above must + be updated. + """ + values = self.send("get_prop", ['all']) properties_count = len(properties) values_count = len(values) diff --git a/miio/discovery.py b/miio/discovery.py index 957cdc02f..027401032 100644 --- a/miio/discovery.py +++ b/miio/discovery.py @@ -10,7 +10,7 @@ from . import (Device, Vacuum, ChuangmiPlug, PowerStrip, AirPurifier, AirFresh, Ceil, PhilipsBulb, PhilipsEyecare, PhilipsMoonlight, ChuangmiIr, AirHumidifier, WaterPurifier, WifiSpeaker, WifiRepeater, - Yeelight, Fan, Cooker, AirConditioningCompanion) + Yeelight, Fan, Cooker, AirConditioningCompanion, AirQualityMonitor) from .chuangmi_plug import (MODEL_CHUANGMI_PLUG_V1, MODEL_CHUANGMI_PLUG_V3, MODEL_CHUANGMI_PLUG_M1, ) @@ -41,6 +41,7 @@ "zhimi-airpurifier-v3": AirPurifier, # v3 "zhimi-airpurifier-v5": AirPurifier, # v5 "zhimi-airpurifier-v6": AirPurifier, # v6 + "zhimi-airpurifier-mc1": AirPurifier, # mc1 "chuangmi-ir-v2": ChuangmiIr, "zhimi-humidifier-v1": AirHumidifier, "zhimi-humidifier-ca1": AirHumidifier, @@ -71,6 +72,7 @@ "zhimi-fan-sa1": partial(Fan, model=MODEL_FAN_SA1), "zhimi-fan-za1": partial(Fan, model=MODEL_FAN_ZA1), "zhimi-airfresh-va2": AirFresh, + "zhimi-airmonitor-v1": AirQualityMonitor, "lumi-gateway-": lambda x: other_package_info( x, "https://github.com/Danielhiversen/PyXiaomiGateway") } # type: Dict[str, Union[Callable, Device]] diff --git a/miio/protocol.py b/miio/protocol.py index db0ddfb91..ce6003875 100644 --- a/miio/protocol.py +++ b/miio/protocol.py @@ -27,9 +27,6 @@ _LOGGER = logging.getLogger(__name__) -# needs to be maintained in sync with setup.py and requirements.txt -assert construct.version_string == "2.9.41" - class Utils: """ This class is adapted from the original xpn.py code by gst666 """ diff --git a/miio/tests/test_airfresh.py b/miio/tests/test_airfresh.py index aa200f445..5259a2129 100644 --- a/miio/tests/test_airfresh.py +++ b/miio/tests/test_airfresh.py @@ -28,7 +28,7 @@ def __init__(self, *args, **kwargs): 'filter_life': 80, 'f_hour': 3500, 'favorite_level': None, - 'led': None, + 'led': 'on', } self.return_values = { 'get_prop': self._get_state, @@ -36,6 +36,7 @@ def __init__(self, *args, **kwargs): 'set_mode': lambda x: self._set_state("mode", x), 'set_buzzer': lambda x: self._set_state("buzzer", x), 'set_child_lock': lambda x: self._set_state("child_lock", x), + 'set_led': lambda x: self._set_state("led", x), 'set_led_level': lambda x: self._set_state("led_level", x), 'reset_filter1': lambda x: ( self._set_state('f1_hour_used', [0]), @@ -90,6 +91,7 @@ def test_status(self): assert self.state().filter_hours_used == self.device.start_state["f1_hour_used"] assert self.state().use_time == self.device.start_state["use_time"] assert self.state().motor_speed == self.device.start_state["motor1_speed"] + assert self.state().led == (self.device.start_state["led"] == 'on') assert self.state().led_brightness == LedBrightness(self.device.start_state["led_level"]) assert self.state().buzzer == (self.device.start_state["buzzer"] == 'on') assert self.state().child_lock == (self.device.start_state["child_lock"] == 'on') @@ -117,6 +119,16 @@ def mode(): self.device.set_mode(OperationMode.Strong) assert mode() == OperationMode.Strong + def test_set_led(self): + def led(): + return self.device.status().led + + self.device.set_led(True) + assert led() is True + + self.device.set_led(False) + assert led() is False + def test_set_led_brightness(self): def led_brightness(): return self.device.status().led_brightness diff --git a/miio/tests/test_airhumidifier.py b/miio/tests/test_airhumidifier.py index 45bc3eb9c..a9bcfb973 100644 --- a/miio/tests/test_airhumidifier.py +++ b/miio/tests/test_airhumidifier.py @@ -4,13 +4,15 @@ from miio import AirHumidifier from miio.airhumidifier import (OperationMode, LedBrightness, - AirHumidifierStatus, AirHumidifierException, ) + AirHumidifierStatus, AirHumidifierException, + MODEL_HUMIDIFIER_V1, MODEL_HUMIDIFIER_CA1) from .dummies import DummyDevice from miio.device import DeviceInfo -class DummyAirHumidifier(DummyDevice, AirHumidifier): +class DummyAirHumidifierV1(DummyDevice, AirHumidifier): def __init__(self, *args, **kwargs): + self.model = MODEL_HUMIDIFIER_V1 self.dummy_device_info = { 'fw_ver': '1.2.9_5033', 'token': '68ffffffffffffffffffffffffffffff', @@ -45,6 +47,197 @@ def __init__(self, *args, **kwargs): 'use_time': 941100, 'button_pressed': 'led', 'hw_version': 0, + } + self.return_values = { + 'get_prop': self._get_state, + 'set_power': lambda x: self._set_state("power", x), + 'set_mode': lambda x: self._set_state("mode", x), + 'set_led_b': lambda x: self._set_state("led_b", x), + 'set_buzzer': lambda x: self._set_state("buzzer", x), + 'set_child_lock': lambda x: self._set_state("child_lock", x), + 'set_limit_hum': lambda x: self._set_state("limit_hum", x), + 'miIO.info': self._get_device_info, + } + super().__init__(args, kwargs) + + def _get_device_info(self, _): + """Return dummy device info.""" + return self.dummy_device_info + + +@pytest.fixture(scope="class") +def airhumidifierv1(request): + request.cls.device = DummyAirHumidifierV1() + # TODO add ability to test on a real device + + +@pytest.mark.usefixtures("airhumidifierv1") +class TestAirHumidifierV1(TestCase): + def is_on(self): + return self.device.status().is_on + + def state(self): + return self.device.status() + + def test_on(self): + self.device.off() # ensure off + assert self.is_on() is False + + self.device.on() + assert self.is_on() is True + + def test_off(self): + self.device.on() # ensure on + assert self.is_on() is True + + self.device.off() + assert self.is_on() is False + + def test_status(self): + self.device._reset_state() + + device_info = DeviceInfo(self.device.dummy_device_info) + + assert repr(self.state()) == repr(AirHumidifierStatus(self.device.start_state, device_info)) + + assert self.is_on() is True + assert self.state().temperature == self.device.start_state["temp_dec"] / 10.0 + assert self.state().humidity == self.device.start_state["humidity"] + assert self.state().mode == OperationMode(self.device.start_state["mode"]) + assert self.state().led_brightness == LedBrightness(self.device.start_state["led_b"]) + assert self.state().buzzer == (self.device.start_state["buzzer"] == 'on') + assert self.state().child_lock == (self.device.start_state["child_lock"] == 'on') + assert self.state().target_humidity == self.device.start_state["limit_hum"] + assert self.state().trans_level == self.device.start_state["trans_level"] + assert self.state().speed is None + assert self.state().depth is None + assert self.state().dry is None + assert self.state().use_time == self.device.start_state["use_time"] + assert self.state().hardware_version == self.device.start_state["hw_version"] + assert self.state().button_pressed == self.device.start_state["button_pressed"] + + assert self.state().firmware_version == device_info.firmware_version + assert self.state().firmware_version_major == device_info.firmware_version.rsplit('_', 1)[0] + assert self.state().firmware_version_minor == int(device_info.firmware_version.rsplit('_', 1)[1]) + assert self.state().strong_mode_enabled is False + + def test_set_mode(self): + def mode(): + return self.device.status().mode + + self.device.set_mode(OperationMode.Silent) + assert mode() == OperationMode.Silent + + self.device.set_mode(OperationMode.Medium) + assert mode() == OperationMode.Medium + + self.device.set_mode(OperationMode.High) + assert mode() == OperationMode.High + + def test_set_led_brightness(self): + def led_brightness(): + return self.device.status().led_brightness + + self.device.set_led_brightness(LedBrightness.Bright) + assert led_brightness() == LedBrightness.Bright + + self.device.set_led_brightness(LedBrightness.Dim) + assert led_brightness() == LedBrightness.Dim + + self.device.set_led_brightness(LedBrightness.Off) + assert led_brightness() == LedBrightness.Off + + def test_set_buzzer(self): + def buzzer(): + return self.device.status().buzzer + + self.device.set_buzzer(True) + assert buzzer() is True + + self.device.set_buzzer(False) + assert buzzer() is False + + def test_status_without_temperature(self): + self.device._reset_state() + self.device.state["temp_dec"] = None + + assert self.state().temperature is None + + def test_status_without_led_brightness(self): + self.device._reset_state() + self.device.state["led_b"] = None + + assert self.state().led_brightness is None + + def test_set_target_humidity(self): + def target_humidity(): + return self.device.status().target_humidity + + self.device.set_target_humidity(30) + assert target_humidity() == 30 + self.device.set_target_humidity(60) + assert target_humidity() == 60 + self.device.set_target_humidity(80) + assert target_humidity() == 80 + + with pytest.raises(AirHumidifierException): + self.device.set_target_humidity(-1) + + with pytest.raises(AirHumidifierException): + self.device.set_target_humidity(20) + + with pytest.raises(AirHumidifierException): + self.device.set_target_humidity(90) + + with pytest.raises(AirHumidifierException): + self.device.set_target_humidity(110) + + def test_set_child_lock(self): + def child_lock(): + return self.device.status().child_lock + + self.device.set_child_lock(True) + assert child_lock() is True + + self.device.set_child_lock(False) + assert child_lock() is False + + +class DummyAirHumidifierCA1(DummyDevice, AirHumidifier): + def __init__(self, *args, **kwargs): + self.model = MODEL_HUMIDIFIER_CA1 + self.dummy_device_info = { + 'fw_ver': '1.2.9_5033', + 'token': '68ffffffffffffffffffffffffffffff', + 'otu_stat': [101, 74, 5343, 0, 5327, 407], + 'mmfree': 228248, + 'netif': {'gw': '192.168.0.1', + 'localIp': '192.168.0.25', + 'mask': '255.255.255.0'}, + 'ott_stat': [0, 0, 0, 0], + 'model': 'zhimi.humidifier.v1', + 'cfg_time': 0, + 'life': 575661, + 'ap': {'rssi': -35, 'ssid': 'ap', + 'bssid': 'FF:FF:FF:FF:FF:FF'}, + 'wifi_fw_ver': 'SD878x-14.76.36.p84-702.1.0-WM', + 'hw_ver': 'MW300', + 'ot': 'otu', + 'mac': '78:11:FF:FF:FF:FF' + } + self.device_info = None + + self.state = { + 'power': 'on', + 'mode': 'medium', + 'temp_dec': 294, + 'humidity': 33, + 'buzzer': 'off', + 'led_b': 2, + 'child_lock': 'on', + 'limit_hum': 40, + 'use_time': 941100, + 'hw_version': 0, # Additional attributes of the zhimi.humidifier.ca1 'speed': 100, 'depth': 1, @@ -69,13 +262,13 @@ def _get_device_info(self, _): @pytest.fixture(scope="class") -def airhumidifier(request): - request.cls.device = DummyAirHumidifier() +def airhumidifierca1(request): + request.cls.device = DummyAirHumidifierCA1() # TODO add ability to test on a real device -@pytest.mark.usefixtures("airhumidifier") -class TestAirHumidifier(TestCase): +@pytest.mark.usefixtures("airhumidifierca1") +class TestAirHumidifierCA1(TestCase): def is_on(self): return self.device.status().is_on @@ -111,13 +304,13 @@ def test_status(self): assert self.state().buzzer == (self.device.start_state["buzzer"] == 'on') assert self.state().child_lock == (self.device.start_state["child_lock"] == 'on') assert self.state().target_humidity == self.device.start_state["limit_hum"] - assert self.state().trans_level == self.device.start_state["trans_level"] + assert self.state().trans_level is None assert self.state().speed == self.device.start_state["speed"] assert self.state().depth == self.device.start_state["depth"] assert self.state().dry == (self.device.start_state["dry"] == 'on') assert self.state().use_time == self.device.start_state["use_time"] assert self.state().hardware_version == self.device.start_state["hw_version"] - assert self.state().button_pressed == self.device.start_state["button_pressed"] + assert self.state().button_pressed is None assert self.state().firmware_version == device_info.firmware_version assert self.state().firmware_version_major == device_info.firmware_version.rsplit('_', 1)[0] @@ -214,9 +407,3 @@ def dry(): self.device.set_dry(False) assert dry() is False - - def test_status_without_dry(self): - self.device._reset_state() - self.device.state["dry"] = None - - assert self.state().dry is None diff --git a/miio/updater.py b/miio/updater.py index a22988d04..2ffe9e1df 100644 --- a/miio/updater.py +++ b/miio/updater.py @@ -78,7 +78,7 @@ def url(self, ip=None): def serve_once(self): self.server.handle_request() if getattr(self.server, "got_request"): - _LOGGER.info("Got a request, shold be downloading now.") + _LOGGER.info("Got a request, should be downloading now.") return True else: _LOGGER.error("No request was made..") diff --git a/miio/vacuum.py b/miio/vacuum.py index 52fbc3595..b1916a25a 100644 --- a/miio/vacuum.py +++ b/miio/vacuum.py @@ -6,7 +6,9 @@ import os import pathlib import time -from typing import List, Any + +from typing import List, Optional, Union, Any + import click import pytz @@ -192,17 +194,39 @@ def clean_history(self) -> CleaningSummary: """Return generic cleaning history.""" return CleaningSummary(self.send("get_clean_summary")) + @command() + def last_clean_details(self) -> CleaningDetails: + """Return details from the last cleaning.""" + last_clean_id = self.clean_history().ids.pop() + return self.clean_details(last_clean_id, return_list=False) + @command( click.argument("id_", type=int, metavar="ID"), + click.argument("return_list", type=bool, default=False) ) - def clean_details(self, id_: int) -> List[CleaningDetails]: + def clean_details(self, id_: int, return_list=True) -> Union[ + List[CleaningDetails], + Optional[CleaningDetails]]: """Return details about specific cleaning.""" details = self.send("get_clean_record", [id_]) - res = list() - for rec in details: - res.append(CleaningDetails(rec)) + if not details: + _LOGGER.warning("No cleaning record found for id %s" % id_) + return None + + if return_list: + _LOGGER.warning("This method will be returning the details " + "without wrapping them into a list in the " + "near future. The current behavior can be " + "kept by passing return_list=True and this " + "warning will be removed when the default gets " + "changed.") + return [CleaningDetails(entry) for entry in details] + + if len(details) > 1: + _LOGGER.warning("Got multiple clean details, returning the first") + res = CleaningDetails(details.pop()) return res @command() @@ -443,7 +467,7 @@ def callback(ctx, *args, id_file, **kwargs): @dg.resultcallback() @dg.device_pass - def cleanup(vac: Vacuum, **kwargs): + def cleanup(vac: Vacuum, *args, **kwargs): if vac.ip is None: # dummy Device for discovery, skip teardown return id_file = kwargs['id_file'] diff --git a/miio/vacuum_cli.py b/miio/vacuum_cli.py index d480c1af0..5156be137 100644 --- a/miio/vacuum_cli.py +++ b/miio/vacuum_cli.py @@ -74,7 +74,7 @@ def cli(ctx, ip: str, token: str, debug: int, id_file: str, nextgen: bool): @cli.resultcallback() @pass_dev -def cleanup(vac: miio.Vacuum, **kwargs): +def cleanup(vac: miio.Vacuum, *args, **kwargs): if vac.ip is None: # dummy Device for discovery, skip teardown return id_file = kwargs['id_file'] @@ -419,15 +419,15 @@ def cleaning_history(vac: miio.Vacuum): res.total_area)) click.echo() for idx, id_ in enumerate(res.ids): - for e in vac.clean_details(id_): - color = "green" if e.complete else "yellow" - click.echo(click.style( - "Clean #%s: %s-%s (complete: %s, error: %s)" % ( - idx, e.start, e.end, e.complete, e.error), - bold=True, fg=color)) - click.echo(" Area cleaned: %s m²" % e.area) - click.echo(" Duration: (%s)" % e.duration) - click.echo() + details = vac.clean_details(id_, return_list=False) + color = "green" if details.complete else "yellow" + click.echo(click.style( + "Clean #%s: %s-%s (complete: %s, error: %s)" % ( + idx, details.start, details.end, details.complete, details.error), + bold=True, fg=color)) + click.echo(" Area cleaned: %s m²" % details.area) + click.echo(" Duration: (%s)" % details.duration) + click.echo() @cli.command() diff --git a/miio/vacuumcontainers.py b/miio/vacuumcontainers.py index 50a559cec..bf4189b32 100644 --- a/miio/vacuumcontainers.py +++ b/miio/vacuumcontainers.py @@ -150,7 +150,7 @@ def is_on(self) -> bool: @property def got_error(self) -> bool: """True if an error has occured.""" - return self.state_code == 12 + return self.error_code != 0 def __repr__(self) -> str: s = " WaterPurifierStatus: 'volume', 'filter', 'usage', 'temperature', 'uv_life', 'uv_state', 'elecval_state'] - values = self.send( - "get_prop", - properties - ) + _props_per_request = 1 + _props = properties.copy() + values = [] + while _props: + values.extend(self.send("get_prop", _props[:_props_per_request])) + _props[:] = _props[_props_per_request:] properties_count = len(properties) values_count = len(values) if properties_count != values_count: - _LOGGER.debug( + _LOGGER.error( "Count (%s) of requested properties does not match the " "count (%s) of received values.", properties_count, values_count) - return WaterPurifierStatus( - defaultdict(lambda: None, zip(properties, values))) + return WaterPurifierStatus(dict(zip(properties, values))) @command( default_output=format_output("Powering on"), diff --git a/miio/yeelight.py b/miio/yeelight.py index d5c5eaa45..058ffe197 100644 --- a/miio/yeelight.py +++ b/miio/yeelight.py @@ -1,7 +1,9 @@ import warnings +import click from enum import IntEnum from typing import Tuple, Optional +from .click_common import command, format_output from .device import Device, DeviceException @@ -108,6 +110,20 @@ def __init__(self, *args, **kwargs): "for more complete support.", stacklevel=2) super().__init__(*args, **kwargs) + @command( + default_output=format_output( + "", + "Name: {result.name}\n" + "Power: {result.is_on}\n" + "Brightness: {result.brightness}\n" + "Color mode: {result.color_mode}\n" + "RGB: {result.rgb}\n" + "HSV: {result.hsv}\n" + "Temperature: {result.color_temp}\n" + "Developer mode: {result.developer_mode}\n" + "Update default on change: {result.save_state_on_change}\n" + "\n") + ) def status(self) -> YeelightStatus: """Retrieve properties.""" properties = [ @@ -130,7 +146,12 @@ def status(self) -> YeelightStatus: return YeelightStatus(dict(zip(properties, values))) - def on(self): + @command( + click.option("--transition", type=int, required=False, default=0), + click.option("--mode", type=int, required=False, default=0), + default_output=format_output("Powering on"), + ) + def on(self, transition=0, mode=0): """Power on.""" """ set_power ["on|off", "smooth", time_in_ms, mode] @@ -142,23 +163,46 @@ def on(self): 4: color flow 5: moonlight """ + if transition > 0 or mode > 0: + return self.send("set_power", ["on", "smooth", transition, mode]) return self.send("set_power", ["on"]) - def off(self): + @command( + click.option("--transition", type=int, required=False, default=0), + default_output=format_output("Powering off"), + ) + def off(self, transition=0): """Power off.""" + if transition > 0: + return self.send("set_power", ["off", "smooth", transition]) return self.send("set_power", ["off"]) - def set_brightness(self, bright): + @command( + click.argument("level", type=int), + click.option("--transition", type=int, required=False, default=0), + default_output=format_output("Setting brightness to {level}") + ) + def set_brightness(self, level, transition=0): """Set brightness.""" - if bright < 0 or bright > 100: - raise YeelightException("Invalid brightness: %s" % bright) - return self.send("set_bright", [bright]) - - def set_color_temp(self, ct): + if level < 0 or level > 100: + raise YeelightException("Invalid brightness: %s" % level) + if transition > 0: + return self.send("set_bright", [level, "smooth", transition]) + return self.send("set_bright", [level]) + + @command( + click.argument("level", type=int), + click.option("--transition", type=int, required=False, default=0), + default_output=format_output("Setting color temperature to {level}") + ) + def set_color_temp(self, level, transition=500): """Set color temp in kelvin.""" - if ct > 6500 or ct < 1700: - raise YeelightException("Invalid color temperature: %s" % ct) - return self.send("set_ct_abx", [ct, "smooth", 500]) + if level > 6500 or level < 1700: + raise YeelightException("Invalid color temperature: %s" % level) + if transition > 0: + return self.send("set_ct_abx", [level, "smooth", transition]) + else: + return self.send("set_ct_abx", [level]) def set_rgb(self, rgb): """Set color in encoded RGB.""" @@ -168,22 +212,40 @@ def set_hsv(self, hsv): """Set color in HSV.""" return self.send("set_hsv", [hsv]) + @command( + click.argument("enable", type=bool), + default_output=format_output("Setting developer mode to {enable}") + ) def set_developer_mode(self, enable: bool) -> bool: """Enable or disable the developer mode.""" return self.send("set_ps", ["cfg_lan_ctrl", str(int(enable))]) + @command( + click.argument("enable", type=bool), + default_output=format_output("Setting save state on change {enable}") + ) def set_save_state_on_change(self, enable: bool) -> bool: """Enable or disable saving the state on changes.""" return self.send("set_ps", ["cfg_save_state", str(int(enable))]) + @command( + click.argument("name", type=bool), + default_output=format_output("Setting name to {enable}") + ) def set_name(self, name: str) -> bool: """Set an internal name for the bulb.""" return self.send("set_name", [name]) + @command( + default_output=format_output("Toggling the bulb"), + ) def toggle(self): """Toggle bulb state.""" return self.send("toggle") + @command( + default_output=format_output("Setting current settings to default"), + ) def set_default(self): """Set current state as default.""" return self.send("set_default") diff --git a/mirobo/__init__.py b/mirobo/__init__.py deleted file mode 100644 index 0a8227ce4..000000000 --- a/mirobo/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# flake8: noqa -from miio import * -import warnings -warnings.simplefilter('always', DeprecationWarning) -warnings.warn("Please convert to using 'miio' package, this package will " - "be removed at some point in the future", DeprecationWarning, - stacklevel=2) diff --git a/requirements.txt b/requirements.txt index e9c649bc7..8dcf3be11 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ click cryptography pretty_cron -construct==2.9.41 +construct zeroconf attrs typing # for py3.4 support diff --git a/setup.py b/setup.py index dee676a9e..cc4173113 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def readme(): license='GPLv3', classifiers=[ - 'Development Status :: 3 - Alpha', + 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 'Programming Language :: Python :: 3.5', @@ -38,14 +38,14 @@ def readme(): keywords='xiaomi miio vacuum', - packages=["miio", "mirobo"], + packages=["miio"], include_package_data=True, python_requires='>=3.5', install_requires=[ - 'construct==2.9.41', + 'construct', 'click', 'cryptography', 'pretty_cron', diff --git a/tox.ini b/tox.ini index 3eb0665e9..58ab03564 100644 --- a/tox.ini +++ b/tox.ini @@ -46,4 +46,10 @@ source = miio branch = True omit = miio/*cli.py + miio/extract_tokens.py miio/tests/* + miio/version.py +[coverage:report] +exclude_lines = + def __repr__ +