diff --git a/miio/airpurifier.py b/miio/airpurifier.py index 96168804a..b4042cc24 100644 --- a/miio/airpurifier.py +++ b/miio/airpurifier.py @@ -3,7 +3,9 @@ import re from typing import Any, Dict, Optional from collections import defaultdict +import click from .device import Device, DeviceException +from .click_common import command, format_output, EnumType _LOGGER = logging.getLogger(__name__) @@ -352,10 +354,45 @@ 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( + default_output=format_output( + "", + "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.""" @@ -388,18 +425,32 @@ def status(self) -> AirPurifierStatus: return AirPurifierStatus( defaultdict(lambda: None, zip(properties, values))) + @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( + click.argument("mode", type=EnumType(OperationMode, False)), + default_output=format_output("Setting mode to '{mode.value}'") + ) def set_mode(self, mode: OperationMode): """Set mode.""" return self.send("set_mode", [mode.value]) + @command( + click.argument("level", type=int), + default_output=format_output("Setting favorite level to {level}") + ) def set_favorite_level(self, level: int): """Set favorite level.""" if level < 0 or level > 16: @@ -411,10 +462,22 @@ def set_favorite_level(self, level: int): # should be between 0 and 16. return self.send("set_level_favorite", [level]) # 0 ... 16 + @command( + click.argument("brightness", type=EnumType(LedBrightness, False)), + default_output=format_output( + "Setting LED brightness to {brightness}") + ) def set_led_brightness(self, brightness: LedBrightness): """Set led brightness.""" return self.send("set_led_b", [brightness.value]) + @command( + click.argument("led", type=bool), + default_output=format_output( + lambda led: "Turning on LED" + if led else "Turning off LED" + ) + ) def set_led(self, led: bool): """Turn led on/off.""" if led: @@ -422,6 +485,13 @@ def set_led(self, led: bool): else: return self.send("set_led", ['off']) + @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.""" if buzzer: @@ -429,6 +499,13 @@ def set_buzzer(self, buzzer: bool): else: return self.send("set_buzzer", ["off"]) + @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.""" if lock: @@ -436,6 +513,10 @@ def set_child_lock(self, lock: bool): else: return self.send("set_child_lock", ["off"]) + @command( + click.argument("volume", type=int), + default_output=format_output("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 +524,13 @@ def set_volume(self, volume: int): return self.send("set_volume", [volume]) + @command( + click.argument("learn_mode", type=bool), + default_output=format_output( + 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 +538,13 @@ def set_learn_mode(self, learn_mode: bool): else: return self.send("set_act_sleep", ["close"]) + @command( + click.argument("auto_detect", type=bool), + default_output=format_output( + 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 +552,10 @@ def set_auto_detect(self, auto_detect: bool): else: return self.send("set_act_det", ["off"]) + @command( + click.argument("value", type=int), + default_output=format_output("Setting extra to {value}") + ) def set_extra_features(self, value: int): """Storage register to enable extra features at the app. @@ -467,6 +566,9 @@ def set_extra_features(self, value: int): return self.send("set_app_extra", [value]) + @command( + default_output=format_output("Resetting filter") + ) def reset_filter(self): """Resets filter hours used and remaining life.""" return self.send('reset_filter1') diff --git a/miio/chuangmi_plug.py b/miio/chuangmi_plug.py index 5da4a30a8..f1ad78229 100644 --- a/miio/chuangmi_plug.py +++ b/miio/chuangmi_plug.py @@ -1,8 +1,10 @@ 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 command, format_output _LOGGER = logging.getLogger(__name__) @@ -84,6 +86,9 @@ def __repr__(self) -> str: self.wifi_led) return s + def __json__(self): + return self.data + class ChuangmiPlug(Device): """Main class representing the Chuangmi Plug V1 and V3.""" @@ -98,6 +103,15 @@ def __init__(self, ip: str = None, token: str = None, start_id: int = 0, else: self.model = MODEL_CHUANGMI_PLUG_M1 + @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] @@ -123,6 +137,9 @@ def status(self) -> ChuangmiPlugStatus: return ChuangmiPlugStatus( defaultdict(lambda: None, zip(properties, values))) + @command( + default_output = format_output("Powering on"), + ) def on(self): """Power on.""" if self.model == MODEL_CHUANGMI_PLUG_V1: @@ -130,6 +147,9 @@ def on(self): return self.send("set_power", ["on"]) + @command( + default_output = format_output("Powering off"), + ) def off(self): """Power off.""" if self.model == MODEL_CHUANGMI_PLUG_V1: @@ -137,14 +157,27 @@ def off(self): return self.send("set_power", ["off"]) + @command( + default_output = format_output("Powering USB on"), + ) def usb_on(self): """Power on.""" return self.send("set_usb_on", []) + @command( + default_output = format_output("Powering USB off"), + ) def usb_off(self): """Power off.""" return self.send("set_usb_off", []) + @command( + click.argument("wifi_led", type=bool), + default_output=format_output( + 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: diff --git a/miio/cli.py b/miio/cli.py new file mode 100644 index 000000000..04ddb662a --- /dev/null +++ b/miio/cli.py @@ -0,0 +1,45 @@ +# -*- coding: UTF-8 -*- +import logging +import click +from miio.click_common import ( + ExceptionHandlerGroup, DeviceGroupMeta, GlobalContextObject, + json_output, +) + +_LOGGER = logging.getLogger(__name__) + + +@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, 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, + output=output_func, + ) + + +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..1de4c0f52 100644 --- a/miio/click_common.py +++ b/miio/click_common.py @@ -11,6 +11,12 @@ import ipaddress import miio import logging +import json +import re +from typing import Union +from functools import wraps +from functools import partial +from .exceptions import DeviceError _LOGGER = logging.getLogger(__name__) @@ -48,3 +54,225 @@ 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 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 + self.output = output + + +class DeviceGroupMeta(type): + + device_classes = set() + + def __new__(mcs, name, bases, namespace) -> type: + commands = {} + + 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: + + 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, *, 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): + 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, 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) + + 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(ctx, self.device_pass(partial( + self.command_callback, cmd + ))) + + def list_commands(self, ctx): + return sorted(self.commands.keys()) + + +def command(*decorators, name=None, default_output=None, **kwargs): + return DeviceGroup.Command( + name, decorators, default_output=default_output, **kwargs + ) + + +def format_output(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 + + +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 3a89fa593..0a0dd0383 100644 --- a/miio/device.py +++ b/miio/device.py @@ -4,24 +4,19 @@ 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, 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" @@ -64,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.""" @@ -108,7 +106,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 +287,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 + @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 +300,15 @@ def raw_command(self, cmd, params): :param dict params: Parameters to send""" return self.send(cmd, params) + @command( + 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. This includes information about connected wlan network, 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/vacuum.py b/miio/vacuum.py index 24f0a8a7c..125499456 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, command, GlobalContextObject +) _LOGGER = logging.getLogger(__name__) @@ -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 + @command() def start(self): """Start cleaning.""" return self.send("app_start") + @command() def stop(self): """Stop cleaning.""" return self.send("app_stop") + @command() def spot(self): """Start spot cleaning.""" return self.send("app_spot") + @command() def pause(self): """Pause cleaning.""" return self.send("app_pause") + @command() def home(self): """Stop cleaning and return home.""" self.send("app_stop") return self.send("app_charge") + @command() def manual_start(self): """Start manual control mode.""" self.manual_seqnum = 0 return self.send("app_rc_start") + @command() def manual_stop(self): """Stop manual control mode.""" self.manual_seqnum = 0 return self.send("app_rc_end") + @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 + @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]) + @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") + @command() def log_upload_status(self): # {"result": [{"log_upload_status": 7}], "id": 1} return self.send("get_log_upload_status") + @command() def consumable_status(self) -> ConsumableStatus: """Return information about consumables.""" return ConsumableStatus(self.send("get_consumable")[0]) + @command( + click.argument("consumable", type=Consumable), + ) def consumable_reset(self, consumable: Consumable): """Reset consumable information.""" return self.send("reset_consumable", [consumable.value]) + @command() def map(self): """Return map token.""" # returns ['retry'] without internet return self.send("get_map_v1") + @command() def clean_history(self) -> CleaningSummary: """Return generic cleaning history.""" return CleaningSummary(self.send("get_clean_summary")) + @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 + @command() def find(self): """Find the robot.""" return self.send("find_me", [""]) + @command() def timer(self) -> List[Timer]: """Return a list of timers.""" timers = list() @@ -154,6 +191,11 @@ def timer(self) -> List[Timer]: return timers + @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]]] ]) + @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)]) + @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]) + @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]) + @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]) + @command() def disable_dnd(self): """Disable do-not-disturb.""" return self.send("close_dnd_timer", [""]) + @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]) + @command() def fan_speed(self): """Return fan speed.""" return self.send("get_custom_mode")[0] + @command() def sound_info(self): """Get voice settings.""" return SoundStatus(self.send("get_current_sound")[0]) + @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]) + @command() def sound_install_progress(self): """Get sound installation progress.""" return SoundInstallStatus(self.send("get_sound_progress")[0]) + @command() def sound_volume(self) -> int: """Get sound volume.""" return self.send("get_sound_volume")[0] + @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]) + @command() def test_sound_volume(self): """Test current sound volume.""" return self.send("test_sound_volume") + @command() def serial_number(self): """Get serial number.""" return self.send("get_serial_number")[0]["serial_number"] + @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 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 diff --git a/setup.py b/setup.py index 5ffb39443..f8849464f 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', + 'miiocli=miio.cli:create_cli', ], }, )