From 1ee45687a7f2506345cbc608990755ad0c4d0f7d Mon Sep 17 00:00:00 2001 From: Marcin Jaworski Date: Sun, 28 Jan 2018 22:59:07 +0100 Subject: [PATCH 01/16] Framework for device class based cli configuration --- miio/cli.py | 35 +++++++++++ miio/click_common.py | 139 +++++++++++++++++++++++++++++++++++++++++++ setup.py | 3 +- 3 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 miio/cli.py diff --git a/miio/cli.py b/miio/cli.py new file mode 100644 index 000000000..6160e5913 --- /dev/null +++ b/miio/cli.py @@ -0,0 +1,35 @@ +# -*- coding: UTF-8 -*- +import logging +import click +from miio.click_common import ( + ExceptionHandlerGroup, DeviceGroupMeta, GlobalContextObject +) + +_LOGGER = logging.getLogger(__name__) + + +@click.group(cls=ExceptionHandlerGroup) +@click.option('-d', '--debug', default=False, count=True) +@click.pass_context +def cli(ctx, debug: int): + if debug: + logging.basicConfig(level=logging.DEBUG) + _LOGGER.info("Debug mode active") + else: + logging.basicConfig(level=logging.INFO) + + ctx.obj = GlobalContextObject( + debug=debug + ) + + +for device_class in DeviceGroupMeta.device_classes: + cli.add_command(device_class.get_device_group()) + + +def create_cli(): + return cli(auto_envvar_prefix="MIIO") + + +if __name__ == '__main__': + create_cli() diff --git a/miio/click_common.py b/miio/click_common.py index 63a16b485..ed30e5800 100644 --- a/miio/click_common.py +++ b/miio/click_common.py @@ -11,6 +11,9 @@ import ipaddress import miio import logging +from typing import Union +from functools import wraps +from functools import partial _LOGGER = logging.getLogger(__name__) @@ -48,3 +51,139 @@ def __call__(self, *args, **kwargs): except miio.DeviceException as ex: _LOGGER.debug("Exception: %s", ex, exc_info=True) click.echo(click.style("Error: %s" % ex, fg='red', bold=True)) + + +class GlobalContextObject: + def __init__(self, debug: int=0): + self.debug = debug + + +class DeviceGroupMeta(type): + + device_classes = set() + + def __new__(mcs, name, bases, namespace) -> type: + commands = {} + for key, val in namespace.items(): + if not callable(val): + continue + device_group_command = getattr(val, '_device_group_command', None) + if device_group_command is None: + continue + commands[device_group_command.command_name] = device_group_command + + namespace['_device_group_commands'] = commands + if 'get_device_group' not in namespace: + + def get_device_group(dcls): + return DeviceGroup(dcls) + + namespace['get_device_group'] = classmethod(get_device_group) + + cls = super().__new__(mcs, name, bases, namespace) + mcs.device_classes.add(cls) + return cls + + +class DeviceGroup(click.MultiCommand): + + class Command: + def __init__(self, name, decorators, **kwargs): + self.name = name + self.decorators = list(decorators) + self.decorators.reverse() + self.kwargs = kwargs + + def __call__(self, func): + self.func = func + func._device_group_command = self + self.kwargs.setdefault('help', self.func.__doc__) + return func + + @property + def command_name(self): + return self.name or self.func.__name__.lower() + + def wrap(self, func): + for decorator in self.decorators: + func = decorator(func) + return click.command(self.command_name, **self.kwargs)(func) + + def call(self, owner, *args, **kwargs): + method = getattr(owner, self.func.__name__) + return method(*args, **kwargs) + + DEFAULT_PARAMS = [ + click.Option(['--ip'], required=True, callback=validate_ip), + click.Option(['--token'], required=True, callback=validate_token), + ] + + def __init__(self, device_class, name=None, invoke_without_command=False, + no_args_is_help=None, subcommand_metavar=None, chain=False, + result_callback=None, result_callback_pass_device=True, + **attrs): + + self.commands = getattr(device_class, '_device_group_commands', None) + if self.commands is None: + raise RuntimeError( + "Class {} doesn't use DeviceGroupMeta meta class." + " It can't be used with DeviceGroup." + ) + + self.device_class = device_class + self.device_pass = click.make_pass_decorator(device_class) + + attrs.setdefault('params', self.DEFAULT_PARAMS) + attrs.setdefault('callback', click.pass_context(self.group_callback)) + if result_callback_pass_device and callable(result_callback): + result_callback = self.device_pass(result_callback) + + super().__init__(name or device_class.__name__.lower(), + invoke_without_command, no_args_is_help, + subcommand_metavar, chain, result_callback, **attrs) + + def group_callback(self, ctx, *args, **kwargs): + gco = ctx.find_object(GlobalContextObject) + if gco: + kwargs['debug'] = gco.debug + ctx.obj = self.device_class(*args, **kwargs) + + def command_callback(self, command, device, *args, **kwargs): + return command.call(device, *args, **kwargs) + + def get_command(self, ctx, cmd_name): + cmd = self.commands[cmd_name] + return self.commands[cmd_name].wrap(self.device_pass(partial( + self.command_callback, cmd + ))) + + def list_commands(self, ctx): + return sorted(self.commands.keys()) + + +def device_command(*decorators, name=None, **kwargs): + return DeviceGroup.Command(name, decorators, **kwargs) + + +def echo_return_status(msg_fmt: Union[str, callable]="", + result_msg_fmt: Union[str, callable]="{result}"): + def decorator(func): + @wraps(func) + def wrap(*args, **kwargs): + if msg_fmt: + if callable(msg_fmt): + msg = msg_fmt(**kwargs) + else: + msg = msg_fmt.format(**kwargs) + if msg: + click.echo(msg.strip()) + kwargs['result'] = func(*args, **kwargs) + if result_msg_fmt: + if callable(result_msg_fmt): + result_msg = result_msg_fmt(**kwargs) + else: + result_msg = result_msg_fmt.format(**kwargs) + if result_msg: + click.echo(result_msg.strip()) + return wrap + return decorator diff --git a/setup.py b/setup.py index 5ffb39443..afe0d4703 100644 --- a/setup.py +++ b/setup.py @@ -61,7 +61,8 @@ def readme(): 'miplug=miio.plug_cli:cli', 'miceil=miio.ceil_cli:cli', 'mieye=miio.philips_eyecare_cli:cli', - 'miio-extract-tokens=miio.extract_tokens:main' + 'miio-extract-tokens=miio.extract_tokens:main', + 'miio=miio.cli:create_cli', ], }, ) From d140f236af29795686aa35ae70ff65a95f2bc7c6 Mon Sep 17 00:00:00 2001 From: Marcin Jaworski Date: Sun, 28 Jan 2018 22:59:33 +0100 Subject: [PATCH 02/16] Air Purifier support for new cli --- miio/airpurifier.py | 101 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/miio/airpurifier.py b/miio/airpurifier.py index 96168804a..f5bc3925a 100644 --- a/miio/airpurifier.py +++ b/miio/airpurifier.py @@ -3,7 +3,10 @@ import re from typing import Any, Dict, Optional from collections import defaultdict +from functools import wraps +import click from .device import Device, DeviceException +from .click_common import DeviceGroupMeta, device_command, echo_return_status _LOGGER = logging.getLogger(__name__) @@ -353,9 +356,40 @@ def __repr__(self) -> str: return s -class AirPurifier(Device): +class AirPurifier(Device, metaclass=DeviceGroupMeta): """Main class representing the air purifier.""" + @device_command( + echo_return_status("", """ +Power: {result.power} +AQI: {result.aqi} μg/m³ +Average AQI: {result.average_aqi} μg/m³ +Temperature: {result.temperature} °C +Humidity: {result.humidity} % +Mode: {result.mode.value} +LED: {result.led} +LED brightness: {result.led_brightness} +Illuminance: {result.illuminance} lx +Buzzer: {result.buzzer} +Child lock: {result.child_lock} +Favorite level: {result.favorite_level} +Filter life remaining: {result.filter_life_remaining} % +Filter hours used: {result.filter_hours_used} +Use time: {result.use_time} s +Purify volume: {result.purify_volume} m³ +Motor speed: {result.motor_speed} rpm +Motor 2 speed: {result.motor2_speed} rpm +Sound volume: {result.volume} % +Filter RFID product id: {result.filter_rfid_product_id} +Filter RFID tag: {result.filter_rfid_tag} +Filter type: {result.filter_type.value} +Learn mode: {result.learn_mode} +Sleep mode: {result.sleep_mode.value} +Sleep time: {result.sleep_time} +Sleep mode learn count: {result.sleep_mode_learn_count} +AQI sensor enabled on power off: {result.auto_detect} + """) + ) def status(self) -> AirPurifierStatus: """Retrieve properties.""" @@ -388,18 +422,32 @@ def status(self) -> AirPurifierStatus: return AirPurifierStatus( defaultdict(lambda: None, zip(properties, values))) + @device_command( + echo_return_status("Powering on"), + ) def on(self): """Power on.""" return self.send("set_power", ["on"]) + @device_command( + echo_return_status("Powering off") + ) def off(self): """Power off.""" return self.send("set_power", ["off"]) + @device_command( + click.argument("mode", type=OperationMode), + echo_return_status("Setting mode to '{mode.value}'") + ) def set_mode(self, mode: OperationMode): """Set mode.""" return self.send("set_mode", [mode.value]) + @device_command( + click.argument("level", type=int), + echo_return_status("Setting favorite level to {level}") + ) def set_favorite_level(self, level: int): """Set favorite level.""" if level < 0 or level > 16: @@ -411,10 +459,22 @@ def set_favorite_level(self, level: int): # should be between 0 and 16. return self.send("set_level_favorite", [level]) # 0 ... 16 + @device_command( + click.argument("brightness", type=LedBrightness), + echo_return_status( + "Setting LED brightness to {brightness}") + ) def set_led_brightness(self, brightness: LedBrightness): """Set led brightness.""" return self.send("set_led_b", [brightness.value]) + @device_command( + click.argument("led", type=bool), + echo_return_status( + lambda led: "Turning on LED" + if led else "Turning off LED" + ) + ) def set_led(self, led: bool): """Turn led on/off.""" if led: @@ -422,6 +482,13 @@ def set_led(self, led: bool): else: return self.send("set_led", ['off']) + @device_command( + click.argument("buzzer", type=bool), + echo_return_status( + lambda buzzer: "Turning on buzzer" + if buzzer else "Turning off buzzer" + ) + ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" if buzzer: @@ -429,6 +496,13 @@ def set_buzzer(self, buzzer: bool): else: return self.send("set_buzzer", ["off"]) + @device_command( + click.argument("lock", type=bool), + echo_return_status( + 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.""" if lock: @@ -436,6 +510,10 @@ def set_child_lock(self, lock: bool): else: return self.send("set_child_lock", ["off"]) + @device_command( + click.argument("volume", type=int), + echo_return_status("Setting favorite level to {volume}") + ) def set_volume(self, volume: int): """Set volume of sound notifications [0-100].""" if volume < 0 or volume > 100: @@ -443,6 +521,13 @@ def set_volume(self, volume: int): return self.send("set_volume", [volume]) + @device_command( + click.argument("learn_mode", type=bool), + echo_return_status( + lambda learn_mode: "Turning on learn mode" + if learn_mode else "Turning off learn mode" + ) + ) def set_learn_mode(self, learn_mode: bool): """Set the Learn Mode on/off.""" if learn_mode: @@ -450,6 +535,13 @@ def set_learn_mode(self, learn_mode: bool): else: return self.send("set_act_sleep", ["close"]) + @device_command( + click.argument("auto_detect", type=bool), + echo_return_status( + lambda auto_detect: "Turning on auto detect" + if auto_detect else "Turning off auto detect" + ) + ) def set_auto_detect(self, auto_detect: bool): """Set auto detect on/off. It's a feature of the AirPurifier V1 & V3""" if auto_detect: @@ -457,6 +549,10 @@ def set_auto_detect(self, auto_detect: bool): else: return self.send("set_act_det", ["off"]) + @device_command( + click.argument("value", type=int), + echo_return_status("Setting extra to {value}") + ) def set_extra_features(self, value: int): """Storage register to enable extra features at the app. @@ -467,6 +563,9 @@ def set_extra_features(self, value: int): return self.send("set_app_extra", [value]) + @device_command( + echo_return_status("Resetting filter") + ) def reset_filter(self): """Resets filter hours used and remaining life.""" return self.send('reset_filter1') From ac79d8b83d0218b871421395c34841e1453fe0fa Mon Sep 17 00:00:00 2001 From: Marcin Jaworski Date: Sun, 28 Jan 2018 22:59:51 +0100 Subject: [PATCH 03/16] WIP: Vacuum support for new cli --- miio/vacuum.py | 133 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 130 insertions(+), 3 deletions(-) diff --git a/miio/vacuum.py b/miio/vacuum.py index 24f0a8a7c..382a45781 100644 --- a/miio/vacuum.py +++ b/miio/vacuum.py @@ -1,15 +1,22 @@ import logging +import os import math import time from typing import List import enum import datetime +import json +import pathlib import pytz - +import click +from appdirs import user_cache_dir from .vacuumcontainers import (VacuumStatus, ConsumableStatus, DNDStatus, CleaningSummary, CleaningDetails, Timer, SoundStatus, SoundInstallStatus) from .device import Device, DeviceException +from .click_common import ( + DeviceGroup, DeviceGroupMeta, device_command, GlobalContextObject +) _LOGGER = logging.getLogger(__name__) @@ -30,7 +37,7 @@ class Consumable(enum.Enum): SensorDirty = "sensor_dirty_time" -class Vacuum(Device): +class Vacuum(Device, metaclass=DeviceGroupMeta): """Main class representing the vacuum.""" def __init__(self, ip: str, token: str = None, start_id: int = 0, @@ -38,37 +45,49 @@ def __init__(self, ip: str, token: str = None, start_id: int = 0, super().__init__(ip, token, start_id, debug) self.manual_seqnum = -1 + @device_command() def start(self): """Start cleaning.""" return self.send("app_start") + @device_command() def stop(self): """Stop cleaning.""" return self.send("app_stop") + @device_command() def spot(self): """Start spot cleaning.""" return self.send("app_spot") + @device_command() def pause(self): """Pause cleaning.""" return self.send("app_pause") + @device_command() def home(self): """Stop cleaning and return home.""" self.send("app_stop") return self.send("app_charge") + @device_command() def manual_start(self): """Start manual control mode.""" self.manual_seqnum = 0 return self.send("app_rc_start") + @device_command() def manual_stop(self): """Stop manual control mode.""" self.manual_seqnum = 0 return self.send("app_rc_end") + @device_command( + click.argument("rotation", type=int), + click.argument("velocity", type=float), + click.argument("duration", type=int, required=False, default=1500) + ) def manual_control_once( self, rotation: int, velocity: float, duration: int=1500): """Starts the remote control mode and executes @@ -85,6 +104,11 @@ def manual_control_once( time.sleep(2) number_of_tries -= 1 + @device_command( + click.argument("rotation", type=int), + click.argument("velocity", type=float), + click.argument("duration", type=int, required=False, default=1500) + ) def manual_control(self, rotation: int, velocity: float, duration: int=1500): """Give a command over manual control interface.""" @@ -103,6 +127,7 @@ def manual_control(self, rotation: int, velocity: float, self.send("app_rc_move", [params]) + @device_command() def status(self) -> VacuumStatus: """Return status of the vacuum.""" return VacuumStatus(self.send("get_status")[0]) @@ -111,27 +136,37 @@ def enable_log_upload(self): raise NotImplementedError("unknown parameters") # return self.send("enable_log_upload") + @device_command() def log_upload_status(self): # {"result": [{"log_upload_status": 7}], "id": 1} return self.send("get_log_upload_status") + @device_command() def consumable_status(self) -> ConsumableStatus: """Return information about consumables.""" return ConsumableStatus(self.send("get_consumable")[0]) + @device_command( + click.argument("consumable", type=Consumable), + ) def consumable_reset(self, consumable: Consumable): """Reset consumable information.""" return self.send("reset_consumable", [consumable.value]) + @device_command() def map(self): """Return map token.""" # returns ['retry'] without internet return self.send("get_map_v1") + @device_command() def clean_history(self) -> CleaningSummary: """Return generic cleaning history.""" return CleaningSummary(self.send("get_clean_summary")) + @device_command( + click.argument("id_", type=int, metavar="ID"), + ) def clean_details(self, id_: int) -> List[CleaningDetails]: """Return details about specific cleaning.""" details = self.send("get_clean_record", [id_]) @@ -142,10 +177,12 @@ def clean_details(self, id_: int) -> List[CleaningDetails]: return res + @device_command() def find(self): """Find the robot.""" return self.send("find_me", [""]) + @device_command() def timer(self) -> List[Timer]: """Return a list of timers.""" timers = list() @@ -154,6 +191,11 @@ def timer(self) -> List[Timer]: return timers + @device_command( + click.argument("cron"), + click.argument("command", required=False, default=""), + click.argument("parameters", required=False, default=""), + ) def add_timer(self, cron: str, command: str, parameters: str): """Add a timer. @@ -166,12 +208,19 @@ def add_timer(self, cron: str, command: str, parameters: str): [str(ts), [cron, [command, parameters]]] ]) + @device_command( + click.argument("timer_id", type=int), + ) def delete_timer(self, timer_id: int): """Delete a timer with given ID. :param int timer_id: Timer ID""" return self.send("del_timer", [str(timer_id)]) + @device_command( + click.argument("timer_id", type=int), + click.argument("mode", type=TimerState), + ) def update_timer(self, timer_id: int, mode: TimerState): """Update a timer with given ID. @@ -181,12 +230,19 @@ def update_timer(self, timer_id: int, mode: TimerState): raise DeviceException("Only 'On' or 'Off' are allowed") return self.send("upd_timer", [str(timer_id), mode.value]) + @device_command() def dnd_status(self): """Returns do-not-disturb status.""" # {'result': [{'enabled': 1, 'start_minute': 0, 'end_minute': 0, # 'start_hour': 22, 'end_hour': 8}], 'id': 1} return DNDStatus(self.send("get_dnd_timer")[0]) + @device_command( + click.argument("start_hr", type=int), + click.argument("start_min", type=int), + click.argument("end_hr", type=int), + click.argument("end_min", type=int), + ) def set_dnd(self, start_hr: int, start_min: int, end_hr: int, end_min: int): """Set do-not-disturb. @@ -198,10 +254,14 @@ def set_dnd(self, start_hr: int, start_min: int, return self.send("set_dnd_timer", [start_hr, start_min, end_hr, end_min]) + @device_command() def disable_dnd(self): """Disable do-not-disturb.""" return self.send("close_dnd_timer", [""]) + @device_command( + click.argument("speed", type=int), + ) def set_fan_speed(self, speed: int): """Set fan speed. @@ -209,14 +269,21 @@ def set_fan_speed(self, speed: int): # speed = [38, 60 or 77] return self.send("set_custom_mode", [speed]) + @device_command() def fan_speed(self): """Return fan speed.""" return self.send("get_custom_mode")[0] + @device_command() def sound_info(self): """Get voice settings.""" return SoundStatus(self.send("get_current_sound")[0]) + @device_command( + click.argument("url"), + click.argument("md5sum"), + click.argument("sound_id", type=int), + ) def install_sound(self, url: str, md5sum: str, sound_id: int): """Install sound from the given url.""" payload = { @@ -226,29 +293,38 @@ def install_sound(self, url: str, md5sum: str, sound_id: int): } return SoundInstallStatus(self.send("dnld_install_sound", payload)[0]) + @device_command() def sound_install_progress(self): """Get sound installation progress.""" return SoundInstallStatus(self.send("get_sound_progress")[0]) + @device_command() def sound_volume(self) -> int: """Get sound volume.""" return self.send("get_sound_volume")[0] + @device_command( + click.argument("vol", type=int), + ) def set_sound_volume(self, vol: int): """Set sound volume [0-100].""" return self.send("change_sound_volume", [vol]) + @device_command() def test_sound_volume(self): """Test current sound volume.""" return self.send("test_sound_volume") + @device_command() def serial_number(self): """Get serial number.""" return self.send("get_serial_number")[0]["serial_number"] + @device_command() def timezone(self): """Get the timezone.""" - return self.send("get_timezone")[0] + # return self.send("get_timezone")[0] + return None def set_timezone(self, new_zone): """Set the timezone.""" @@ -268,3 +344,54 @@ def configure_wifi(self, ssid, password, uid=0, timezone=None): def raw_command(self, cmd, params): """Send a raw command to the robot.""" return self.send(cmd, params) + + @classmethod + def get_device_group(cls): + + @click.pass_context + def callback(ctx, *args, id_file, **kwargs): + gco = ctx.find_object(GlobalContextObject) + if gco: + kwargs['debug'] = gco.debug + + start_id = manual_seq = 0 + try: + with open(id_file, 'r') as f: + x = json.load(f) + start_id = x.get("seq", 0) + manual_seq = x.get("manual_seq", 0) + _LOGGER.debug("Read stored sequence ids: %s", x) + except (FileNotFoundError, TypeError, ValueError): + pass + + ctx.obj = cls(*args, start_id=start_id, **kwargs) + ctx.obj.manual_seqnum = manual_seq + + dg = DeviceGroup(cls, params=DeviceGroup.DEFAULT_PARAMS + [ + click.Option( + ['--id-file'], type=click.Path(dir_okay=False, writable=True), + default=os.path.join( + user_cache_dir('python-miio'), + 'python-mirobo.seq' + ) + ), + ], callback=callback) + + @dg.resultcallback() + @dg.device_pass + def cleanup(vac: Vacuum, **kwargs): + if vac.ip is None: # dummy Device for discovery, skip teardown + return + id_file = kwargs['id_file'] + seqs = {'seq': vac.raw_id, 'manual_seq': vac.manual_seqnum} + _LOGGER.debug("Writing %s to %s", seqs, id_file) + path_obj = pathlib.Path(id_file) + cache_dir = path_obj.parents[0] + try: + cache_dir.mkdir(parents=True) + except FileExistsError: + pass # after dropping py3.4 support, use exist_ok for mkdir + with open(id_file, 'w') as f: + json.dump(seqs, f) + + return dg From 6b1d5fac53f8be02b634f404a898bbcd03ed77bd Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 30 Jan 2018 20:16:16 +0100 Subject: [PATCH 04/16] Add commands from the base classes --- miio/click_common.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/miio/click_common.py b/miio/click_common.py index ed30e5800..f6ca5f52e 100644 --- a/miio/click_common.py +++ b/miio/click_common.py @@ -64,13 +64,25 @@ class DeviceGroupMeta(type): def __new__(mcs, name, bases, namespace) -> type: commands = {} - for key, val in namespace.items(): - if not callable(val): - continue - device_group_command = getattr(val, '_device_group_command', None) - if device_group_command is None: - continue - commands[device_group_command.command_name] = device_group_command + + def _get_commands_for_namespace(namespace): + commands = {} + for key, val in namespace.items(): + if not callable(val): + continue + device_group_command = getattr(val, '_device_group_command', None) + if device_group_command is None: + continue + commands[device_group_command.command_name] = device_group_command + + return commands + + # 1. Go through base classes for commands + for base in bases: + commands.update(getattr(base, '_device_group_commands', {})) + + # 2. Add commands from the current class + commands.update(_get_commands_for_namespace(namespace)) namespace['_device_group_commands'] = commands if 'get_device_group' not in namespace: From d72944ff8b3039cfc0a5c0701426a78e52d80544 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 30 Jan 2018 20:16:49 +0100 Subject: [PATCH 05/16] Add generic commands --- miio/device.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/miio/device.py b/miio/device.py index 3a89fa593..7465e164a 100644 --- a/miio/device.py +++ b/miio/device.py @@ -4,9 +4,13 @@ import logging import construct import binascii +import click from typing import Any, List, Optional # noqa: F401 from enum import Enum +from .click_common import ( + DeviceGroupMeta, device_command, echo_return_status +) from .protocol import Message _LOGGER = logging.getLogger(__name__) @@ -108,7 +112,7 @@ def raw(self): return self.data -class Device: +class Device(metaclass=DeviceGroupMeta): """Base class for all device implementations. This is the main class providing the basic protocol handling for devices using the ``miIO`` protocol. @@ -289,6 +293,10 @@ def send(self, command: str, parameters: Any=None, retry_count=3) -> Any: _LOGGER.error("Got error when receiving: %s", ex) raise DeviceException("No response from the device") from ex + @device_command( + click.argument('cmd', required=True), + click.argument('parameters', required=False), + ) def raw_command(self, cmd, params): """Send a raw command to the device. This is mostly useful when trying out commands which are not @@ -298,6 +306,14 @@ def raw_command(self, cmd, params): :param dict params: Parameters to send""" return self.send(cmd, params) + @device_command( + echo_return_status("", + "Model: {result.model}\n" + "Hardware version: {result.hardware_version}\n" + "Firmware version: {result.firmware_version}\n" + "Network: {result.network_interface}\n" + "AP: {result.accesspoint}\n") + ) def info(self) -> DeviceInfo: """Get miIO protocol information from the device. This includes information about connected wlan network, From 03bbab37731439fa6e2c90898a96dde2a4659b0e Mon Sep 17 00:00:00 2001 From: Marcin Jaworski Date: Mon, 5 Feb 2018 11:36:12 +0100 Subject: [PATCH 06/16] Metaclass already specified in base Device class. No need to specify it in subclasses. --- miio/airpurifier.py | 4 +-- miio/plug.py | 73 +++++++++++++++++++++++++++++++++++++++++++++ miio/vacuum.py | 4 +-- 3 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 miio/plug.py diff --git a/miio/airpurifier.py b/miio/airpurifier.py index f5bc3925a..8ececf4e5 100644 --- a/miio/airpurifier.py +++ b/miio/airpurifier.py @@ -6,7 +6,7 @@ from functools import wraps import click from .device import Device, DeviceException -from .click_common import DeviceGroupMeta, device_command, echo_return_status +from .click_common import device_command, echo_return_status _LOGGER = logging.getLogger(__name__) @@ -356,7 +356,7 @@ def __repr__(self) -> str: return s -class AirPurifier(Device, metaclass=DeviceGroupMeta): +class AirPurifier(Device): """Main class representing the air purifier.""" @device_command( diff --git a/miio/plug.py b/miio/plug.py new file mode 100644 index 000000000..054e1f9dc --- /dev/null +++ b/miio/plug.py @@ -0,0 +1,73 @@ +import logging +from typing import Dict, Any, Optional +from collections import defaultdict +from .device import Device +from .click_common import device_command, echo_return_status + +_LOGGER = logging.getLogger(__name__) + + +class PlugStatus: + """Container for status reports from the plug.""" + + 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: + """Return True if the device is on.""" + return self.power == "on" + + @property + def temperature(self) -> float: + """Return temperature.""" + return self.data["temperature"] + + def __str__(self) -> str: + s = "" % \ + (self.power, + self.temperature) + return s + + +class Plug(Device): + """Main class representing the smart wifi socket / plug.""" + + @device_command( + echo_return_status("", + "Power: {result.power}\n" + "Temperature: {result.temperature}") + ) + def status(self) -> PlugStatus: + """Retrieve properties.""" + properties = ['power', 'temperature'] + values = self.send( + "get_prop", + properties + ) + + properties_count = len(properties) + values_count = len(values) + if properties_count != values_count: + _LOGGER.debug( + "Count (%s) of requested properties does not match the " + "count (%s) of received values.", + properties_count, values_count) + + return PlugStatus( + defaultdict(lambda: None, zip(properties, values))) + + @device_command(echo_return_status("Powering on")) + def on(self): + """Power on.""" + return self.send("set_power", ["on"]) == ['ok'] + + @device_command(echo_return_status("Powering off")) + def off(self): + """Power off.""" + return self.send("set_power", ["off"]) == ['ok'] diff --git a/miio/vacuum.py b/miio/vacuum.py index 382a45781..ff397ec50 100644 --- a/miio/vacuum.py +++ b/miio/vacuum.py @@ -15,7 +15,7 @@ SoundStatus, SoundInstallStatus) from .device import Device, DeviceException from .click_common import ( - DeviceGroup, DeviceGroupMeta, device_command, GlobalContextObject + DeviceGroup, device_command, GlobalContextObject ) _LOGGER = logging.getLogger(__name__) @@ -37,7 +37,7 @@ class Consumable(enum.Enum): SensorDirty = "sensor_dirty_time" -class Vacuum(Device, metaclass=DeviceGroupMeta): +class Vacuum(Device): """Main class representing the vacuum.""" def __init__(self, ip: str, token: str = None, start_id: int = 0, From 52383f4c2ffa0519a4457bc1073c8624b120d10c Mon Sep 17 00:00:00 2001 From: Marcin Jaworski Date: Mon, 5 Feb 2018 12:24:05 +0100 Subject: [PATCH 07/16] Rename device_command decorator to command --- miio/airpurifier.py | 31 ++++++++++---------- miio/click_common.py | 2 +- miio/device.py | 6 ++-- miio/plug.py | 8 ++--- miio/vacuum.py | 70 ++++++++++++++++++++++---------------------- 5 files changed, 58 insertions(+), 59 deletions(-) diff --git a/miio/airpurifier.py b/miio/airpurifier.py index 8ececf4e5..064be713c 100644 --- a/miio/airpurifier.py +++ b/miio/airpurifier.py @@ -3,10 +3,9 @@ import re from typing import Any, Dict, Optional from collections import defaultdict -from functools import wraps import click from .device import Device, DeviceException -from .click_common import device_command, echo_return_status +from .click_common import command, echo_return_status _LOGGER = logging.getLogger(__name__) @@ -359,7 +358,7 @@ def __repr__(self) -> str: class AirPurifier(Device): """Main class representing the air purifier.""" - @device_command( + @command( echo_return_status("", """ Power: {result.power} AQI: {result.aqi} μg/m³ @@ -422,21 +421,21 @@ def status(self) -> AirPurifierStatus: return AirPurifierStatus( defaultdict(lambda: None, zip(properties, values))) - @device_command( + @command( echo_return_status("Powering on"), ) def on(self): """Power on.""" return self.send("set_power", ["on"]) - @device_command( + @command( echo_return_status("Powering off") ) def off(self): """Power off.""" return self.send("set_power", ["off"]) - @device_command( + @command( click.argument("mode", type=OperationMode), echo_return_status("Setting mode to '{mode.value}'") ) @@ -444,7 +443,7 @@ def set_mode(self, mode: OperationMode): """Set mode.""" return self.send("set_mode", [mode.value]) - @device_command( + @command( click.argument("level", type=int), echo_return_status("Setting favorite level to {level}") ) @@ -459,7 +458,7 @@ def set_favorite_level(self, level: int): # should be between 0 and 16. return self.send("set_level_favorite", [level]) # 0 ... 16 - @device_command( + @command( click.argument("brightness", type=LedBrightness), echo_return_status( "Setting LED brightness to {brightness}") @@ -468,7 +467,7 @@ def set_led_brightness(self, brightness: LedBrightness): """Set led brightness.""" return self.send("set_led_b", [brightness.value]) - @device_command( + @command( click.argument("led", type=bool), echo_return_status( lambda led: "Turning on LED" @@ -482,7 +481,7 @@ def set_led(self, led: bool): else: return self.send("set_led", ['off']) - @device_command( + @command( click.argument("buzzer", type=bool), echo_return_status( lambda buzzer: "Turning on buzzer" @@ -496,7 +495,7 @@ def set_buzzer(self, buzzer: bool): else: return self.send("set_buzzer", ["off"]) - @device_command( + @command( click.argument("lock", type=bool), echo_return_status( lambda lock: "Turning on child lock" @@ -510,7 +509,7 @@ def set_child_lock(self, lock: bool): else: return self.send("set_child_lock", ["off"]) - @device_command( + @command( click.argument("volume", type=int), echo_return_status("Setting favorite level to {volume}") ) @@ -521,7 +520,7 @@ def set_volume(self, volume: int): return self.send("set_volume", [volume]) - @device_command( + @command( click.argument("learn_mode", type=bool), echo_return_status( lambda learn_mode: "Turning on learn mode" @@ -535,7 +534,7 @@ def set_learn_mode(self, learn_mode: bool): else: return self.send("set_act_sleep", ["close"]) - @device_command( + @command( click.argument("auto_detect", type=bool), echo_return_status( lambda auto_detect: "Turning on auto detect" @@ -549,7 +548,7 @@ def set_auto_detect(self, auto_detect: bool): else: return self.send("set_act_det", ["off"]) - @device_command( + @command( click.argument("value", type=int), echo_return_status("Setting extra to {value}") ) @@ -563,7 +562,7 @@ def set_extra_features(self, value: int): return self.send("set_app_extra", [value]) - @device_command( + @command( echo_return_status("Resetting filter") ) def reset_filter(self): diff --git a/miio/click_common.py b/miio/click_common.py index f6ca5f52e..bb6bd22fc 100644 --- a/miio/click_common.py +++ b/miio/click_common.py @@ -173,7 +173,7 @@ def list_commands(self, ctx): return sorted(self.commands.keys()) -def device_command(*decorators, name=None, **kwargs): +def command(*decorators, name=None, **kwargs): return DeviceGroup.Command(name, decorators, **kwargs) diff --git a/miio/device.py b/miio/device.py index 7465e164a..b815f2c38 100644 --- a/miio/device.py +++ b/miio/device.py @@ -9,7 +9,7 @@ from enum import Enum from .click_common import ( - DeviceGroupMeta, device_command, echo_return_status + DeviceGroupMeta, command, echo_return_status ) from .protocol import Message @@ -293,7 +293,7 @@ def send(self, command: str, parameters: Any=None, retry_count=3) -> Any: _LOGGER.error("Got error when receiving: %s", ex) raise DeviceException("No response from the device") from ex - @device_command( + @command( click.argument('cmd', required=True), click.argument('parameters', required=False), ) @@ -306,7 +306,7 @@ def raw_command(self, cmd, params): :param dict params: Parameters to send""" return self.send(cmd, params) - @device_command( + @command( echo_return_status("", "Model: {result.model}\n" "Hardware version: {result.hardware_version}\n" diff --git a/miio/plug.py b/miio/plug.py index 054e1f9dc..e12558b53 100644 --- a/miio/plug.py +++ b/miio/plug.py @@ -2,7 +2,7 @@ from typing import Dict, Any, Optional from collections import defaultdict from .device import Device -from .click_common import device_command, echo_return_status +from .click_common import command, echo_return_status _LOGGER = logging.getLogger(__name__) @@ -38,7 +38,7 @@ def __str__(self) -> str: class Plug(Device): """Main class representing the smart wifi socket / plug.""" - @device_command( + @command( echo_return_status("", "Power: {result.power}\n" "Temperature: {result.temperature}") @@ -62,12 +62,12 @@ def status(self) -> PlugStatus: return PlugStatus( defaultdict(lambda: None, zip(properties, values))) - @device_command(echo_return_status("Powering on")) + @command(echo_return_status("Powering on")) def on(self): """Power on.""" return self.send("set_power", ["on"]) == ['ok'] - @device_command(echo_return_status("Powering off")) + @command(echo_return_status("Powering off")) def off(self): """Power off.""" return self.send("set_power", ["off"]) == ['ok'] diff --git a/miio/vacuum.py b/miio/vacuum.py index ff397ec50..125499456 100644 --- a/miio/vacuum.py +++ b/miio/vacuum.py @@ -15,7 +15,7 @@ SoundStatus, SoundInstallStatus) from .device import Device, DeviceException from .click_common import ( - DeviceGroup, device_command, GlobalContextObject + DeviceGroup, command, GlobalContextObject ) _LOGGER = logging.getLogger(__name__) @@ -45,45 +45,45 @@ def __init__(self, ip: str, token: str = None, start_id: int = 0, super().__init__(ip, token, start_id, debug) self.manual_seqnum = -1 - @device_command() + @command() def start(self): """Start cleaning.""" return self.send("app_start") - @device_command() + @command() def stop(self): """Stop cleaning.""" return self.send("app_stop") - @device_command() + @command() def spot(self): """Start spot cleaning.""" return self.send("app_spot") - @device_command() + @command() def pause(self): """Pause cleaning.""" return self.send("app_pause") - @device_command() + @command() def home(self): """Stop cleaning and return home.""" self.send("app_stop") return self.send("app_charge") - @device_command() + @command() def manual_start(self): """Start manual control mode.""" self.manual_seqnum = 0 return self.send("app_rc_start") - @device_command() + @command() def manual_stop(self): """Stop manual control mode.""" self.manual_seqnum = 0 return self.send("app_rc_end") - @device_command( + @command( click.argument("rotation", type=int), click.argument("velocity", type=float), click.argument("duration", type=int, required=False, default=1500) @@ -104,7 +104,7 @@ def manual_control_once( time.sleep(2) number_of_tries -= 1 - @device_command( + @command( click.argument("rotation", type=int), click.argument("velocity", type=float), click.argument("duration", type=int, required=False, default=1500) @@ -127,7 +127,7 @@ def manual_control(self, rotation: int, velocity: float, self.send("app_rc_move", [params]) - @device_command() + @command() def status(self) -> VacuumStatus: """Return status of the vacuum.""" return VacuumStatus(self.send("get_status")[0]) @@ -136,35 +136,35 @@ def enable_log_upload(self): raise NotImplementedError("unknown parameters") # return self.send("enable_log_upload") - @device_command() + @command() def log_upload_status(self): # {"result": [{"log_upload_status": 7}], "id": 1} return self.send("get_log_upload_status") - @device_command() + @command() def consumable_status(self) -> ConsumableStatus: """Return information about consumables.""" return ConsumableStatus(self.send("get_consumable")[0]) - @device_command( + @command( click.argument("consumable", type=Consumable), ) def consumable_reset(self, consumable: Consumable): """Reset consumable information.""" return self.send("reset_consumable", [consumable.value]) - @device_command() + @command() def map(self): """Return map token.""" # returns ['retry'] without internet return self.send("get_map_v1") - @device_command() + @command() def clean_history(self) -> CleaningSummary: """Return generic cleaning history.""" return CleaningSummary(self.send("get_clean_summary")) - @device_command( + @command( click.argument("id_", type=int, metavar="ID"), ) def clean_details(self, id_: int) -> List[CleaningDetails]: @@ -177,12 +177,12 @@ def clean_details(self, id_: int) -> List[CleaningDetails]: return res - @device_command() + @command() def find(self): """Find the robot.""" return self.send("find_me", [""]) - @device_command() + @command() def timer(self) -> List[Timer]: """Return a list of timers.""" timers = list() @@ -191,7 +191,7 @@ def timer(self) -> List[Timer]: return timers - @device_command( + @command( click.argument("cron"), click.argument("command", required=False, default=""), click.argument("parameters", required=False, default=""), @@ -208,7 +208,7 @@ def add_timer(self, cron: str, command: str, parameters: str): [str(ts), [cron, [command, parameters]]] ]) - @device_command( + @command( click.argument("timer_id", type=int), ) def delete_timer(self, timer_id: int): @@ -217,7 +217,7 @@ def delete_timer(self, timer_id: int): :param int timer_id: Timer ID""" return self.send("del_timer", [str(timer_id)]) - @device_command( + @command( click.argument("timer_id", type=int), click.argument("mode", type=TimerState), ) @@ -230,14 +230,14 @@ def update_timer(self, timer_id: int, mode: TimerState): raise DeviceException("Only 'On' or 'Off' are allowed") return self.send("upd_timer", [str(timer_id), mode.value]) - @device_command() + @command() def dnd_status(self): """Returns do-not-disturb status.""" # {'result': [{'enabled': 1, 'start_minute': 0, 'end_minute': 0, # 'start_hour': 22, 'end_hour': 8}], 'id': 1} return DNDStatus(self.send("get_dnd_timer")[0]) - @device_command( + @command( click.argument("start_hr", type=int), click.argument("start_min", type=int), click.argument("end_hr", type=int), @@ -254,12 +254,12 @@ def set_dnd(self, start_hr: int, start_min: int, return self.send("set_dnd_timer", [start_hr, start_min, end_hr, end_min]) - @device_command() + @command() def disable_dnd(self): """Disable do-not-disturb.""" return self.send("close_dnd_timer", [""]) - @device_command( + @command( click.argument("speed", type=int), ) def set_fan_speed(self, speed: int): @@ -269,17 +269,17 @@ def set_fan_speed(self, speed: int): # speed = [38, 60 or 77] return self.send("set_custom_mode", [speed]) - @device_command() + @command() def fan_speed(self): """Return fan speed.""" return self.send("get_custom_mode")[0] - @device_command() + @command() def sound_info(self): """Get voice settings.""" return SoundStatus(self.send("get_current_sound")[0]) - @device_command( + @command( click.argument("url"), click.argument("md5sum"), click.argument("sound_id", type=int), @@ -293,34 +293,34 @@ def install_sound(self, url: str, md5sum: str, sound_id: int): } return SoundInstallStatus(self.send("dnld_install_sound", payload)[0]) - @device_command() + @command() def sound_install_progress(self): """Get sound installation progress.""" return SoundInstallStatus(self.send("get_sound_progress")[0]) - @device_command() + @command() def sound_volume(self) -> int: """Get sound volume.""" return self.send("get_sound_volume")[0] - @device_command( + @command( click.argument("vol", type=int), ) def set_sound_volume(self, vol: int): """Set sound volume [0-100].""" return self.send("change_sound_volume", [vol]) - @device_command() + @command() def test_sound_volume(self): """Test current sound volume.""" return self.send("test_sound_volume") - @device_command() + @command() def serial_number(self): """Get serial number.""" return self.send("get_serial_number")[0]["serial_number"] - @device_command() + @command() def timezone(self): """Get the timezone.""" # return self.send("get_timezone")[0] From 6b8b74f3c4a7ff72de841ecbabff67c09d5c7308 Mon Sep 17 00:00:00 2001 From: Marcin Jaworski Date: Mon, 5 Feb 2018 12:57:54 +0100 Subject: [PATCH 08/16] Reformat status format string for Air Purifier --- miio/airpurifier.py | 59 +++++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/miio/airpurifier.py b/miio/airpurifier.py index 064be713c..c348c6752 100644 --- a/miio/airpurifier.py +++ b/miio/airpurifier.py @@ -359,35 +359,36 @@ class AirPurifier(Device): """Main class representing the air purifier.""" @command( - echo_return_status("", """ -Power: {result.power} -AQI: {result.aqi} μg/m³ -Average AQI: {result.average_aqi} μg/m³ -Temperature: {result.temperature} °C -Humidity: {result.humidity} % -Mode: {result.mode.value} -LED: {result.led} -LED brightness: {result.led_brightness} -Illuminance: {result.illuminance} lx -Buzzer: {result.buzzer} -Child lock: {result.child_lock} -Favorite level: {result.favorite_level} -Filter life remaining: {result.filter_life_remaining} % -Filter hours used: {result.filter_hours_used} -Use time: {result.use_time} s -Purify volume: {result.purify_volume} m³ -Motor speed: {result.motor_speed} rpm -Motor 2 speed: {result.motor2_speed} rpm -Sound volume: {result.volume} % -Filter RFID product id: {result.filter_rfid_product_id} -Filter RFID tag: {result.filter_rfid_tag} -Filter type: {result.filter_type.value} -Learn mode: {result.learn_mode} -Sleep mode: {result.sleep_mode.value} -Sleep time: {result.sleep_time} -Sleep mode learn count: {result.sleep_mode_learn_count} -AQI sensor enabled on power off: {result.auto_detect} - """) + echo_return_status( + "", + "Power: {result.power}\n" + "AQI: {result.aqi} μg/m³\n" + "Average AQI: {result.average_aqi} μg/m³\n" + "Temperature: {result.temperature} °C\n" + "Humidity: {result.humidity} %\n" + "Mode: {result.mode.value}\n" + "LED: {result.led}\n" + "LED brightness: {result.led_brightness}\n" + "Illuminance: {result.illuminance} lx\n" + "Buzzer: {result.buzzer}\n" + "Child lock: {result.child_lock}\n" + "Favorite level: {result.favorite_level}\n" + "Filter life remaining: {result.filter_life_remaining} %\n" + "Filter hours used: {result.filter_hours_used}\n" + "Use time: {result.use_time} s\n" + "Purify volume: {result.purify_volume} m³\n" + "Motor speed: {result.motor_speed} rpm\n" + "Motor 2 speed: {result.motor2_speed} rpm\n" + "Sound volume: {result.volume} %\n" + "Filter RFID product id: {result.filter_rfid_product_id}\n" + "Filter RFID tag: {result.filter_rfid_tag}\n" + "Filter type: {result.filter_type.value}\n" + "Learn mode: {result.learn_mode}\n" + "Sleep mode: {result.sleep_mode.value}\n" + "Sleep time: {result.sleep_time}\n" + "Sleep mode learn count: {result.sleep_mode_learn_count}\n" + "AQI sensor enabled on power off: {result.auto_detect}\n" + ) ) def status(self) -> AirPurifierStatus: """Retrieve properties.""" From 4b975f8a23d5410836625cbaf3874e36c9787806 Mon Sep 17 00:00:00 2001 From: Marcin Jaworski Date: Mon, 12 Feb 2018 12:55:38 +0100 Subject: [PATCH 09/16] Support for configurable output options + JSON output implementation --- miio/airpurifier.py | 33 +++++++++++++----------- miio/cli.py | 16 +++++++++--- miio/click_common.py | 54 ++++++++++++++++++++++++++++++++++------ miio/device.py | 29 +++++++++------------ miio/exceptions.py | 8 ++++++ miio/plug.py | 11 +++++--- miio/vacuumcontainers.py | 24 ++++++++++++++++++ 7 files changed, 128 insertions(+), 47 deletions(-) create mode 100644 miio/exceptions.py diff --git a/miio/airpurifier.py b/miio/airpurifier.py index c348c6752..0a2b578b3 100644 --- a/miio/airpurifier.py +++ b/miio/airpurifier.py @@ -5,7 +5,7 @@ from collections import defaultdict import click from .device import Device, DeviceException -from .click_common import command, echo_return_status +from .click_common import command, format_output _LOGGER = logging.getLogger(__name__) @@ -354,12 +354,15 @@ def __repr__(self) -> str: self.button_pressed) return s + def __json__(self): + return self.data + class AirPurifier(Device): """Main class representing the air purifier.""" @command( - echo_return_status( + default_output=format_output( "", "Power: {result.power}\n" "AQI: {result.aqi} μg/m³\n" @@ -423,14 +426,14 @@ def status(self) -> AirPurifierStatus: defaultdict(lambda: None, zip(properties, values))) @command( - echo_return_status("Powering on"), + default_output=format_output("Powering on"), ) def on(self): """Power on.""" return self.send("set_power", ["on"]) @command( - echo_return_status("Powering off") + format_output("Powering off") ) def off(self): """Power off.""" @@ -438,7 +441,7 @@ def off(self): @command( click.argument("mode", type=OperationMode), - echo_return_status("Setting mode to '{mode.value}'") + default_output=format_output("Setting mode to '{mode.value}'") ) def set_mode(self, mode: OperationMode): """Set mode.""" @@ -446,7 +449,7 @@ def set_mode(self, mode: OperationMode): @command( click.argument("level", type=int), - echo_return_status("Setting favorite level to {level}") + default_output=format_output("Setting favorite level to {level}") ) def set_favorite_level(self, level: int): """Set favorite level.""" @@ -461,7 +464,7 @@ def set_favorite_level(self, level: int): @command( click.argument("brightness", type=LedBrightness), - echo_return_status( + default_output=format_output( "Setting LED brightness to {brightness}") ) def set_led_brightness(self, brightness: LedBrightness): @@ -470,7 +473,7 @@ def set_led_brightness(self, brightness: LedBrightness): @command( click.argument("led", type=bool), - echo_return_status( + default_output=format_output( lambda led: "Turning on LED" if led else "Turning off LED" ) @@ -484,7 +487,7 @@ def set_led(self, led: bool): @command( click.argument("buzzer", type=bool), - echo_return_status( + default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ) @@ -498,7 +501,7 @@ def set_buzzer(self, buzzer: bool): @command( click.argument("lock", type=bool), - echo_return_status( + default_output=format_output( lambda lock: "Turning on child lock" if lock else "Turning off child lock" ) @@ -512,7 +515,7 @@ def set_child_lock(self, lock: bool): @command( click.argument("volume", type=int), - echo_return_status("Setting favorite level to {volume}") + default_output=format_output("Setting favorite level to {volume}") ) def set_volume(self, volume: int): """Set volume of sound notifications [0-100].""" @@ -523,7 +526,7 @@ def set_volume(self, volume: int): @command( click.argument("learn_mode", type=bool), - echo_return_status( + default_output=format_output( lambda learn_mode: "Turning on learn mode" if learn_mode else "Turning off learn mode" ) @@ -537,7 +540,7 @@ def set_learn_mode(self, learn_mode: bool): @command( click.argument("auto_detect", type=bool), - echo_return_status( + default_output=format_output( lambda auto_detect: "Turning on auto detect" if auto_detect else "Turning off auto detect" ) @@ -551,7 +554,7 @@ def set_auto_detect(self, auto_detect: bool): @command( click.argument("value", type=int), - echo_return_status("Setting extra to {value}") + default_output=format_output("Setting extra to {value}") ) def set_extra_features(self, value: int): """Storage register to enable extra features at the app. @@ -564,7 +567,7 @@ def set_extra_features(self, value: int): return self.send("set_app_extra", [value]) @command( - echo_return_status("Resetting filter") + default_output=format_output("Resetting filter") ) def reset_filter(self): """Resets filter hours used and remaining life.""" diff --git a/miio/cli.py b/miio/cli.py index 6160e5913..04ddb662a 100644 --- a/miio/cli.py +++ b/miio/cli.py @@ -2,7 +2,8 @@ import logging import click from miio.click_common import ( - ExceptionHandlerGroup, DeviceGroupMeta, GlobalContextObject + ExceptionHandlerGroup, DeviceGroupMeta, GlobalContextObject, + json_output, ) _LOGGER = logging.getLogger(__name__) @@ -10,16 +11,25 @@ @click.group(cls=ExceptionHandlerGroup) @click.option('-d', '--debug', default=False, count=True) +@click.option('-o', '--output', type=click.Choice([ + 'default', 'json', 'json_pretty', +]), default='default') @click.pass_context -def cli(ctx, debug: int): +def cli(ctx, debug: int, output: str): if debug: logging.basicConfig(level=logging.DEBUG) _LOGGER.info("Debug mode active") else: logging.basicConfig(level=logging.INFO) + if output in ('json', 'json_pretty'): + output_func = json_output(pretty=output == 'json_pretty') + else: + output_func = None + ctx.obj = GlobalContextObject( - debug=debug + debug=debug, + output=output_func, ) diff --git a/miio/click_common.py b/miio/click_common.py index bb6bd22fc..5685d8531 100644 --- a/miio/click_common.py +++ b/miio/click_common.py @@ -11,9 +11,11 @@ import ipaddress import miio import logging +import json from typing import Union from functools import wraps from functools import partial +from .exceptions import DeviceError _LOGGER = logging.getLogger(__name__) @@ -54,8 +56,9 @@ def __call__(self, *args, **kwargs): class GlobalContextObject: - def __init__(self, debug: int=0): + def __init__(self, debug: int=0, output: callable=None): self.debug = debug + self.output = output class DeviceGroupMeta(type): @@ -100,10 +103,12 @@ def get_device_group(dcls): class DeviceGroup(click.MultiCommand): class Command: - def __init__(self, name, decorators, **kwargs): + def __init__(self, name, decorators, *, default_output=None, **kwargs): self.name = name self.decorators = list(decorators) self.decorators.reverse() + self.default_output = default_output + self.kwargs = kwargs def __call__(self, func): @@ -116,7 +121,18 @@ def __call__(self, func): def command_name(self): return self.name or self.func.__name__.lower() - def wrap(self, func): + def wrap(self, ctx, func): + gco = ctx.find_object(GlobalContextObject) + if gco is not None and gco.output is not None: + output = gco.output + elif self.default_output: + output = self.default_output + else: + output = format_output( + "Running command {0}".format(self.command_name) + ) + + func = output(func) for decorator in self.decorators: func = decorator(func) return click.command(self.command_name, **self.kwargs)(func) @@ -165,7 +181,7 @@ def command_callback(self, command, device, *args, **kwargs): def get_command(self, ctx, cmd_name): cmd = self.commands[cmd_name] - return self.commands[cmd_name].wrap(self.device_pass(partial( + return self.commands[cmd_name].wrap(ctx, self.device_pass(partial( self.command_callback, cmd ))) @@ -173,12 +189,14 @@ def list_commands(self, ctx): return sorted(self.commands.keys()) -def command(*decorators, name=None, **kwargs): - return DeviceGroup.Command(name, decorators, **kwargs) +def command(*decorators, name=None, default_output=None, **kwargs): + return DeviceGroup.Command( + name, decorators, default_output=default_output, **kwargs + ) -def echo_return_status(msg_fmt: Union[str, callable]="", - result_msg_fmt: Union[str, callable]="{result}"): +def format_output(msg_fmt: Union[str, callable]= "", + result_msg_fmt: Union[str, callable]="{result}"): def decorator(func): @wraps(func) def wrap(*args, **kwargs): @@ -199,3 +217,23 @@ def wrap(*args, **kwargs): click.echo(result_msg.strip()) return wrap return decorator + + +def json_output(pretty=False): + indent = 2 if pretty else None + def decorator(func): + @wraps(func) + def wrap(*args, **kwargs): + try: + result = func(*args, **kwargs) + except DeviceError as ex: + click.echo(json.dumps(ex.args[0], indent=indent)) + return + + get_json_data_func = getattr(result, '__json__', None) + if get_json_data_func is not None: + result = get_json_data_func() + click.echo(json.dumps(result, indent=indent)) + + return wrap + return decorator diff --git a/miio/device.py b/miio/device.py index b815f2c38..0a0dd0383 100644 --- a/miio/device.py +++ b/miio/device.py @@ -9,23 +9,14 @@ from enum import Enum from .click_common import ( - DeviceGroupMeta, command, echo_return_status + DeviceGroupMeta, command, format_output ) from .protocol import Message +from .exceptions import DeviceException, DeviceError _LOGGER = logging.getLogger(__name__) -class DeviceException(Exception): - """Exception wrapping any communication errors with the device.""" - pass - - -class DeviceError(DeviceException): - """Exception communicating an error delivered by the target device.""" - pass - - class UpdateState(Enum): Downloading = "downloading" Installing = "installing" @@ -68,6 +59,9 @@ def __repr__(self): self.network_interface["localIp"], self.data["token"]) + def __json__(self): + return self.data + @property def network_interface(self): """Information about network configuration.""" @@ -307,12 +301,13 @@ def raw_command(self, cmd, params): return self.send(cmd, params) @command( - echo_return_status("", - "Model: {result.model}\n" - "Hardware version: {result.hardware_version}\n" - "Firmware version: {result.firmware_version}\n" - "Network: {result.network_interface}\n" - "AP: {result.accesspoint}\n") + default_output=format_output( + "", + "Model: {result.model}\n" + "Hardware version: {result.hardware_version}\n" + "Firmware version: {result.firmware_version}\n" + "Network: {result.network_interface}\n" + "AP: {result.accesspoint}\n") ) def info(self) -> DeviceInfo: """Get miIO protocol information from the device. diff --git a/miio/exceptions.py b/miio/exceptions.py new file mode 100644 index 000000000..7bcb4f2db --- /dev/null +++ b/miio/exceptions.py @@ -0,0 +1,8 @@ +class DeviceException(Exception): + """Exception wrapping any communication errors with the device.""" + pass + + +class DeviceError(DeviceException): + """Exception communicating an error delivered by the target device.""" + pass diff --git a/miio/plug.py b/miio/plug.py index e12558b53..04fa8bb1d 100644 --- a/miio/plug.py +++ b/miio/plug.py @@ -2,7 +2,7 @@ from typing import Dict, Any, Optional from collections import defaultdict from .device import Device -from .click_common import command, echo_return_status +from .click_common import command, format_output _LOGGER = logging.getLogger(__name__) @@ -34,12 +34,15 @@ def __str__(self) -> str: self.temperature) return s + def __json__(self): + return self.data + class Plug(Device): """Main class representing the smart wifi socket / plug.""" @command( - echo_return_status("", + default_output=format_output("", "Power: {result.power}\n" "Temperature: {result.temperature}") ) @@ -62,12 +65,12 @@ def status(self) -> PlugStatus: return PlugStatus( defaultdict(lambda: None, zip(properties, values))) - @command(echo_return_status("Powering on")) + @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_power", ["on"]) == ['ok'] - @command(echo_return_status("Powering off")) + @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("set_power", ["off"]) == ['ok'] diff --git a/miio/vacuumcontainers.py b/miio/vacuumcontainers.py index 1a36cebcf..a6335b55f 100644 --- a/miio/vacuumcontainers.py +++ b/miio/vacuumcontainers.py @@ -146,6 +146,9 @@ def __repr__(self) -> str: s += "cleaned %s m² in %s>" % (self.clean_area, self.clean_time) return s + def __json__(self): + return self.data + class CleaningSummary: """Contains summarized information about available cleaning runs.""" @@ -185,6 +188,9 @@ def __repr__(self) -> str: self.total_area, self.ids) + def __json__(self): + return self.data + class CleaningDetails: """Contains details about a specific cleaning run.""" @@ -235,6 +241,9 @@ def __repr__(self) -> str: self.start, self.duration, self.complete, self.area ) + def __json__(self): + return self.data + class ConsumableStatus: """Container for consumable status information, @@ -300,6 +309,9 @@ def __repr__(self) -> str: return "" % ( # noqa: E501 self.main_brush, self.side_brush, self.filter, self.sensor_dirty) + def __json__(self): + return self.data + class DNDStatus: """A container for the do-not-disturb status.""" @@ -331,6 +343,9 @@ def __repr__(self): self.start, self.end) + def __json__(self): + return self.data + class Timer: """A container for scheduling. @@ -373,6 +388,9 @@ def __repr__(self) -> str: return "" % (self.id, self.ts, self.enabled, self.cron) + def __json__(self): + return self.data + class SoundStatus: """Container for sound status.""" @@ -393,6 +411,9 @@ def __repr__(self): self.current, self.being_installed) + def __json__(self): + return self.data + class SoundInstallState(IntEnum): Unknown = 0 @@ -450,3 +471,6 @@ def __repr__(self) -> str: return "" % (self.sid, self.state, self.error, self.progress) + + def __json__(self): + return self.data From c3b4eb2c44b5e67dd697c19237fd11de8b4ddf88 Mon Sep 17 00:00:00 2001 From: Marcin Jaworski Date: Sat, 10 Mar 2018 11:13:25 +0100 Subject: [PATCH 10/16] Fix output formatting for airpurifier off command --- miio/airpurifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/miio/airpurifier.py b/miio/airpurifier.py index 0a2b578b3..2422f2ca9 100644 --- a/miio/airpurifier.py +++ b/miio/airpurifier.py @@ -433,7 +433,7 @@ def on(self): return self.send("set_power", ["on"]) @command( - format_output("Powering off") + default_output=format_output("Powering off"), ) def off(self): """Power off.""" From 689bbf237bc899ea58c82f4f80022e035e50239c Mon Sep 17 00:00:00 2001 From: Marcin Jaworski Date: Sat, 10 Mar 2018 12:33:12 +0100 Subject: [PATCH 11/16] EnumType type for Click Code provided by skycaptain in https://github.com/pallets/click/issues/605 --- miio/click_common.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/miio/click_common.py b/miio/click_common.py index 5685d8531..1de4c0f52 100644 --- a/miio/click_common.py +++ b/miio/click_common.py @@ -12,6 +12,7 @@ import miio import logging import json +import re from typing import Union from functools import wraps from functools import partial @@ -55,6 +56,43 @@ def __call__(self, *args, **kwargs): click.echo(click.style("Error: %s" % ex, fg='red', bold=True)) +class EnumType(click.Choice): + def __init__(self, enumcls, casesensitive=True): + choices = enumcls.__members__ + + if not casesensitive: + choices = (_.lower() for _ in choices) + + self._enumcls = enumcls + self._casesensitive = casesensitive + + super().__init__(list(sorted(set(choices)))) + + def convert(self, value, param, ctx): + if not self._casesensitive: + value = value.lower() + + value = super().convert(value, param, ctx) + + if not self._casesensitive: + return next(_ for _ in self._enumcls if _.name.lower() == value.lower()) + else: + return next(_ for _ in self._enumcls if _.name == value) + + def get_metavar(self, param): + word = self._enumcls.__name__ + + # Stolen from jpvanhal/inflection + word = re.sub(r"([A-Z]+)([A-Z][a-z])", r'\1_\2', word) + word = re.sub(r"([a-z\d])([A-Z])", r'\1_\2', word) + word = word.replace("-", "_").lower().split("_") + + if word[-1] == "enum": + word.pop() + + return ("_".join(word)).upper() + + class GlobalContextObject: def __init__(self, debug: int=0, output: callable=None): self.debug = debug @@ -221,6 +259,7 @@ def wrap(*args, **kwargs): def json_output(pretty=False): indent = 2 if pretty else None + def decorator(func): @wraps(func) def wrap(*args, **kwargs): From 4e3e3e5d57cc0e8df8209a43446cae5d3fc3ca75 Mon Sep 17 00:00:00 2001 From: Marcin Jaworski Date: Sat, 10 Mar 2018 12:33:33 +0100 Subject: [PATCH 12/16] Use EnumType for commands taking enums as args --- miio/airpurifier.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/miio/airpurifier.py b/miio/airpurifier.py index 2422f2ca9..b4042cc24 100644 --- a/miio/airpurifier.py +++ b/miio/airpurifier.py @@ -5,7 +5,7 @@ from collections import defaultdict import click from .device import Device, DeviceException -from .click_common import command, format_output +from .click_common import command, format_output, EnumType _LOGGER = logging.getLogger(__name__) @@ -440,7 +440,7 @@ def off(self): return self.send("set_power", ["off"]) @command( - click.argument("mode", type=OperationMode), + click.argument("mode", type=EnumType(OperationMode, False)), default_output=format_output("Setting mode to '{mode.value}'") ) def set_mode(self, mode: OperationMode): @@ -463,7 +463,7 @@ def set_favorite_level(self, level: int): return self.send("set_level_favorite", [level]) # 0 ... 16 @command( - click.argument("brightness", type=LedBrightness), + click.argument("brightness", type=EnumType(LedBrightness, False)), default_output=format_output( "Setting LED brightness to {brightness}") ) From f8178889fcf6b24a69795a811a386bca3e37c1f1 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Sat, 31 Mar 2018 20:08:48 +0200 Subject: [PATCH 13/16] Rename cli tool to miiocli --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index afe0d4703..f8849464f 100644 --- a/setup.py +++ b/setup.py @@ -62,7 +62,7 @@ def readme(): 'miceil=miio.ceil_cli:cli', 'mieye=miio.philips_eyecare_cli:cli', 'miio-extract-tokens=miio.extract_tokens:main', - 'miio=miio.cli:create_cli', + 'miiocli=miio.cli:create_cli', ], }, ) From 902b43d82e587ac68bc072465b6b82ee6fa9b1e4 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Sat, 31 Mar 2018 20:29:04 +0200 Subject: [PATCH 14/16] Port cli code to the new plug implementation --- miio/chuangmi_plug.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/miio/chuangmi_plug.py b/miio/chuangmi_plug.py index 5da4a30a8..be7d06abe 100644 --- a/miio/chuangmi_plug.py +++ b/miio/chuangmi_plug.py @@ -1,8 +1,12 @@ import logging +import click from typing import Dict, Any, Optional from collections import defaultdict from .device import Device from .utils import deprecated +from .click_common import ( + DeviceGroupMeta, device_command, echo_return_status +) _LOGGER = logging.getLogger(__name__) @@ -85,7 +89,7 @@ def __repr__(self) -> str: return s -class ChuangmiPlug(Device): +class ChuangmiPlug(Device, metaclass=DeviceGroupMeta): """Main class representing the Chuangmi Plug V1 and V3.""" def __init__(self, ip: str = None, token: str = None, start_id: int = 0, @@ -98,6 +102,14 @@ def __init__(self, ip: str = None, token: str = None, start_id: int = 0, else: self.model = MODEL_CHUANGMI_PLUG_M1 + @device_command( + echo_return_status("", + "Power: {result.power}\n" + "USB Power: {result.usb_power}\n" + "Temperature: {result.temperature} °C\n" + "Load power: {result.load_power}\n" + "WiFi LED: {result.wifi_led}") + ) def status(self) -> ChuangmiPlugStatus: """Retrieve properties.""" properties = AVAILABLE_PROPERTIES[self.model] @@ -123,6 +135,7 @@ def status(self) -> ChuangmiPlugStatus: return ChuangmiPlugStatus( defaultdict(lambda: None, zip(properties, values))) + @device_command(echo_return_status("Powering on")) def on(self): """Power on.""" if self.model == MODEL_CHUANGMI_PLUG_V1: @@ -130,6 +143,7 @@ def on(self): return self.send("set_power", ["on"]) + @device_command(echo_return_status("Powering off")) def off(self): """Power off.""" if self.model == MODEL_CHUANGMI_PLUG_V1: @@ -137,14 +151,23 @@ def off(self): return self.send("set_power", ["off"]) + @device_command(echo_return_status("Powering USB on")) def usb_on(self): """Power on.""" return self.send("set_usb_on", []) + @device_command(echo_return_status("Powering USB off")) def usb_off(self): """Power off.""" return self.send("set_usb_off", []) + @device_command( + click.argument("wifi_led", type=bool), + echo_return_status( + lambda wifi_led: "Turning on WiFi LED" + if wifi_led else "Turning off WiFi LED" + ) + ) def set_wifi_led(self, led: bool): """Set the wifi led on/off.""" if led: From 2885f92c3a32fa3bb78f455d6f7a68bf0424bce8 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Sat, 31 Mar 2018 20:30:33 +0200 Subject: [PATCH 15/16] Remove plug.py --- miio/plug.py | 76 ---------------------------------------------------- 1 file changed, 76 deletions(-) delete mode 100644 miio/plug.py diff --git a/miio/plug.py b/miio/plug.py deleted file mode 100644 index 04fa8bb1d..000000000 --- a/miio/plug.py +++ /dev/null @@ -1,76 +0,0 @@ -import logging -from typing import Dict, Any, Optional -from collections import defaultdict -from .device import Device -from .click_common import command, format_output - -_LOGGER = logging.getLogger(__name__) - - -class PlugStatus: - """Container for status reports from the plug.""" - - 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: - """Return True if the device is on.""" - return self.power == "on" - - @property - def temperature(self) -> float: - """Return temperature.""" - return self.data["temperature"] - - def __str__(self) -> str: - s = "" % \ - (self.power, - self.temperature) - return s - - def __json__(self): - return self.data - - -class Plug(Device): - """Main class representing the smart wifi socket / plug.""" - - @command( - default_output=format_output("", - "Power: {result.power}\n" - "Temperature: {result.temperature}") - ) - def status(self) -> PlugStatus: - """Retrieve properties.""" - properties = ['power', 'temperature'] - values = self.send( - "get_prop", - properties - ) - - properties_count = len(properties) - values_count = len(values) - if properties_count != values_count: - _LOGGER.debug( - "Count (%s) of requested properties does not match the " - "count (%s) of received values.", - properties_count, values_count) - - return PlugStatus( - defaultdict(lambda: None, zip(properties, values))) - - @command(default_output=format_output("Powering on")) - def on(self): - """Power on.""" - return self.send("set_power", ["on"]) == ['ok'] - - @command(default_output=format_output("Powering off")) - def off(self): - """Power off.""" - return self.send("set_power", ["off"]) == ['ok'] From 1b66cced35a3dd4208206c99343eb85c7c34f726 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Sat, 31 Mar 2018 20:42:16 +0200 Subject: [PATCH 16/16] Use new schema --- miio/chuangmi_plug.py | 46 ++++++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/miio/chuangmi_plug.py b/miio/chuangmi_plug.py index be7d06abe..f1ad78229 100644 --- a/miio/chuangmi_plug.py +++ b/miio/chuangmi_plug.py @@ -4,9 +4,7 @@ from collections import defaultdict from .device import Device from .utils import deprecated -from .click_common import ( - DeviceGroupMeta, device_command, echo_return_status -) +from .click_common import command, format_output _LOGGER = logging.getLogger(__name__) @@ -88,8 +86,11 @@ def __repr__(self) -> str: self.wifi_led) return s + def __json__(self): + return self.data -class ChuangmiPlug(Device, metaclass=DeviceGroupMeta): + +class ChuangmiPlug(Device): """Main class representing the Chuangmi Plug V1 and V3.""" def __init__(self, ip: str = None, token: str = None, start_id: int = 0, @@ -102,14 +103,15 @@ def __init__(self, ip: str = None, token: str = None, start_id: int = 0, else: self.model = MODEL_CHUANGMI_PLUG_M1 - @device_command( - echo_return_status("", - "Power: {result.power}\n" - "USB Power: {result.usb_power}\n" - "Temperature: {result.temperature} °C\n" - "Load power: {result.load_power}\n" - "WiFi LED: {result.wifi_led}") - ) + @command( + default_output=format_output( + "", + "Power: {result.power}\n" + "USB Power: {result.usb_power}\n" + "Temperature: {result.temperature} °C\n" + "Load power: {result.load_power}\n" + "WiFi LED: {result.wifi_led}\n") + ) def status(self) -> ChuangmiPlugStatus: """Retrieve properties.""" properties = AVAILABLE_PROPERTIES[self.model] @@ -135,7 +137,9 @@ def status(self) -> ChuangmiPlugStatus: return ChuangmiPlugStatus( defaultdict(lambda: None, zip(properties, values))) - @device_command(echo_return_status("Powering on")) + @command( + default_output = format_output("Powering on"), + ) def on(self): """Power on.""" if self.model == MODEL_CHUANGMI_PLUG_V1: @@ -143,7 +147,9 @@ def on(self): return self.send("set_power", ["on"]) - @device_command(echo_return_status("Powering off")) + @command( + default_output = format_output("Powering off"), + ) def off(self): """Power off.""" if self.model == MODEL_CHUANGMI_PLUG_V1: @@ -151,19 +157,23 @@ def off(self): return self.send("set_power", ["off"]) - @device_command(echo_return_status("Powering USB on")) + @command( + default_output = format_output("Powering USB on"), + ) def usb_on(self): """Power on.""" return self.send("set_usb_on", []) - @device_command(echo_return_status("Powering USB off")) + @command( + default_output = format_output("Powering USB off"), + ) def usb_off(self): """Power off.""" return self.send("set_usb_off", []) - @device_command( + @command( click.argument("wifi_led", type=bool), - echo_return_status( + default_output=format_output( lambda wifi_led: "Turning on WiFi LED" if wifi_led else "Turning off WiFi LED" )