diff --git a/CHANGELOG.md b/CHANGELOG.md index 19c12181f..71cb27b40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Change Log +## [0.5.6](https://github.com/rytilahti/python-miio/tree/0.5.6) (2021-05-05) + +[Full Changelog](https://github.com/rytilahti/python-miio/compare/0.5.5.2...0.5.6) + +**Implemented enhancements:** + +- RFC: Add a script to simplify finding supported properties for miio [\#919](https://github.com/rytilahti/python-miio/issues/919) +- Improve test\_properties output [\#1024](https://github.com/rytilahti/python-miio/pull/1024) ([rytilahti](https://github.com/rytilahti)) +- Relax zeroconf version requirement [\#1023](https://github.com/rytilahti/python-miio/pull/1023) ([rytilahti](https://github.com/rytilahti)) +- Add test\_properties command to device class [\#1014](https://github.com/rytilahti/python-miio/pull/1014) ([rytilahti](https://github.com/rytilahti)) +- Add discover command to miiocli [\#1013](https://github.com/rytilahti/python-miio/pull/1013) ([rytilahti](https://github.com/rytilahti)) +- Fix supported oscillation angles of the dmaker.fan.p9 [\#1011](https://github.com/rytilahti/python-miio/pull/1011) ([syssi](https://github.com/syssi)) +- Add additional operation mode of the deerma.humidifier.jsq1 [\#1010](https://github.com/rytilahti/python-miio/pull/1010) ([syssi](https://github.com/syssi)) +- Roborock S7: Parse history details returned as dict [\#1006](https://github.com/rytilahti/python-miio/pull/1006) ([fettlaus](https://github.com/fettlaus)) + +**Fixed bugs:** + +- zeroconf 0.29.0 which is incompatible [\#1022](https://github.com/rytilahti/python-miio/issues/1022) +- Remove superfluous decryption failure for handshake responses [\#1008](https://github.com/rytilahti/python-miio/issues/1008) +- Skip pausing on Roborock S50 [\#1005](https://github.com/rytilahti/python-miio/issues/1005) +- Roborock S7 after Firmware Update 4.1.2-0928 - KeyError [\#1004](https://github.com/rytilahti/python-miio/issues/1004) +- No air quality value when aqi is 1 [\#958](https://github.com/rytilahti/python-miio/issues/958) +- Fix exception on devices with removed lan\_ctrl [\#1028](https://github.com/rytilahti/python-miio/pull/1028) ([Kirmas](https://github.com/Kirmas)) +- Fix start bug and improve error handling in walkingpad integration [\#1017](https://github.com/rytilahti/python-miio/pull/1017) ([dewgenenny](https://github.com/dewgenenny)) +- gateway: fix zigbee lights [\#1016](https://github.com/rytilahti/python-miio/pull/1016) ([starkillerOG](https://github.com/starkillerOG)) +- Silence unable to decrypt warning for handshake responses [\#1015](https://github.com/rytilahti/python-miio/pull/1015) ([rytilahti](https://github.com/rytilahti)) +- Fix set\_mode\_and\_speed mode for airdog airpurifier [\#993](https://github.com/rytilahti/python-miio/pull/993) ([alexeypetrenko](https://github.com/alexeypetrenko)) + +**Closed issues:** + +- Add Dafang camera \(isa.camera.df3\) support [\#996](https://github.com/rytilahti/python-miio/issues/996) +- Roborock S7 [\#989](https://github.com/rytilahti/python-miio/issues/989) +- WalkingPad A1 Pro [\#797](https://github.com/rytilahti/python-miio/issues/797) + +**Merged pull requests:** + +- Add basic dmaker.fan.1c support [\#1012](https://github.com/rytilahti/python-miio/pull/1012) ([syssi](https://github.com/syssi)) +- Always return aqi value \[Revert PR\#930\] [\#1007](https://github.com/rytilahti/python-miio/pull/1007) ([bieniu](https://github.com/bieniu)) +- Added S6 to skip pause on docking [\#1002](https://github.com/rytilahti/python-miio/pull/1002) ([Sian-Lee-SA](https://github.com/Sian-Lee-SA)) +- Added number of dust collections to CleaningSummary if available [\#992](https://github.com/rytilahti/python-miio/pull/992) ([fettlaus](https://github.com/fettlaus)) +- Reformat history data if returned as a dict/Roborock S7 Support \(\#989\) [\#990](https://github.com/rytilahti/python-miio/pull/990) ([fettlaus](https://github.com/fettlaus)) +- Add support for Walkingpad A1 \(ksmb.walkingpad.v3\) [\#975](https://github.com/rytilahti/python-miio/pull/975) ([dewgenenny](https://github.com/dewgenenny)) + + ## [0.5.5.2](https://github.com/rytilahti/python-miio/tree/0.5.5.2) (2021-03-24) This release is mainly to re-add mapping parameter to MiotDevice constructor for backwards-compatibility reasons, diff --git a/README.rst b/README.rst index d2c7f619a..8dc189afa 100644 --- a/README.rst +++ b/README.rst @@ -86,7 +86,7 @@ To ease the process of setting up a development environment we have prepared `a Supported devices ----------------- -- Xiaomi Mi Robot Vacuum V1, S5, M1S +- Xiaomi Mi Robot Vacuum V1, S5, M1S, S7 - Xiaomi Mi Home Air Conditioner Companion - Xiaomi Mi Smart Air Conditioner A (xiaomi.aircondition.mc1, mc2, mc4, mc5) - Xiaomi Mi Air Purifier 2, 3H, 3C, Pro (zhimi.airpurifier.m2, mb3, mb4, v7) @@ -110,7 +110,7 @@ Supported devices - Xiaomi Philips Zhirui Bedroom Smart Lamp - Huayi Huizuo Lamps - Xiaomi Universal IR Remote Controller (Chuangmi IR) -- Xiaomi Mi Smart Pedestal Fan V2, V3, SA1, ZA1, ZA3, ZA4, P5, P9, P10, P11 +- Xiaomi Mi Smart Pedestal Fan V2, V3, SA1, ZA1, ZA3, ZA4, 1C, P5, P9, P10, P11 - Xiaomi Rosou SS4 Ventilator (leshow.fan.ss4) - Xiaomi Mi Air Humidifier V1, CA1, CA4, CB1, MJJSQ, JSQ, JSQ1, JSQ001 - Xiaomi Mi Water Purifier (Basic support: Turn on & off) @@ -133,6 +133,7 @@ Supported devices - Yeelight Dual Control Module (yeelink.switch.sw1) - Scishare coffee maker (scishare.coffee.s1102) - Qingping Air Monitor Lite (cgllc.airm.cgdn1) +- Xiaomi Walkingpad A1 (ksmb.walkingpad.v3) *Feel free to create a pull request to add support for new devices as diff --git a/docs/api/miio.gateway.rst b/docs/api/miio.gateway.rst index cfa6209e6..a010f3119 100644 --- a/docs/api/miio.gateway.rst +++ b/docs/api/miio.gateway.rst @@ -1,5 +1,29 @@ -miio.gateway module -=================== +miio.gateway package +==================== + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + miio.gateway.devices + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + miio.gateway.alarm + miio.gateway.gateway + miio.gateway.gatewaydevice + miio.gateway.light + miio.gateway.radio + miio.gateway.zigbee + +Module contents +--------------- .. automodule:: miio.gateway :members: diff --git a/docs/api/miio.rst b/docs/api/miio.rst index 08897c3c6..b628f99fd 100644 --- a/docs/api/miio.rst +++ b/docs/api/miio.rst @@ -1,12 +1,21 @@ miio package ============ +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + miio.gateway + Submodules ---------- .. toctree:: :maxdepth: 4 + miio.airconditioner_miot miio.airconditioningcompanion miio.airconditioningcompanionMCN miio.airdehumidifier @@ -18,8 +27,10 @@ Submodules miio.airhumidifier_miot miio.airhumidifier_mjjsq miio.airpurifier + miio.airpurifier_airdog miio.airpurifier_miot miio.airqualitymonitor + miio.airqualitymonitor_miot miio.alarmclock miio.aqaracamera miio.ceil @@ -30,15 +41,19 @@ Submodules miio.cli miio.click_common miio.cooker + miio.curtain_youpin miio.device miio.discovery + miio.dreamevacuum_miot miio.exceptions miio.extract_tokens miio.fan miio.fan_common + miio.fan_leshow miio.fan_miot - miio.gateway miio.heater + miio.heater_miot + miio.huizuo miio.miioprotocol miio.miot_device miio.philips_bulb @@ -50,17 +65,22 @@ Submodules miio.powerstrip miio.protocol miio.pwzn_relay + miio.scishare_coffeemaker miio.toiletlid miio.updater miio.utils miio.vacuum miio.vacuum_cli + miio.vacuum_tui miio.vacuumcontainers miio.viomivacuum + miio.walkingpad miio.waterpurifier + miio.waterpurifier_yunmi miio.wifirepeater miio.wifispeaker miio.yeelight + miio.yeelight_dual_switch Module contents --------------- diff --git a/docs/discovery.rst b/docs/discovery.rst index ce14d95e3..124648750 100644 --- a/docs/discovery.rst +++ b/docs/discovery.rst @@ -19,8 +19,12 @@ do this on Debian-based systems (like Rasperry Pi) with Device discovery ================ -Devices already connected on the same network where the command-line tool -is run are automatically detected when ``mirobo discover`` is invoked. +Devices already connected to the same network where the command-line tool +is run are automatically detected when ``miiocli discover`` is invoked. +This command will execute two types of discovery: discovery by handshake and discovery by mDNS. +mDNS discovery returns information that can be used to detect the device type which does not work with all devices. +The handshake method works on all MiIO devices and may expose the token needed to communicate +with the device, but does not provide device type information. To be able to communicate with devices their IP address and a device-specific encryption token must be known. @@ -29,48 +33,6 @@ it is likely a valid token which can be used directly for communication. If not, the token needs to be extracted from the Mi Home Application, see :ref:`logged_tokens` for information how to do this. -.. IMPORTANT:: - - For some devices (e.g. the vacuum cleaner) the automatic discovery works only before the device has been connected over the app to your local wifi. - This does not work starting from firmware version 3.3.9\_003077 onwards, in which case the procedure shown in :ref:`creating_backup` has to be used - to obtain the token. - -.. NOTE:: - - Some devices also do not announce themselves via mDNS (e.g. Philips' bulbs, - and the vacuum when not connected to the Internet), - but are nevertheless discoverable by using a miIO discovery. - See :ref:`handshake_discovery` for more information about the topic. - -.. _handshake_discovery: - -Discovery by a handshake ------------------------- - -The devices supporting miIO protocol answer to a broadcasted handshake packet, -which also sometime contain the required token. - -Executing ``mirobo discover`` with ``--handshake 1`` option will send -a broadcast handshake. -Devices supporting the protocol will response with a message -potentially containing a valid token. - -.. code-block:: bash - - $ mirobo discover --handshake 1 - INFO:miio.device: IP 192.168.8.1: Xiaomi Mi Robot Vacuum - token: b'ffffffffffffffffffffffffffffffff' - - -.. NOTE:: - This method can also be useful for devices not yet connected to any network. - In those cases the device trying to do the discovery has to connect to the - network advertised by the corresponding device (e.g. rockrobo-XXXX for vacuum) - - -Tokens full of ``0``\ s or ``f``\ s (as above) are either already paired -with the mobile app or will not yield a token through this method. -In those cases the procedure shown in :ref:`logged_tokens` has to be used. - .. _logged_tokens: Tokens from Mi Home logs diff --git a/docs/vacuum.rst b/docs/vacuum.rst index 085a510e4..6c5604270 100644 --- a/docs/vacuum.rst +++ b/docs/vacuum.rst @@ -117,6 +117,8 @@ Deleting a timer Cleaning history ~~~~~~~~~~~~~~~~ +Will also report amount of times the dust was collected if available. + :: $ mirobo cleaning-history diff --git a/miio/__init__.py b/miio/__init__.py index 0160941f7..1cabe73d9 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -36,7 +36,7 @@ from miio.exceptions import DeviceError, DeviceException from miio.fan import Fan, FanP5, FanSA1, FanV2, FanZA1, FanZA4 from miio.fan_leshow import FanLeshow -from miio.fan_miot import FanMiot, FanP9, FanP10, FanP11 +from miio.fan_miot import Fan1C, FanMiot, FanP9, FanP10, FanP11 from miio.gateway import Gateway from miio.heater import Heater from miio.heater_miot import HeaterMiot @@ -62,6 +62,7 @@ VacuumStatus, ) from miio.viomivacuum import ViomiVacuum +from miio.walkingpad import Walkingpad from miio.waterpurifier import WaterPurifier from miio.waterpurifier_yunmi import WaterPurifierYunmi from miio.wifirepeater import WifiRepeater diff --git a/miio/airhumidifier_mjjsq.py b/miio/airhumidifier_mjjsq.py index 7822c993c..ca3ef0458 100644 --- a/miio/airhumidifier_mjjsq.py +++ b/miio/airhumidifier_mjjsq.py @@ -43,6 +43,7 @@ class OperationMode(enum.Enum): Medium = 2 High = 3 Humidity = 4 + WetAndProtect = 5 class AirHumidifierStatus(DeviceStatus): diff --git a/miio/airpurifier_airdog.py b/miio/airpurifier_airdog.py index cbc93ed0b..90c8e3268 100644 --- a/miio/airpurifier_airdog.py +++ b/miio/airpurifier_airdog.py @@ -172,7 +172,7 @@ def set_mode_and_speed(self, mode: OperationMode, speed: int = 1): if speed < 1 or speed > max_speed: raise AirDogException("Invalid speed: %s" % speed) - return self.send("set_wind", [OperationModeMapping[mode.name], speed]) + return self.send("set_wind", [OperationModeMapping[mode.name].value, speed]) @command( click.argument("lock", type=bool), diff --git a/miio/airpurifier_miot.py b/miio/airpurifier_miot.py index 8824229b6..776346b58 100644 --- a/miio/airpurifier_miot.py +++ b/miio/airpurifier_miot.py @@ -105,10 +105,6 @@ def power(self) -> str: @property def aqi(self) -> int: """Air quality index.""" - # zhimi-airpurifier-mb3 returns 1 as AQI value if the measurement was - # unsuccessful - if self.data["aqi"] == 1: - return None return self.data["aqi"] @property diff --git a/miio/cli.py b/miio/cli.py index 891f7a731..9af8a1c58 100644 --- a/miio/cli.py +++ b/miio/cli.py @@ -2,12 +2,14 @@ import click +from miio import Discovery from miio.click_common import ( DeviceGroupMeta, ExceptionHandlerGroup, GlobalContextObject, json_output, ) +from miio.miioprotocol import MiIOProtocol _LOGGER = logging.getLogger(__name__) @@ -41,6 +43,22 @@ def cli(ctx, debug: int, output: str): cli.add_command(device_class.get_device_group()) +@click.command() +@click.option("--mdns/--no-mdns", default=True, is_flag=True) +@click.option("--handshake/--no-handshake", default=True, is_flag=True) +@click.option("--network", default=None) +@click.option("--timeout", type=int, default=5) +def discover(mdns, handshake, network, timeout): + """Discover devices using both handshake and mdns methods.""" + if handshake: + MiIOProtocol.discover(addr=network, timeout=timeout) + if mdns: + Discovery.discover_mdns(timeout=timeout) + + +cli.add_command(discover) + + def create_cli(): return cli(auto_envvar_prefix="MIIO") diff --git a/miio/device.py b/miio/device.py index 31d987c1b..71c7da86e 100644 --- a/miio/device.py +++ b/miio/device.py @@ -1,6 +1,7 @@ import inspect import logging from enum import Enum +from pprint import pformat as pf from typing import Any, Optional # noqa: F401 import click @@ -283,5 +284,97 @@ def get_properties( return values + @command( + click.argument("properties", type=str, nargs=-1, required=True), + ) + def test_properties(self, properties): + """Helper to test device properties.""" + + def ok(x): + click.echo(click.style(x, fg="green", bold=True)) + + def fail(x): + click.echo(click.style(x, fg="red", bold=True)) + + try: + model = self.info().model + except Exception as ex: + _LOGGER.warning("Unable to obtain device model: %s", ex) + model = "" + + click.echo(f"Testing properties {properties} for {model}") + valid_properties = {} + max_property_len = max([len(p) for p in properties]) + for property in properties: + try: + click.echo(f"Testing {property:{max_property_len+2}} ", nl=False) + value = self.get_properties([property]) + # Handle list responses + if isinstance(value, list): + # unwrap single-element lists + if len(value) == 1: + value = value.pop() + # report on unexpected multi-element lists + elif len(value) > 1: + _LOGGER.error("Got an array as response: %s", value) + # otherwise we received an empty list, which we consider here as None + else: + value = None + + if value is None: + fail("None") + else: + valid_properties[property] = value + ok(f"{repr(value)} {type(value)}") + except Exception as ex: + _LOGGER.warning("Unable to request %s: %s", property, ex) + + click.echo( + f"Found {len(valid_properties)} valid properties, testing max_properties.." + ) + + props_to_test = list(valid_properties.keys()) + max_properties = -1 + while len(props_to_test) > 1: + try: + click.echo( + f"Testing {len(props_to_test)} properties at once ({' '.join(props_to_test)}): ", + nl=False, + ) + resp = self.get_properties(props_to_test) + + if len(resp) == len(props_to_test): + max_properties = len(props_to_test) + ok(f"OK for {max_properties} properties") + break + else: + removed_property = props_to_test.pop() + fail( + f"Got different amount of properties ({len(props_to_test)}) than requested ({len(resp)}), removing {removed_property}" + ) + + except Exception as ex: + removed_property = props_to_test.pop() + msg = f"Unable to request properties: {ex} - removing {removed_property} for next try" + _LOGGER.warning(msg) + fail(ex) + + non_empty_properties = { + k: v for k, v in valid_properties.items() if v is not None + } + + click.echo( + click.style("\nPlease copy the results below to your report", bold=True) + ) + click.echo("### Results ###") + click.echo(f"Model: {model}") + _LOGGER.debug(f"All responsive properties:\n{pf(valid_properties)}") + click.echo(f"Total responsives: {len(valid_properties)}") + click.echo(f"Total non-empty: {len(non_empty_properties)}") + click.echo(f"All non-empty properties:\n{pf(non_empty_properties)}") + click.echo(f"Max properties: {max_properties}") + + return "Done" + def __repr__(self): return f"<{self.__class__.__name__ }: {self.ip} (token: {self.token})>" diff --git a/miio/discovery.py b/miio/discovery.py index 1682dfe90..85fa9e603 100644 --- a/miio/discovery.py +++ b/miio/discovery.py @@ -1,6 +1,7 @@ import codecs import inspect import logging +import time from functools import partial from ipaddress import ip_address from typing import Callable, Dict, Optional, Union # noqa: F401 @@ -85,7 +86,7 @@ MODEL_FAN_ZA3, MODEL_FAN_ZA4, ) -from .fan_miot import MODEL_FAN_P9, MODEL_FAN_P10, MODEL_FAN_P11 +from .fan_miot import MODEL_FAN_1C, MODEL_FAN_P9, MODEL_FAN_P10, MODEL_FAN_P11 from .heater import MODEL_HEATER_MA1, MODEL_HEATER_ZA1 from .powerstrip import MODEL_POWER_STRIP_V1, MODEL_POWER_STRIP_V2 from .toiletlid import MODEL_TOILETLID_V1 @@ -179,6 +180,7 @@ "zhimi-fan-za1": partial(Fan, model=MODEL_FAN_ZA1), "zhimi-fan-za3": partial(Fan, model=MODEL_FAN_ZA3), "zhimi-fan-za4": partial(Fan, model=MODEL_FAN_ZA4), + "dmaker-fan-1c": partial(FanMiot, model=MODEL_FAN_1C), "dmaker-fan-p5": partial(Fan, model=MODEL_FAN_P5), "dmaker-fan-p9": partial(FanMiot, model=MODEL_FAN_P9), "dmaker-fan-p10": partial(FanMiot, model=MODEL_FAN_P10), @@ -287,16 +289,16 @@ class Discovery: """ @staticmethod - def discover_mdns() -> Dict[str, Device]: + def discover_mdns(*, timeout=5) -> Dict[str, Device]: """Discover devices with mdns until any keyboard input.""" - _LOGGER.info("Discovering devices with mDNS, press any key to quit...") + _LOGGER.info("Discovering devices with mDNS for %s seconds...", timeout) listener = Listener() browser = zeroconf.ServiceBrowser( zeroconf.Zeroconf(), "_miio._udp.local.", listener ) - input() # to keep execution running until a key is pressed + time.sleep(timeout) browser.cancel() return listener.found_devices diff --git a/miio/fan_miot.py b/miio/fan_miot.py index 79db30f3c..87c70ee11 100644 --- a/miio/fan_miot.py +++ b/miio/fan_miot.py @@ -10,8 +10,20 @@ MODEL_FAN_P9 = "dmaker.fan.p9" MODEL_FAN_P10 = "dmaker.fan.p10" MODEL_FAN_P11 = "dmaker.fan.p11" +MODEL_FAN_1C = "dmaker.fan.1c" MIOT_MAPPING = { + MODEL_FAN_1C: { + # https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:fan:0000A005:dmaker-1c:1 + "power": {"siid": 2, "piid": 1}, + "fan_level": {"siid": 2, "piid": 2}, + "child_lock": {"siid": 3, "piid": 1}, + "swing_mode": {"siid": 2, "piid": 3}, + "power_off_time": {"siid": 2, "piid": 10}, + "buzzer": {"siid": 2, "piid": 11}, + "light": {"siid": 2, "piid": 12}, + "mode": {"siid": 2, "piid": 7}, + }, MODEL_FAN_P9: { # Source https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:fan:0000A005:dmaker-p9:1 "power": {"siid": 2, "piid": 1}, @@ -57,6 +69,12 @@ }, } +SUPPORTED_ANGLES = { + MODEL_FAN_P9: [30, 60, 90, 120, 150], + MODEL_FAN_P10: [30, 60, 90, 120, 140], + MODEL_FAN_P11: [30, 60, 90, 120, 140], +} + class OperationModeMiot(enum.Enum): Normal = 0 @@ -122,7 +140,7 @@ def angle(self) -> int: @property def delay_off_countdown(self) -> int: - """Countdown until turning off in seconds.""" + """Countdown until turning off in minutes.""" return self.data["power_off_time"] @property @@ -141,6 +159,76 @@ def child_lock(self) -> bool: return self.data["child_lock"] +class FanStatus1C(DeviceStatus): + """Container for status reports for Xiaomi Mi Smart Pedestal Fan DMaker 1C.""" + + def __init__(self, data: Dict[str, Any]) -> None: + self.data = data + """ + Response of a Fan1C (dmaker.fan.1c): + + { + 'id': 1, + 'result': [ + {'did': 'power', 'siid': 2, 'piid': 1, 'code': 0, 'value': True}, + {'did': 'fan_level', 'siid': 2, 'piid': 2, 'code': 0, 'value': 2}, + {'did': 'child_lock', 'siid': 3, 'piid': 1, 'code': 0, 'value': False}, + {'did': 'swing_mode', 'siid': 2, 'piid': 3, 'code': 0, 'value': False}, + {'did': 'power_off_time', 'siid': 2, 'piid': 10, 'code': 0, 'value': 0}, + {'did': 'buzzer', 'siid': 2, 'piid': 11, 'code': 0, 'value': False}, + {'did': 'light', 'siid': 2, 'piid': 12, 'code': 0, 'value': True}, + {'did': 'mode', 'siid': 2, 'piid': 7, 'code': 0, 'value': 0}, + ], + 'exe_time': 280 + } + """ + + @property + def power(self) -> str: + """Power state.""" + return "on" if self.data["power"] else "off" + + @property + def is_on(self) -> bool: + """True if device is currently on.""" + return self.data["power"] + + @property + def mode(self) -> OperationMode: + """Operation mode.""" + return OperationMode[OperationModeMiot(self.data["mode"]).name] + + @property + def speed(self) -> int: + """Speed of the motor.""" + return self.data["fan_level"] + + @property + def oscillate(self) -> bool: + """True if oscillation is enabled.""" + return self.data["swing_mode"] + + @property + def delay_off_countdown(self) -> int: + """Countdown until turning off in minutes.""" + return self.data["power_off_time"] + + @property + def led(self) -> bool: + """True if LED is turned on.""" + return self.data["light"] + + @property + def buzzer(self) -> bool: + """True if buzzer is turned on.""" + return self.data["buzzer"] + + @property + def child_lock(self) -> bool: + """True if child lock is on.""" + return self.data["child_lock"] + + class FanMiot(MiotDevice): mapping = MIOT_MAPPING[MODEL_FAN_P10] @@ -217,9 +305,10 @@ def set_speed(self, speed: int): ) def set_angle(self, angle: int): """Set the oscillation angle.""" - if angle not in [30, 60, 90, 120, 140]: + if angle not in SUPPORTED_ANGLES[self.model]: raise FanException( - "Unsupported angle. Supported values: 30, 60, 90, 120, 140" + "Unsupported angle. Supported values: " + + ", ".join("{0}".format(i) for i in SUPPORTED_ANGLES[self.model]) ) return self.set_property("swing_mode_angle", angle) @@ -282,7 +371,7 @@ def set_child_lock(self, lock: bool): def delay_off(self, minutes: int): """Set delay off minutes.""" - if minutes < 0: + if minutes < 0 or minutes > 480: raise FanException("Invalid value for a delayed turn off: %s" % minutes) return self.set_property("power_off_time", minutes) @@ -305,3 +394,124 @@ class FanP10(FanMiot): class FanP11(FanMiot): mapping = MIOT_MAPPING[MODEL_FAN_P11] + + +class Fan1C(MiotDevice): + mapping = MIOT_MAPPING[MODEL_FAN_1C] + + def __init__( + self, + ip: str = None, + token: str = None, + start_id: int = 0, + debug: int = 0, + lazy_discover: bool = True, + model: str = MODEL_FAN_1C, + ) -> None: + super().__init__(ip, token, start_id, debug, lazy_discover) + self.model = model + + @command( + default_output=format_output( + "", + "Power: {result.power}\n" + "Operation mode: {result.mode}\n" + "Speed: {result.speed}\n" + "Oscillate: {result.oscillate}\n" + "LED: {result.led}\n" + "Buzzer: {result.buzzer}\n" + "Child lock: {result.child_lock}\n" + "Power-off time: {result.delay_off_countdown}\n", + ) + ) + def status(self) -> FanStatus1C: + """Retrieve properties.""" + return FanStatus1C( + { + prop["did"]: prop["value"] if prop["code"] == 0 else None + for prop in self.get_properties_for_mapping() + } + ) + + @command(default_output=format_output("Powering on")) + def on(self): + """Power on.""" + return self.set_property("power", True) + + @command(default_output=format_output("Powering off")) + def off(self): + """Power off.""" + return self.set_property("power", False) + + @command( + click.argument("mode", type=EnumType(OperationMode)), + default_output=format_output("Setting mode to '{mode.value}'"), + ) + def set_mode(self, mode: OperationMode): + """Set mode.""" + return self.set_property("mode", OperationModeMiot[mode.name].value) + + @command( + click.argument("speed", type=int), + default_output=format_output("Setting speed to {speed}"), + ) + def set_speed(self, speed: int): + """Set speed.""" + if speed not in (1, 2, 3): + raise FanException("Invalid speed: %s" % speed) + + return self.set_property("fan_level", speed) + + @command( + click.argument("oscillate", type=bool), + default_output=format_output( + lambda oscillate: "Turning on oscillate" + if oscillate + else "Turning off oscillate" + ), + ) + def set_oscillate(self, oscillate: bool): + """Set oscillate on/off.""" + return self.set_property("swing_mode", oscillate) + + @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.""" + return self.set_property("light", led) + + @command( + click.argument("buzzer", type=bool), + default_output=format_output( + lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" + ), + ) + def set_buzzer(self, buzzer: bool): + """Set buzzer on/off.""" + return self.set_property("buzzer", buzzer) + + @command( + click.argument("lock", type=bool), + default_output=format_output( + lambda lock: "Turning on child lock" if lock else "Turning off child lock" + ), + ) + def set_child_lock(self, lock: bool): + """Set child lock on/off.""" + return self.set_property("child_lock", lock) + + @command( + click.argument("minutes", type=int), + default_output=format_output("Setting delayed turn off to {minutes} minutes"), + ) + def delay_off(self, minutes: int): + """Set delay off minutes.""" + + if minutes < 0 or minutes > 480: + raise FanException("Invalid value for a delayed turn off: %s" % minutes) + + return self.set_property("power_off_time", minutes) diff --git a/miio/gateway/devices/light.py b/miio/gateway/devices/light.py index fa367e761..3604470e6 100644 --- a/miio/gateway/devices/light.py +++ b/miio/gateway/devices/light.py @@ -9,16 +9,6 @@ class LightBulb(SubDevice): """Base class for subdevice light bulbs.""" - @command() - def update(self): - """Update all device properties.""" - self._props["brightness"] = self.send("get_bright").pop() - self._props["color_temp"] = self.send("get_ct").pop() - if self._props["brightness"] > 0 and self._props["brightness"] <= 100: - self._props["status"] = "on" - else: - self._props["status"] = "off" - @command() def on(self): """Turn bulb on.""" diff --git a/miio/gateway/devices/subdevices.yaml b/miio/gateway/devices/subdevices.yaml index 96fcc5bbc..fea5145ae 100644 --- a/miio/gateway/devices/subdevices.yaml +++ b/miio/gateway/devices/subdevices.yaml @@ -128,11 +128,17 @@ type: LightBulb class: LightBulb properties: - - property: status # 'on' / 'off' - - property: brightness + - property: power_status # 'on' / 'off' + name: status + get: get_property_exp + - property: light_level + name: brightness unit: percent - - property: color_temp + get: get_property_exp + - property: colour_temperature + name: color_temp unit: cct + get: get_property_exp - property: cct_min unit: cct default: 153 @@ -147,11 +153,17 @@ type: LightBulb class: LightBulb properties: - - property: status # 'on' / 'off' - - property: brightness + - property: power_status # 'on' / 'off' + name: status + get: get_property_exp + - property: light_level + name: brightness unit: percent - - property: color_temp + get: get_property_exp + - property: colour_temperature + name: color_temp unit: cct + get: get_property_exp - property: cct_min unit: cct default: 153 @@ -166,11 +178,17 @@ type: LightBulb class: LightBulb properties: - - property: status # 'on' / 'off' - - property: brightness + - property: power_status # 'on' / 'off' + name: status + get: get_property_exp + - property: light_level + name: brightness unit: percent - - property: color_temp + get: get_property_exp + - property: colour_temperature + name: color_temp unit: cct + get: get_property_exp - property: cct_min unit: cct default: 153 @@ -185,11 +203,17 @@ type: LightBulb class: LightBulb properties: - - property: status # 'on' / 'off' - - property: brightness + - property: power_status # 'on' / 'off' + name: status + get: get_property_exp + - property: light_level + name: brightness unit: percent - - property: color_temp + get: get_property_exp + - property: colour_temperature + name: color_temp unit: cct + get: get_property_exp - property: cct_min unit: cct default: 153 @@ -204,11 +228,17 @@ type: LightBulb class: LightBulb properties: - - property: status # 'on' / 'off' - - property: brightness + - property: power_status # 'on' / 'off' + name: status + get: get_property_exp + - property: light_level + name: brightness unit: percent - - property: color_temp + get: get_property_exp + - property: colour_temperature + name: color_temp unit: cct + get: get_property_exp - property: cct_min unit: cct default: 153 @@ -223,11 +253,17 @@ type: LightBulb class: LightBulb properties: - - property: status # 'on' / 'off' - - property: brightness + - property: power_status # 'on' / 'off' + name: status + get: get_property_exp + - property: light_level + name: brightness unit: percent - - property: color_temp + get: get_property_exp + - property: colour_temperature + name: color_temp unit: cct + get: get_property_exp - property: cct_min unit: cct default: 153 @@ -242,11 +278,17 @@ type: LightBulb class: LightBulb properties: - - property: status # 'on' / 'off' - - property: brightness + - property: power_status # 'on' / 'off' + name: status + get: get_property_exp + - property: light_level + name: brightness unit: percent - - property: color_temp + get: get_property_exp + - property: colour_temperature + name: color_temp unit: cct + get: get_property_exp - property: cct_min unit: cct default: 153 @@ -261,11 +303,17 @@ type: LightBulb class: LightBulb properties: - - property: status # 'on' / 'off' - - property: brightness + - property: power_status # 'on' / 'off' + name: status + get: get_property_exp + - property: light_level + name: brightness unit: percent - - property: color_temp + get: get_property_exp + - property: colour_temperature + name: color_temp unit: cct + get: get_property_exp - property: cct_min unit: cct default: 153 diff --git a/miio/protocol.py b/miio/protocol.py index 8c6ec09e3..721c4f644 100644 --- a/miio/protocol.py +++ b/miio/protocol.py @@ -172,7 +172,8 @@ def _decode(self, obj, context, path): decrypted = Utils.decrypt(obj, context["_"]["token"]) decrypted = decrypted.rstrip(b"\x00") except Exception: - _LOGGER.debug("Unable to decrypt, returning raw bytes: %s", obj) + if obj: + _LOGGER.debug("Unable to decrypt, returning raw bytes: %s", obj) return obj # list of adaption functions for malformed json payload (quirks) diff --git a/miio/tests/test_fan_miot.py b/miio/tests/test_fan_miot.py index 2fcac7eb0..e80834ea7 100644 --- a/miio/tests/test_fan_miot.py +++ b/miio/tests/test_fan_miot.py @@ -2,8 +2,15 @@ import pytest -from miio import FanMiot -from miio.fan_miot import MODEL_FAN_P9, FanException, OperationMode +from miio import Fan1C, FanMiot +from miio.fan_miot import ( + MODEL_FAN_1C, + MODEL_FAN_P9, + MODEL_FAN_P10, + MODEL_FAN_P11, + FanException, + OperationMode, +) from .dummies import DummyMiotDevice @@ -16,26 +23,12 @@ def __init__(self, *args, **kwargs): "mode": 0, "fan_speed": 35, "swing_mode": False, - "swing_mode_angle": 140, + "swing_mode_angle": 30, "power_off_time": 0, "light": True, "buzzer": False, "child_lock": False, } - - self.return_values = { - "get_prop": self._get_state, - "power": lambda x: self._set_state("power", x), - "mode": lambda x: self._set_state("mode", x), - "fan_speed": lambda x: self._set_state("fan_speed", x), - "swing_mode": lambda x: self._set_state("swing_mode", x), - "swing_mode_angle": lambda x: self._set_state("swing_mode_angle", x), - "power_off_time": lambda x: self._set_state("power_off_time", x), - "light": lambda x: self._set_state("light", x), - "buzzer": lambda x: self._set_state("buzzer", x), - "child_lock": lambda x: self._set_state("child_lock", x), - "set_move": lambda x: True, - } super().__init__(args, kwargs) @@ -93,6 +86,106 @@ def speed(): with pytest.raises(FanException): self.device.set_speed(101) + def test_set_angle(self): + def angle(): + return self.device.status().angle + + self.device.set_angle(30) + assert angle() == 30 + self.device.set_angle(60) + assert angle() == 60 + self.device.set_angle(90) + assert angle() == 90 + self.device.set_angle(120) + assert angle() == 120 + self.device.set_angle(150) + assert angle() == 150 + + with pytest.raises(FanException): + self.device.set_angle(-1) + + with pytest.raises(FanException): + self.device.set_angle(1) + + with pytest.raises(FanException): + self.device.set_angle(31) + + with pytest.raises(FanException): + self.device.set_angle(140) + + with pytest.raises(FanException): + self.device.set_angle(151) + + def test_set_oscillate(self): + def oscillate(): + return self.device.status().oscillate + + self.device.set_oscillate(True) + assert oscillate() is True + + self.device.set_oscillate(False) + assert oscillate() is False + + 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_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_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 + + def test_delay_off(self): + def delay_off_countdown(): + return self.device.status().delay_off_countdown + + self.device.delay_off(0) + assert delay_off_countdown() == 0 + self.device.delay_off(1) + assert delay_off_countdown() == 1 + self.device.delay_off(480) + assert delay_off_countdown() == 480 + + with pytest.raises(FanException): + self.device.delay_off(-1) + with pytest.raises(FanException): + self.device.delay_off(481) + + +class DummyFanMiotP10(DummyFanMiot, FanMiot): + def __init__(self, *args, **kwargs): + super().__init__(args, kwargs) + self.model = MODEL_FAN_P10 + + +@pytest.fixture(scope="class") +def fanmiotp10(request): + request.cls.device = DummyFanMiotP10() + + +@pytest.mark.usefixtures("fanmiotp10") +class TestFanMiotP10(TestCase): def test_set_angle(self): def angle(): return self.device.status().angle @@ -117,9 +210,99 @@ def angle(): with pytest.raises(FanException): self.device.set_angle(31) + with pytest.raises(FanException): + self.device.set_angle(150) + with pytest.raises(FanException): self.device.set_angle(141) + +class DummyFanMiotP11(DummyFanMiot, FanMiot): + def __init__(self, *args, **kwargs): + super().__init__(args, kwargs) + self.model = MODEL_FAN_P11 + + +@pytest.fixture(scope="class") +def fanmiotp11(request): + request.cls.device = DummyFanMiotP11() + + +@pytest.mark.usefixtures("fanmiotp11") +class TestFanMiotP11(TestFanMiotP10, TestCase): + pass + + +class DummyFan1C(DummyMiotDevice, Fan1C): + def __init__(self, *args, **kwargs): + self.model = MODEL_FAN_1C + self.state = { + "power": True, + "mode": 0, + "fan_level": 1, + "swing_mode": False, + "power_off_time": 0, + "light": True, + "buzzer": False, + "child_lock": False, + } + super().__init__(args, kwargs) + + +@pytest.fixture(scope="class") +def fan1c(request): + request.cls.device = DummyFan1C() + + +@pytest.mark.usefixtures("fan1c") +class TestFan1C(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_set_mode(self): + def mode(): + return self.device.status().mode + + self.device.set_mode(OperationMode.Normal) + assert mode() == OperationMode.Normal + + self.device.set_mode(OperationMode.Nature) + assert mode() == OperationMode.Nature + + def test_set_speed(self): + def speed(): + return self.device.status().speed + + self.device.set_speed(1) + assert speed() == 1 + self.device.set_speed(2) + assert speed() == 2 + self.device.set_speed(3) + assert speed() == 3 + + with pytest.raises(FanException): + self.device.set_speed(0) + + with pytest.raises(FanException): + self.device.set_speed(4) + def test_set_oscillate(self): def oscillate(): return self.device.status().oscillate @@ -164,12 +347,14 @@ def test_delay_off(self): def delay_off_countdown(): return self.device.status().delay_off_countdown - self.device.delay_off(100) - assert delay_off_countdown() == 100 - self.device.delay_off(200) - assert delay_off_countdown() == 200 self.device.delay_off(0) assert delay_off_countdown() == 0 + self.device.delay_off(1) + assert delay_off_countdown() == 1 + self.device.delay_off(480) + assert delay_off_countdown() == 480 with pytest.raises(FanException): self.device.delay_off(-1) + with pytest.raises(FanException): + self.device.delay_off(481) diff --git a/miio/tests/test_vacuum.py b/miio/tests/test_vacuum.py index 0cecb92c8..f125f0d2e 100644 --- a/miio/tests/test_vacuum.py +++ b/miio/tests/test_vacuum.py @@ -170,3 +170,108 @@ def test_timezone(self): with patch.object(self.device, "send", return_value=0): assert self.device.timezone() == "UTC" + + def test_history(self): + with patch.object( + self.device, + "send", + return_value=[ + 174145, + 2410150000, + 82, + [ + 1488240000, + 1488153600, + 1488067200, + 1487980800, + 1487894400, + 1487808000, + 1487548800, + ], + ], + ): + assert self.device.clean_history().total_duration == datetime.timedelta( + days=2, seconds=1345 + ) + + assert self.device.clean_history().dust_collection_count is None + + assert self.device.clean_history().ids[0] == 1488240000 + + def test_history_dict(self): + with patch.object( + self.device, + "send", + return_value={ + "clean_time": 174145, + "clean_area": 2410150000, + "clean_count": 82, + "dust_collection_count": 5, + "records": [ + 1488240000, + 1488153600, + 1488067200, + 1487980800, + 1487894400, + 1487808000, + 1487548800, + ], + }, + ): + assert self.device.clean_history().total_duration == datetime.timedelta( + days=2, seconds=1345 + ) + + assert self.device.clean_history().dust_collection_count == 5 + + assert self.device.clean_history().ids[0] == 1488240000 + + def test_history_details(self): + with patch.object( + self.device, + "send", + return_value=[[1488347071, 1488347123, 16, 0, 0, 0]], + ): + assert self.device.clean_details( + 123123, return_list=False + ).duration == datetime.timedelta(seconds=16) + + def test_history_details_dict(self): + with patch.object( + self.device, + "send", + return_value=[ + { + "begin": 1616757243, + "end": 1616758193, + "duration": 950, + "area": 10852500, + "error": 0, + "complete": 1, + "start_type": 2, + "clean_type": 1, + "finish_reason": 52, + "dust_collection_status": 0, + } + ], + ): + assert self.device.clean_details( + 123123, return_list=False + ).duration == datetime.timedelta(seconds=950) + + def test_history_empty(self): + with patch.object( + self.device, + "send", + return_value={ + "clean_time": 174145, + "clean_area": 2410150000, + "clean_count": 82, + "dust_collection_count": 5, + }, + ): + assert self.device.clean_history().total_duration == datetime.timedelta( + days=2, seconds=1345 + ) + + assert len(self.device.clean_history().ids) == 0 diff --git a/miio/tests/test_walkingpad.py b/miio/tests/test_walkingpad.py new file mode 100644 index 000000000..df5c05197 --- /dev/null +++ b/miio/tests/test_walkingpad.py @@ -0,0 +1,207 @@ +from datetime import timedelta +from unittest import TestCase + +import pytest + +from miio import Walkingpad +from miio.walkingpad import ( + OperationMode, + OperationSensitivity, + WalkingpadException, + WalkingpadStatus, +) + +from .dummies import DummyDevice + + +class DummyWalkingpad(DummyDevice, Walkingpad): + def _get_state(self, props): + """Return wanted properties.""" + + # Overriding here to deal with case of 'all' being requested + + if props[0] == "all": + return self.state[props[0]] + + return [self.state[x] for x in props if x in self.state] + + def _set_state(self, var, value): + """Set a state of a variable, the value is expected to be an array with length + of 1.""" + + # Overriding here to deal with case of 'all' being set + + if var == "all": + self.state[var] = value + else: + self.state[var] = value.pop(0) + + def __init__(self, *args, **kwargs): + self.state = { + "power": "on", + "mode": OperationMode.Manual, + "time": 1387, + "step": 2117, + "sensitivity": OperationSensitivity.Low, + "dist": 1150, + "sp": 3.15, + "cal": 71710, + "start_speed": 3.1, + "all": [ + "mode:" + str(OperationMode.Manual.value), + "time:1387", + "sp:3.15", + "dist:1150", + "cal:71710", + "step:2117", + ], + } + 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_speed": lambda x: ( + self._set_state( + "all", + [ + "mode:1", + "time:1387", + "sp:" + str(x[0]), + "dist:1150", + "cal:71710", + "step:2117", + ], + ), + self._set_state("sp", x), + ), + "set_step": lambda x: self._set_state("step", x), + "set_sensitivity": lambda x: self._set_state("sensitivity", x), + "set_start_speed": lambda x: self._set_state("start_speed", x), + "set_time": lambda x: self._set_state("time", x), + "set_distance": lambda x: self._set_state("dist", x), + } + super().__init__(args, kwargs) + + +@pytest.fixture(scope="class") +def walkingpad(request): + request.cls.device = DummyWalkingpad() + + +@pytest.mark.usefixtures("walkingpad") +class TestWalkingpad(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() + + assert repr(self.state()) == repr(WalkingpadStatus(self.device.start_state)) + assert self.is_on() is True + assert self.state().power == self.device.start_state["power"] + assert self.state().mode == self.device.start_state["mode"] + assert self.state().speed == self.device.start_state["sp"] + assert self.state().step_count == self.device.start_state["step"] + assert self.state().distance == self.device.start_state["dist"] + assert self.state().sensitivity == self.device.start_state["sensitivity"] + assert self.state().walking_time == timedelta( + seconds=self.device.start_state["time"] + ) + + def test_set_mode(self): + def mode(): + return self.device.status().mode + + self.device.set_mode(OperationMode.Auto) + assert mode() == OperationMode.Auto + + self.device.set_mode(OperationMode.Manual) + assert mode() == OperationMode.Manual + + with pytest.raises(WalkingpadException): + self.device.set_mode(-1) + + with pytest.raises(WalkingpadException): + self.device.set_mode(3) + + with pytest.raises(WalkingpadException): + self.device.set_mode("blah") + + def test_set_speed(self): + def speed(): + return self.device.status().speed + + self.device.on() + self.device.set_speed(3.055) + assert speed() == 3.055 + + with pytest.raises(WalkingpadException): + self.device.set_speed(7.6) + + with pytest.raises(WalkingpadException): + self.device.set_speed(-1) + + with pytest.raises(WalkingpadException): + self.device.set_speed("blah") + + with pytest.raises(WalkingpadException): + self.device.off() + self.device.set_speed(3.4) + + def test_set_start_speed(self): + def speed(): + return self.device.status().start_speed + + self.device.on() + + self.device.set_start_speed(3.055) + assert speed() == 3.055 + + with pytest.raises(WalkingpadException): + self.device.set_start_speed(7.6) + + with pytest.raises(WalkingpadException): + self.device.set_start_speed(-1) + + with pytest.raises(WalkingpadException): + self.device.set_start_speed("blah") + + with pytest.raises(WalkingpadException): + self.device.off() + self.device.set_start_speed(3.4) + + def test_set_sensitivity(self): + def sensitivity(): + return self.device.status().sensitivity + + self.device.set_sensitivity(OperationSensitivity.High) + assert sensitivity() == OperationSensitivity.High + + self.device.set_sensitivity(OperationSensitivity.Medium) + assert sensitivity() == OperationSensitivity.Medium + + with pytest.raises(WalkingpadException): + self.device.set_sensitivity(-1) + + with pytest.raises(WalkingpadException): + self.device.set_sensitivity(99) + + with pytest.raises(WalkingpadException): + self.device.set_sensitivity("blah") diff --git a/miio/vacuum.py b/miio/vacuum.py index b40d1b2d4..566590c24 100644 --- a/miio/vacuum.py +++ b/miio/vacuum.py @@ -95,6 +95,7 @@ class WaterFlow(enum.Enum): ROCKROBO_V1 = "rockrobo.vacuum.v1" ROCKROBO_S5 = "roborock.vacuum.s5" +ROCKROBO_S6 = "roborock.vacuum.s6" ROCKROBO_S6_MAXV = "roborock.vacuum.a10" @@ -152,6 +153,7 @@ def home(self): SKIP_PAUSE = [ ROCKROBO_S5, + ROCKROBO_S6, ROCKROBO_S6_MAXV, ] diff --git a/miio/vacuum_cli.py b/miio/vacuum_cli.py index 5c995ffa8..7817d1db0 100644 --- a/miio/vacuum_cli.py +++ b/miio/vacuum_cli.py @@ -438,6 +438,8 @@ def cleaning_history(vac: miio.Vacuum): res = vac.clean_history() click.echo("Total clean count: %s" % res.count) click.echo("Cleaned for: %s (area: %s m²)" % (res.total_duration, res.total_area)) + if res.dust_collection_count is not None: + click.echo("Emptied dust collection bin: %s times" % res.dust_collection_count) click.echo() for idx, id_ in enumerate(res.ids): details = vac.clean_details(id_, return_list=False) diff --git a/miio/vacuumcontainers.py b/miio/vacuumcontainers.py index 203199568..7c828d046 100644 --- a/miio/vacuumcontainers.py +++ b/miio/vacuumcontainers.py @@ -1,7 +1,7 @@ # -*- coding: UTF-8 -*# from datetime import datetime, time, timedelta from enum import IntEnum -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional, Union from croniter import croniter @@ -184,73 +184,105 @@ def got_error(self) -> bool: class CleaningSummary(DeviceStatus): """Contains summarized information about available cleaning runs.""" - def __init__(self, data: List[Any]) -> None: + def __init__(self, data: Union[List[Any], Dict[str, Any]]) -> None: # total duration, total area, amount of cleans # [ list, of, ids ] # { "result": [ 174145, 2410150000, 82, # [ 1488240000, 1488153600, 1488067200, 1487980800, # 1487894400, 1487808000, 1487548800 ] ], # "id": 1 } - self.data = data + # newer models return a dict + if type(data) is list: + self.data = { + "clean_time": data[0], + "clean_area": data[1], + "clean_count": data[2], + } + if len(data) > 3: + self.data["records"] = data[3] + else: + self.data = data + + if "records" not in self.data: + self.data["records"] = [] @property def total_duration(self) -> timedelta: """Total cleaning duration.""" - return pretty_seconds(self.data[0]) + return pretty_seconds(self.data["clean_time"]) @property def total_area(self) -> float: """Total cleaned area.""" - return pretty_area(self.data[1]) + return pretty_area(self.data["clean_area"]) @property def count(self) -> int: """Number of cleaning runs.""" - return int(self.data[2]) + return int(self.data["clean_count"]) @property def ids(self) -> List[int]: """A list of available cleaning IDs, see also :class:`CleaningDetails`.""" - return list(self.data[3]) + return list(self.data["records"]) + + @property + def dust_collection_count(self) -> Optional[int]: + """Total number of dust collections.""" + if "dust_collection_count" in self.data: + return int(self.data["dust_collection_count"]) + else: + return None class CleaningDetails(DeviceStatus): """Contains details about a specific cleaning run.""" - def __init__(self, data: List[Any]) -> None: + def __init__(self, data: Union[List[Any], Dict[str, Any]]) -> None: # start, end, duration, area, unk, complete # { "result": [ [ 1488347071, 1488347123, 16, 0, 0, 0 ] ], "id": 1 } - self.data = data + # newer models return a dict + if type(data) is list: + self.data = { + "begin": data[0], + "end": data[1], + "duration": data[2], + "area": data[3], + "error": data[4], + "complete": data[5], + } + else: + self.data = data @property def start(self) -> datetime: """When cleaning was started.""" - return pretty_time(self.data[0]) + return pretty_time(self.data["begin"]) @property def end(self) -> datetime: """When cleaning was finished.""" - return pretty_time(self.data[1]) + return pretty_time(self.data["end"]) @property def duration(self) -> timedelta: """Total duration of the cleaning run.""" - return pretty_seconds(self.data[2]) + return pretty_seconds(self.data["duration"]) @property def area(self) -> float: """Total cleaned area.""" - return pretty_area(self.data[3]) + return pretty_area(self.data["area"]) @property def error_code(self) -> int: """Error code.""" - return int(self.data[4]) + return int(self.data["error"]) @property def error(self) -> str: """Error state of this cleaning run.""" - return error_codes[self.data[4]] + return error_codes[self.data["error"]] @property def complete(self) -> bool: @@ -258,7 +290,7 @@ def complete(self) -> bool: see also :func:`error`. """ - return bool(self.data[5] == 1) + return bool(self.data["complete"] == 1) class ConsumableStatus(DeviceStatus): diff --git a/miio/walkingpad.py b/miio/walkingpad.py new file mode 100644 index 000000000..d9bb877c5 --- /dev/null +++ b/miio/walkingpad.py @@ -0,0 +1,287 @@ +import enum +import logging +from datetime import timedelta +from typing import Any, Dict + +import click + +from .click_common import EnumType, command, format_output +from .device import Device, DeviceStatus +from .exceptions import DeviceException + +_LOGGER = logging.getLogger(__name__) + + +class WalkingpadException(DeviceException): + pass + + +class OperationMode(enum.Enum): + Auto = 0 + Manual = 1 + Off = 2 + + +class OperationSensitivity(enum.Enum): + High = 1 + Medium = 2 + Low = 3 + + +class WalkingpadStatus(DeviceStatus): + """Container for status reports from Xiaomi Walkingpad A1 (ksmb.walkingpad.v3). + + Input data dictionary to initialise this class: + + {'cal': 6130, + 'dist': 90, + 'mode': 1, + 'power': 'on', + 'sensitivity': 1, + 'sp': 3.0, + 'start_speed': 3.0, + 'step': 180, + 'time': 121} + """ + + def __init__(self, data: Dict[str, Any]) -> None: + self.data = data + + @property + def power(self) -> str: + """Power state.""" + return self.data["power"] + + @property + def is_on(self) -> bool: + """True if the device is turned on.""" + return self.power == "on" + + @property + def walking_time(self) -> timedelta: + """Current walking duration in seconds.""" + return timedelta(seconds=int(self.data["time"])) + + @property + def speed(self) -> float: + """Current speed.""" + return float(self.data["sp"]) + + @property + def start_speed(self) -> float: + """Current start speed.""" + return self.data["start_speed"] + + @property + def mode(self) -> OperationMode: + """Current mode.""" + return OperationMode(self.data["mode"]) + + @property + def sensitivity(self) -> OperationSensitivity: + """Current sensitivity.""" + return OperationSensitivity(self.data["sensitivity"]) + + @property + def step_count(self) -> int: + """Current steps.""" + return int(self.data["step"]) + + @property + def distance(self) -> int: + """Current distance in meters.""" + return int(self.data["dist"]) + + @property + def calories(self) -> int: + """Current calories burnt.""" + return int(self.data["cal"]) + + +class Walkingpad(Device): + """Main class representing Xiaomi Walkingpad.""" + + @command( + default_output=format_output( + "", + "Power: {result.power}\n" + "Mode: {result.mode.name}\n" + "Time: {result.walking_time}\n" + "Steps: {result.step_count}\n" + "Speed: {result.speed}\n" + "Start Speed: {result.start_speed}\n" + "Sensitivity: {result.sensitivity.name}\n" + "Distance: {result.distance}\n" + "Calories: {result.calories}", + ) + ) + def status(self) -> WalkingpadStatus: + """Retrieve properties.""" + + data = self._get_quick_status() + + # The quick status only retrieves a subset of the properties. The rest of them are retrieved here. + properties_additional = ["power", "mode", "start_speed", "sensitivity"] + values_additional = self.get_properties(properties_additional, max_properties=1) + + additional_props = dict(zip(properties_additional, values_additional)) + data.update(additional_props) + + return WalkingpadStatus(data) + + @command( + default_output=format_output( + "", + "Mode: {result.mode.name}\n" + "Walking time: {result.walking_time}\n" + "Steps: {result.step_count}\n" + "Speed: {result.speed}\n" + "Distance: {result.distance}\n" + "Calories: {result.calories}", + ) + ) + def quick_status(self) -> WalkingpadStatus: + """Retrieve quick status. + + The walkingpad provides the option to retrieve a subset of properties in one call: + steps, mode, speed, distance, calories and time. + + `status()` will do four more separate I/O requests for power, mode, start_speed, and sensitivity. + If you don't need any of that, prefer this method for status updates. + """ + + data = self._get_quick_status() + + return WalkingpadStatus(data) + + @command(default_output=format_output("Powering on")) + def on(self): + """Power on.""" + return self.send("set_power", ["on"]) + + @command(default_output=format_output("Powering off")) + def off(self): + """Power off.""" + return self.send("set_power", ["off"]) + + @command(default_output=format_output("Locking")) + def lock(self): + """Lock device.""" + return self.send("set_lock", [1]) + + @command(default_output=format_output("Unlocking")) + def unlock(self): + """Unlock device.""" + return self.send("set_lock", [0]) + + @command(default_output=format_output("Starting the treadmill")) + def start(self): + """Start the treadmill.""" + + # In case the treadmill is not already turned on, turn it on. + if not self.status().is_on: + self.on() + + return self.send("set_state", ["run"]) + + @command(default_output=format_output("Stopping the treadmill")) + def stop(self): + """Stop the treadmill.""" + return self.send("set_state", ["stop"]) + + @command( + click.argument("mode", type=EnumType(OperationMode)), + default_output=format_output("Setting mode to '{mode.name}'"), + ) + def set_mode(self, mode: OperationMode): + """Set mode (auto/manual).""" + + if not isinstance(mode, OperationMode): + raise WalkingpadException("Invalid mode: %s" % mode) + + return self.send("set_mode", [mode.value]) + + @command( + click.argument("speed", type=float), + default_output=format_output("Setting speed to {speed}"), + ) + def set_speed(self, speed: float): + """Set speed.""" + + # In case the treadmill is not already turned on, throw an exception. + if not self.status().is_on: + raise WalkingpadException("Cannot set the speed, device is turned off") + + if not isinstance(speed, float): + raise WalkingpadException("Invalid speed: %s" % speed) + + if speed < 0 or speed > 6: + raise WalkingpadException("Invalid speed: %s" % speed) + + return self.send("set_speed", [speed]) + + @command( + click.argument("speed", type=float), + default_output=format_output("Setting start speed to {speed}"), + ) + def set_start_speed(self, speed: float): + """Set start speed.""" + + # In case the treadmill is not already turned on, throw an exception. + if not self.status().is_on: + raise WalkingpadException( + "Cannot set the start speed, device is turned off" + ) + + if not isinstance(speed, float): + raise WalkingpadException("Invalid start speed: %s" % speed) + + if speed < 0 or speed > 6: + raise WalkingpadException("Invalid start speed: %s" % speed) + + return self.send("set_start_speed", [speed]) + + @command( + click.argument("sensitivity", type=EnumType(OperationSensitivity)), + default_output=format_output("Setting sensitivity to {sensitivity}"), + ) + def set_sensitivity(self, sensitivity: OperationSensitivity): + """Set sensitivity.""" + + if not isinstance(sensitivity, OperationSensitivity): + raise WalkingpadException("Invalid mode: %s" % sensitivity) + + return self.send("set_sensitivity", [sensitivity.value]) + + def _get_quick_status(self): + """Internal helper to get the quick status via the "all" property.""" + + # Walkingpad A1 allows you to quickly retrieve a subset of values with "all" + # all other properties need to be retrieved one by one and are therefore slower + # eg ['mode:1', 'time:1387', 'sp:3.0', 'dist:1150', 'cal:71710', 'step:2117'] + + properties = ["all"] + + values = self.get_properties(properties, max_properties=1) + + value_map = { + "sp": float, + "step": int, + "cal": int, + "time": int, + "dist": int, + "mode": int, + } + + data = {} + for x in values: + prop, value = x.split(":") + + if prop not in value_map: + _LOGGER.warning("Received unknown data from device: %s=%s", prop, value) + + data[prop] = value + + converted_data = {key: value_map[key](value) for key, value in data.items()} + + return converted_data diff --git a/miio/yeelight.py b/miio/yeelight.py index d87f5e003..4841ab0b0 100644 --- a/miio/yeelight.py +++ b/miio/yeelight.py @@ -70,7 +70,10 @@ def color_temp(self) -> Optional[int]: @property def developer_mode(self) -> bool: """Return whether the developer mode is active.""" - return bool(int(self.data["lan_ctrl"])) + lan_ctrl = self.data["lan_ctrl"] + if lan_ctrl: + return bool(int(lan_ctrl)) + return None @property def save_state_on_change(self) -> bool: diff --git a/poetry.lock b/poetry.lock index 4ad181d5e..0aaf28b29 100644 --- a/poetry.lock +++ b/poetry.lock @@ -108,7 +108,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "construct" -version = "2.10.63" +version = "2.10.67" description = "A powerful declarative symmetric parser/builder for binary data" category = "main" optional = false @@ -142,7 +142,7 @@ python-dateutil = "*" [[package]] name = "cryptography" -version = "3.4.6" +version = "3.4.7" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." category = "main" optional = false @@ -220,7 +220,7 @@ python-versions = "*" [[package]] name = "identify" -version = "2.2.0" +version = "2.2.4" description = "File identification library for Python" category = "dev" optional = false @@ -349,7 +349,7 @@ python-versions = "*" [[package]] name = "nodeenv" -version = "1.5.0" +version = "1.6.0" description = "Node.js virtual environment builder" category = "dev" optional = false @@ -368,7 +368,7 @@ pyparsing = ">=2.0.2" [[package]] name = "pbr" -version = "5.5.1" +version = "5.6.0" description = "Python Build Reasonableness" category = "main" optional = false @@ -390,7 +390,7 @@ dev = ["pre-commit", "tox"] [[package]] name = "pre-commit" -version = "2.11.1" +version = "2.12.1" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -478,11 +478,11 @@ testing = ["fields", "hunter", "process-tests (==2.0.2)", "six", "pytest-xdist", [[package]] name = "pytest-mock" -version = "3.5.1" +version = "3.6.0" description = "Thin-wrapper around the mock package for easier use with pytest" category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [package.dependencies] pytest = ">=5.0" @@ -564,7 +564,7 @@ python-versions = "*" [[package]] name = "sphinx" -version = "3.5.3" +version = "3.5.4" description = "Python documentation generator" category = "main" optional = true @@ -574,7 +574,7 @@ python-versions = ">=3.5" alabaster = ">=0.7,<0.8" babel = ">=1.3" colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} -docutils = ">=0.12" +docutils = ">=0.12,<0.17" imagesize = "*" Jinja2 = ">=2.3" packaging = "*" @@ -608,13 +608,14 @@ sphinx = ">=1.5,<4.0" [[package]] name = "sphinx-rtd-theme" -version = "0.5.1" +version = "0.5.2" description = "Read the Docs theme for Sphinx" category = "main" optional = true python-versions = "*" [package.dependencies] +docutils = "<0.17" sphinx = "*" [package.extras] @@ -748,7 +749,7 @@ testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "psutil (>=5.6.1)", "pytes [[package]] name = "tqdm" -version = "4.59.0" +version = "4.60.0" description = "Fast, Extensible Progress Meter" category = "main" optional = false @@ -782,7 +783,7 @@ brotli = ["brotlipy (>=0.6.0)"] [[package]] name = "virtualenv" -version = "20.4.3" +version = "20.4.4" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -818,7 +819,7 @@ python-versions = "*" [[package]] name = "zeroconf" -version = "0.28.8" +version = "0.29.0" description = "Pure Python Multicast DNS Service Discovery Library (Bonjour/Avahi compatible)" category = "main" optional = false @@ -845,7 +846,7 @@ docs = ["sphinx", "sphinx_click", "sphinxcontrib-apidoc", "sphinx_rtd_theme"] [metadata] lock-version = "1.1" python-versions = "^3.6.5" -content-hash = "d5fa1b5667c0c2fe480a25cfc0d087bbd053690f39612d5d755b288ac722f217" +content-hash = "3da0c91560651ae78e026b65bd58de3d56ba8086fb7f3fdde51e1a1b49cab391" [metadata.files] alabaster = [ @@ -931,7 +932,7 @@ colorama = [ {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] construct = [ - {file = "construct-2.10.63.tar.gz", hash = "sha256:b33a0ecf1fcc51360d263792b44fea658d588ec329eba32c4bfedb20fb680ce4"}, + {file = "construct-2.10.67.tar.gz", hash = "sha256:730235fedf4f2fee5cfadda1d14b83ef1bf23790fb1cc579073e10f70a050883"}, ] coverage = [ {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, @@ -992,18 +993,18 @@ croniter = [ {file = "croniter-0.3.37.tar.gz", hash = "sha256:12ced475dfc107bf7c6c1440af031f34be14cd97bbbfaf0f62221a9c11e86404"}, ] cryptography = [ - {file = "cryptography-3.4.6-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:57ad77d32917bc55299b16d3b996ffa42a1c73c6cfa829b14043c561288d2799"}, - {file = "cryptography-3.4.6-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:4169a27b818de4a1860720108b55a2801f32b6ae79e7f99c00d79f2a2822eeb7"}, - {file = "cryptography-3.4.6-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:93cfe5b7ff006de13e1e89830810ecbd014791b042cbe5eec253be11ac2b28f3"}, - {file = "cryptography-3.4.6-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:5ecf2bcb34d17415e89b546dbb44e73080f747e504273e4d4987630493cded1b"}, - {file = "cryptography-3.4.6-cp36-abi3-manylinux2014_x86_64.whl", hash = "sha256:fec7fb46b10da10d9e1d078d1ff8ed9e05ae14f431fdbd11145edd0550b9a964"}, - {file = "cryptography-3.4.6-cp36-abi3-win32.whl", hash = "sha256:df186fcbf86dc1ce56305becb8434e4b6b7504bc724b71ad7a3239e0c9d14ef2"}, - {file = "cryptography-3.4.6-cp36-abi3-win_amd64.whl", hash = "sha256:66b57a9ca4b3221d51b237094b0303843b914b7d5afd4349970bb26518e350b0"}, - {file = "cryptography-3.4.6-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:066bc53f052dfeda2f2d7c195cf16fb3e5ff13e1b6b7415b468514b40b381a5b"}, - {file = "cryptography-3.4.6-pp36-pypy36_pp73-manylinux2014_x86_64.whl", hash = "sha256:600cf9bfe75e96d965509a4c0b2b183f74a4fa6f5331dcb40fb7b77b7c2484df"}, - {file = "cryptography-3.4.6-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:0923ba600d00718d63a3976f23cab19aef10c1765038945628cd9be047ad0336"}, - {file = "cryptography-3.4.6-pp37-pypy37_pp73-manylinux2014_x86_64.whl", hash = "sha256:9e98b452132963678e3ac6c73f7010fe53adf72209a32854d55690acac3f6724"}, - {file = "cryptography-3.4.6.tar.gz", hash = "sha256:2d32223e5b0ee02943f32b19245b61a62db83a882f0e76cc564e1cec60d48f87"}, + {file = "cryptography-3.4.7-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:3d8427734c781ea5f1b41d6589c293089704d4759e34597dce91014ac125aad1"}, + {file = "cryptography-3.4.7-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e56e16617872b0957d1c9742a3f94b43533447fd78321514abbe7db216aa250"}, + {file = "cryptography-3.4.7-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:37340614f8a5d2fb9aeea67fd159bfe4f5f4ed535b1090ce8ec428b2f15a11f2"}, + {file = "cryptography-3.4.7-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:240f5c21aef0b73f40bb9f78d2caff73186700bf1bc6b94285699aff98cc16c6"}, + {file = "cryptography-3.4.7-cp36-abi3-manylinux2014_x86_64.whl", hash = "sha256:1e056c28420c072c5e3cb36e2b23ee55e260cb04eee08f702e0edfec3fb51959"}, + {file = "cryptography-3.4.7-cp36-abi3-win32.whl", hash = "sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d"}, + {file = "cryptography-3.4.7-cp36-abi3-win_amd64.whl", hash = "sha256:de4e5f7f68220d92b7637fc99847475b59154b7a1b3868fb7385337af54ac9ca"}, + {file = "cryptography-3.4.7-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:26965837447f9c82f1855e0bc8bc4fb910240b6e0d16a664bb722df3b5b06873"}, + {file = "cryptography-3.4.7-pp36-pypy36_pp73-manylinux2014_x86_64.whl", hash = "sha256:eb8cc2afe8b05acbd84a43905832ec78e7b3873fb124ca190f574dca7389a87d"}, + {file = "cryptography-3.4.7-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:7ec5d3b029f5fa2b179325908b9cd93db28ab7b85bb6c1db56b10e0b54235177"}, + {file = "cryptography-3.4.7-pp37-pypy37_pp73-manylinux2014_x86_64.whl", hash = "sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9"}, + {file = "cryptography-3.4.7.tar.gz", hash = "sha256:3d10de8116d25649631977cb37da6cbdd2d6fa0e0281d014a5b7d337255ca713"}, ] defusedxml = [ {file = "defusedxml-0.6.0-py2.py3-none-any.whl", hash = "sha256:6687150770438374ab581bb7a1b327a847dd9c5749e396102de3fad4e8a3ef93"}, @@ -1029,8 +1030,8 @@ filelock = [ {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, ] identify = [ - {file = "identify-2.2.0-py2.py3-none-any.whl", hash = "sha256:39c0b110c9d0cd2391b6c38cd0ff679ee4b4e98f8db8b06c5d9d9e502711a1e1"}, - {file = "identify-2.2.0.tar.gz", hash = "sha256:efbf090a619255bc31c4fbba709e2805f7d30913fd4854ad84ace52bd276e2f6"}, + {file = "identify-2.2.4-py2.py3-none-any.whl", hash = "sha256:ad9f3fa0c2316618dc4d840f627d474ab6de106392a4f00221820200f490f5a8"}, + {file = "identify-2.2.4.tar.gz", hash = "sha256:9bcc312d4e2fa96c7abebcdfb1119563b511b5e3985ac52f60d9116277865b2e"}, ] idna = [ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, @@ -1128,24 +1129,24 @@ netifaces = [ {file = "netifaces-0.10.9.tar.gz", hash = "sha256:2dee9ffdd16292878336a58d04a20f0ffe95555465fee7c9bd23b3490ef2abf3"}, ] nodeenv = [ - {file = "nodeenv-1.5.0-py2.py3-none-any.whl", hash = "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9"}, - {file = "nodeenv-1.5.0.tar.gz", hash = "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"}, + {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, + {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, ] packaging = [ {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, ] pbr = [ - {file = "pbr-5.5.1-py2.py3-none-any.whl", hash = "sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00"}, - {file = "pbr-5.5.1.tar.gz", hash = "sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9"}, + {file = "pbr-5.6.0-py2.py3-none-any.whl", hash = "sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4"}, + {file = "pbr-5.6.0.tar.gz", hash = "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd"}, ] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] pre-commit = [ - {file = "pre_commit-2.11.1-py2.py3-none-any.whl", hash = "sha256:94c82f1bf5899d56edb1d926732f4e75a7df29a0c8c092559c77420c9d62428b"}, - {file = "pre_commit-2.11.1.tar.gz", hash = "sha256:de55c5c72ce80d79106e48beb1b54104d16495ce7f95b0c7b13d4784193a00af"}, + {file = "pre_commit-2.12.1-py2.py3-none-any.whl", hash = "sha256:70c5ec1f30406250b706eda35e868b87e3e4ba099af8787e3e8b4b01e84f4712"}, + {file = "pre_commit-2.12.1.tar.gz", hash = "sha256:900d3c7e1bf4cf0374bb2893c24c23304952181405b4d88c9c40b72bda1bb8a9"}, ] py = [ {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, @@ -1172,8 +1173,8 @@ pytest-cov = [ {file = "pytest_cov-2.11.1-py2.py3-none-any.whl", hash = "sha256:bdb9fdb0b85a7cc825269a4c56b48ccaa5c7e365054b6038772c32ddcdc969da"}, ] pytest-mock = [ - {file = "pytest-mock-3.5.1.tar.gz", hash = "sha256:a1e2aba6af9560d313c642dae7e00a2a12b022b80301d9d7fc8ec6858e1dd9fc"}, - {file = "pytest_mock-3.5.1-py3-none-any.whl", hash = "sha256:379b391cfad22422ea2e252bdfc008edd08509029bcde3c25b2c0bd741e0424e"}, + {file = "pytest-mock-3.6.0.tar.gz", hash = "sha256:f7c3d42d6287f4e45846c8231c31902b6fa2bea98735af413a43da4cf5b727f1"}, + {file = "pytest_mock-3.6.0-py3-none-any.whl", hash = "sha256:952139a535b5b48ac0bb2f90b5dd36b67c7e1ba92601f3a8012678c4bd7f0bcc"}, ] python-dateutil = [ {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, @@ -1222,16 +1223,16 @@ snowballstemmer = [ {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"}, ] sphinx = [ - {file = "Sphinx-3.5.3-py3-none-any.whl", hash = "sha256:3f01732296465648da43dec8fb40dc451ba79eb3e2cc5c6d79005fd98197107d"}, - {file = "Sphinx-3.5.3.tar.gz", hash = "sha256:ce9c228456131bab09a3d7d10ae58474de562a6f79abb3dc811ae401cf8c1abc"}, + {file = "Sphinx-3.5.4-py3-none-any.whl", hash = "sha256:2320d4e994a191f4b4be27da514e46b3d6b420f2ff895d064f52415d342461e8"}, + {file = "Sphinx-3.5.4.tar.gz", hash = "sha256:19010b7b9fa0dc7756a6e105b2aacd3a80f798af3c25c273be64d7beeb482cb1"}, ] sphinx-click = [ {file = "sphinx-click-2.7.1.tar.gz", hash = "sha256:1b6175df5392564fd3780000d4627e5a2c8c3b29d05ad311dbbe38fcf5f3327b"}, {file = "sphinx_click-2.7.1-py2.py3-none-any.whl", hash = "sha256:e738a2c7a87f23e67da4a9e28ca6f085d3ca626f0e4164847f77ff3c36c65df1"}, ] sphinx-rtd-theme = [ - {file = "sphinx_rtd_theme-0.5.1-py2.py3-none-any.whl", hash = "sha256:fa6bebd5ab9a73da8e102509a86f3fcc36dec04a0b52ea80e5a033b2aba00113"}, - {file = "sphinx_rtd_theme-0.5.1.tar.gz", hash = "sha256:eda689eda0c7301a80cf122dad28b1861e5605cbf455558f3775e1e8200e83a5"}, + {file = "sphinx_rtd_theme-0.5.2-py2.py3-none-any.whl", hash = "sha256:4a05bdbe8b1446d77a01e20a23ebc6777c74f43237035e76be89699308987d6f"}, + {file = "sphinx_rtd_theme-0.5.2.tar.gz", hash = "sha256:32bd3b5d13dc8186d7a42fc816a23d32e83a4827d7d9882948e7b837c232da5a"}, ] sphinxcontrib-apidoc = [ {file = "sphinxcontrib-apidoc-0.3.0.tar.gz", hash = "sha256:729bf592cf7b7dd57c4c05794f732dc026127275d785c2a5494521fdde773fb9"}, @@ -1274,8 +1275,8 @@ tox = [ {file = "tox-3.23.0.tar.gz", hash = "sha256:05a4dbd5e4d3d8269b72b55600f0b0303e2eb47ad5c6fe76d3576f4c58d93661"}, ] tqdm = [ - {file = "tqdm-4.59.0-py2.py3-none-any.whl", hash = "sha256:9fdf349068d047d4cfbe24862c425883af1db29bcddf4b0eeb2524f6fbdb23c7"}, - {file = "tqdm-4.59.0.tar.gz", hash = "sha256:d666ae29164da3e517fcf125e41d4fe96e5bb375cd87ff9763f6b38b5592fe33"}, + {file = "tqdm-4.60.0-py2.py3-none-any.whl", hash = "sha256:daec693491c52e9498632dfbe9ccfc4882a557f5fa08982db1b4d3adbe0887c3"}, + {file = "tqdm-4.60.0.tar.gz", hash = "sha256:ebdebdb95e3477ceea267decfc0784859aa3df3e27e22d23b83e9b272bf157ae"}, ] untokenize = [ {file = "untokenize-0.1.1.tar.gz", hash = "sha256:3865dbbbb8efb4bb5eaa72f1be7f3e0be00ea8b7f125c69cbd1f5fda926f37a2"}, @@ -1285,8 +1286,8 @@ urllib3 = [ {file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"}, ] virtualenv = [ - {file = "virtualenv-20.4.3-py2.py3-none-any.whl", hash = "sha256:83f95875d382c7abafe06bd2a4cdd1b363e1bb77e02f155ebe8ac082a916b37c"}, - {file = "virtualenv-20.4.3.tar.gz", hash = "sha256:49ec4eb4c224c6f7dd81bb6d0a28a09ecae5894f4e593c89b0db0885f565a107"}, + {file = "virtualenv-20.4.4-py2.py3-none-any.whl", hash = "sha256:a935126db63128861987a7d5d30e23e8ec045a73840eeccb467c148514e29535"}, + {file = "virtualenv-20.4.4.tar.gz", hash = "sha256:09c61377ef072f43568207dc8e46ddeac6bcdcaf288d49011bda0e7f4d38c4a2"}, ] voluptuous = [ {file = "voluptuous-0.12.1-py3-none-any.whl", hash = "sha256:8ace33fcf9e6b1f59406bfaf6b8ec7bcc44266a9f29080b4deb4fe6ff2492386"}, @@ -1297,8 +1298,8 @@ wcwidth = [ {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, ] zeroconf = [ - {file = "zeroconf-0.28.8-py3-none-any.whl", hash = "sha256:3608be2db58f6f0dc70665e02ab420fb8bf428016f2c78403d879e066ecc9bff"}, - {file = "zeroconf-0.28.8.tar.gz", hash = "sha256:4be24a10aa9c73406f48d42a8b3b077c217b0e8d7ed1e57639630da520c25959"}, + {file = "zeroconf-0.29.0-py3-none-any.whl", hash = "sha256:85fdeeef88b08965ab87559177457cfdb5dd2e4bc62a476208c2473a51dfa0b2"}, + {file = "zeroconf-0.29.0.tar.gz", hash = "sha256:7aefbb658b452b1fd7e51124364f938c6f5e42d6ea893fa2557bea8c06c540af"}, ] zipp = [ {file = "zipp-3.4.1-py3-none-any.whl", hash = "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098"}, diff --git a/pyproject.toml b/pyproject.toml index 96217604b..fc3edd125 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-miio" -version = "0.5.5.2" +version = "0.5.6" description = "Python library for interfacing with Xiaomi smart appliances" authors = ["Teemu R "] repository = "https://github.com/rytilahti/python-miio" @@ -25,7 +25,7 @@ python = "^3.6.5" click = "^7" cryptography = "^3" construct = "^2.10.56" -zeroconf = "^0.28" +zeroconf = "^0" attrs = "*" pytz = "*" appdirs = "^1"