From 0489610ae06348305909ffef303a4fe2b92043bc Mon Sep 17 00:00:00 2001 From: dewgenenny Date: Fri, 9 Apr 2021 16:01:20 +0200 Subject: [PATCH] Add support for Walkingpad A1 (ksmb.walkingpad.v3) (#975) * Initial integration walkingpad * Removed print statement * Implement PR review suggestions * Fix step_count bug * Update miio/walkingpad.py Co-authored-by: Teemu R. * Fix step_count bug * Update based on PR feedback & add startup speed / sensitivity functions * Update docstring with class initialisation * Implement PR feedback * Rename time to walking_time and change to return timedelta * Rename time to walking_time and change to return timedelta * Correct the description for starting & stopping * Fix mode and sensitivity return types. Resolve more PR feedback * Change start function to power-on if treadmill is off when called. Also other minor PR feedback changes. Co-authored-by: Teemu R. --- README.rst | 1 + docs/api/miio.gateway.rst | 28 +++- docs/api/miio.rst | 22 ++- miio/__init__.py | 1 + miio/tests/test_walkingpad.py | 193 +++++++++++++++++++++++ miio/walkingpad.py | 277 ++++++++++++++++++++++++++++++++++ 6 files changed, 519 insertions(+), 3 deletions(-) create mode 100644 miio/tests/test_walkingpad.py create mode 100644 miio/walkingpad.py diff --git a/README.rst b/README.rst index 891905310..87c404bff 100644 --- a/README.rst +++ b/README.rst @@ -133,6 +133,7 @@ Supported devices - Yeelight Dual Control Module (yeelink.switch.sw1) - Scishare coffee maker (scishare.coffee.s1102) - Qingping Air Monitor Lite (cgllc.airm.cgdn1) +- Xiaomi Walkingpad A1 (ksmb.walkingpad.v3) *Feel free to create a pull request to add support for new devices as diff --git a/docs/api/miio.gateway.rst b/docs/api/miio.gateway.rst index cfa6209e6..a010f3119 100644 --- a/docs/api/miio.gateway.rst +++ b/docs/api/miio.gateway.rst @@ -1,5 +1,29 @@ -miio.gateway module -=================== +miio.gateway package +==================== + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + miio.gateway.devices + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + miio.gateway.alarm + miio.gateway.gateway + miio.gateway.gatewaydevice + miio.gateway.light + miio.gateway.radio + miio.gateway.zigbee + +Module contents +--------------- .. automodule:: miio.gateway :members: diff --git a/docs/api/miio.rst b/docs/api/miio.rst index 08897c3c6..b628f99fd 100644 --- a/docs/api/miio.rst +++ b/docs/api/miio.rst @@ -1,12 +1,21 @@ miio package ============ +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + miio.gateway + Submodules ---------- .. toctree:: :maxdepth: 4 + miio.airconditioner_miot miio.airconditioningcompanion miio.airconditioningcompanionMCN miio.airdehumidifier @@ -18,8 +27,10 @@ Submodules miio.airhumidifier_miot miio.airhumidifier_mjjsq miio.airpurifier + miio.airpurifier_airdog miio.airpurifier_miot miio.airqualitymonitor + miio.airqualitymonitor_miot miio.alarmclock miio.aqaracamera miio.ceil @@ -30,15 +41,19 @@ Submodules miio.cli miio.click_common miio.cooker + miio.curtain_youpin miio.device miio.discovery + miio.dreamevacuum_miot miio.exceptions miio.extract_tokens miio.fan miio.fan_common + miio.fan_leshow miio.fan_miot - miio.gateway miio.heater + miio.heater_miot + miio.huizuo miio.miioprotocol miio.miot_device miio.philips_bulb @@ -50,17 +65,22 @@ Submodules miio.powerstrip miio.protocol miio.pwzn_relay + miio.scishare_coffeemaker miio.toiletlid miio.updater miio.utils miio.vacuum miio.vacuum_cli + miio.vacuum_tui miio.vacuumcontainers miio.viomivacuum + miio.walkingpad miio.waterpurifier + miio.waterpurifier_yunmi miio.wifirepeater miio.wifispeaker miio.yeelight + miio.yeelight_dual_switch Module contents --------------- diff --git a/miio/__init__.py b/miio/__init__.py index 188c92b3f..12cf13d80 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -63,6 +63,7 @@ VacuumStatus, ) from miio.viomivacuum import ViomiVacuum +from miio.walkingpad import Walkingpad from miio.waterpurifier import WaterPurifier from miio.waterpurifier_yunmi import WaterPurifierYunmi from miio.wifirepeater import WifiRepeater diff --git a/miio/tests/test_walkingpad.py b/miio/tests/test_walkingpad.py new file mode 100644 index 000000000..d1e094bcb --- /dev/null +++ b/miio/tests/test_walkingpad.py @@ -0,0 +1,193 @@ +from unittest import TestCase + +import pytest + +from miio import Walkingpad +from miio.walkingpad import ( + OperationMode, + OperationSensitivity, + WalkingpadException, + WalkingpadStatus, +) + +from .dummies import DummyDevice + + +class DummyWalkingpad(DummyDevice, Walkingpad): + def _get_state(self, props): + """Return wanted properties.""" + + # Overriding here to deal with case of 'all' being requested + + if props[0] == "all": + return self.state[props[0]] + + return [self.state[x] for x in props if x in self.state] + + def _set_state(self, var, value): + """Set a state of a variable, the value is expected to be an array with length + of 1.""" + + # Overriding here to deal with case of 'all' being set + + if var == "all": + self.state[var] = value + else: + self.state[var] = value.pop(0) + + def __init__(self, *args, **kwargs): + self.state = { + "power": "on", + "mode": OperationMode.Manual, + "time": 1387, + "step": 2117, + "sensitivity": OperationSensitivity.Low, + "dist": 1150, + "sp": 3.15, + "cal": 71710, + "start_speed": 3.1, + "all": [ + "mode:" + str(OperationMode.Manual.value), + "time:1387", + "sp:3.15", + "dist:1150", + "cal:71710", + "step:2117", + ], + } + self.return_values = { + "get_prop": self._get_state, + "set_power": lambda x: self._set_state("power", x), + "set_mode": lambda x: self._set_state("mode", x), + "set_speed": lambda x: ( + self._set_state( + "all", + [ + "mode:1", + "time:1387", + "sp:" + str(x[0]), + "dist:1150", + "cal:71710", + "step:2117", + ], + ), + self._set_state("sp", x), + ), + "set_step": lambda x: self._set_state("step", x), + "set_sensitivity": lambda x: self._set_state("sensitivity", x), + "set_start_speed": lambda x: self._set_state("start_speed", x), + "set_time": lambda x: self._set_state("time", x), + "set_distance": lambda x: self._set_state("dist", x), + } + super().__init__(args, kwargs) + + +@pytest.fixture(scope="class") +def walkingpad(request): + request.cls.device = DummyWalkingpad() + + +@pytest.mark.usefixtures("walkingpad") +class TestWalkingpad(TestCase): + def is_on(self): + return self.device.status().is_on + + def state(self): + return self.device.status() + + def test_on(self): + self.device.off() # ensure off + assert self.is_on() is False + + self.device.on() + assert self.is_on() is True + + def test_off(self): + self.device.on() # ensure on + assert self.is_on() is True + + self.device.off() + assert self.is_on() is False + + def test_status(self): + self.device._reset_state() + + assert repr(self.state()) == repr(WalkingpadStatus(self.device.start_state)) + assert self.is_on() is True + assert self.state().power == self.device.start_state["power"] + assert self.state().mode == self.device.start_state["mode"] + assert self.state().speed == self.device.start_state["sp"] + assert self.state().step_count == self.device.start_state["step"] + assert self.state().distance == self.device.start_state["dist"] + assert self.state().sensitivity == self.device.start_state["sensitivity"] + assert self.state().walking_time == self.device.start_state["time"] + + def test_set_mode(self): + def mode(): + return self.device.status().mode + + self.device.set_mode(OperationMode.Auto) + assert mode() == OperationMode.Auto + + self.device.set_mode(OperationMode.Manual) + assert mode() == OperationMode.Manual + + with pytest.raises(WalkingpadException): + self.device.set_mode(-1) + + with pytest.raises(WalkingpadException): + self.device.set_mode(3) + + with pytest.raises(WalkingpadException): + self.device.set_mode("blah") + + def test_set_speed(self): + def speed(): + return self.device.status().speed + + self.device.set_speed(3.055) + assert speed() == 3.055 + + with pytest.raises(WalkingpadException): + self.device.set_speed(7.6) + + with pytest.raises(WalkingpadException): + self.device.set_speed(-1) + + with pytest.raises(WalkingpadException): + self.device.set_speed("blah") + + def test_set_start_speed(self): + def speed(): + return self.device.status().start_speed + + self.device.set_start_speed(3.055) + assert speed() == 3.055 + + with pytest.raises(WalkingpadException): + self.device.set_start_speed(7.6) + + with pytest.raises(WalkingpadException): + self.device.set_start_speed(-1) + + with pytest.raises(WalkingpadException): + self.device.set_start_speed("blah") + + def test_set_sensitivity(self): + def sensitivity(): + return self.device.status().sensitivity + + self.device.set_sensitivity(OperationSensitivity.High) + assert sensitivity() == OperationSensitivity.High + + self.device.set_sensitivity(OperationSensitivity.Medium) + assert sensitivity() == OperationSensitivity.Medium + + with pytest.raises(WalkingpadException): + self.device.set_sensitivity(-1) + + with pytest.raises(WalkingpadException): + self.device.set_sensitivity(99) + + with pytest.raises(WalkingpadException): + self.device.set_sensitivity("blah") diff --git a/miio/walkingpad.py b/miio/walkingpad.py new file mode 100644 index 000000000..e5470c61d --- /dev/null +++ b/miio/walkingpad.py @@ -0,0 +1,277 @@ +import enum +import logging +from datetime import timedelta +from typing import Any, Dict + +import click + +from .click_common import EnumType, command, format_output +from .device import Device, DeviceStatus +from .exceptions import DeviceException + +_LOGGER = logging.getLogger(__name__) + + +class WalkingpadException(DeviceException): + pass + + +class OperationMode(enum.Enum): + Auto = 0 + Manual = 1 + Off = 2 + + +class OperationSensitivity(enum.Enum): + High = 1 + Medium = 2 + Low = 3 + + +class WalkingpadStatus(DeviceStatus): + """Container for status reports from Xiaomi Walkingpad A1 (ksmb.walkingpad.v3). + + Input data dictionary to initialise this class: + + {'cal': 6130, + 'dist': 90, + 'mode': 1, + 'power': 'on', + 'sensitivity': 1, + 'sp': 3.0, + 'start_speed': 3.0, + 'step': 180, + 'time': 121} + """ + + def __init__(self, data: Dict[str, Any]) -> None: + self.data = data + + @property + def power(self) -> str: + """Power state.""" + return self.data["power"] + + @property + def is_on(self) -> bool: + """True if the device is turned on.""" + return self.power == "on" + + @property + def walking_time(self) -> timedelta: + """Current walking duration in seconds.""" + return int(self.data["time"]) + + @property + def speed(self) -> float: + """Current speed.""" + return float(self.data["sp"]) + + @property + def start_speed(self) -> float: + """Current start speed.""" + return self.data["start_speed"] + + @property + def mode(self) -> OperationMode: + """Current mode.""" + return OperationMode(self.data["mode"]) + + @property + def sensitivity(self) -> OperationSensitivity: + """Current sensitivity.""" + return OperationSensitivity(self.data["sensitivity"]) + + @property + def step_count(self) -> int: + """Current steps.""" + return int(self.data["step"]) + + @property + def distance(self) -> int: + """Current distance in meters.""" + return int(self.data["dist"]) + + @property + def calories(self) -> int: + """Current calories burnt.""" + return int(self.data["cal"]) + + +class Walkingpad(Device): + """Main class representing Xiaomi Walkingpad.""" + + @command( + default_output=format_output( + "", + "Power: {result.power}\n" + "Mode: {result.mode.name}\n" + "Time: {result.walking_time}\n" + "Steps: {result.step_count}\n" + "Speed: {result.speed}\n" + "Start Speed: {result.start_speed}\n" + "Sensitivity: {result.sensitivity.name}\n" + "Distance: {result.distance}\n" + "Calories: {result.calories}", + ) + ) + def status(self) -> WalkingpadStatus: + """Retrieve properties.""" + + data = self._get_quick_status() + + # The quick status only retrieves a subset of the properties. The rest of them are retrieved here. + properties_additional = ["power", "mode", "start_speed", "sensitivity"] + values_additional = self.get_properties(properties_additional, max_properties=1) + + additional_props = dict(zip(properties_additional, values_additional)) + data.update(additional_props) + + return WalkingpadStatus(data) + + @command( + default_output=format_output( + "", + "Mode: {result.mode.name}\n" + "Time: {result.walking_time}\n" + "Steps: {result.step_count}\n" + "Speed: {result.speed}\n" + "Distance: {result.distance}\n" + "Calories: {result.calories}", + ) + ) + def quick_status(self) -> WalkingpadStatus: + """Retrieve quick status. + + The walkingpad provides the option to retrieve a subset of properties in one call: + steps, mode, speed, distance, calories and time. + + `status()` will do four more separate I/O requests for power, mode, start_speed, and sensitivity. + If you don't need any of that, prefer this method for status updates. + """ + + data = self._get_quick_status() + + return WalkingpadStatus(data) + + @command(default_output=format_output("Powering on")) + def on(self): + """Power on.""" + return self.send("set_power", ["on"]) + + @command(default_output=format_output("Powering off")) + def off(self): + """Power off.""" + return self.send("set_power", ["off"]) + + @command(default_output=format_output("Locking")) + def lock(self): + """Lock device.""" + return self.send("set_lock", [1]) + + @command(default_output=format_output("Unlocking")) + def unlock(self): + """Unlock device.""" + return self.send("set_lock", [0]) + + @command(default_output=format_output("Starting the treadmill")) + def start(self): + """Start the treadmill.""" + + # In case the treadmill is not already turned on, turn it on. + if not self.status().is_on: + self.on(self) + + return self.send("set_state", ["run"]) + + @command(default_output=format_output("Stopping the treadmill")) + def stop(self): + """Stop the treadmill.""" + return self.send("set_state", ["stop"]) + + @command( + click.argument("mode", type=EnumType(OperationMode)), + default_output=format_output("Setting mode to '{mode.name}'"), + ) + def set_mode(self, mode: OperationMode): + """Set mode (auto/manual).""" + + if not isinstance(mode, OperationMode): + raise WalkingpadException("Invalid mode: %s" % mode) + + return self.send("set_mode", [mode.value]) + + @command( + click.argument("speed", type=float), + default_output=format_output("Setting speed to {speed}"), + ) + def set_speed(self, speed: float): + """Set speed.""" + + if not isinstance(speed, float): + raise WalkingpadException("Invalid speed: %s" % speed) + + if speed < 0 or speed > 6: + raise WalkingpadException("Invalid speed: %s" % speed) + + return self.send("set_speed", [speed]) + + @command( + click.argument("speed", type=float), + default_output=format_output("Setting start speed to {speed}"), + ) + def set_start_speed(self, speed: float): + """Set start speed.""" + + if not isinstance(speed, float): + raise WalkingpadException("Invalid start speed: %s" % speed) + + if speed < 0 or speed > 6: + raise WalkingpadException("Invalid start speed: %s" % speed) + + return self.send("set_start_speed", [speed]) + + @command( + click.argument("sensitivity", type=EnumType(OperationSensitivity)), + default_output=format_output("Setting sensitivity to {sensitivity}"), + ) + def set_sensitivity(self, sensitivity: OperationSensitivity): + """Set sensitivity.""" + + if not isinstance(sensitivity, OperationSensitivity): + raise WalkingpadException("Invalid mode: %s" % sensitivity) + + return self.send("set_sensitivity", [sensitivity.value]) + + def _get_quick_status(self): + """Internal helper to get the quick status via the "all" property.""" + + # Walkingpad A1 allows you to quickly retrieve a subset of values with "all" + # all other properties need to be retrieved one by one and are therefore slower + # eg ['mode:1', 'time:1387', 'sp:3.0', 'dist:1150', 'cal:71710', 'step:2117'] + + properties = ["all"] + + values = self.get_properties(properties, max_properties=1) + + value_map = { + "sp": float, + "step": int, + "cal": int, + "time": int, + "dist": int, + "mode": int, + } + + data = {} + for x in values: + prop, value = x.split(":") + + if prop not in value_map: + _LOGGER.warning("Received unknown data from device: %s=%s", prop, value) + + data[prop] = value + + converted_data = {key: value_map[key](value) for key, value in data.items()} + + return converted_data