diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..c084e25d1 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,46 @@ +name: Publish packages +on: + push: + branches: + - master + +jobs: + build-n-publish: + name: Build release packages + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@master + - name: Setup python + uses: actions/setup-python@v1 + with: + python-version: 3.9 + + - name: Install pypa/build + run: >- + python -m + pip install + build + --user + + - name: Build a binary wheel and a source tarball + run: >- + python -m + build + --sdist + --wheel + --outdir dist/ + . + + - name: Publish on test pypi + uses: pypa/gh-action-pypi-publish@master + continue-on-error: true + with: + password: ${{ secrets.TEST_PYPI_API_TOKEN }} + repository_url: https://test.pypi.org/legacy/ + + - name: Publish release on pypi + if: startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@master + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github_changelog_generator b/.github_changelog_generator new file mode 100644 index 000000000..c2cf9d108 --- /dev/null +++ b/.github_changelog_generator @@ -0,0 +1,4 @@ +breaking_labels=breaking change +issues=false +add-sections={"newdevs":{"prefix":"**New devices:**","labels":["new device"]},"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]}} +release_branch=master diff --git a/.gitignore b/.gitignore index 57c105412..cb43877a8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ __pycache__ .coverage docs/_build/ +.vscode/settings.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 511c88ee5..128adc7a2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.4.0 + rev: v4.0.1 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -12,19 +12,19 @@ repos: - id: check-ast - repo: https://github.com/psf/black - rev: 20.8b1 + rev: 21.11b1 hooks: - id: black language_version: python3 - repo: https://github.com/pre-commit/mirrors-isort - rev: v5.7.0 + rev: v5.9.3 hooks: - id: isort additional_dependencies: [toml] - repo: https://github.com/PyCQA/doc8 - rev: 0.8.1 + rev: 0.10.1 hooks: - id: doc8 @@ -35,20 +35,20 @@ repos: args: [--in-place, --wrap-summaries, '88', --wrap-descriptions, '88'] - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.4 + rev: 3.9.2 hooks: - id: flake8 additional_dependencies: [flake8-docstrings, flake8-bugbear, flake8-builtins, flake8-print, flake8-pytest-style, flake8-return, flake8-simplify, flake8-annotations] - repo: https://github.com/PyCQA/bandit - rev: 1.7.0 + rev: 1.7.1 hooks: - id: bandit args: [-x, 'tests'] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.812 + rev: v0.910-1 hooks: - id: mypy -# args: [--no-strict-optional, --ignore-missing-imports] + additional_dependencies: [types-attrs, types-PyYAML, types-requests, types-pytz, types-croniter] diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e804c1d8..5f531cdcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,68 @@ # Change Log +## [0.5.9](https://github.com/rytilahti/python-miio/tree/0.5.9) (2021-11-30) + +Besides enhancements and bug fixes, this release includes plenty of janitoral work to enable common base classes in the future. + +For library users: +* Integrations are slowly moving to their own packages and directories, e.g. the vacuum module is now located in `miio.integrations.vacuum.roborock`. +* Using `Vacuum` is now deprecated and will be later used as the common interface class for all vacuum implementations. For roborock vacuums, use `RoborockVacuum` instead. + + +[Full Changelog](https://github.com/rytilahti/python-miio/compare/0.5.8...0.5.9) + +**Breaking changes:** + +- Move vacuums to self-contained integrations [\#1165](https://github.com/rytilahti/python-miio/pull/1165) ([rytilahti](https://github.com/rytilahti)) +- Remove unnecessary subclass constructors, deprecate subclasses only setting the model [\#1146](https://github.com/rytilahti/python-miio/pull/1146) ([rytilahti](https://github.com/rytilahti)) +- Remove deprecated cli tools \(plug,miceil,mieye\) [\#1130](https://github.com/rytilahti/python-miio/pull/1130) ([rytilahti](https://github.com/rytilahti)) + +**Implemented enhancements:** + +- Upgrage install and pre-commit dependencies [\#1192](https://github.com/rytilahti/python-miio/pull/1192) ([rytilahti](https://github.com/rytilahti)) +- Add py.typed to the package [\#1184](https://github.com/rytilahti/python-miio/pull/1184) ([rytilahti](https://github.com/rytilahti)) +- airhumidifer\_\(mj\)jsq: Add use\_time for better API compatibility [\#1179](https://github.com/rytilahti/python-miio/pull/1179) ([rytilahti](https://github.com/rytilahti)) +- vacuum: return none on is\_water\_box\_attached if unsupported [\#1178](https://github.com/rytilahti/python-miio/pull/1178) ([rytilahti](https://github.com/rytilahti)) +- Add more supported vacuum models [\#1173](https://github.com/rytilahti/python-miio/pull/1173) ([OGKevin](https://github.com/OGKevin)) +- Reorganize yeelight specs file [\#1166](https://github.com/rytilahti/python-miio/pull/1166) ([Kirmas](https://github.com/Kirmas)) +- enable G1 vacuum for miiocli [\#1164](https://github.com/rytilahti/python-miio/pull/1164) ([ghoost82](https://github.com/ghoost82)) +- Add light specs for yeelight [\#1163](https://github.com/rytilahti/python-miio/pull/1163) ([Kirmas](https://github.com/Kirmas)) +- Add S5 MAX model to support models list. [\#1157](https://github.com/rytilahti/python-miio/pull/1157) ([OGKevin](https://github.com/OGKevin)) +- Use poetry-core as build-system [\#1152](https://github.com/rytilahti/python-miio/pull/1152) ([rytilahti](https://github.com/rytilahti)) +- Support for Xiaomi Mijia G1 \(mijia.vacuum.v2\) [\#867](https://github.com/rytilahti/python-miio/pull/867) ([neturmel](https://github.com/neturmel)) + +**Fixed bugs:** + +- Fix test\_properties command logic [\#1180](https://github.com/rytilahti/python-miio/pull/1180) ([Zuz666](https://github.com/Zuz666)) +- Make sure all device-derived classes accept model kwarg [\#1143](https://github.com/rytilahti/python-miio/pull/1143) ([rytilahti](https://github.com/rytilahti)) +- Make cli work again for offline gen1 vacs, fix tests [\#1141](https://github.com/rytilahti/python-miio/pull/1141) ([rytilahti](https://github.com/rytilahti)) +- Fix `water_level` calculation for humidifiers [\#1140](https://github.com/rytilahti/python-miio/pull/1140) ([bieniu](https://github.com/bieniu)) +- fix TypeError in gateway property exception handling [\#1138](https://github.com/rytilahti/python-miio/pull/1138) ([starkillerOG](https://github.com/starkillerOG)) +- Do not get battery status for mains powered devices [\#1131](https://github.com/rytilahti/python-miio/pull/1131) ([starkillerOG](https://github.com/starkillerOG)) + +**Deprecated:** + +- Deprecate roborock specific miio.Vacuum [\#1191](https://github.com/rytilahti/python-miio/pull/1191) ([rytilahti](https://github.com/rytilahti)) + +**New devices:** + +- add support for smart pet water dispenser mmgg.pet\_waterer.s1 [\#1174](https://github.com/rytilahti/python-miio/pull/1174) ([ofen](https://github.com/ofen)) + +**Documentation updates:** + +- Docs: Add workaround for file upload failure [\#1155](https://github.com/rytilahti/python-miio/pull/1155) ([martin-kokos](https://github.com/martin-kokos)) +- Add examples how to avoid model autodetection [\#1142](https://github.com/rytilahti/python-miio/pull/1142) ([rytilahti](https://github.com/rytilahti)) +- Restructure & improve documentation [\#1139](https://github.com/rytilahti/python-miio/pull/1139) ([rytilahti](https://github.com/rytilahti)) + +**Merged pull requests:** + +- Add Air Purifier Pro H support [\#1185](https://github.com/rytilahti/python-miio/pull/1185) ([pvizeli](https://github.com/pvizeli)) +- Allow publish on test pypi workflow to fail [\#1177](https://github.com/rytilahti/python-miio/pull/1177) ([rytilahti](https://github.com/rytilahti)) +- Relax pyyaml version requirement [\#1176](https://github.com/rytilahti/python-miio/pull/1176) ([rytilahti](https://github.com/rytilahti)) +- create separate directory for yeelight [\#1160](https://github.com/rytilahti/python-miio/pull/1160) ([Kirmas](https://github.com/Kirmas)) +- Add workflow to publish packages on pypi [\#1145](https://github.com/rytilahti/python-miio/pull/1145) ([rytilahti](https://github.com/rytilahti)) +- Add tests for DeviceInfo [\#1144](https://github.com/rytilahti/python-miio/pull/1144) ([rytilahti](https://github.com/rytilahti)) +- Mark device\_classes inside devicegroupmeta as private [\#1129](https://github.com/rytilahti/python-miio/pull/1129) ([rytilahti](https://github.com/rytilahti)) + ## [0.5.8](https://github.com/rytilahti/python-miio/tree/0.5.8) (2021-09-01) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..2e19b001a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,5 @@ +## Contributing to python-miio + +Looking to contribute to this project? Thank you! + +Please check [the contribution section in the documentation](https://python-miio.readthedocs.io/en/latest/contributing.html) for some tips and details on how to get started. diff --git a/README.rst b/README.rst index bdc783c80..3447527a3 100644 --- a/README.rst +++ b/README.rst @@ -55,6 +55,12 @@ You can get the list of available commands for any given module by passing `--he add_timer Add a timer. .. +Each command invocation will automatically detect the device model necessary for some actions by querying the device. +You can avoid this by specifying the model manually:: + + miiocli vacuum --model roborock.vacuum.s5 --ip --token start + + API usage --------- All functionality is accessible through the `miio` module:: @@ -65,7 +71,15 @@ All functionality is accessible through the `miio` module:: vac.start() Each separate device type inherits from `miio.Device` -(and in case of MIoT devices, `miio.MiotDevice`) which provides common API. +(and in case of MIoT devices, `miio.MiotDevice`) which provides a common API. + +Each command invocation will automatically detect (and cache) the device model necessary for some actions +by querying the device. +You can avoid this by specifying the model manually:: + + from miio import Vacuum + + vac = Vacuum("", "", model="roborock.vacuum.s5") Please refer to `API documentation `__ for more information. @@ -86,10 +100,10 @@ To ease the process of setting up a development environment we have prepared `a Supported devices ----------------- -- Xiaomi Mi Robot Vacuum V1, S5, M1S, S7 +- Xiaomi Mi Robot Vacuum V1, S4, S4 MAX, S5, S5 MAX, S6 Pure, M1S, S7 - Xiaomi Mi Home Air Conditioner Companion - Xiaomi Mi Smart Air Conditioner A (xiaomi.aircondition.mc1, mc2, mc4, mc5) -- Xiaomi Mi Air Purifier 2, 3H, 3C, Pro (zhimi.airpurifier.m2, mb3, mb4, v7) +- Xiaomi Mi Air Purifier 2, 3H, 3C, Pro, Pro H (zhimi.airpurifier.m2, mb3, mb4, v7, vb2) - Xiaomi Mi Air (Purifier) Dog X3, X5, X7SM (airdog.airpurifier.x3, airdog.airpurifier.x5, airdog.airpurifier.x7sm) - Xiaomi Mi Air Humidifier - Xiaomi Aqara Camera @@ -97,6 +111,7 @@ Supported devices - Xiaomi Mijia 360 1080p - Xiaomi Mijia STYJ02YM (Viomi) - Xiaomi Mijia 1C STYTJ01ZHM (Dreame) +- Xiaomi Mi Home (Mijia) G1 Robot Vacuum Mop MJSTG1 - Xiaomi Roidmi Eve - Xiaomi Mi Smart WiFi Socket - Xiaomi Chuangmi Plug V1 (1 Socket, 1 USB Port) @@ -135,6 +150,7 @@ Supported devices - Scishare coffee maker (scishare.coffee.s1102) - Qingping Air Monitor Lite (cgllc.airm.cgdn1) - Xiaomi Walkingpad A1 (ksmb.walkingpad.v3) +- Xiaomi Smart Pet Water Dispenser (mmgg.pet_waterer.s1, s4) *Feel free to create a pull request to add support for new devices as @@ -174,7 +190,8 @@ Home Assistant (custom) Other related projects ---------------------- -This is a list of other projects around the xiaomi ecosystem that you can find interesting. Feel free to submit more related projects. +This is a list of other projects around the Xiaomi ecosystem that you can find interesting. +Feel free to submit more related projects. - `dustcloud `__ (reverse engineering and rooting xiaomi devices) - `Valetudo `__ (cloud free vacuum firmware) diff --git a/docs/api/miio.ceil_cli.rst b/docs/api/miio.ceil_cli.rst deleted file mode 100644 index a459868af..000000000 --- a/docs/api/miio.ceil_cli.rst +++ /dev/null @@ -1,7 +0,0 @@ -miio.ceil\_cli module -===================== - -.. automodule:: miio.ceil_cli - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/api/miio.parse_ast.rst b/docs/api/miio.parse_ast.rst deleted file mode 100644 index 021c87c5d..000000000 --- a/docs/api/miio.parse_ast.rst +++ /dev/null @@ -1,7 +0,0 @@ -miio.parse\_ast module -====================== - -.. automodule:: miio.parse_ast - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/api/miio.philips_eyecare_cli.rst b/docs/api/miio.philips_eyecare_cli.rst deleted file mode 100644 index 4df31d460..000000000 --- a/docs/api/miio.philips_eyecare_cli.rst +++ /dev/null @@ -1,7 +0,0 @@ -miio.philips\_eyecare\_cli module -================================= - -.. automodule:: miio.philips_eyecare_cli - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/api/miio.plug_cli.rst b/docs/api/miio.plug_cli.rst deleted file mode 100644 index a84a7a835..000000000 --- a/docs/api/miio.plug_cli.rst +++ /dev/null @@ -1,7 +0,0 @@ -miio.plug\_cli module -===================== - -.. automodule:: miio.plug_cli - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/api/miio.rst b/docs/api/miio.rst index bc3d92956..870ddee98 100644 --- a/docs/api/miio.rst +++ b/docs/api/miio.rst @@ -34,7 +34,6 @@ Submodules miio.alarmclock miio.aqaracamera miio.ceil - miio.ceil_cli miio.chuangmi_camera miio.chuangmi_ir miio.chuangmi_plug @@ -59,10 +58,8 @@ Submodules miio.miot_device miio.philips_bulb miio.philips_eyecare - miio.philips_eyecare_cli miio.philips_moonlight miio.philips_rwread - miio.plug_cli miio.powerstrip miio.protocol miio.pwzn_relay diff --git a/docs/new_devices.rst b/docs/contributing.rst similarity index 57% rename from docs/new_devices.rst rename to docs/contributing.rst index e616991bf..7dfd31408 100644 --- a/docs/new_devices.rst +++ b/docs/contributing.rst @@ -6,6 +6,15 @@ so we hope this short introduction will help you to get started! Shortly put: we use black_ to format our code, isort_ to sort our imports, pytest_ to test our code, flake8_ to do its checks, and doc8_ for documentation checks. +See :ref:`devenv` for setting up a development environment, +and :ref:`new_devices` for some helpful tips for adding support for new devices. + +.. contents:: Contents + :local: + + +.. _devenv: + Development environment ----------------------- @@ -31,6 +40,9 @@ Therefore the first step after setting up the development environment is to inst You can always `execute the checks <#code-checks>`_ also without doing a commit. + +.. _linting: + Code checks ~~~~~~~~~~~ @@ -40,6 +52,9 @@ This will execute the same checks that would be done automatically by precommit_ tox -e lint + +.. _tests: + Tests ~~~~~ @@ -55,32 +70,92 @@ Generating documentation You can compile the documentation and open it locally in your browser:: - sphinx docs/ generated_docs + sphinx-build docs/ generated_docs $BROWSER generated_docs/index.html Replace `$BROWSER` with your preferred browser, if the environment variable is not set. + +.. _new_devices: + Adding support for new devices ------------------------------ -The `miio javascript library `__ -contains some hints on devices which could be supported, however, the -Xiaomi Smart Home gateway (`Home Assistant -component `__ already work in -progress) as well as Yeelight bulbs are currently not in the scope of -this project. +.. _checklist: + +Development checklist +~~~~~~~~~~~~~~~~~~~~~ + +1. All device classes are derived from either :class:`miio.device.Device` (for MiIO) + or :class:`miio.miot_device.MiotDevice` (for MiOT) (:ref:`Minimal example`). +2. All commands and their arguments should be decorated with `@command` decorator, + which will make them accessible to `miiocli` (:ref:`miiocli`). +3. Status containers is derived from `DeviceStatus` class and all properties should + have type annotations for their return values. +4. Creating tests (:ref:`adding_tests`). +5. Updating documentation is generally not needed as the API documentation + will be generated automatically. + + +Minimal example +~~~~~~~~~~~~~~~ + +.. TODO:: + Add or link to an example. + + +miiocli integration +~~~~~~~~~~~~~~~~~~~ + +All user-exposed methods of the device class should be decorated with +:meth:`miio.click_common.command` to provide console interface. +The decorated methods will be exposed as click_ commands for the given module. +For example, the following definition: + +.. code-block:: python + + @command( + click.argument("string_argument", type=str), + click.argument("int_argument", type=int, required=False) + ) + def command(string_argument: str, int_argument: int): + click.echo(f"Got {string_argument} and {int_argument}") + +Produces a command ``miiocli example`` command requiring an argument +that is passed to the method as string, and an optional integer argument. + + +Status containers +~~~~~~~~~~~~~~~~~ + +The status container (returned by `status()` method of the device class) +is the main way for library users to access properties exposed by the device. +The status container should inherit :class:`miio.device.DeviceStatus` to ensure a generic :meth:`__repr__`. + + + +MiIO devices +~~~~~~~~~~~~ .. TODO:: Add instructions how to extract protocol from network captures + +MiOT devices +~~~~~~~~~~~~ + +.. _adding_tests: + Adding tests ------------- +~~~~~~~~~~~~ .. TODO:: Describe how to create tests. This part of documentation needs your help! Please consider submitting a pull request to update this. +.. _documentation: + Documentation ------------- @@ -89,6 +164,7 @@ Documentation This part of documentation needs your help! Please consider submitting a pull request to update this. +.. _click: https://click.palletsprojects.com .. _virtualenv: https://virtualenv.pypa.io .. _isort: https://github.com/timothycrosley/isort .. _pipenv: https://github.com/pypa/pipenv diff --git a/docs/ceil.rst b/docs/device_docs/ceil.rst similarity index 100% rename from docs/ceil.rst rename to docs/device_docs/ceil.rst diff --git a/docs/eyecare.rst b/docs/device_docs/eyecare.rst similarity index 100% rename from docs/eyecare.rst rename to docs/device_docs/eyecare.rst diff --git a/docs/device_docs/index.rst b/docs/device_docs/index.rst new file mode 100644 index 000000000..404d0d30f --- /dev/null +++ b/docs/device_docs/index.rst @@ -0,0 +1,14 @@ +Device-specific documentation +============================= + +This section contains device-specific documentation, if available. + +.. contents:: + :local: + :depth: 1 + + +.. toctree:: + :glob: + + * diff --git a/docs/plug.rst b/docs/device_docs/plug.rst similarity index 100% rename from docs/plug.rst rename to docs/device_docs/plug.rst diff --git a/docs/vacuum.rst b/docs/device_docs/vacuum.rst similarity index 93% rename from docs/vacuum.rst rename to docs/device_docs/vacuum.rst index 6c5604270..83b05ad7d 100644 --- a/docs/vacuum.rst +++ b/docs/device_docs/vacuum.rst @@ -190,6 +190,13 @@ and updating from an URL requires you to pass the md5 hash of the file. mirobo update-firmware v11_003094.pkg +If you can control the device but the firmware update is not working (e.g., you are receiving a ```BrokenPipeError`` during the update process `_ , you can host the file on any HTTP server (such as ``python2 -m SimpleHTTPServer``) by passing the URL and the md5sum of the file to the command: + +:: + + mirobo update-firmware http://example.com/firmware_update.pkg 5eb63bbbe01eeed093cb22bb8f5acdc3 + + Manual control ~~~~~~~~~~~~~~ diff --git a/docs/yeelight.rst b/docs/device_docs/yeelight.rst similarity index 100% rename from docs/yeelight.rst rename to docs/device_docs/yeelight.rst diff --git a/docs/index.rst b/docs/index.rst index 4157e555a..d365ccb7b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,16 +22,13 @@ Furthermore thanks goes to contributors of this project who have helped to extend this to cover not only the vacuum cleaner. .. toctree:: - :maxdepth: 2 + :maxdepth: 1 :caption: Contents: Home discovery - new_devices - vacuum - plug - ceil - eyecare - yeelight - API troubleshooting + contributing + device_docs/index + + API diff --git a/miio/__init__.py b/miio/__init__.py index 968ec8a21..a78538e75 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -32,7 +32,6 @@ from miio.cooker import Cooker from miio.curtain_youpin import CurtainMiot from miio.device import Device, DeviceStatus -from miio.dreamevacuum_miot import DreameVacuumMiot from miio.exceptions import DeviceError, DeviceException from miio.fan import Fan, FanP5, FanSA1, FanV2, FanZA1, FanZA4 from miio.fan_leshow import FanLeshow @@ -41,6 +40,21 @@ from miio.heater import Heater from miio.heater_miot import HeaterMiot from miio.huizuo import Huizuo, HuizuoLampFan, HuizuoLampHeater, HuizuoLampScene +from miio.integrations.petwaterdispenser import PetWaterDispenser +from miio.integrations.vacuum.dreame.dreamevacuum_miot import DreameVacuumMiot +from miio.integrations.vacuum.mijia import G1Vacuum +from miio.integrations.vacuum.roborock import RoborockVacuum, Vacuum, VacuumException +from miio.integrations.vacuum.roborock.vacuumcontainers import ( + CleaningDetails, + CleaningSummary, + ConsumableStatus, + DNDStatus, + Timer, + VacuumStatus, +) +from miio.integrations.vacuum.roidmi.roidmivacuum_miot import RoidmiVacuumMiot +from miio.integrations.vacuum.viomi.viomivacuum import ViomiVacuum +from miio.integrations.yeelight import Yeelight from miio.miot_device import MiotDevice from miio.philips_bulb import PhilipsBulb, PhilipsWhiteBulb from miio.philips_eyecare import PhilipsEyecare @@ -49,26 +63,13 @@ from miio.powerstrip import PowerStrip from miio.protocol import Message, Utils from miio.pwzn_relay import PwznRelay -from miio.roidmivacuum_miot import RoidmiVacuumMiot from miio.scishare_coffeemaker import ScishareCoffee from miio.toiletlid import Toiletlid -from miio.vacuum import Vacuum, VacuumException -from miio.vacuum_tui import VacuumTUI -from miio.vacuumcontainers import ( - CleaningDetails, - CleaningSummary, - ConsumableStatus, - DNDStatus, - Timer, - 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 from miio.wifispeaker import WifiSpeaker -from miio.yeelight import Yeelight from miio.yeelight_dual_switch import YeelightDualControlModule from miio.discovery import Discovery diff --git a/miio/airconditioningcompanion.py b/miio/airconditioningcompanion.py index 6c522fb8c..aba46c6e7 100644 --- a/miio/airconditioningcompanion.py +++ b/miio/airconditioningcompanion.py @@ -236,12 +236,9 @@ def __init__( lazy_discover: bool = True, model: str = MODEL_ACPARTNER_V2, ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) + super().__init__(ip, token, start_id, debug, lazy_discover, model=model) - if model in MODELS_SUPPORTED: - self.model = model - else: - self.model = MODEL_ACPARTNER_V2 + if self.model not in MODELS_SUPPORTED: _LOGGER.error( "Device model %s unsupported. Falling back to %s.", model, self.model ) diff --git a/miio/airconditioningcompanionMCN.py b/miio/airconditioningcompanionMCN.py index 3f763063d..cc90b016c 100644 --- a/miio/airconditioningcompanionMCN.py +++ b/miio/airconditioningcompanionMCN.py @@ -113,7 +113,7 @@ def __init__( ) -> None: if start_id is None: start_id = random.randint(0, 999) # nosec - super().__init__(ip, token, start_id, debug, lazy_discover) + super().__init__(ip, token, start_id, debug, lazy_discover, model=model) if model != MODEL_ACPARTNER_MCN02: _LOGGER.error( diff --git a/miio/airdehumidifier.py b/miio/airdehumidifier.py index 7f69cbf26..2b5906241 100644 --- a/miio/airdehumidifier.py +++ b/miio/airdehumidifier.py @@ -158,24 +158,6 @@ def alarm(self) -> str: class AirDehumidifier(Device): """Implementation of Xiaomi Mi Air Dehumidifier.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - model: str = MODEL_DEHUMIDIFIER_V1, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in AVAILABLE_PROPERTIES: - self.model = model - else: - self.model = MODEL_DEHUMIDIFIER_V1 - - self.device_info: DeviceInfo - @command( default_output=format_output( "", @@ -198,15 +180,14 @@ def __init__( def status(self) -> AirDehumidifierStatus: """Retrieve properties.""" - if self.device_info is None: - self.device_info = self.info() - - properties = AVAILABLE_PROPERTIES[self.model] + properties = AVAILABLE_PROPERTIES.get( + self.model, AVAILABLE_PROPERTIES[MODEL_DEHUMIDIFIER_V1] + ) values = self.get_properties(properties, max_properties=1) return AirDehumidifierStatus( - defaultdict(lambda: None, zip(properties, values)), self.device_info + defaultdict(lambda: None, zip(properties, values)), self.info() ) @command(default_output=format_output("Powering on")) diff --git a/miio/airfresh.py b/miio/airfresh.py index 356cefa67..084322c01 100644 --- a/miio/airfresh.py +++ b/miio/airfresh.py @@ -8,6 +8,7 @@ from .click_common import EnumType, command, format_output from .device import Device, DeviceStatus from .exceptions import DeviceException +from .utils import deprecated _LOGGER = logging.getLogger(__name__) @@ -218,22 +219,6 @@ def extra_features(self) -> Optional[int]: class AirFresh(Device): """Main class representing the air fresh.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - model: str = MODEL_AIRFRESH_VA2, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in AVAILABLE_PROPERTIES: - self.model = model - else: - self.model = MODEL_AIRFRESH_VA2 - @command( default_output=format_output( "", @@ -259,7 +244,9 @@ def __init__( def status(self) -> AirFreshStatus: """Retrieve properties.""" - properties = AVAILABLE_PROPERTIES[self.model] + properties = AVAILABLE_PROPERTIES.get( + self.model, AVAILABLE_PROPERTIES[MODEL_AIRFRESH_VA2] + ) values = self.get_properties(properties, max_properties=15) return AirFreshStatus( @@ -361,6 +348,9 @@ def set_ptc(self, ptc: bool): return self.send("set_ptc_state", ["off"]) +@deprecated( + "This will be removed in the future, use AirFresh(..., model='zhimi.airfresh.va4'" +) class AirFreshVA4(AirFresh): """Main class representing the air fresh va4.""" diff --git a/miio/airfresh_t2017.py b/miio/airfresh_t2017.py index db1bf325f..1bf6800c4 100644 --- a/miio/airfresh_t2017.py +++ b/miio/airfresh_t2017.py @@ -224,22 +224,6 @@ def display_orientation(self) -> Optional[DisplayOrientation]: class AirFreshA1(Device): """Main class representing the air fresh a1.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - model: str = MODEL_AIRFRESH_A1, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in AVAILABLE_PROPERTIES: - self.model = model - else: - self.model = MODEL_AIRFRESH_A1 - @command( default_output=format_output( "", @@ -262,7 +246,9 @@ def __init__( def status(self) -> AirFreshStatus: """Retrieve properties.""" - properties = AVAILABLE_PROPERTIES[self.model] + properties = AVAILABLE_PROPERTIES.get( + self.model, AVAILABLE_PROPERTIES[MODEL_AIRFRESH_A1] + ) values = self.get_properties(properties, max_properties=15) return AirFreshStatus(defaultdict(lambda: None, zip(properties, values))) @@ -371,22 +357,6 @@ def get_timer(self): class AirFreshT2017(AirFreshA1): """Main class representing the air fresh t2017.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - model: str = MODEL_AIRFRESH_T2017, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in AVAILABLE_PROPERTIES: - self.model = model - else: - self.model = MODEL_AIRFRESH_T2017 - @command( default_output=format_output( "", @@ -410,11 +380,6 @@ def __init__( "Display orientation: {result.display_orientation}\n", ) ) - def status(self) -> AirFreshStatus: - """Retrieve properties.""" - - return super().status() - @command( click.argument("speed", type=int), default_output=format_output("Setting favorite speed to {speed}"), diff --git a/miio/airhumidifier.py b/miio/airhumidifier.py index 21100cafc..d084e8a0b 100644 --- a/miio/airhumidifier.py +++ b/miio/airhumidifier.py @@ -8,6 +8,7 @@ from .click_common import EnumType, command, format_output from .device import Device, DeviceInfo, DeviceStatus from .exceptions import DeviceError, DeviceException +from .utils import deprecated _LOGGER = logging.getLogger(__name__) @@ -197,12 +198,16 @@ def depth(self) -> Optional[int]: def water_level(self) -> Optional[int]: """Return current water level in percent. - If water tank is full, depth is 125. + If water tank is full, depth is 120. If water tank is overfilled, depth is 125. """ depth = self.data.get("depth") - if depth is not None and depth <= 125: - return int(depth / 1.25) - return None + if depth is None or depth > 125: + return None + + if depth < 0: + return 0 + + return int(min(depth / 1.2, 100)) @property def water_tank_detached(self) -> Optional[bool]: @@ -246,25 +251,6 @@ def button_pressed(self) -> Optional[str]: class AirHumidifier(Device): """Implementation of Xiaomi Mi Air Humidifier.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - model: str = MODEL_HUMIDIFIER_V1, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in AVAILABLE_PROPERTIES: - self.model = model - else: - self.model = MODEL_HUMIDIFIER_V1 - - # TODO: convert to use generic device info in the future - self.device_info: Optional[DeviceInfo] = None - @command( default_output=format_output( "", @@ -289,10 +275,10 @@ def __init__( ) def status(self) -> AirHumidifierStatus: """Retrieve properties.""" - if self.device_info is None: - self.device_info = self.info() - properties = AVAILABLE_PROPERTIES[self.model] + properties = AVAILABLE_PROPERTIES.get( + self.model, AVAILABLE_PROPERTIES[MODEL_HUMIDIFIER_V1] + ) # A single request is limited to 16 properties. Therefore the # properties are divided into multiple requests @@ -309,7 +295,7 @@ def status(self) -> AirHumidifierStatus: values = self.get_properties(properties, max_properties=_props_per_request) return AirHumidifierStatus( - defaultdict(lambda: None, zip(properties, values)), self.device_info + defaultdict(lambda: None, zip(properties, values)), self.info() ) @command(default_output=format_output("Powering on")) @@ -412,6 +398,7 @@ def set_dry(self, dry: bool): return self.send("set_dry", ["off"]) +@deprecated("Use AirHumidifer(model='zhimi.humidifier.ca1") class AirHumidifierCA1(AirHumidifier): def __init__( self, @@ -426,6 +413,7 @@ def __init__( ) +@deprecated("Use AirHumidifer(model='zhimi.humidifier.cb1") class AirHumidifierCB1(AirHumidifier): def __init__( self, @@ -440,6 +428,7 @@ def __init__( ) +@deprecated("Use AirHumidifier(model='zhimi.humidifier.cb2')") class AirHumidifierCB2(AirHumidifier): def __init__( self, diff --git a/miio/airhumidifier_jsq.py b/miio/airhumidifier_jsq.py index 16379e543..c3dee049d 100644 --- a/miio/airhumidifier_jsq.py +++ b/miio/airhumidifier_jsq.py @@ -1,6 +1,6 @@ import enum import logging -from typing import Any, Dict +from typing import Any, Dict, Optional import click @@ -129,22 +129,17 @@ def lid_opened(self) -> bool: """True if the water tank is detached.""" return self.data["lid_opened"] == 1 + @property + def use_time(self) -> Optional[int]: + """How long the device has been active in seconds. -class AirHumidifierJsq(Device): - """Implementation of Xiaomi Zero Fog Humidifier: shuii.humidifier.jsq001.""" + Not supported by the device, so we return none here. + """ + return None - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - model: str = MODEL_HUMIDIFIER_JSQ001, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - self.model = model if model in AVAILABLE_PROPERTIES else MODEL_HUMIDIFIER_JSQ001 +class AirHumidifierJsq(Device): + """Implementation of Xiaomi Zero Fog Humidifier: shuii.humidifier.jsq001.""" @command( default_output=format_output( @@ -178,7 +173,9 @@ def status(self) -> AirHumidifierStatus: # status[7]: water level state (0: ok, 1: add water) # status[8]: lid state (0: ok, 1: lid is opened) - properties = AVAILABLE_PROPERTIES[self.model] + properties = AVAILABLE_PROPERTIES.get( + self.model, AVAILABLE_PROPERTIES[MODEL_HUMIDIFIER_JSQ001] + ) if len(properties) != len(values): _LOGGER.error( "Count (%s) of requested properties (%s) does not match the " diff --git a/miio/airhumidifier_miot.py b/miio/airhumidifier_miot.py index 37d96c13e..72b09b7be 100644 --- a/miio/airhumidifier_miot.py +++ b/miio/airhumidifier_miot.py @@ -129,11 +129,17 @@ def target_humidity(self) -> int: def water_level(self) -> Optional[int]: """Return current water level in percent. - If water tank is full, depth is 125. + If water tank is full, raw water_level value is 120. If water tank is + overfilled, raw water_level value is 125. """ - if self.data["water_level"] <= 125: - return int(self.data["water_level"] / 1.25) - return None + water_level = self.data["water_level"] + if water_level > 125: + return None + + if water_level < 0: + return 0 + + return int(min(water_level / 1.2, 100)) @property def water_tank_detached(self) -> bool: diff --git a/miio/airhumidifier_mjjsq.py b/miio/airhumidifier_mjjsq.py index ca3ef0458..0e24be59a 100644 --- a/miio/airhumidifier_mjjsq.py +++ b/miio/airhumidifier_mjjsq.py @@ -120,23 +120,17 @@ def wet_protection(self) -> Optional[bool]: return None + @property + def use_time(self) -> Optional[int]: + """How long the device has been active in seconds. + + Not supported by the device, so we return none here. + """ + return None + class AirHumidifierMjjsq(Device): - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - model: str = MODEL_HUMIDIFIER_MJJSQ, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in AVAILABLE_PROPERTIES: - self.model = model - else: - self.model = MODEL_HUMIDIFIER_MJJSQ + """Support for deerma.humidifier.(mj)jsq.""" @command( default_output=format_output( @@ -156,7 +150,9 @@ def __init__( def status(self) -> AirHumidifierStatus: """Retrieve properties.""" - properties = AVAILABLE_PROPERTIES[self.model] + properties = AVAILABLE_PROPERTIES.get( + self.model, AVAILABLE_PROPERTIES[MODEL_HUMIDIFIER_MJJSQ] + ) values = self.get_properties(properties, max_properties=1) return AirHumidifierStatus(defaultdict(lambda: None, zip(properties, values))) diff --git a/miio/airpurifier_airdog.py b/miio/airpurifier_airdog.py index 90c8e3268..9ce047a58 100644 --- a/miio/airpurifier_airdog.py +++ b/miio/airpurifier_airdog.py @@ -8,6 +8,7 @@ from .click_common import EnumType, command, format_output from .device import Device, DeviceStatus from .exceptions import DeviceException +from .utils import deprecated _LOGGER = logging.getLogger(__name__) @@ -100,22 +101,6 @@ def hcho(self) -> Optional[int]: class AirDogX3(Device): - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - model: str = MODEL_AIRDOG_X3, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in AVAILABLE_PROPERTIES: - self.model = model - else: - self.model = MODEL_AIRDOG_X3 - @command( default_output=format_output( "", @@ -131,7 +116,9 @@ def __init__( def status(self) -> AirDogStatus: """Retrieve properties.""" - properties = AVAILABLE_PROPERTIES[self.model] + properties = AVAILABLE_PROPERTIES.get( + self.model, AVAILABLE_PROPERTIES[MODEL_AIRDOG_X3] + ) values = self.get_properties(properties, max_properties=10) return AirDogStatus(defaultdict(lambda: None, zip(properties, values))) @@ -191,6 +178,7 @@ def set_filters_cleaned(self): class AirDogX5(AirDogX3): + @deprecated("Use AirDogX3(model='airdog.airpurifier.x5')") def __init__( self, ip: str = None, @@ -200,15 +188,11 @@ def __init__( lazy_discover: bool = True, model: str = MODEL_AIRDOG_X5, ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in AVAILABLE_PROPERTIES: - self.model = model - else: - self.model = MODEL_AIRDOG_X5 + super().__init__(ip, token, start_id, debug, lazy_discover, model=model) class AirDogX7SM(AirDogX3): + @deprecated("Use AirDogX3(model='airdog.airpurifier.x7sm')") def __init__( self, ip: str = None, @@ -218,9 +202,4 @@ def __init__( lazy_discover: bool = True, model: str = MODEL_AIRDOG_X7SM, ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in AVAILABLE_PROPERTIES: - self.model = model - else: - self.model = MODEL_AIRDOG_X7SM + super().__init__(ip, token, start_id, debug, lazy_discover, model=model) diff --git a/miio/airqualitymonitor.py b/miio/airqualitymonitor.py index 05eaf5587..5d97db0a4 100644 --- a/miio/airqualitymonitor.py +++ b/miio/airqualitymonitor.py @@ -151,28 +151,6 @@ def tvoc(self) -> Optional[int]: class AirQualityMonitor(Device): """Xiaomi PM2.5 Air Quality Monitor.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - model: str = MODEL_AIRQUALITYMONITOR_V1, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in AVAILABLE_PROPERTIES: - self.model = model - elif model is not None: - self.model = MODEL_AIRQUALITYMONITOR_V1 - _LOGGER.error( - "Device model %s unsupported. Falling back to %s.", model, self.model - ) - else: - # Force autodetection. - self.model = None - @command( default_output=format_output( "", @@ -191,12 +169,9 @@ def __init__( ) def status(self) -> AirQualityMonitorStatus: """Return device status.""" - - if self.model is None: - info = self.info() - self.model = info.model - - properties = AVAILABLE_PROPERTIES[self.model] + properties = AVAILABLE_PROPERTIES.get( + self.model, AVAILABLE_PROPERTIES[MODEL_AIRQUALITYMONITOR_V1] + ) if self.model == MODEL_AIRQUALITYMONITOR_B1: values = self.send("get_air_data") diff --git a/miio/ceil_cli.py b/miio/ceil_cli.py deleted file mode 100644 index 1b7da2c62..000000000 --- a/miio/ceil_cli.py +++ /dev/null @@ -1,170 +0,0 @@ -import logging -import sys - -import click - -import miio # noqa: E402 -from miio.click_common import ExceptionHandlerGroup, validate_ip, validate_token -from miio.miioprotocol import MiIOProtocol - -_LOGGER = logging.getLogger(__name__) -pass_dev = click.make_pass_decorator(miio.Ceil) - - -def validate_percentage(ctx, param, value): - value = int(value) - if value < 1 or value > 100: - raise click.BadParameter("Should be a positive int between 1-100.") - return value - - -def validate_seconds(ctx, param, value): - value = int(value) - if value < 0 or value > 21600: - raise click.BadParameter("Should be a positive int between 1-21600.") - return value - - -def validate_scene(ctx, param, value): - value = int(value) - if value < 1 or value > 4: - raise click.BadParameter("Should be a positive int between 1-4.") - return value - - -@click.group(invoke_without_command=True, cls=ExceptionHandlerGroup) -@click.option("--ip", envvar="DEVICE_IP", callback=validate_ip) -@click.option("--token", envvar="DEVICE_TOKEN", callback=validate_token) -@click.option("-d", "--debug", default=False, count=True) -@click.pass_context -def cli(ctx, ip: str, token: str, debug: int): - """A tool to command Xiaomi Philips LED Ceiling Lamp.""" - - if debug: - logging.basicConfig(level=logging.DEBUG) - _LOGGER.info("Debug mode active") - else: - logging.basicConfig(level=logging.INFO) - - _LOGGER.warning( - "This script is deprecated and will be removed soon, use `miiocli ceil` instead" - ) - - # if we are scanning, we do not try to connect. - if ctx.invoked_subcommand == "discover": - return - - if ip is None or token is None: - click.echo("You have to give ip and token!") - sys.exit(-1) - - dev = miio.Ceil(ip, token, debug) - _LOGGER.debug("Connecting to %s with token %s", ip, token) - - ctx.obj = dev - - if ctx.invoked_subcommand is None: - ctx.invoke(status) - - -@cli.command() -def discover(): - """Search for plugs in the network.""" - MiIOProtocol.discover() - - -@cli.command() -@pass_dev -def status(dev: miio.Ceil): - """Returns the state information.""" - res = dev.status() - if not res: - return # bail out - - click.echo(click.style("Power: %s" % res.power, bold=True)) - click.echo("Brightness: %s" % res.brightness) - click.echo("Color temperature: %s" % res.color_temperature) - click.echo("Scene: %s" % res.scene) - click.echo("Smart Night Light: %s" % res.smart_night_light) - click.echo("Auto CCT: %s" % res.automatic_color_temperature) - click.echo( - "Countdown of the delayed turn off: %s seconds" % res.delay_off_countdown - ) - - -@cli.command() -@pass_dev -def on(dev: miio.Ceil): - """Power on.""" - click.echo("Power on: %s" % dev.on()) - - -@cli.command() -@pass_dev -def off(dev: miio.Ceil): - """Power off.""" - click.echo("Power off: %s" % dev.off()) - - -@cli.command() -@click.argument("level", callback=validate_percentage, required=True) -@pass_dev -def set_brightness(dev: miio.Ceil, level): - """Set brightness level.""" - click.echo("Brightness: %s" % dev.set_brightness(level)) - - -@cli.command() -@click.argument("level", callback=validate_percentage, required=True) -@pass_dev -def set_color_temperature(dev: miio.Ceil, level): - """Set CCT level.""" - click.echo("Color temperature level: %s" % dev.set_color_temperature(level)) - - -@cli.command() -@click.argument("seconds", callback=validate_seconds, required=True) -@pass_dev -def delay_off(dev: miio.Ceil, seconds): - """Set delay off in seconds.""" - click.echo("Delay off: %s" % dev.delay_off(seconds)) - - -@cli.command() -@click.argument("scene", callback=validate_scene, required=True) -@pass_dev -def set_scene(dev: miio.Ceil, scene): - """Set scene number.""" - click.echo("Eyecare Scene: %s" % dev.set_scene(scene)) - - -@cli.command() -@pass_dev -def smart_night_light_on(dev: miio.Ceil): - """Smart Night Light on.""" - click.echo("Smart Night Light On: %s" % dev.smart_night_light_on()) - - -@cli.command() -@pass_dev -def smart_night_light_off(dev: miio.Ceil): - """Smart Night Light off.""" - click.echo("Smart Night Light Off: %s" % dev.smart_night_light_off()) - - -@cli.command() -@pass_dev -def automatic_color_temperature_on(dev: miio.Ceil): - """Auto CCT on.""" - click.echo("Auto CCT On: %s" % dev.automatic_color_temperature_on()) - - -@cli.command() -@pass_dev -def automatic_color_temperature_off(dev: miio.Ceil): - """Auto CCT on.""" - click.echo("Auto CCT Off: %s" % dev.automatic_color_temperature_off()) - - -if __name__ == "__main__": - cli() diff --git a/miio/chuangmi_plug.py b/miio/chuangmi_plug.py index 7e09a4583..5a7af9646 100644 --- a/miio/chuangmi_plug.py +++ b/miio/chuangmi_plug.py @@ -89,22 +89,6 @@ def wifi_led(self) -> Optional[bool]: class ChuangmiPlug(Device): """Main class representing the Chuangmi Plug.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - model: str = MODEL_CHUANGMI_PLUG_M1, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in AVAILABLE_PROPERTIES: - self.model = model - else: - self.model = MODEL_CHUANGMI_PLUG_M1 - @command( default_output=format_output( "", @@ -117,7 +101,9 @@ def __init__( ) def status(self) -> ChuangmiPlugStatus: """Retrieve properties.""" - properties = AVAILABLE_PROPERTIES[self.model].copy() + properties = AVAILABLE_PROPERTIES.get( + self.model, AVAILABLE_PROPERTIES[MODEL_CHUANGMI_PLUG_M1] + ).copy() values = self.get_properties(properties) if self.model == MODEL_CHUANGMI_PLUG_V3: @@ -182,6 +168,8 @@ def __init__( start_id: int = 0, debug: int = 0, lazy_discover: bool = True, + *, + model: str = None ) -> None: super().__init__( ip, token, start_id, debug, lazy_discover, model=MODEL_CHUANGMI_PLUG_M1 diff --git a/miio/cli.py b/miio/cli.py index 9af8a1c58..3feb1d948 100644 --- a/miio/cli.py +++ b/miio/cli.py @@ -39,7 +39,7 @@ def cli(ctx, debug: int, output: str): ctx.obj = GlobalContextObject(debug=debug, output=output_func) -for device_class in DeviceGroupMeta.device_classes: +for device_class in DeviceGroupMeta._device_classes: cli.add_command(device_class.get_device_group()) diff --git a/miio/click_common.py b/miio/click_common.py index 01fe16ecd..34677e5d2 100644 --- a/miio/click_common.py +++ b/miio/click_common.py @@ -117,7 +117,7 @@ def __init__(self, debug: int = 0, output: Callable = None): class DeviceGroupMeta(type): - device_classes: Set[Type] = set() + _device_classes: Set[Type] = set() def __new__(mcs, name, bases, namespace): commands = {} @@ -150,7 +150,7 @@ def get_device_group(dcls): namespace["get_device_group"] = classmethod(get_device_group) cls = super().__new__(mcs, name, bases, namespace) - mcs.device_classes.add(cls) + mcs._device_classes.add(cls) return cls @@ -168,6 +168,30 @@ def __call__(self, func): self.func = func func._device_group_command = self self.kwargs.setdefault("help", self.func.__doc__) + + def _autodetect_model_if_needed(func): + def _wrap(self, *args, **kwargs): + skip_autodetect = func._device_group_command.kwargs.pop( + "skip_autodetect", False + ) + if ( + not skip_autodetect + and self._model is None + and self._info is None + ): + _LOGGER.debug( + "Unknown model, trying autodetection. %s %s" + % (self._model, self._info) + ) + self._fetch_info() + return func(self, *args, **kwargs) + + # TODO HACK to make the command visible to cli + _wrap._device_group_command = func._device_group_command + return _wrap + + func = _autodetect_model_if_needed(func) + return func @property @@ -183,6 +207,9 @@ def wrap(self, ctx, func): else: output = format_output("Running command {0}".format(self.command_name)) + # Remove skip_autodetect before constructing the click.command + self.kwargs.pop("skip_autodetect", None) + func = output(func) for decorator in self.decorators: func = decorator(func) @@ -195,6 +222,7 @@ def call(self, owner, *args, **kwargs): DEFAULT_PARAMS = [ click.Option(["--ip"], required=True, callback=validate_ip), click.Option(["--token"], required=True, callback=validate_token), + click.Option(["--model"], required=False), ] def __init__( diff --git a/miio/cooker.py b/miio/cooker.py index 4bc085a0c..dc91de04c 100644 --- a/miio/cooker.py +++ b/miio/cooker.py @@ -620,7 +620,7 @@ def status(self) -> CookerStatus: Some cookers doesn't support a list of properties here. Therefore "all" properties are requested. If the property count or order changes the property list above must be updated. - """ + """ # noqa: B018 values = self.send("get_prop", ["all"]) properties_count = len(properties) diff --git a/miio/device.py b/miio/device.py index d3d8622e0..c74b5bf4a 100644 --- a/miio/device.py +++ b/miio/device.py @@ -2,7 +2,7 @@ import logging from enum import Enum from pprint import pformat as pf -from typing import Any, Optional # noqa: F401 +from typing import Any, List, Optional # noqa: F401 import click @@ -54,6 +54,7 @@ class Device(metaclass=DeviceGroupMeta): retry_count = 3 timeout = 5 + _supported_models: List[str] = [] def __init__( self, @@ -63,9 +64,13 @@ def __init__( debug: int = 0, lazy_discover: bool = True, timeout: int = None, + *, + model: str = None, ) -> None: self.ip = ip - self.token = token + self.token: Optional[str] = token + self._model: Optional[str] = model + self._info: Optional[DeviceInfo] = None timeout = timeout if timeout is not None else self.timeout self._protocol = MiIOProtocol( ip, token, start_id, debug, lazy_discover, timeout @@ -92,6 +97,7 @@ def send( :param dict parameters: Parameters to send :param int retry_count: How many times to retry on error :param dict extra_parameters: Extra top-level parameters + :param str model: Force model to avoid autodetection """ retry_count = retry_count if retry_count is not None else self.retry_count return self._protocol.send( @@ -121,26 +127,59 @@ def raw_command(self, command, parameters): "Model: {result.model}\n" "Hardware version: {result.hardware_version}\n" "Firmware version: {result.firmware_version}\n", - ) + ), + skip_autodetect=True, ) - def info(self) -> DeviceInfo: - """Get miIO protocol information from the device. + def info(self, *, skip_cache=False) -> DeviceInfo: + """Get (and cache) miIO protocol information from the device. This includes information about connected wlan network, and hardware and software versions. + + :param skip_cache bool: Skip the cache """ + if self._info is not None and not skip_cache: + return self._info + + return self._fetch_info() + + def _fetch_info(self) -> DeviceInfo: + """Perform miIO.info query on the device and cache the result.""" try: - return DeviceInfo(self.send("miIO.info")) + devinfo = DeviceInfo(self.send("miIO.info")) + self._info = devinfo + _LOGGER.debug("Detected model %s", devinfo.model) + if devinfo.model not in self.supported_models: + _LOGGER.warning( + "Found an unsupported model '%s' for class '%s'. If this is working for you, please open an issue at https://github.com/rytilahti/python-miio/", + self.model, + self.__class__.__name__, + ) + + return devinfo except PayloadDecodeException as ex: raise DeviceInfoUnavailableException( "Unable to request miIO.info from the device" ) from ex @property - def raw_id(self): + def raw_id(self) -> int: """Return the last used protocol sequence id.""" return self._protocol.raw_id + @property + def supported_models(self) -> List[str]: + """Return a list of supported models.""" + return self._supported_models + + @property + def model(self) -> str: + """Return device model.""" + if self._model is not None: + return self._model + + return self.info().model + def update(self, url: str, md5: str): """Start an OTA update.""" payload = { @@ -210,10 +249,10 @@ def test_properties(self, properties): """Helper to test device properties.""" def ok(x): - click.echo(click.style(x, fg="green", bold=True)) + click.echo(click.style(str(x), fg="green", bold=True)) def fail(x): - click.echo(click.style(x, fg="red", bold=True)) + click.echo(click.style(str(x), fg="red", bold=True)) try: model = self.info().model @@ -254,7 +293,7 @@ def fail(x): props_to_test = list(valid_properties.keys()) max_properties = -1 - while len(props_to_test) > 1: + while len(props_to_test) > 0: try: click.echo( f"Testing {len(props_to_test)} properties at once ({' '.join(props_to_test)}): ", diff --git a/miio/discovery.py b/miio/discovery.py index 4a67db329..e29e27055 100644 --- a/miio/discovery.py +++ b/miio/discovery.py @@ -8,6 +8,8 @@ import zeroconf +from miio.integrations.yeelight import Yeelight + from . import ( AirConditionerMiot, AirConditioningCompanion, @@ -48,7 +50,6 @@ WaterPurifierYunmi, WifiRepeater, WifiSpeaker, - Yeelight, ) from .airconditioningcompanion import ( MODEL_ACPARTNER_V1, @@ -139,6 +140,7 @@ "zhimi-airpurifier-mc1": AirPurifier, # mc1 "zhimi-airpurifier-mb3": AirPurifierMiot, # mb3 (3/3H) "zhimi-airpurifier-ma4": AirPurifierMiot, # ma4 (3) + "zhimi-airpurifier-vb2": AirPurifierMiot, # vb2 (Pro H) "chuangmi-camera-ipc009": ChuangmiCamera, "chuangmi-camera-ipc019": ChuangmiCamera, "chuangmi-ir-v2": ChuangmiIr, diff --git a/miio/fan.py b/miio/fan.py index 8ce03640e..cb0056a88 100644 --- a/miio/fan.py +++ b/miio/fan.py @@ -285,12 +285,7 @@ def __init__( lazy_discover: bool = True, model: str = MODEL_FAN_V3, ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in AVAILABLE_PROPERTIES: - self.model = model - else: - self.model = MODEL_FAN_V3 + super().__init__(ip, token, start_id, debug, lazy_discover, model=model) @command( default_output=format_output( @@ -538,12 +533,7 @@ def __init__( lazy_discover: bool = True, model: str = MODEL_FAN_P5, ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in AVAILABLE_PROPERTIES: - self.model = model - else: - self.model = MODEL_FAN_P5 + super().__init__(ip, token, start_id, debug, lazy_discover, model=model) @command( default_output=format_output( diff --git a/miio/fan_leshow.py b/miio/fan_leshow.py index fe795ef49..4b083f421 100644 --- a/miio/fan_leshow.py +++ b/miio/fan_leshow.py @@ -93,22 +93,6 @@ def error_detected(self) -> bool: class FanLeshow(Device): """Main class representing the Xiaomi Rosou SS4 Ventilator.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - model: str = MODEL_FAN_LESHOW_SS4, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in AVAILABLE_PROPERTIES: - self.model = model - else: - self.model = MODEL_FAN_LESHOW_SS4 - @command( default_output=format_output( "", @@ -123,7 +107,9 @@ def __init__( ) def status(self) -> FanLeshowStatus: """Retrieve properties.""" - properties = AVAILABLE_PROPERTIES[self.model] + properties = AVAILABLE_PROPERTIES.get( + self.model, AVAILABLE_PROPERTIES[MODEL_FAN_LESHOW_SS4] + ) values = self.get_properties(properties, max_properties=15) return FanLeshowStatus(dict(zip(properties, values))) diff --git a/miio/fan_miot.py b/miio/fan_miot.py index f986c0616..bd876df37 100644 --- a/miio/fan_miot.py +++ b/miio/fan_miot.py @@ -186,7 +186,6 @@ class FanStatus1C(DeviceStatus): """Container for status reports for Xiaomi Mi Smart Pedestal Fan DMaker 1C.""" def __init__(self, data: Dict[str, Any]) -> None: - self.data = data """Response of a Fan1C (dmaker.fan.1c): { @@ -204,6 +203,7 @@ def __init__(self, data: Dict[str, Any]) -> None: 'exe_time': 280 } """ + self.data = data @property def power(self) -> str: @@ -266,8 +266,7 @@ def __init__( if model not in MIOT_MAPPING: raise FanException("Invalid FanMiot model: %s" % model) - super().__init__(ip, token, start_id, debug, lazy_discover) - self.model = model + super().__init__(ip, token, start_id, debug, lazy_discover, model=model) @command( default_output=format_output( @@ -431,8 +430,7 @@ def __init__( lazy_discover: bool = True, model: str = MODEL_FAN_1C, ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - self.model = model + super().__init__(ip, token, start_id, debug, lazy_discover, model=model) @command( default_output=format_output( @@ -683,7 +681,7 @@ def __init__( model: str = MODEL_FAN_ZA5, ) -> None: super().__init__(ip, token, start_id, debug, lazy_discover) - self.model = model + self._model = model @command( default_output=format_output( diff --git a/miio/gateway/devices/subdevice.py b/miio/gateway/devices/subdevice.py index c11645e55..c0e104afc 100644 --- a/miio/gateway/devices/subdevice.py +++ b/miio/gateway/devices/subdevice.py @@ -41,6 +41,7 @@ def __init__( if model_info is None: model_info = {} self._model_info = model_info + self._battery_powered = model_info.get("battery_powered", True) self._battery = None self._voltage = None self._fw_ver = dev_info.fw_ver @@ -178,7 +179,7 @@ def get_property_exp(self, properties): ).pop() except Exception as ex: raise GatewayException( - "Got an exception while fetching properties %s: %s" % (properties) + "Got an exception while fetching properties %s" % (properties) ) from ex if len(list(properties)) != len(response): @@ -206,8 +207,15 @@ def unpair(self): return self.send("remove_device") @command() - def get_battery(self): + def get_battery(self) -> Optional[int]: """Update the battery level, if available.""" + if not self._battery_powered: + _LOGGER.debug( + "%s is not battery powered, get_battery not supported", + self.name, + ) + return None + if self._gw.model not in [GATEWAY_MODEL_EU, GATEWAY_MODEL_ZIG3]: self._battery = self.send("get_battery").pop() else: @@ -218,8 +226,15 @@ def get_battery(self): return self._battery @command() - def get_voltage(self): + def get_voltage(self) -> Optional[float]: """Update the battery voltage, if available.""" + if not self._battery_powered: + _LOGGER.debug( + "%s is not battery powered, get_voltage not supported", + self.name, + ) + return None + if self._gw.model in [GATEWAY_MODEL_EU, GATEWAY_MODEL_ZIG3]: self._voltage = self.get_property("voltage").pop() / 1000 else: diff --git a/miio/gateway/devices/subdevices.yaml b/miio/gateway/devices/subdevices.yaml index a9eb755a5..098a97ffa 100644 --- a/miio/gateway/devices/subdevices.yaml +++ b/miio/gateway/devices/subdevices.yaml @@ -127,6 +127,7 @@ name: Smart bulb E27 type: LightBulb class: LightBulb + battery_powered: false properties: - property: power_status # 'on' / 'off' name: status @@ -152,6 +153,7 @@ name: Ikea smart bulb E27 white type: LightBulb class: LightBulb + battery_powered: false properties: - property: power_status # 'on' / 'off' name: status @@ -177,6 +179,7 @@ name: Ikea smart bulb E27 white type: LightBulb class: LightBulb + battery_powered: false properties: - property: power_status # 'on' / 'off' name: status @@ -202,6 +205,7 @@ name: Ikea smart bulb E12 white type: LightBulb class: LightBulb + battery_powered: false properties: - property: power_status # 'on' / 'off' name: status @@ -227,6 +231,7 @@ name: Ikea smart bulb GU10 white type: LightBulb class: LightBulb + battery_powered: false properties: - property: power_status # 'on' / 'off' name: status @@ -252,6 +257,7 @@ name: Ikea smart bulb E27 white type: LightBulb class: LightBulb + battery_powered: false properties: - property: power_status # 'on' / 'off' name: status @@ -277,6 +283,7 @@ name: Ikea smart bulb GU10 white type: LightBulb class: LightBulb + battery_powered: false properties: - property: power_status # 'on' / 'off' name: status @@ -302,6 +309,7 @@ name: Ikea smart bulb E12 white type: LightBulb class: LightBulb + battery_powered: false properties: - property: power_status # 'on' / 'off' name: status @@ -474,6 +482,7 @@ type: Switch class: Switch setter: toggle_ctrl_neutral + battery_powered: false properties: - property: neutral_0 # 'on' / 'off' name: status_ch0 @@ -489,6 +498,7 @@ type: Switch class: Switch setter: toggle_ctrl_neutral + battery_powered: false properties: - property: neutral_0 # 'on' / 'off' name: status_ch0 @@ -501,6 +511,7 @@ type: Switch class: Switch setter: toggle_ctrl_neutral + battery_powered: false properties: - property: neutral_0 # 'on' / 'off' name: status_ch0 @@ -516,6 +527,7 @@ type: Switch class: Switch setter: toggle_ctrl_neutral + battery_powered: false properties: - property: neutral_0 # 'on' / 'off' name: status_ch0 @@ -534,6 +546,7 @@ type: Switch class: Switch setter: toggle_ctrl_neutral + battery_powered: false properties: - property: neutral_0 # 'on' / 'off' name: status_ch0 @@ -549,6 +562,7 @@ type: Switch class: Switch setter: toggle_ctrl_neutral + battery_powered: false properties: - property: neutral_0 # 'on' / 'off' name: status_ch0 @@ -567,6 +581,7 @@ type: Switch class: Switch setter: toggle_ctrl_neutral + battery_powered: false properties: - property: neutral_0 # 'on' / 'off' name: status_ch0 @@ -588,6 +603,7 @@ type: Switch class: Switch setter: toggle_ctrl_neutral + battery_powered: false properties: - property: neutral_0 # 'on' / 'off' name: status_ch0 @@ -610,6 +626,7 @@ class: Switch getter: get_prop_plug setter: toggle_plug + battery_powered: false properties: - property: neutral_0 # 'on' / 'off' name: status_ch0 @@ -626,6 +643,7 @@ class: Switch getter: get_prop_plug setter: toggle_plug + battery_powered: false properties: - property: neutral_0 # 'on' / 'off' name: status_ch0 @@ -641,6 +659,7 @@ type: Switch class: Switch setter: toggle_plug + battery_powered: false properties: - property: channel_0 # 'on' / 'off' name: status_ch0 @@ -653,6 +672,7 @@ type: Switch class: Switch setter: toggle_plug + battery_powered: false properties: - property: channel_0 # 'on' / 'off' name: status_ch0 @@ -668,6 +688,7 @@ type: Switch class: Switch setter: toggle_ctrl_neutral + battery_powered: false properties: - property: channel_0 # 'on' / 'off' name: status_ch0 diff --git a/miio/gateway/gateway.py b/miio/gateway/gateway.py index 999962a0a..053e2f1cb 100644 --- a/miio/gateway/gateway.py +++ b/miio/gateway/gateway.py @@ -84,8 +84,10 @@ def __init__( start_id: int = 0, debug: int = 0, lazy_discover: bool = True, + *, + model: str = None, ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) + super().__init__(ip, token, start_id, debug, lazy_discover, model=model) self._alarm = Alarm(parent=self) self._radio = Radio(parent=self) @@ -134,13 +136,6 @@ def mac(self): self._info = self.info() return self._info.mac_address - @property - def model(self): - """Return the zigbee model of the gateway.""" - if self._info is None: - self._info = self.info() - return self._info.model - @property def subdevice_model_map(self): """Return the subdevice model map.""" diff --git a/miio/heater.py b/miio/heater.py index 7ebd0ff27..e0c98518f 100644 --- a/miio/heater.py +++ b/miio/heater.py @@ -127,22 +127,6 @@ def delay_off_countdown(self) -> Optional[int]: class Heater(Device): """Main class representing the Smartmi Zhimi Heater.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - model: str = MODEL_HEATER_ZA1, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in SUPPORTED_MODELS: - self.model = model - else: - self.model = MODEL_HEATER_ZA1 - @command( default_output=format_output( "", @@ -158,7 +142,9 @@ def __init__( ) def status(self) -> HeaterStatus: """Retrieve properties.""" - properties = SUPPORTED_MODELS[self.model]["available_properties"] + properties = SUPPORTED_MODELS.get( + self.model, SUPPORTED_MODELS[MODEL_HEATER_ZA1] + )["available_properties"] # A single request is limited to 16 properties. Therefore the # properties are divided into multiple requests @@ -190,7 +176,9 @@ def set_target_temperature(self, temperature: int): """Set target temperature.""" min_temp: int max_temp: int - min_temp, max_temp = SUPPORTED_MODELS[self.model]["temperature_range"] + min_temp, max_temp = SUPPORTED_MODELS.get( + self.model, SUPPORTED_MODELS[MODEL_HEATER_ZA1] + )["temperature_range"] if not min_temp <= temperature <= max_temp: raise HeaterException("Invalid target temperature: %s" % temperature) @@ -238,7 +226,9 @@ def delay_off(self, seconds: int): """Set delay off seconds.""" min_delay: int max_delay: int - min_delay, max_delay = SUPPORTED_MODELS[self.model]["delay_off_range"] + min_delay, max_delay = SUPPORTED_MODELS.get( + self.model, SUPPORTED_MODELS[MODEL_HEATER_ZA1] + )["delay_off_range"] if not min_delay <= seconds <= max_delay: raise HeaterException("Invalid delay time: %s" % seconds) diff --git a/miio/huizuo.py b/miio/huizuo.py index e4c724ef0..3ad54f746 100644 --- a/miio/huizuo.py +++ b/miio/huizuo.py @@ -231,12 +231,10 @@ def __init__( if model in MODELS_WITH_HEATER: self.mapping.update(_ADDITIONAL_MAPPING_HEATER) - super().__init__(ip, token, start_id, debug, lazy_discover) + super().__init__(ip, token, start_id, debug, lazy_discover, model=model) - if model in MODELS_SUPPORTED: - self.model = model - else: - self.model = MODEL_HUIZUO_PIS123 + if model not in MODELS_SUPPORTED: + self._model = MODEL_HUIZUO_PIS123 _LOGGER.error( "Device model %s unsupported. Falling back to %s.", model, self.model ) diff --git a/miio/integrations/__init__.py b/miio/integrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/miio/integrations/petwaterdispenser/__init__.py b/miio/integrations/petwaterdispenser/__init__.py new file mode 100644 index 000000000..b5c9fa17d --- /dev/null +++ b/miio/integrations/petwaterdispenser/__init__.py @@ -0,0 +1,2 @@ +# flake8: noqa +from .device import PetWaterDispenser diff --git a/miio/integrations/petwaterdispenser/device.py b/miio/integrations/petwaterdispenser/device.py new file mode 100644 index 000000000..a16d833f7 --- /dev/null +++ b/miio/integrations/petwaterdispenser/device.py @@ -0,0 +1,146 @@ +import logging +from typing import Any, Dict, List + +import click + +from miio.click_common import EnumType, command, format_output +from miio.miot_device import MiotDevice + +from .status import OperatingMode, PetWaterDispenserStatus + +_LOGGER = logging.getLogger(__name__) + +MODEL_MMGG_PET_WATERER_S1 = "mmgg.pet_waterer.s1" +MODEL_MMGG_PET_WATERER_S4 = "mmgg.pet_waterer.s4" + +SUPPORTED_MODELS: List[str] = [MODEL_MMGG_PET_WATERER_S1, MODEL_MMGG_PET_WATERER_S4] + +_MAPPING: Dict[str, Dict[str, int]] = { + # https://home.miot-spec.com/spec/mmgg.pet_waterer.s1 + # https://home.miot-spec.com/spec/mmgg.pet_waterer.s4 + "cotton_left_time": {"siid": 5, "piid": 1}, + "reset_cotton_life": {"siid": 5, "aiid": 1}, + "reset_clean_time": {"siid": 6, "aiid": 1}, + "fault": {"siid": 2, "piid": 1}, + "filter_left_time": {"siid": 3, "piid": 1}, + "indicator_light": {"siid": 4, "piid": 1}, + "lid_up_flag": {"siid": 7, "piid": 4}, # missing on mmgg.pet_waterer.s4 + "location": {"siid": 9, "piid": 2}, + "mode": {"siid": 2, "piid": 3}, + "no_water_flag": {"siid": 7, "piid": 1}, + "no_water_time": {"siid": 7, "piid": 2}, + "on": {"siid": 2, "piid": 2}, + "pump_block_flag": {"siid": 7, "piid": 3}, + "remain_clean_time": {"siid": 6, "piid": 1}, + "reset_filter_life": {"siid": 3, "aiid": 1}, + "reset_device": {"siid": 8, "aiid": 1}, + "timezone": {"siid": 9, "piid": 1}, +} + + +class PetWaterDispenser(MiotDevice): + """Main class representing the Pet Waterer / Pet Drinking Fountain / Smart Pet Water + Dispenser.""" + + mapping = _MAPPING + _supported_models = SUPPORTED_MODELS + + @command( + default_output=format_output( + "", + "On: {result.is_on}\n" + "Mode: {result.mode}\n" + "LED on: {result.is_led_on}\n" + "Lid up: {result.is_lid_up}\n" + "No water: {result.is_no_water}\n" + "Time without water: {result.no_water_minutes}\n" + "Pump blocked: {result.is_pump_blocked}\n" + "Error detected: {result.is_error_detected}\n" + "Days before cleaning left: {result.before_cleaning_days}\n" + "Cotton filter live left: {result.cotton_left_days}\n" + "Sponge filter live left: {result.sponge_filter_left_days}\n" + "Location: {result.location}\n" + "Timezone: {result.timezone}\n", + ) + ) + def status(self) -> PetWaterDispenserStatus: + """Retrieve properties.""" + data = { + prop["did"]: prop["value"] if prop["code"] == 0 else None + for prop in self.get_properties_for_mapping() + } + + _LOGGER.debug(data) + + return PetWaterDispenserStatus(data) + + @command(default_output=format_output("Turning device on")) + def on(self) -> List[Dict[str, Any]]: + """Turn device on.""" + return self.set_property("on", True) + + @command(default_output=format_output("Turning device off")) + def off(self) -> List[Dict[str, Any]]: + """Turn device off.""" + return self.set_property("on", False) + + @command( + click.argument("led", type=bool), + default_output=format_output( + lambda led: "Turning LED on" if led else "Turning LED off" + ), + ) + def set_led(self, led: bool) -> List[Dict[str, Any]]: + """Toggle indicator light on/off.""" + if led: + return self.set_property("indicator_light", True) + return self.set_property("indicator_light", False) + + @command( + click.argument("mode", type=EnumType(OperatingMode)), + default_output=format_output('Changing mode to "{mode.name}"'), + ) + def set_mode(self, mode: OperatingMode) -> List[Dict[str, Any]]: + """Switch operation mode.""" + return self.set_property("mode", mode.value) + + @command(default_output=format_output("Resetting sponge filter")) + def reset_sponge_filter(self) -> Dict[str, Any]: + """Reset sponge filter.""" + return self.call_action("reset_filter_life") + + @command(default_output=format_output("Resetting cotton filter")) + def reset_cotton_filter(self) -> Dict[str, Any]: + """Reset cotton filter.""" + return self.call_action("reset_cotton_life") + + @command(default_output=format_output("Resetting all filters")) + def reset_all_filters(self) -> List[Dict[str, Any]]: + """Reset all filters [cotton, sponge].""" + return [self.reset_cotton_filter(), self.reset_sponge_filter()] + + @command(default_output=format_output("Resetting cleaning time")) + def reset_cleaning_time(self) -> Dict[str, Any]: + """Reset cleaning time counter.""" + return self.call_action("reset_clean_time") + + @command(default_output=format_output("Resetting device")) + def reset(self) -> Dict[str, Any]: + """Reset device.""" + return self.call_action("reset_device") + + @command( + click.argument("timezone", type=click.IntRange(-12, 12)), + default_output=format_output('Changing timezone to "{timezone}"'), + ) + def set_timezone(self, timezone: int) -> List[Dict[str, Any]]: + """Change timezone.""" + return self.set_property("timezone", timezone) + + @command( + click.argument("location", type=str), + default_output=format_output('Changing location to "{location}"'), + ) + def set_location(self, location: str) -> List[Dict[str, Any]]: + """Change location.""" + return self.set_property("location", location) diff --git a/miio/integrations/petwaterdispenser/status.py b/miio/integrations/petwaterdispenser/status.py new file mode 100644 index 000000000..4fabd4640 --- /dev/null +++ b/miio/integrations/petwaterdispenser/status.py @@ -0,0 +1,101 @@ +import enum +from datetime import timedelta +from typing import Any, Dict + +from miio.miot_device import DeviceStatus + + +class OperatingMode(enum.Enum): + Normal = 1 + Smart = 2 + + +class PetWaterDispenserStatus(DeviceStatus): + """Container for status reports from Pet Water Dispenser.""" + + def __init__(self, data: Dict[str, Any]) -> None: + """Response of Pet Water Dispenser (mmgg.pet_waterer.s1) + [ + {'code': 0, 'did': 'cotton_left_time', 'piid': 1, 'siid': 5, 'value': 10}, + {'code': 0, 'did': 'fault', 'piid': 1, 'siid': 2, 'value': 0}, + {'code': 0, 'did': 'filter_left_time', 'piid': 1, 'siid': 3, 'value': 10}, + {'code': 0, 'did': 'indicator_light', 'piid': 1, 'siid': 4, 'value': True}, + {'code': 0, 'did': 'lid_up_flag', 'piid': 4, 'siid': 7, 'value': False}, + {'code': 0, 'did': 'location', 'piid': 2, 'siid': 9, 'value': 'ru'}, + {'code': 0, 'did': 'mode', 'piid': 3, 'siid': 2, 'value': 1}, + {'code': 0, 'did': 'no_water_flag', 'piid': 1, 'siid': 7, 'value': True}, + {'code': 0, 'did': 'no_water_time', 'piid': 2, 'siid': 7, 'value': 0}, + {'code': 0, 'did': 'on', 'piid': 2, 'siid': 2, 'value': True}, + {'code': 0, 'did': 'pump_block_flag', 'piid': 3, 'siid': 7, 'value': False}, + {'code': 0, 'did': 'remain_clean_time', 'piid': 1, 'siid': 6, 'value': 4}, + {'code': 0, 'did': 'timezone', 'piid': 1, 'siid': 9, 'value': 3} + ] + """ + self.data = data + + @property + def sponge_filter_left_days(self) -> timedelta: + """Filter life time remaining in days.""" + return timedelta(days=self.data["filter_left_time"]) + + @property + def is_on(self) -> bool: + """True if device is on.""" + return self.data["on"] + + @property + def mode(self) -> OperatingMode: + """OperatingMode.""" + return OperatingMode(self.data["mode"]) + + @property + def is_led_on(self) -> bool: + """True if enabled.""" + return self.data["indicator_light"] + + @property + def cotton_left_days(self) -> timedelta: + """Cotton filter life time remaining in days.""" + return timedelta(days=self.data["cotton_left_time"]) + + @property + def before_cleaning_days(self) -> timedelta: + """Days before cleaning.""" + return timedelta(days=self.data["remain_clean_time"]) + + @property + def is_no_water(self) -> bool: + """True if there is no water left.""" + if self.data["no_water_flag"]: + return False + return True + + @property + def no_water_minutes(self) -> timedelta: + """Minutes without water.""" + return timedelta(minutes=self.data["no_water_time"]) + + @property + def is_pump_blocked(self) -> bool: + """True if pump is blocked.""" + return self.data["pump_block_flag"] + + @property + def is_lid_up(self) -> bool: + """True if lid is up.""" + return self.data["lid_up_flag"] + + @property + def timezone(self) -> int: + """Timezone from -12 to +12.""" + return self.data["timezone"] + + @property + def location(self) -> str: + """Device location string.""" + return self.data["location"] + + @property + def is_error_detected(self) -> bool: + """True if fault detected.""" + return self.data["fault"] > 0 diff --git a/miio/integrations/petwaterdispenser/tests/__init__.py b/miio/integrations/petwaterdispenser/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/miio/integrations/petwaterdispenser/tests/test_status.py b/miio/integrations/petwaterdispenser/tests/test_status.py new file mode 100644 index 000000000..0faed3169 --- /dev/null +++ b/miio/integrations/petwaterdispenser/tests/test_status.py @@ -0,0 +1,37 @@ +from datetime import timedelta + +from ..status import OperatingMode, PetWaterDispenserStatus + +data = { + "cotton_left_time": 10, + "fault": 0, + "filter_left_time": 10, + "indicator_light": True, + "lid_up_flag": False, + "location": "ru", + "mode": 1, + "no_water_flag": True, + "no_water_time": 0, + "on": True, + "pump_block_flag": False, + "remain_clean_time": 2, + "timezone": 3, +} + + +def test_status(): + status = PetWaterDispenserStatus(data) + + assert status.is_on is True + assert status.sponge_filter_left_days == timedelta(days=10) + assert status.mode == OperatingMode(1) + assert status.is_led_on is True + assert status.cotton_left_days == timedelta(days=10) + assert status.before_cleaning_days == timedelta(days=2) + assert status.is_no_water is False + assert status.no_water_minutes == timedelta(minutes=0) + assert status.is_pump_blocked is False + assert status.is_lid_up is False + assert status.timezone == 3 + assert status.location == "ru" + assert status.is_error_detected is False diff --git a/miio/integrations/vacuum/__init__.py b/miio/integrations/vacuum/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/miio/integrations/vacuum/dreame/__init__.py b/miio/integrations/vacuum/dreame/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/miio/dreamevacuum_miot.py b/miio/integrations/vacuum/dreame/dreamevacuum_miot.py similarity index 97% rename from miio/dreamevacuum_miot.py rename to miio/integrations/vacuum/dreame/dreamevacuum_miot.py index 2738f2248..02e7eaca6 100644 --- a/miio/dreamevacuum_miot.py +++ b/miio/integrations/vacuum/dreame/dreamevacuum_miot.py @@ -3,9 +3,9 @@ import logging from enum import Enum -from .click_common import command, format_output -from .miot_device import DeviceStatus as DeviceStatusContainer -from .miot_device import MiotDevice, MiotMapping +from miio.click_common import command, format_output +from miio.miot_device import DeviceStatus as DeviceStatusContainer +from miio.miot_device import MiotDevice, MiotMapping _LOGGER = logging.getLogger(__name__) diff --git a/miio/integrations/vacuum/dreame/tests/__init__.py b/miio/integrations/vacuum/dreame/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/miio/tests/test_dreamevacuum_miot.py b/miio/integrations/vacuum/dreame/tests/test_dreamevacuum_miot.py similarity index 97% rename from miio/tests/test_dreamevacuum_miot.py rename to miio/integrations/vacuum/dreame/tests/test_dreamevacuum_miot.py index 4ef204e3e..b5bc8fd3e 100644 --- a/miio/tests/test_dreamevacuum_miot.py +++ b/miio/integrations/vacuum/dreame/tests/test_dreamevacuum_miot.py @@ -3,7 +3,9 @@ import pytest from miio import DreameVacuumMiot -from miio.dreamevacuum_miot import ( +from miio.tests.dummies import DummyMiotDevice + +from ..dreamevacuum_miot import ( ChargingState, CleaningMode, DeviceStatus, @@ -11,8 +13,6 @@ OperatingMode, ) -from .dummies import DummyMiotDevice - _INITIAL_STATE = { "battery_level": 42, "charging_state": ChargingState.Charging, diff --git a/miio/integrations/vacuum/mijia/__init__.py b/miio/integrations/vacuum/mijia/__init__.py new file mode 100644 index 000000000..2ebcbbdb3 --- /dev/null +++ b/miio/integrations/vacuum/mijia/__init__.py @@ -0,0 +1,2 @@ +# flake8: noqa +from .g1vacuum import G1Vacuum diff --git a/miio/integrations/vacuum/mijia/g1vacuum.py b/miio/integrations/vacuum/mijia/g1vacuum.py new file mode 100644 index 000000000..f6c5afd65 --- /dev/null +++ b/miio/integrations/vacuum/mijia/g1vacuum.py @@ -0,0 +1,376 @@ +import logging +from datetime import timedelta +from enum import Enum + +import click + +from miio.click_common import EnumType, command, format_output +from miio.miot_device import DeviceStatus, MiotDevice + +_LOGGER = logging.getLogger(__name__) +MIJIA_VACUUM_V1 = "mijia.vacuum.v1" +MIJIA_VACUUM_V2 = "mijia.vacuum.v2" + +SUPPORTED_MODELS = [MIJIA_VACUUM_V1, MIJIA_VACUUM_V2] + +MIOT_MAPPING = { + MIJIA_VACUUM_V2: { + # https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:vacuum:0000A006:mijia-v1:1 + "battery": {"siid": 3, "piid": 1}, + "charge_state": {"siid": 3, "piid": 2}, + "error_code": {"siid": 2, "piid": 2}, + "state": {"siid": 2, "piid": 1}, + "fan_speed": {"siid": 2, "piid": 6}, + "operating_mode": {"siid": 2, "piid": 4}, + "mop_state": {"siid": 16, "piid": 1}, + "water_level": {"siid": 2, "piid": 5}, + "main_brush_life_level": {"siid": 14, "piid": 1}, + "main_brush_time_left": {"siid": 14, "piid": 2}, + "side_brush_life_level": {"siid": 15, "piid": 1}, + "side_brush_time_left": {"siid": 15, "piid": 2}, + "filter_life_level": {"siid": 11, "piid": 1}, + "filter_time_left": {"siid": 11, "piid": 2}, + "clean_area": {"siid": 9, "piid": 1}, + "clean_time": {"siid": 9, "piid": 2}, + # totals always return 0 + "total_clean_area": {"siid": 9, "piid": 3}, + "total_clean_time": {"siid": 9, "piid": 4}, + "total_clean_count": {"siid": 9, "piid": 5}, + "home": {"siid": 2, "aiid": 3}, + "find": {"siid": 6, "aiid": 1}, + "start": {"siid": 2, "aiid": 1}, + "stop": {"siid": 2, "aiid": 2}, + "reset_main_brush_life_level": {"siid": 14, "aiid": 1}, + "reset_side_brush_life_level": {"siid": 15, "aiid": 1}, + "reset_filter_life_level": {"siid": 11, "aiid": 1}, + } +} + +ERROR_CODES = { + 0: "No error", + 1: "Left Wheel stuck", + 2: "Right Wheel stuck", + 3: "Cliff error", + 4: "Low battery", + 5: "Bump error", + 6: "Main Brush Error", + 7: "Side Brush Error", + 8: "Fan Motor Error", + 9: "Dustbin Error", + 10: "Charging Error", + 11: "No Water Error", + 12: "Pick Up Error", +} + + +class G1ChargeState(Enum): + """Charging Status.""" + + Discharging = 0 + Charging = 1 + FullyCharged = 2 + + +class G1State(Enum): + """Vacuum Status.""" + + Idle = 1 + Sweeping = 2 + Paused = 3 + Error = 4 + Charging = 5 + GoCharging = 6 + + +class G1Consumable(Enum): + """Consumables.""" + + MainBrush = "main_brush_life_level" + SideBrush = "side_brush_life_level" + Filter = "filter_life_level" + + +class G1VacuumMode(Enum): + """Vacuum Mode.""" + + GlobalClean = 1 + SpotClean = 2 + Wiping = 3 + + +class G1WaterLevel(Enum): + """Water Flow Level.""" + + Level1 = 1 + Level2 = 2 + Level3 = 3 + + +class G1FanSpeed(Enum): + """Fan speeds.""" + + Mute = 0 + Standard = 1 + Medium = 2 + High = 3 + + +class G1Languages(Enum): + """Languages.""" + + Chinese = 0 + English = 1 + + +class G1MopState(Enum): + """Mop Status.""" + + Off = 0 + On = 1 + + +class G1Status(DeviceStatus): + """Container for status reports from Mijia Vacuum G1.""" + + def __init__(self, data): + """Response (MIoT format) of a Mijia Vacuum G1 (mijia.vacuum.v2) + + [ + {'did': 'battery', 'siid': 3, 'piid': 1, 'code': 0, 'value': 100}, + {'did': 'charge_state', 'siid': 3, 'piid': 2, 'code': 0, 'value': 2}, + {'did': 'error_code', 'siid': 2, 'piid': 2, 'code': 0, 'value': 0}, + {'did': 'state', 'siid': 2, 'piid': 1, 'code': 0, 'value': 5}, + {'did': 'fan_speed', 'siid': 2, 'piid': 6, 'code': 0, 'value': 1}, + {'did': 'operating_mode', 'siid': 2, 'piid': 4, 'code': 0, 'value': 1}, + {'did': 'mop_state', 'siid': 16, 'piid': 1, 'code': 0, 'value': 0}, + {'did': 'water_level', 'siid': 2, 'piid': 5, 'code': 0, 'value': 2}, + {'did': 'main_brush_life_level', 'siid': 14, 'piid': 1, 'code': 0, 'value': 99}, + {'did': 'main_brush_time_left', 'siid': 14, 'piid': 2, 'code': 0, 'value': 17959} + {'did': 'side_brush_life_level', 'siid': 15, 'piid': 1, 'code': 0, 'value': 0 }, + {'did': 'side_brush_time_left', 'siid': 15, 'piid': 2', 'code': 0, 'value': 0}, + {'did': 'filter_life_level', 'siid': 11, 'piid': 1, 'code': 0, 'value': 99}, + {'did': 'filter_time_left', 'siid': 11, 'piid': 2, 'code': 0, 'value': 8959}, + {'did': 'clean_area', 'siid': 9, 'piid': 1, 'code': 0, 'value': 0}, + {'did': 'clean_time', 'siid': 9, 'piid': 2, 'code': 0, 'value': 0} + ]""" + self.data = data + + @property + def battery(self) -> int: + """Battery Level.""" + return self.data["battery"] + + @property + def charge_state(self) -> G1ChargeState: + """Charging State.""" + return G1ChargeState(self.data["charge_state"]) + + @property + def error_code(self) -> int: + """Error code as returned by the device.""" + return int(self.data["error_code"]) + + @property + def error(self) -> str: + """Human readable error description, see also :func:`error_code`.""" + try: + return ERROR_CODES[self.error_code] + except KeyError: + return "Definition missing for error %s" % self.error_code + + @property + def state(self) -> G1State: + """Vacuum Status.""" + return G1State(self.data["state"]) + + @property + def fan_speed(self) -> G1FanSpeed: + """Fan Speed.""" + return G1FanSpeed(self.data["fan_speed"]) + + @property + def operating_mode(self) -> G1VacuumMode: + """Operating Mode.""" + return G1VacuumMode(self.data["operating_mode"]) + + @property + def mop_state(self) -> G1MopState: + """Mop State.""" + return G1MopState(self.data["mop_state"]) + + @property + def water_level(self) -> G1WaterLevel: + """Water Level.""" + return G1WaterLevel(self.data["water_level"]) + + @property + def main_brush_life_level(self) -> int: + """Main Brush Life Level in %.""" + return self.data["main_brush_life_level"] + + @property + def main_brush_time_left(self) -> timedelta: + """Main Brush Remaining Time in Minutes.""" + return timedelta(minutes=self.data["main_brush_time_left"]) + + @property + def side_brush_life_level(self) -> int: + """Side Brush Life Level in %.""" + return self.data["side_brush_life_level"] + + @property + def side_brush_time_left(self) -> timedelta: + """Side Brush Remaining Time in Minutes.""" + return timedelta(minutes=self.data["side_brush_time_left"]) + + @property + def filter_life_level(self) -> int: + """Filter Life Level in %.""" + return self.data["filter_life_level"] + + @property + def filter_time_left(self) -> timedelta: + """Filter remaining time.""" + return timedelta(minutes=self.data["filter_time_left"]) + + @property + def clean_area(self) -> int: + """Clean Area in cm2.""" + return self.data["clean_area"] + + @property + def clean_time(self) -> timedelta: + """Clean time.""" + return timedelta(minutes=self.data["clean_time"]) + + +class G1CleaningSummary(DeviceStatus): + """Container for cleaning summary from Mijia Vacuum G1. + + Response (MIoT format) of a Mijia Vacuum G1 (mijia.vacuum.v2) + [ + {'did': 'total_clean_area', 'siid': 9, 'piid': 3, 'code': 0, 'value': 0}, + {'did': 'total_clean_time', 'siid': 9, 'piid': 4, 'code': 0, 'value': 0}, + {'did': 'total_clean_count', 'siid': 9, 'piid': 5, 'code': 0, 'value': 0} + ] + """ + + def __init__(self, data) -> None: + self.data = data + + @property + def total_clean_count(self) -> int: + """Total Number of Cleanings.""" + return self.data["total_clean_count"] + + @property + def total_clean_area(self) -> int: + """Total Area Cleaned in m2.""" + return self.data["total_clean_area"] + + @property + def total_clean_time(self) -> timedelta: + """Total Cleaning Time.""" + return timedelta(hours=self.data["total_clean_area"]) + + +class G1Vacuum(MiotDevice): + """Support for G1 vacuum (G1, mijia.vacuum.v2).""" + + _supported_models = SUPPORTED_MODELS + + mapping = MIOT_MAPPING[MIJIA_VACUUM_V2] + + @command( + default_output=format_output( + "", + "State: {result.state}\n" + "Error: {result.error}\n" + "Battery: {result.battery}%\n" + "Mode: {result.operating_mode}\n" + "Mop State: {result.mop_state}\n" + "Charge Status: {result.charge_state}\n" + "Fan speed: {result.fan_speed}\n" + "Water level: {result.water_level}\n" + "Main Brush Life Level: {result.main_brush_life_level}%\n" + "Main Brush Life Time: {result.main_brush_time_left}\n" + "Side Brush Life Level: {result.side_brush_life_level}%\n" + "Side Brush Life Time: {result.side_brush_time_left}\n" + "Filter Life Level: {result.filter_life_level}%\n" + "Filter Life Time: {result.filter_time_left}\n" + "Clean Area: {result.clean_area}\n" + "Clean Time: {result.clean_time}\n", + ) + ) + def status(self) -> G1Status: + """Retrieve properties.""" + + return G1Status( + { + # max_properties limited to 10 to avoid "Checksum error" + # messages from the device. + prop["did"]: prop["value"] if prop["code"] == 0 else None + for prop in self.get_properties_for_mapping(max_properties=10) + } + ) + + @command( + default_output=format_output( + "", + "Total Cleaning Count: {result.total_clean_count}\n" + "Total Cleaning Time: {result.total_clean_time}\n" + "Total Cleaning Area: {result.total_clean_area}\n", + ) + ) + def cleaning_summary(self) -> G1CleaningSummary: + """Retrieve properties.""" + + return G1CleaningSummary( + { + # max_properties limited to 10 to avoid "Checksum error" + # messages from the device. + prop["did"]: prop["value"] if prop["code"] == 0 else None + for prop in self.get_properties_for_mapping(max_properties=10) + } + ) + + @command() + def home(self): + """Home.""" + return self.call_action("home") + + @command() + def start(self) -> None: + """Start Cleaning.""" + return self.call_action("start") + + @command() + def stop(self): + """Stop Cleaning.""" + return self.call_action("stop") + + @command() + def find(self) -> None: + """Find the robot.""" + return self.call_action("find") + + @command(click.argument("consumable", type=G1Consumable)) + def consumable_reset(self, consumable: G1Consumable): + """Reset consumable information. + + CONSUMABLE=main_brush_life_level|side_brush_life_level|filter_life_level + """ + if consumable.name == G1Consumable.MainBrush: + return self.call_action("reset_main_brush_life_level") + elif consumable.name == G1Consumable.SideBrush: + return self.call_action("reset_side_brush_life_level") + elif consumable.name == G1Consumable.Filter: + return self.call_action("reset_filter_life_level") + + @command( + click.argument("fan_speed", type=EnumType(G1FanSpeed)), + default_output=format_output("Setting fan speed to {fan_speed}"), + ) + def set_fan_speed(self, fan_speed: G1FanSpeed): + """Set fan speed.""" + return self.set_property("fan_speed", fan_speed.value) diff --git a/miio/integrations/vacuum/mijia/tests/__init__.py b/miio/integrations/vacuum/mijia/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/miio/integrations/vacuum/roborock/__init__.py b/miio/integrations/vacuum/roborock/__init__.py new file mode 100644 index 000000000..26d58d8b7 --- /dev/null +++ b/miio/integrations/vacuum/roborock/__init__.py @@ -0,0 +1,2 @@ +# flake8: noqa +from .vacuum import RoborockVacuum, Vacuum, VacuumException, VacuumStatus diff --git a/miio/integrations/vacuum/roborock/tests/__init__.py b/miio/integrations/vacuum/roborock/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/miio/tests/test_vacuum.py b/miio/integrations/vacuum/roborock/tests/test_vacuum.py similarity index 91% rename from miio/tests/test_vacuum.py rename to miio/integrations/vacuum/roborock/tests/test_vacuum.py index 6c23afa3e..d08d8a586 100644 --- a/miio/tests/test_vacuum.py +++ b/miio/integrations/vacuum/roborock/tests/test_vacuum.py @@ -4,13 +4,13 @@ import pytest -from miio import Vacuum, VacuumStatus -from miio.vacuum import CarpetCleaningMode, MopMode +from miio import RoborockVacuum, Vacuum, VacuumStatus +from miio.tests.dummies import DummyDevice -from .dummies import DummyDevice +from ..vacuum import CarpetCleaningMode, MopMode -class DummyVacuum(DummyDevice, Vacuum): +class DummyVacuum(DummyDevice, RoborockVacuum): STATE_CHARGING = 8 STATE_CLEANING = 5 STATE_ZONED_CLEAN = 9 @@ -23,6 +23,7 @@ class DummyVacuum(DummyDevice, Vacuum): STATE_MANUAL = 7 def __init__(self, *args, **kwargs): + self._model = "missing.model.vacuum" self.state = { "state": 8, "dnd_enabled": 1, @@ -51,7 +52,6 @@ def __init__(self, *args, **kwargs): } super().__init__(args, kwargs) - self.model = None def change_mode(self, new_mode): if new_mode == "spot": @@ -277,7 +277,18 @@ def test_history_empty(self): assert len(self.device.clean_history().ids) == 0 + def test_info_no_cloud(self): + """Test the info functionality for non-cloud connected device.""" + from miio.exceptions import DeviceInfoUnavailableException + + with patch( + "miio.Device._fetch_info", side_effect=DeviceInfoUnavailableException() + ): + assert self.device.info().model == "rockrobo.vacuum.v1" + def test_carpet_cleaning_mode(self): + assert self.device.carpet_cleaning_mode() is None + with patch.object(self.device, "send", return_value=[{"carpet_clean_mode": 0}]): assert self.device.carpet_cleaning_mode() == CarpetCleaningMode.Avoid @@ -300,3 +311,11 @@ def test_mop_mode(self): with patch.object(self.device, "send", return_value=[32453]): assert self.device.mop_mode() is None + + +def test_deprecated_vacuum(caplog): + with pytest.deprecated_call(): + Vacuum("127.1.1.1", "68ffffffffffffffffffffffffffffff") + + with pytest.deprecated_call(): + from miio.vacuum import ROCKROBO_S6 # noqa: F401 diff --git a/miio/integrations/vacuum/roborock/vacuum.py b/miio/integrations/vacuum/roborock/vacuum.py new file mode 100644 index 000000000..d3d1734e3 --- /dev/null +++ b/miio/integrations/vacuum/roborock/vacuum.py @@ -0,0 +1,905 @@ +import contextlib +import datetime +import enum +import json +import logging +import math +import os +import pathlib +import time +from typing import Dict, List, Optional, Type, Union + +import click +import pytz +from appdirs import user_cache_dir + +from miio.click_common import ( + DeviceGroup, + EnumType, + GlobalContextObject, + LiteralParamType, + command, +) +from miio.device import Device, DeviceInfo +from miio.exceptions import DeviceException, DeviceInfoUnavailableException +from miio.utils import deprecated + +from .vacuumcontainers import ( + CarpetModeStatus, + CleaningDetails, + CleaningSummary, + ConsumableStatus, + DNDStatus, + SoundInstallStatus, + SoundStatus, + Timer, + VacuumStatus, +) + +_LOGGER = logging.getLogger(__name__) + + +class VacuumException(DeviceException): + pass + + +class TimerState(enum.Enum): + On = "on" + Off = "off" + + +class Consumable(enum.Enum): + MainBrush = "main_brush_work_time" + SideBrush = "side_brush_work_time" + Filter = "filter_work_time" + SensorDirty = "sensor_dirty_time" + + +class FanspeedEnum(enum.Enum): + pass + + +class FanspeedV1(FanspeedEnum): + Silent = 38 + Standard = 60 + Medium = 77 + Turbo = 90 + + +class FanspeedV2(FanspeedEnum): + Silent = 101 + Standard = 102 + Medium = 103 + Turbo = 104 + Gentle = 105 + Auto = 106 + + +class FanspeedV3(FanspeedEnum): + Silent = 38 + Standard = 60 + Medium = 75 + Turbo = 100 + + +class FanspeedE2(FanspeedEnum): + # Original names from the app: Gentle, Silent, Standard, Strong, Max + Gentle = 41 + Silent = 50 + Standard = 68 + Medium = 79 + Turbo = 100 + + +class FanspeedS7(FanspeedEnum): + Silent = 101 + Standard = 102 + Medium = 103 + Turbo = 104 + + +class WaterFlow(enum.Enum): + """Water flow strength on s5 max.""" + + Minimum = 200 + Low = 201 + High = 202 + Maximum = 203 + + +class MopMode(enum.Enum): + """Mop routing on S7.""" + + Standard = 300 + Deep = 301 + + +class CarpetCleaningMode(enum.Enum): + """Type of carpet cleaning/avoidance.""" + + Avoid = 0 + Rise = 1 + Ignore = 2 + + +ROCKROBO_V1 = "rockrobo.vacuum.v1" +ROCKROBO_S4 = "roborock.vacuum.s4" +ROCKROBO_S4_MAX = "roborock.vacuum.a19" +ROCKROBO_S5 = "roborock.vacuum.s5" +ROCKROBO_S5_MAX = "roborock.vacuum.s5e" +ROCKROBO_S6 = "roborock.vacuum.s6" +ROCKROBO_S6_PURE = "roborock.vacuum.a08" +ROCKROBO_S7 = "roborock.vacuum.a15" +ROCKROBO_S6_MAXV = "roborock.vacuum.a10" +ROCKROBO_E2 = "roborock.vacuum.e2" + +SUPPORTED_MODELS = [ + ROCKROBO_V1, + ROCKROBO_S4, + ROCKROBO_S4_MAX, + ROCKROBO_S5, + ROCKROBO_S5_MAX, + ROCKROBO_S6, + ROCKROBO_S6_PURE, + ROCKROBO_S7, + ROCKROBO_S6_MAXV, + ROCKROBO_E2, +] + + +class RoborockVacuum(Device): + """Main class for roborock vacuums (roborock.vacuum.*).""" + + _supported_models = SUPPORTED_MODELS + + def __init__( + self, + ip: str, + token: str = None, + start_id: int = 0, + debug: int = 0, + *, + model=None + ): + super().__init__(ip, token, start_id, debug, model=model) + self.manual_seqnum = -1 + + @command() + def start(self): + """Start cleaning.""" + return self.send("app_start") + + @command() + def stop(self): + """Stop cleaning. + + Note, prefer 'pause' instead of this for wider support. Some newer vacuum models + do not support this command. + """ + 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 resume_or_start(self): + """A shortcut for resuming or starting cleaning.""" + status = self.status() + if status.in_zone_cleaning and (status.is_paused or status.got_error): + return self.resume_zoned_clean() + if status.in_segment_cleaning and (status.is_paused or status.got_error): + return self.resume_segment_clean() + + return self.start() + + def _fetch_info(self) -> DeviceInfo: + """Return info about the device. + + This is overrides the base class info to account for gen1 devices that do not + respond to info query properly when not connected to the cloud. + """ + try: + info = super()._fetch_info() + return info + except (TypeError, DeviceInfoUnavailableException): + # cloud-blocked gen1 vacuums will not return proper payloads + dummy_v1 = DeviceInfo( + { + "model": ROCKROBO_V1, + "token": self.token, + "netif": {"localIp": self.ip}, + "fw_ver": "1.0_dummy", + } + ) + + self._info = dummy_v1 + _LOGGER.debug( + "Unable to query info, falling back to dummy %s", dummy_v1.model + ) + return self._info + + @command() + def home(self): + """Stop cleaning and return home.""" + + PAUSE_BEFORE_HOME = [ + ROCKROBO_V1, + ] + + if self.model in PAUSE_BEFORE_HOME: + self.send("app_pause") + + return self.send("app_charge") + + @command(click.argument("x_coord", type=int), click.argument("y_coord", type=int)) + def goto(self, x_coord: int, y_coord: int): + """Go to specific target. + + :param int x_coord: x coordinate + :param int y_coord: y coordinate + """ + return self.send("app_goto_target", [x_coord, y_coord]) + + @command(click.argument("zones", type=LiteralParamType(), required=True)) + def zoned_clean(self, zones: List): + """Clean zones. + + :param List zones: List of zones to clean: [[x1,y1,x2,y2, iterations],[x1,y1,x2,y2, iterations]] + """ + return self.send("app_zoned_clean", zones) + + @command() + def resume_zoned_clean(self): + """Resume zone cleaning after being paused.""" + return self.send("resume_zoned_clean") + + @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") + + MANUAL_ROTATION_MAX = 180 + MANUAL_ROTATION_MIN = -MANUAL_ROTATION_MAX + MANUAL_VELOCITY_MAX = 0.3 + MANUAL_VELOCITY_MIN = -MANUAL_VELOCITY_MAX + MANUAL_DURATION_DEFAULT = 1500 + + @command( + click.argument("rotation", type=int), + click.argument("velocity", type=float), + click.argument( + "duration", type=int, required=False, default=MANUAL_DURATION_DEFAULT + ), + ) + def manual_control_once( + self, rotation: int, velocity: float, duration: int = MANUAL_DURATION_DEFAULT + ): + """Starts the remote control mode and executes the action once before + deactivating the mode.""" + number_of_tries = 3 + self.manual_start() + while number_of_tries > 0: + if self.status().state_code == 7: + time.sleep(5) + self.manual_control(rotation, velocity, duration) + time.sleep(5) + return self.manual_stop() + + 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=MANUAL_DURATION_DEFAULT + ), + ) + def manual_control( + self, rotation: int, velocity: float, duration: int = MANUAL_DURATION_DEFAULT + ): + """Give a command over manual control interface.""" + if rotation < self.MANUAL_ROTATION_MIN or rotation > self.MANUAL_ROTATION_MAX: + raise DeviceException( + "Given rotation is invalid, should be ]%s, %s[, was %s" + % (self.MANUAL_ROTATION_MIN, self.MANUAL_ROTATION_MAX, rotation) + ) + if velocity < self.MANUAL_VELOCITY_MIN or velocity > self.MANUAL_VELOCITY_MAX: + raise DeviceException( + "Given velocity is invalid, should be ]%s, %s[, was: %s" + % (self.MANUAL_VELOCITY_MIN, self.MANUAL_VELOCITY_MAX, velocity) + ) + + self.manual_seqnum += 1 + params = { + "omega": round(math.radians(rotation), 1), + "velocity": velocity, + "duration": duration, + "seqnum": self.manual_seqnum, + } + + self.send("app_rc_move", [params]) + + @command() + def status(self) -> VacuumStatus: + """Return status of the vacuum.""" + return VacuumStatus(self.send("get_status")[0]) + + 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(click.argument("start", type=bool)) + def edit_map(self, start): + """Start map editing?""" + if start: + return self.send("start_edit_map")[0] == "ok" + else: + return self.send("end_edit_map")[0] == "ok" + + @command(click.option("--version", default=1)) + def fresh_map(self, version): + """Return fresh map?""" + if version not in [1, 2]: + raise VacuumException("Unknown map version: %s" % version) + + if version == 1: + return self.send("get_fresh_map") + elif version == 2: + return self.send("get_fresh_map_v2") + + @command(click.option("--version", default=1)) + def persist_map(self, version): + """Return fresh map?""" + if version not in [1, 2]: + raise VacuumException("Unknown map version: %s" % version) + + if version == 1: + return self.send("get_persist_map") + elif version == 2: + return self.send("get_persist_map_v2") + + @command( + click.argument("x1", type=int), + click.argument("y1", type=int), + click.argument("x2", type=int), + click.argument("y2", type=int), + ) + def create_software_barrier(self, x1, y1, x2, y2): + """Create software barrier (gen2 only?). + + NOTE: Multiple nogo zones and barriers could be added by passing + a list of them to save_map. + + Requires new fw version. + 3.3.9_001633+? + """ + # First parameter indicates the type, 1 = barrier + payload = [1, x1, y1, x2, y2] + return self.send("save_map", payload)[0] == "ok" + + @command( + click.argument("x1", type=int), + click.argument("y1", type=int), + click.argument("x2", type=int), + click.argument("y2", type=int), + click.argument("x3", type=int), + click.argument("y3", type=int), + click.argument("x4", type=int), + click.argument("y4", type=int), + ) + def create_nogo_zone(self, x1, y1, x2, y2, x3, y3, x4, y4): + """Create a rectangular no-go zone (gen2 only?). + + NOTE: Multiple nogo zones and barriers could be added by passing + a list of them to save_map. + + Requires new fw version. + 3.3.9_001633+? + """ + # First parameter indicates the type, 0 = zone + payload = [0, x1, y1, x2, y2, x3, y3, x4, y4] + return self.send("save_map", payload)[0] == "ok" + + @command(click.argument("enable", type=bool)) + def enable_lab_mode(self, enable): + """Enable persistent maps and software barriers. + + This is required to use create_nogo_zone and create_software_barrier commands. + """ + return self.send("set_lab_status", int(enable))["ok"] + + @command() + def clean_history(self) -> CleaningSummary: + """Return generic cleaning history.""" + return CleaningSummary(self.send("get_clean_summary")) + + @command() + def last_clean_details(self) -> Optional[CleaningDetails]: + """Return details from the last cleaning. + + Returns None if there has been no cleanups. + """ + history = self.clean_history() + if not history.ids: + return None + + last_clean_id = history.ids.pop(0) + return self.clean_details(last_clean_id) + + @command( + click.argument("id_", type=int, metavar="ID"), + ) + def clean_details( + self, id_: int + ) -> Union[List[CleaningDetails], Optional[CleaningDetails]]: + """Return details about specific cleaning.""" + details = self.send("get_clean_record", [id_]) + + if not details: + _LOGGER.warning("No cleaning record found for id %s", id_) + return None + + res = CleaningDetails(details.pop()) + 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[Timer] = list() + res = self.send("get_timer", [""]) + if not res: + return timers + + timezone = pytz.timezone(self.timezone()) + for rec in res: + try: + timers.append(Timer(rec, timezone=timezone)) + except Exception as ex: + _LOGGER.warning("Unable to add timer for %s: %s", rec, ex) + + 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. + + :param cron: schedule in cron format + :param command: ignored by the vacuum. + :param parameters: ignored by the vacuum. + """ + import time + + ts = int(round(time.time() * 1000)) + return self.send("set_timer", [[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. + + :param int timer_id: Timer ID + :param TimerStae mode: either On or Off + """ + if mode != TimerState.On and mode != TimerState.Off: + 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. + + :param int start_hr: Start hour + :param int start_min: Start minute + :param int end_hr: End hour + :param int end_min: End minute + """ + 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. + + :param int speed: Fan speed to set + """ + # 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 fan_speed_presets(self) -> Dict[str, int]: + """Return dictionary containing supported fan speeds.""" + + def _enum_as_dict(cls): + return {x.name: x.value for x in list(cls)} + + if self.model is None: + return _enum_as_dict(FanspeedV1) + + fanspeeds: Type[FanspeedEnum] = FanspeedV1 + + if self.model == ROCKROBO_V1: + _LOGGER.debug("Got robov1, checking for firmware version") + fw_version = self.info().firmware_version + version, build = fw_version.split("_") + version = tuple(map(int, version.split("."))) + if version >= (3, 5, 8): + fanspeeds = FanspeedV3 + elif version == (3, 5, 7): + fanspeeds = FanspeedV2 + else: + fanspeeds = FanspeedV1 + elif self.model == ROCKROBO_E2: + fanspeeds = FanspeedE2 + elif self.model == ROCKROBO_S7: + self._fanspeeds = FanspeedS7 + else: + fanspeeds = FanspeedV2 + + _LOGGER.debug("Using fanspeeds %s for %s", fanspeeds, self.model) + + return _enum_as_dict(fanspeeds) + + @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 = {"url": url, "md5": md5sum, "sid": int(sound_id)} + 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.""" + serial = self.send("get_serial_number") + if isinstance(serial, list): + return serial[0]["serial_number"] + return serial + + @command() + def locale(self): + """Return locale information.""" + return self.send("app_get_locale") + + @command() + def timezone(self): + """Get the timezone.""" + res = self.send("get_timezone") + + def _fallback_timezone(data): + fallback = "UTC" + _LOGGER.error( + "Unsupported timezone format (%s), falling back to %s", data, fallback + ) + return fallback + + if isinstance(res, int): + return _fallback_timezone(res) + + res = res[0] + if isinstance(res, dict): + # Xiaowa E25 example + # {'olson': 'Europe/Berlin', 'posix': 'CET-1CEST,M3.5.0,M10.5.0/3'} + if "olson" not in res: + return _fallback_timezone(res) + + return res["olson"] + + return res + + def set_timezone(self, new_zone): + """Set the timezone.""" + return self.send("set_timezone", [new_zone])[0] == "ok" + + def configure_wifi(self, ssid, password, uid=0, timezone=None): + """Configure the wifi settings.""" + extra_params = {} + if timezone is not None: + now = datetime.datetime.now(pytz.timezone(timezone)) + offset_as_float = now.utcoffset().total_seconds() / 60 / 60 + extra_params["tz"] = timezone + extra_params["gmt_offset"] = offset_as_float + + return super().configure_wifi(ssid, password, uid, extra_params) + + @command() + def carpet_mode(self): + """Get carpet mode settings.""" + return CarpetModeStatus(self.send("get_carpet_mode")[0]) + + @command( + click.argument("enabled", required=True, type=bool), + click.argument("stall_time", required=False, default=10, type=int), + click.argument("low", required=False, default=400, type=int), + click.argument("high", required=False, default=500, type=int), + click.argument("integral", required=False, default=450, type=int), + ) + def set_carpet_mode( + self, + enabled: bool, + stall_time: int = 10, + low: int = 400, + high: int = 500, + integral: int = 450, + ): + """Set the carpet mode.""" + click.echo("Setting carpet mode: %s" % enabled) + data = { + "enable": int(enabled), + "stall_time": stall_time, + "current_low": low, + "current_high": high, + "current_integral": integral, + } + return self.send("set_carpet_mode", [data])[0] == "ok" + + @command() + def carpet_cleaning_mode(self) -> Optional[CarpetCleaningMode]: + """Get carpet cleaning mode/avoidance setting.""" + try: + return CarpetCleaningMode( + self.send("get_carpet_clean_mode")[0]["carpet_clean_mode"] + ) + except Exception as err: + _LOGGER.warning("Error while requesting carpet clean mode: %s", err) + return None + + @command(click.argument("mode", type=EnumType(CarpetCleaningMode))) + def set_carpet_cleaning_mode(self, mode: CarpetCleaningMode): + """Set carpet cleaning mode/avoidance setting.""" + return ( + self.send("set_carpet_clean_mode", {"carpet_clean_mode": mode.value})[0] + == "ok" + ) + + @command() + def stop_zoned_clean(self): + """Stop cleaning a zone.""" + return self.send("stop_zoned_clean") + + @command() + def stop_segment_clean(self): + """Stop cleaning a segment.""" + return self.send("stop_segment_clean") + + @command() + def resume_segment_clean(self): + """Resuming cleaning a segment.""" + return self.send("resume_segment_clean") + + @command(click.argument("segments", type=LiteralParamType(), required=True)) + def segment_clean(self, segments: List): + """Clean segments. + + :param List segments: List of segments to clean: [16,17,18] + """ + return self.send("app_segment_clean", segments) + + @command() + def get_room_mapping(self): + """Retrieves a list of segments.""" + return self.send("get_room_mapping") + + @command() + def get_backup_maps(self): + """Get backup maps.""" + return self.send("get_recover_maps") + + @command(click.argument("id", type=int)) + def use_backup_map(self, id: int): + """Set backup map.""" + click.echo("Setting the map %s as active" % id) + return self.send("recover_map", [id]) + + @command() + def get_segment_status(self): + """Get the status of a segment.""" + return self.send("get_segment_status") + + def name_segment(self): + raise NotImplementedError("unknown parameters") + # return self.send("name_segment") + + def merge_segment(self): + raise NotImplementedError("unknown parameters") + # return self.send("merge_segment") + + def split_segment(self): + raise NotImplementedError("unknown parameters") + # return self.send("split_segment") + + @command() + def waterflow(self) -> WaterFlow: + """Get water flow setting.""" + return WaterFlow(self.send("get_water_box_custom_mode")[0]) + + @command(click.argument("waterflow", type=EnumType(WaterFlow))) + def set_waterflow(self, waterflow: WaterFlow): + """Set water flow setting.""" + return self.send("set_water_box_custom_mode", [waterflow.value]) + + @command() + def mop_mode(self) -> Optional[MopMode]: + """Get mop mode setting.""" + try: + return MopMode(self.send("get_mop_mode")[0]) + except ValueError as err: + _LOGGER.warning("Device returned unknown MopMode: %s", err) + return None + + @command(click.argument("mop_mode", type=EnumType(MopMode))) + def set_mop_mode(self, mop_mode: MopMode): + """Set mop mode setting.""" + return self.send("set_mop_mode", [mop_mode.value])[0] == "ok" + + @command() + def child_lock(self) -> bool: + """Get child lock setting.""" + return self.send("get_child_lock_status")["lock_status"] == 1 + + @command(click.argument("lock", type=bool)) + def set_child_lock(self, lock: bool) -> bool: + """Set child lock setting.""" + return self.send("set_child_lock_status", {"lock_status": int(lock)})[0] == "ok" + + @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 + with contextlib.suppress(FileNotFoundError, TypeError, ValueError), 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) + + 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, *args, **kwargs): + if vac.ip is None: # dummy Device for discovery, skip teardown + return + id_file = kwargs["id_file"] + seqs = {"seq": vac._protocol.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] + cache_dir.mkdir(parents=True, exist_ok=True) + with open(id_file, "w") as f: + json.dump(seqs, f) + + return dg + + +class Vacuum(RoborockVacuum): + """Main class for roborock vacuums.""" + + @deprecated( + "This class will become the base class for all vacuum implementations. Use RoborockVacuum to control roborock vacuums." + ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) diff --git a/miio/vacuum_cli.py b/miio/integrations/vacuum/roborock/vacuum_cli.py similarity index 88% rename from miio/vacuum_cli.py rename to miio/integrations/vacuum/roborock/vacuum_cli.py index 267b076ff..a7659c7ed 100644 --- a/miio/vacuum_cli.py +++ b/miio/integrations/vacuum/roborock/vacuum_cli.py @@ -13,21 +13,24 @@ from appdirs import user_cache_dir from tqdm import tqdm -import miio # noqa: E402 from miio.click_common import ( ExceptionHandlerGroup, LiteralParamType, validate_ip, validate_token, ) -from miio.device import UpdateState +from miio.device import Device, UpdateState from miio.exceptions import DeviceInfoUnavailableException from miio.miioprotocol import MiIOProtocol from miio.updater import OneShotServer -from miio.vacuum import CarpetCleaningMode + +from .vacuum import CarpetCleaningMode, Consumable, RoborockVacuum, TimerState +from .vacuum_tui import VacuumTUI + +from miio.discovery import Discovery _LOGGER = logging.getLogger(__name__) -pass_dev = click.make_pass_decorator(miio.Device, ensure=True) +pass_dev = click.make_pass_decorator(Device, ensure=True) @click.group(invoke_without_command=True, cls=ExceptionHandlerGroup) @@ -58,7 +61,6 @@ def cli(ctx, ip: str, token: str, debug: int, id_file: str): click.echo("You have to give ip and token!") sys.exit(-1) - start_id = manual_seq = 0 with contextlib.suppress(FileNotFoundError, TypeError, ValueError), open( id_file, "r" ) as f: @@ -67,7 +69,7 @@ def cli(ctx, ip: str, token: str, debug: int, id_file: str): manual_seq = x.get("manual_seq", 0) _LOGGER.debug("Read stored sequence ids: %s", x) - vac = miio.Vacuum(ip, token, start_id, debug) + vac = RoborockVacuum(ip, token, start_id, debug) vac.manual_seqnum = manual_seq _LOGGER.debug("Connecting to %s with token %s", ip, token) @@ -81,7 +83,7 @@ def cli(ctx, ip: str, token: str, debug: int, id_file: str): @cli.resultcallback() @pass_dev -def cleanup(vac: miio.Vacuum, *args, **kwargs): +def cleanup(vac: RoborockVacuum, *args, **kwargs): if vac.ip is None: # dummy Device for discovery, skip teardown return id_file = kwargs["id_file"] @@ -103,12 +105,12 @@ def discover(handshake): if handshake: MiIOProtocol.discover() else: - miio.Discovery.discover_mdns() + Discovery.discover_mdns() @cli.command() @pass_dev -def status(vac: miio.Vacuum): +def status(vac: RoborockVacuum): """Returns the state information.""" res = vac.status() if not res: @@ -123,9 +125,6 @@ def status(vac: miio.Vacuum): click.echo("Fanspeed: %s %%" % res.fanspeed) click.echo("Cleaning since: %s" % res.clean_time) click.echo("Cleaned area: %s m²" % res.clean_area) - # click.echo("DND enabled: %s" % res.dnd) - # click.echo("Map present: %s" % res.map) - # click.echo("in_cleaning: %s" % res.in_cleaning) click.echo("Water box attached: %s" % res.is_water_box_attached) if res.is_water_box_carriage_attached is not None: click.echo("Mop attached: %s" % res.is_water_box_carriage_attached) @@ -133,7 +132,7 @@ def status(vac: miio.Vacuum): @cli.command() @pass_dev -def consumables(vac: miio.Vacuum): +def consumables(vac: RoborockVacuum): """Return consumables status.""" res = vac.consumable_status() click.echo("Main brush: %s (left %s)" % (res.main_brush, res.main_brush_left)) @@ -145,13 +144,11 @@ def consumables(vac: miio.Vacuum): @cli.command() @click.argument("name", type=str, required=True) @pass_dev -def reset_consumable(vac: miio.Vacuum, name): +def reset_consumable(vac: RoborockVacuum, name): """Reset consumable state. Allowed values: main_brush, side_brush, filter, sensor_dirty """ - from miio.vacuum import Consumable - if name == "main_brush": consumable = Consumable.MainBrush elif name == "side_brush": @@ -171,35 +168,35 @@ def reset_consumable(vac: miio.Vacuum, name): @cli.command() @pass_dev -def start(vac: miio.Vacuum): +def start(vac: RoborockVacuum): """Start cleaning.""" click.echo("Starting cleaning: %s" % vac.start()) @cli.command() @pass_dev -def spot(vac: miio.Vacuum): +def spot(vac: RoborockVacuum): """Start spot cleaning.""" click.echo("Starting spot cleaning: %s" % vac.spot()) @cli.command() @pass_dev -def pause(vac: miio.Vacuum): +def pause(vac: RoborockVacuum): """Pause cleaning.""" click.echo("Pausing: %s" % vac.pause()) @cli.command() @pass_dev -def stop(vac: miio.Vacuum): +def stop(vac: RoborockVacuum): """Stop cleaning.""" click.echo("Stop cleaning: %s" % vac.stop()) @cli.command() @pass_dev -def home(vac: miio.Vacuum): +def home(vac: RoborockVacuum): """Return home.""" click.echo("Requesting return to home: %s" % vac.home()) @@ -208,7 +205,7 @@ def home(vac: miio.Vacuum): @pass_dev @click.argument("x_coord", type=int) @click.argument("y_coord", type=int) -def goto(vac: miio.Vacuum, x_coord: int, y_coord: int): +def goto(vac: RoborockVacuum, x_coord: int, y_coord: int): """Go to specific target.""" click.echo("Going to target : %s" % vac.goto(x_coord, y_coord)) @@ -216,7 +213,7 @@ def goto(vac: miio.Vacuum, x_coord: int, y_coord: int): @cli.command() @pass_dev @click.argument("zones", type=LiteralParamType(), required=True) -def zoned_clean(vac: miio.Vacuum, zones: List): +def zoned_clean(vac: RoborockVacuum, zones: List): """Clean zone.""" click.echo("Cleaning zone(s) : %s" % vac.zoned_clean(zones)) @@ -224,7 +221,7 @@ def zoned_clean(vac: miio.Vacuum, zones: List): @cli.group() @pass_dev # @click.argument('command', required=False) -def manual(vac: miio.Vacuum): +def manual(vac: RoborockVacuum): """Control the robot manually.""" command = "" if command == "start": @@ -238,14 +235,14 @@ def manual(vac: miio.Vacuum): @manual.command() @pass_dev -def tui(vac: miio.Vacuum): +def tui(vac: RoborockVacuum): """TUI for the manual mode.""" - miio.VacuumTUI(vac).run() + VacuumTUI(vac).run() @manual.command(name="start") @pass_dev -def manual_start(vac: miio.Vacuum): # noqa: F811 # redef of start +def manual_start(vac: RoborockVacuum): # noqa: F811 # redef of start """Activate the manual mode.""" click.echo("Activating manual controls") return vac.manual_start() @@ -253,7 +250,7 @@ def manual_start(vac: miio.Vacuum): # noqa: F811 # redef of start @manual.command(name="stop") @pass_dev -def manual_stop(vac: miio.Vacuum): # noqa: F811 # redef of stop +def manual_stop(vac: RoborockVacuum): # noqa: F811 # redef of stop """Deactivate the manual mode.""" click.echo("Deactivating manual controls") return vac.manual_stop() @@ -262,7 +259,7 @@ def manual_stop(vac: miio.Vacuum): # noqa: F811 # redef of stop @manual.command() @pass_dev @click.argument("degrees", type=int) -def left(vac: miio.Vacuum, degrees: int): +def left(vac: RoborockVacuum, degrees: int): """Turn to left.""" click.echo("Turning %s degrees left" % degrees) return vac.manual_control(degrees, 0) @@ -271,7 +268,7 @@ def left(vac: miio.Vacuum, degrees: int): @manual.command() @pass_dev @click.argument("degrees", type=int) -def right(vac: miio.Vacuum, degrees: int): +def right(vac: RoborockVacuum, degrees: int): """Turn to right.""" click.echo("Turning right") return vac.manual_control(-degrees, 0) @@ -280,7 +277,7 @@ def right(vac: miio.Vacuum, degrees: int): @manual.command() @click.argument("amount", type=float) @pass_dev -def forward(vac: miio.Vacuum, amount: float): +def forward(vac: RoborockVacuum, amount: float): """Run forwards.""" click.echo("Moving forwards") return vac.manual_control(0, amount) @@ -289,7 +286,7 @@ def forward(vac: miio.Vacuum, amount: float): @manual.command() @click.argument("amount", type=float) @pass_dev -def backward(vac: miio.Vacuum, amount: float): +def backward(vac: RoborockVacuum, amount: float): """Run backwards.""" click.echo("Moving backwards") return vac.manual_control(0, -amount) @@ -300,7 +297,7 @@ def backward(vac: miio.Vacuum, amount: float): @click.argument("rotation", type=float) @click.argument("velocity", type=float) @click.argument("duration", type=int) -def move(vac: miio.Vacuum, rotation: int, velocity: float, duration: int): +def move(vac: RoborockVacuum, rotation: int, velocity: float, duration: int): """Pass raw manual values.""" return vac.manual_control(rotation, velocity, duration) @@ -313,7 +310,12 @@ def move(vac: miio.Vacuum, rotation: int, velocity: float, duration: int): @click.argument("end_min", type=int, required=False) @pass_dev def dnd( - vac: miio.Vacuum, cmd: str, start_hr: int, start_min: int, end_hr: int, end_min: int + vac: RoborockVacuum, + cmd: str, + start_hr: int, + start_min: int, + end_hr: int, + end_min: int, ): """Query and adjust do-not-disturb mode.""" if cmd == "off": @@ -337,7 +339,7 @@ def dnd( @cli.command() @click.argument("speed", type=int, required=False) @pass_dev -def fanspeed(vac: miio.Vacuum, speed): +def fanspeed(vac: RoborockVacuum, speed): """Query and adjust the fan speed.""" if speed: click.echo("Setting fan speed to %s" % speed) @@ -349,7 +351,7 @@ def fanspeed(vac: miio.Vacuum, speed): @cli.group(invoke_without_command=True) @pass_dev @click.pass_context -def timer(ctx, vac: miio.Vacuum): +def timer(ctx, vac: RoborockVacuum): """List and modify existing timers.""" if ctx.invoked_subcommand is not None: return @@ -375,7 +377,7 @@ def timer(ctx, vac: miio.Vacuum): @click.option("--command", default="", required=False) @click.option("--params", default="", required=False) @pass_dev -def add(vac: miio.Vacuum, cron, command, params): +def add(vac: RoborockVacuum, cron, command, params): """Add a timer.""" click.echo(vac.add_timer(cron, command, params)) @@ -383,7 +385,7 @@ def add(vac: miio.Vacuum, cron, command, params): @timer.command() @click.argument("timer_id", type=int, required=True) @pass_dev -def delete(vac: miio.Vacuum, timer_id): +def delete(vac: RoborockVacuum, timer_id): """Delete a timer.""" click.echo(vac.delete_timer(timer_id)) @@ -393,10 +395,8 @@ def delete(vac: miio.Vacuum, timer_id): @click.option("--enable", is_flag=True) @click.option("--disable", is_flag=True) @pass_dev -def update(vac: miio.Vacuum, timer_id, enable, disable): +def update(vac: RoborockVacuum, timer_id, enable, disable): """Enable/disable a timer.""" - from miio.vacuum import TimerState - if enable and not disable: vac.update_timer(timer_id, TimerState.On) elif disable and not enable: @@ -407,7 +407,7 @@ def update(vac: miio.Vacuum, timer_id, enable, disable): @cli.command() @pass_dev -def find(vac: miio.Vacuum): +def find(vac: RoborockVacuum): """Find the robot.""" click.echo("Sending find the robot calls.") click.echo(vac.find()) @@ -415,14 +415,14 @@ def find(vac: miio.Vacuum): @cli.command() @pass_dev -def map(vac: miio.Vacuum): +def map(vac: RoborockVacuum): """Return the map token.""" click.echo(vac.map()) @cli.command() @pass_dev -def info(vac: miio.Vacuum): +def info(vac: RoborockVacuum): """Return device information.""" try: res = vac.info() @@ -438,7 +438,7 @@ def info(vac: miio.Vacuum): @cli.command() @pass_dev -def cleaning_history(vac: miio.Vacuum): +def cleaning_history(vac: RoborockVacuum): """Query the cleaning history.""" res = vac.clean_history() click.echo("Total clean count: %s" % res.count) @@ -466,7 +466,7 @@ def cleaning_history(vac: miio.Vacuum): @click.argument("volume", type=int, required=False) @click.option("--test", "test_mode", is_flag=True, help="play a test tune") @pass_dev -def sound(vac: miio.Vacuum, volume: int, test_mode: bool): +def sound(vac: RoborockVacuum, volume: int, test_mode: bool): """Query and change sound settings.""" if volume is not None: click.echo("Setting sound volume to %s" % volume) @@ -484,7 +484,7 @@ def sound(vac: miio.Vacuum, volume: int, test_mode: bool): @click.option("--sid", type=int, required=False, default=10000) @click.option("--ip", required=False) @pass_dev -def install_sound(vac: miio.Vacuum, url: str, md5sum: str, sid: int, ip: str): +def install_sound(vac: RoborockVacuum, url: str, md5sum: str, sid: int, ip: str): """Install a sound. When passing a local file this will create a self-hosting server @@ -534,7 +534,7 @@ def install_sound(vac: miio.Vacuum, url: str, md5sum: str, sid: int, ip: str): @cli.command() @pass_dev -def serial_number(vac: miio.Vacuum): +def serial_number(vac: RoborockVacuum): """Query serial number.""" click.echo("Serial#: %s" % vac.serial_number()) @@ -542,7 +542,7 @@ def serial_number(vac: miio.Vacuum): @cli.command() @click.argument("tz", required=False) @pass_dev -def timezone(vac: miio.Vacuum, tz=None): +def timezone(vac: RoborockVacuum, tz=None): """Query or set the timezone.""" if tz is not None: click.echo("Setting timezone to: %s" % tz) @@ -554,7 +554,7 @@ def timezone(vac: miio.Vacuum, tz=None): @cli.command() @click.argument("enabled", required=False, type=bool) @pass_dev -def carpet_mode(vac: miio.Vacuum, enabled=None): +def carpet_mode(vac: RoborockVacuum, enabled=None): """Query or set the carpet mode.""" if enabled is None: click.echo(vac.carpet_mode()) @@ -565,7 +565,7 @@ def carpet_mode(vac: miio.Vacuum, enabled=None): @cli.command() @click.argument("mode", required=False, type=str) @pass_dev -def carpet_cleaning_mode(vac: miio.Vacuum, mode=None): +def carpet_cleaning_mode(vac: RoborockVacuum, mode=None): """Query or set the carpet cleaning/avoidance mode. Allowed values: Avoid, Rise, Ignore @@ -586,7 +586,9 @@ def carpet_cleaning_mode(vac: miio.Vacuum, mode=None): @click.argument("uid", type=int, required=False) @click.option("--timezone", type=str, required=False, default=None) @pass_dev -def configure_wifi(vac: miio.Vacuum, ssid: str, password: str, uid: int, timezone: str): +def configure_wifi( + vac: RoborockVacuum, ssid: str, password: str, uid: int, timezone: str +): """Configure the wifi settings. Note that some newer firmwares may expect you to define the timezone by using @@ -598,7 +600,7 @@ def configure_wifi(vac: miio.Vacuum, ssid: str, password: str, uid: int, timezon @cli.command() @pass_dev -def update_status(vac: miio.Vacuum): +def update_status(vac: RoborockVacuum): """Return update state and progress.""" update_state = vac.update_state() click.echo("Update state: %s" % update_state) @@ -612,7 +614,7 @@ def update_status(vac: miio.Vacuum): @click.argument("md5", required=False, default=None) @click.option("--ip", required=False) @pass_dev -def update_firmware(vac: miio.Vacuum, url: str, md5: str, ip: str): +def update_firmware(vac: RoborockVacuum, url: str, md5: str, ip: str): """Update device firmware. If `url` starts with http* it is expected to be an URL. @@ -669,7 +671,7 @@ def update_firmware(vac: miio.Vacuum, url: str, md5: str, ip: str): @click.argument("cmd", required=True) @click.argument("parameters", required=False) @pass_dev -def raw_command(vac: miio.Vacuum, cmd, parameters): +def raw_command(vac: RoborockVacuum, cmd, parameters): """Run a raw command.""" params = [] # type: Any if parameters: diff --git a/miio/vacuum_tui.py b/miio/integrations/vacuum/roborock/vacuum_tui.py similarity index 98% rename from miio/vacuum_tui.py rename to miio/integrations/vacuum/roborock/vacuum_tui.py index 986dc9c72..6dd2ab25c 100644 --- a/miio/vacuum_tui.py +++ b/miio/integrations/vacuum/roborock/vacuum_tui.py @@ -8,7 +8,7 @@ import enum from typing import Tuple -from .vacuum import Vacuum +from .vacuum import RoborockVacuum as Vacuum class Control(enum.Enum): diff --git a/miio/vacuumcontainers.py b/miio/integrations/vacuum/roborock/vacuumcontainers.py similarity index 98% rename from miio/vacuumcontainers.py rename to miio/integrations/vacuum/roborock/vacuumcontainers.py index 0f0d6e82b..9629efa94 100644 --- a/miio/vacuumcontainers.py +++ b/miio/integrations/vacuum/roborock/vacuumcontainers.py @@ -5,8 +5,8 @@ from croniter import croniter -from .device import DeviceStatus -from .utils import pretty_seconds, pretty_time +from miio.device import DeviceStatus +from miio.utils import pretty_seconds, pretty_time def pretty_area(x: float) -> float: @@ -186,9 +186,11 @@ def is_on(self) -> bool: ) @property - def is_water_box_attached(self) -> bool: + def is_water_box_attached(self) -> Optional[bool]: """Return True is water box is installed.""" - return "water_box_status" in self.data and self.data["water_box_status"] == 1 + if "water_box_status" in self.data: + return self.data["water_box_status"] == 1 + return None @property def is_water_box_carriage_attached(self) -> Optional[bool]: diff --git a/miio/integrations/vacuum/roidmi/__init__.py b/miio/integrations/vacuum/roidmi/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/miio/roidmivacuum_miot.py b/miio/integrations/vacuum/roidmi/roidmivacuum_miot.py similarity index 99% rename from miio/roidmivacuum_miot.py rename to miio/integrations/vacuum/roidmi/roidmivacuum_miot.py index 2ad9e3e81..916d9f580 100644 --- a/miio/roidmivacuum_miot.py +++ b/miio/integrations/vacuum/roidmi/roidmivacuum_miot.py @@ -9,9 +9,9 @@ import click -from .click_common import EnumType, command -from .miot_device import DeviceStatus, MiotDevice, MiotMapping -from .vacuumcontainers import DNDStatus +from miio.click_common import EnumType, command +from miio.integrations.vacuum.roborock.vacuumcontainers import DNDStatus +from miio.miot_device import DeviceStatus, MiotDevice, MiotMapping _LOGGER = logging.getLogger(__name__) diff --git a/miio/integrations/vacuum/roidmi/tests/__init__.py b/miio/integrations/vacuum/roidmi/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/miio/tests/test_roidmivacuum_miot.py b/miio/integrations/vacuum/roidmi/tests/test_roidmivacuum_miot.py similarity index 97% rename from miio/tests/test_roidmivacuum_miot.py rename to miio/integrations/vacuum/roidmi/tests/test_roidmivacuum_miot.py index 2c421002f..3278a8271 100644 --- a/miio/tests/test_roidmivacuum_miot.py +++ b/miio/integrations/vacuum/roidmi/tests/test_roidmivacuum_miot.py @@ -3,19 +3,19 @@ import pytest -from miio import RoidmiVacuumMiot -from miio.roidmivacuum_miot import ( +from miio.integrations.vacuum.roborock.vacuumcontainers import DNDStatus +from miio.tests.dummies import DummyMiotDevice + +from ..roidmivacuum_miot import ( ChargingState, FanSpeed, PathMode, RoidmiState, + RoidmiVacuumMiot, SweepMode, SweepType, WaterLevel, ) -from miio.vacuumcontainers import DNDStatus - -from .dummies import DummyMiotDevice _INITIAL_STATE = { "auto_boost": 1, diff --git a/miio/integrations/vacuum/viomi/__init__.py b/miio/integrations/vacuum/viomi/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/miio/viomivacuum.py b/miio/integrations/vacuum/viomi/viomivacuum.py similarity index 98% rename from miio/viomivacuum.py rename to miio/integrations/vacuum/viomi/viomivacuum.py index 947fffd75..399c7a5c7 100644 --- a/miio/viomivacuum.py +++ b/miio/integrations/vacuum/viomi/viomivacuum.py @@ -51,11 +51,14 @@ import click -from .click_common import EnumType, command, format_output -from .device import Device, DeviceStatus -from .exceptions import DeviceException -from .utils import pretty_seconds -from .vacuumcontainers import ConsumableStatus, DNDStatus +from miio.click_common import EnumType, command, format_output +from miio.device import Device, DeviceStatus +from miio.exceptions import DeviceException +from miio.integrations.vacuum.roborock.vacuumcontainers import ( + ConsumableStatus, + DNDStatus, +) +from miio.utils import pretty_seconds _LOGGER = logging.getLogger(__name__) @@ -480,9 +483,15 @@ class ViomiVacuum(Device): retry_count = 10 def __init__( - self, ip: str, token: str = None, start_id: int = 0, debug: int = 0 + self, + ip: str, + token: str = None, + start_id: int = 0, + debug: int = 0, + *, + model: str = None, ) -> None: - super().__init__(ip, token, start_id, debug) + super().__init__(ip, token, start_id, debug, model=model) self.manual_seqnum = -1 self._cache: Dict[str, Any] = {"edge_state": None, "rooms": {}, "maps": {}} diff --git a/miio/yeelight.py b/miio/integrations/yeelight/__init__.py similarity index 94% rename from miio/yeelight.py rename to miio/integrations/yeelight/__init__.py index bdd523bab..0010d74c8 100644 --- a/miio/yeelight.py +++ b/miio/integrations/yeelight/__init__.py @@ -3,21 +3,18 @@ import click -from .click_common import command, format_output -from .device import Device, DeviceStatus -from .exceptions import DeviceException -from .utils import int_to_rgb, rgb_to_int +from miio.click_common import command, format_output +from miio.device import Device, DeviceStatus +from miio.exceptions import DeviceException +from miio.utils import int_to_rgb, rgb_to_int + +from .spec_helper import YeelightSpecHelper, YeelightSubLightType class YeelightException(DeviceException): pass -class YeelightSubLightType(IntEnum): - Main = 1 - Background = 2 - - SUBLIGHT_PROP_PREFIX = { YeelightSubLightType.Main: "", YeelightSubLightType.Background: "bg_", @@ -37,6 +34,7 @@ class YeelightMode(IntEnum): class YeelightSubLight(DeviceStatus): def __init__(self, data, type): + self.data = data self.type = type @@ -256,6 +254,25 @@ class Yeelight(Device): which however requires enabling the developer mode on the bulbs. """ + _supported_models: List[str] = [] + _spec_helper = None + + def __init__( + self, + ip: str = None, + token: str = None, + start_id: int = 0, + debug: int = 0, + lazy_discover: bool = True, + model: str = None, + ) -> None: + super().__init__(ip, token, start_id, debug, lazy_discover, model=model) + if Yeelight._spec_helper is None: + Yeelight._spec_helper = YeelightSpecHelper() + Yeelight._supported_models = Yeelight._spec_helper.supported_models + + self._model_info = Yeelight._spec_helper.get_model_info(self.model) + @command(default_output=format_output("", "{result.cli_format}")) def status(self) -> YeelightStatus: """Retrieve properties.""" @@ -301,9 +318,9 @@ def status(self) -> YeelightStatus: default_output=format_output("Powering on"), ) def on(self, transition=0, mode=0): - """Power on.""" - """ - set_power ["on|off", "smooth", time_in_ms, mode] + """Power on. + + set_power ["on|off", "sudden|smooth", time_in_ms, mode] where mode: 0: last mode 1: normal mode diff --git a/miio/integrations/yeelight/spec_helper.py b/miio/integrations/yeelight/spec_helper.py new file mode 100644 index 000000000..e794964cb --- /dev/null +++ b/miio/integrations/yeelight/spec_helper.py @@ -0,0 +1,86 @@ +import logging +import os +from enum import IntEnum +from typing import Dict, NamedTuple + +import attr +import yaml + +_LOGGER = logging.getLogger(__name__) + + +class YeelightSubLightType(IntEnum): + Main = 0 + Background = 1 + + +class ColorTempRange(NamedTuple): + """Color temperature range.""" + + min: int + max: int + + +@attr.s(auto_attribs=True) +class YeelightLampInfo: + color_temp: ColorTempRange + supports_color: bool + + +@attr.s(auto_attribs=True) +class YeelightModelInfo: + model: str + night_light: bool + lamps: Dict[YeelightSubLightType, YeelightLampInfo] + + +class YeelightSpecHelper: + _models: Dict[str, YeelightModelInfo] = {} + + def __init__(self): + if not YeelightSpecHelper._models: + self._parse_specs_yaml() + + def _parse_specs_yaml(self): + generic_info = YeelightModelInfo( + "generic", + False, + { + YeelightSubLightType.Main: YeelightLampInfo( + ColorTempRange(1700, 6500), False + ) + }, + ) + YeelightSpecHelper._models["generic"] = generic_info + # read the yaml file to populate the internal model cache + with open(os.path.dirname(__file__) + "/specs.yaml") as filedata: + models = yaml.safe_load(filedata) + for key, value in models.items(): + lamps = { + YeelightSubLightType.Main: YeelightLampInfo( + ColorTempRange(*value["color_temp"]), + value["supports_color"], + ) + } + + if "background" in value: + lamps[YeelightSubLightType.Background] = YeelightLampInfo( + ColorTempRange(*value["background"]["color_temp"]), + value["background"]["supports_color"], + ) + + info = YeelightModelInfo(key, value["night_light"], lamps) + YeelightSpecHelper._models[key] = info + + @property + def supported_models(self): + return self._models.keys() + + def get_model_info(self, model) -> YeelightModelInfo: + if model not in self._models: + _LOGGER.warning( + "Unknown model %s, please open an issue and supply features for this light. Returning generic information.", + model, + ) + return self._models["generic"] + return self._models[model] diff --git a/miio/integrations/yeelight/specs.yaml b/miio/integrations/yeelight/specs.yaml new file mode 100644 index 000000000..c34e1104a --- /dev/null +++ b/miio/integrations/yeelight/specs.yaml @@ -0,0 +1,159 @@ +yeelink.light.bslamp1: + night_light: False + color_temp: [1700, 6500] + supports_color: True +yeelink.light.bslamp2: + night_light: True + color_temp: [1700, 6500] + supports_color: True +yeelink.light.bslamp3: + night_light: True + color_temp: [1700, 6500] + supports_color: True +yeelink.light.ceil26: + night_light: True + color_temp: [2700, 6500] + supports_color: False +yeelink.light.ceila: + night_light: True + color_temp: [2700, 6500] + supports_color: False +yeelink.light.ceiling1: + night_light: True + color_temp: [2700, 6500] + supports_color: False +yeelink.light.ceiling2: + night_light: True + color_temp: [2700, 6500] + supports_color: False +yeelink.light.ceiling3: + night_light: True + color_temp: [2700, 6500] + supports_color: False +yeelink.light.ceiling4: + night_light: True + color_temp: [2700, 6500] + supports_color: False + background: + color_temp: [1700, 6500] + supports_color: True +yeelink.light.ceiling5: + night_light: True + color_temp: [2700, 6500] + supports_color: False +yeelink.light.ceiling6: + night_light: True + color_temp: [2700, 6500] + supports_color: False +yeelink.light.ceiling10: + night_light: True + color_temp: [2700, 6500] + supports_color: False + background: + color_temp: [1700, 6500] + supports_color: True +yeelink.light.ceiling13: + night_light: True + color_temp: [2700, 6500] + supports_color: False +yeelink.light.ceiling15: + night_light: True + color_temp: [2700, 6500] + supports_color: False +yeelink.light.ceiling18: + night_light: True + color_temp: [2700, 6500] + supports_color: False +yeelink.light.ceiling19: + night_light: True + color_temp: [2700, 6500] + supports_color: False + background: + color_temp: [1700, 6500] + supports_color: True +yeelink.light.ceiling20: + night_light: True + color_temp: [2700, 6500] + supports_color: False + background: + color_temp: [1700, 6500] + supports_color: True +yeelink.light.ceiling22: + night_light: True + color_temp: [2700, 6500] + supports_color: False +yeelink.light.ceiling24: + night_light: True + color_temp: [2700, 6500] + supports_color: False +yeelink.light.color1: + night_light: False + color_temp: [1700, 6500] + supports_color: True +yeelink.light.color2: + night_light: False + color_temp: [2700, 6500] + supports_color: True +yeelink.light.color4: + night_light: False + color_temp: [1700, 6500] + supports_color: True +yeelink.light.colorc: + night_light: False + color_temp: [2700, 6500] + supports_color: True +yeelink.light.color: + night_light: False + color_temp: [1700, 6500] + supports_color: True +yeelink.light.ct_bulb: + night_light: False + color_temp: [2700, 6500] + supports_color: False +yeelink.light.ct2: + night_light: False + color_temp: [2700, 6500] + supports_color: False +yeelink.light.lamp1: + night_light: False + color_temp: [2700, 5000] + supports_color: False +yeelink.light.lamp4: + night_light: False + color_temp: [2600, 5000] + supports_color: False +yeelink.light.lamp15: + night_light: False + color_temp: [2700, 6500] + supports_color: False + background: + color_temp: [1700, 6500] + supports_color: True +yeelink.light.mono1: + night_light: False + color_temp: [2700, 2700] + supports_color: False +yeelink.light.mono5: + night_light: False + color_temp: [2700, 2700] + supports_color: False +yeelink.light.mono: + night_light: False + color_temp: [2700, 2700] + supports_color: False +yeelink.light.monob: + night_light: False + color_temp: [2700, 2700] + supports_color: False +yeelink.light.strip1: + night_light: False + color_temp: [1700, 6500] + supports_color: True +yeelink.light.strip2: + night_light: False + color_temp: [2700, 6500] + supports_color: True +yeelink.light.strip4: + night_light: False + color_temp: [2700, 6500] + supports_color: True diff --git a/miio/integrations/yeelight/tests/__init__.py b/miio/integrations/yeelight/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/miio/tests/test_yeelight.py b/miio/integrations/yeelight/tests/test_yeelight.py similarity index 98% rename from miio/tests/test_yeelight.py rename to miio/integrations/yeelight/tests/test_yeelight.py index b578e11ea..453597cb1 100644 --- a/miio/tests/test_yeelight.py +++ b/miio/integrations/yeelight/tests/test_yeelight.py @@ -2,14 +2,15 @@ import pytest -from miio import Yeelight -from miio.yeelight import YeelightException, YeelightMode, YeelightStatus +from miio.tests.dummies import DummyDevice -from .dummies import DummyDevice +from .. import Yeelight, YeelightException, YeelightMode, YeelightStatus class DummyLight(DummyDevice, Yeelight): def __init__(self, *args, **kwargs): + self._model = "missing.model.yeelight" + self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), diff --git a/miio/integrations/yeelight/tests/test_yeelight_spec_helper.py b/miio/integrations/yeelight/tests/test_yeelight_spec_helper.py new file mode 100644 index 000000000..e6a92dc9b --- /dev/null +++ b/miio/integrations/yeelight/tests/test_yeelight_spec_helper.py @@ -0,0 +1,25 @@ +from ..spec_helper import ColorTempRange, YeelightSpecHelper, YeelightSubLightType + + +def test_get_model_info(): + spec_helper = YeelightSpecHelper() + model_info = spec_helper.get_model_info("yeelink.light.bslamp1") + assert model_info.model == "yeelink.light.bslamp1" + assert model_info.night_light is False + assert model_info.lamps[YeelightSubLightType.Main].color_temp == ColorTempRange( + 1700, 6500 + ) + assert model_info.lamps[YeelightSubLightType.Main].supports_color is True + assert YeelightSubLightType.Background not in model_info.lamps + + +def test_get_unknown_model_info(): + spec_helper = YeelightSpecHelper() + model_info = spec_helper.get_model_info("notreal") + assert model_info.model == "generic" + assert model_info.night_light is False + assert model_info.lamps[YeelightSubLightType.Main].color_temp == ColorTempRange( + 1700, 6500 + ) + assert model_info.lamps[YeelightSubLightType.Main].supports_color is False + assert YeelightSubLightType.Background not in model_info.lamps diff --git a/miio/miot_device.py b/miio/miot_device.py index 413d9ea83..d53557454 100644 --- a/miio/miot_device.py +++ b/miio/miot_device.py @@ -41,15 +41,16 @@ def __init__( lazy_discover: bool = True, timeout: int = None, *, + model: str = None, mapping: MiotMapping = None, ): """Overloaded to accept keyword-only `mapping` parameter.""" - super().__init__(ip, token, start_id, debug, lazy_discover, timeout) + super().__init__( + ip, token, start_id, debug, lazy_discover, timeout, model=model + ) if mapping is None and not hasattr(self, "mapping"): - raise DeviceException( - "Neither the class nor the parameter defines the mapping" - ) + _LOGGER.warning("Neither the class nor the parameter defines the mapping") if mapping is not None: self.mapping = mapping diff --git a/miio/philips_bulb.py b/miio/philips_bulb.py index 643de372b..a70a0ef26 100644 --- a/miio/philips_bulb.py +++ b/miio/philips_bulb.py @@ -68,22 +68,6 @@ def delay_off_countdown(self) -> int: class PhilipsWhiteBulb(Device): """Main class representing Xiaomi Philips White LED Ball Lamp.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - model: str = MODEL_PHILIPS_LIGHT_HBULB, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in AVAILABLE_PROPERTIES: - self.model = model - else: - self.model = MODEL_PHILIPS_LIGHT_HBULB - @command( default_output=format_output( "", @@ -97,7 +81,9 @@ def __init__( def status(self) -> PhilipsBulbStatus: """Retrieve properties.""" - properties = AVAILABLE_PROPERTIES[self.model] + properties = AVAILABLE_PROPERTIES.get( + self.model, AVAILABLE_PROPERTIES[MODEL_PHILIPS_LIGHT_HBULB] + ) values = self.get_properties(properties) return PhilipsBulbStatus(defaultdict(lambda: None, zip(properties, values))) @@ -139,22 +125,6 @@ def delay_off(self, seconds: int): class PhilipsBulb(PhilipsWhiteBulb): - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - model: str = MODEL_PHILIPS_LIGHT_BULB, - ) -> None: - if model in AVAILABLE_PROPERTIES: - self.model = model - else: - self.model = MODEL_PHILIPS_LIGHT_BULB - - super().__init__(ip, token, start_id, debug, lazy_discover, self.model) - @command( click.argument("level", type=int), default_output=format_output("Setting color temperature to {level}"), diff --git a/miio/philips_eyecare_cli.py b/miio/philips_eyecare_cli.py deleted file mode 100644 index c725df8f9..000000000 --- a/miio/philips_eyecare_cli.py +++ /dev/null @@ -1,200 +0,0 @@ -import logging -import sys - -import click - -import miio # noqa: E402 -from miio.click_common import ExceptionHandlerGroup, validate_ip, validate_token -from miio.miioprotocol import MiIOProtocol - -_LOGGER = logging.getLogger(__name__) -pass_dev = click.make_pass_decorator(miio.PhilipsEyecare) - - -def validate_brightness(ctx, param, value): - value = int(value) - if value < 1 or value > 100: - raise click.BadParameter("Should be a positive int between 1-100.") - return value - - -def validate_minutes(ctx, param, value): - value = int(value) - if value < 0 or value > 60: - raise click.BadParameter("Should be a positive int between 1-60.") - return value - - -def validate_scene(ctx, param, value): - value = int(value) - if value < 1 or value > 3: - raise click.BadParameter("Should be a positive int between 1-3.") - return value - - -@click.group(invoke_without_command=True, cls=ExceptionHandlerGroup) -@click.option("--ip", envvar="DEVICE_IP", callback=validate_ip) -@click.option("--token", envvar="DEVICE_TOKEN", callback=validate_token) -@click.option("-d", "--debug", default=False, count=True) -@click.pass_context -def cli(ctx, ip: str, token: str, debug: int): - """A tool to command Xiaomi Philips Eyecare Smart Lamp 2.""" - - if debug: - logging.basicConfig(level=logging.DEBUG) - _LOGGER.info("Debug mode active") - else: - logging.basicConfig(level=logging.INFO) - - _LOGGER.warning( - "This script is deprecated and will be removed soon, use `miiocli philipseyecare` instead" - ) - - # if we are scanning, we do not try to connect. - if ctx.invoked_subcommand == "discover": - return - - if ip is None or token is None: - click.echo("You have to give ip and token!") - sys.exit(-1) - - dev = miio.PhilipsEyecare(ip, token, debug) - _LOGGER.debug("Connecting to %s with token %s", ip, token) - - ctx.obj = dev - - if ctx.invoked_subcommand is None: - ctx.invoke(status) - - -@cli.command() -def discover(): - """Search for plugs in the network.""" - MiIOProtocol.discover() - - -@cli.command() -@pass_dev -def status(dev: miio.PhilipsEyecare): - """Returns the state information.""" - res = dev.status() - if not res: - return # bail out - - click.echo(click.style("Power: %s" % res.power, bold=True)) - click.echo("Brightness: %s" % res.brightness) - click.echo("Eye Fatigue Reminder: %s" % res.reminder) - click.echo("Ambient Light: %s" % res.ambient) - click.echo("Ambient Light Brightness: %s" % res.ambient_brightness) - click.echo("Eyecare Mode: %s" % res.eyecare) - click.echo("Eyecare Scene: %s" % res.scene) - click.echo("Night Light: %s " % res.smart_night_light) - click.echo( - "Countdown of the delayed turn off: %s minutes" % res.delay_off_countdown - ) - - -@cli.command() -@pass_dev -def on(dev: miio.PhilipsEyecare): - """Power on.""" - click.echo("Power on: %s" % dev.on()) - - -@cli.command() -@pass_dev -def off(dev: miio.PhilipsEyecare): - """Power off.""" - click.echo("Power off: %s" % dev.off()) - - -@cli.command() -@pass_dev -def eyecare_on(dev: miio.PhilipsEyecare): - """Turn eyecare on.""" - click.echo("Eyecare on: %s" % dev.eyecare_on()) - - -@cli.command() -@pass_dev -def eyecare_off(dev: miio.PhilipsEyecare): - """Turn eyecare off.""" - click.echo("Eyecare off: %s" % dev.eyecare_off()) - - -@cli.command() -@click.argument("level", callback=validate_brightness, required=True) -@pass_dev -def set_brightness(dev: miio.PhilipsEyecare, level): - """Set brightness level.""" - click.echo("Brightness: %s" % dev.set_brightness(level)) - - -@cli.command() -@click.argument("scene", callback=validate_scene, required=True) -@pass_dev -def set_scene(dev: miio.PhilipsEyecare, scene): - """Set eyecare scene number.""" - click.echo("Eyecare Scene: %s" % dev.set_scene(scene)) - - -@cli.command() -@click.argument("minutes", callback=validate_minutes, required=True) -@pass_dev -def delay_off(dev: miio.PhilipsEyecare, minutes): - """Set delay off in minutes.""" - click.echo("Delay off: %s" % dev.delay_off(minutes)) - - -@cli.command() -@pass_dev -def bl_on(dev: miio.PhilipsEyecare): - """Night Light on.""" - click.echo("Night Light On: %s" % dev.smart_night_light_on()) - - -@cli.command() -@pass_dev -def bl_off(dev: miio.PhilipsEyecare): - """Night Light off.""" - click.echo("Night Light off: %s" % dev.smart_night_light_off()) - - -@cli.command() -@pass_dev -def notify_on(dev: miio.PhilipsEyecare): - """Eye Fatigue Reminder On.""" - click.echo("Eye Fatigue Reminder On: %s" % dev.reminder_on()) - - -@cli.command() -@pass_dev -def notify_off(dev: miio.PhilipsEyecare): - """Eye Fatigue Reminder off.""" - click.echo("Eye Fatigue Reminder Off: %s" % dev.reminder_off()) - - -@cli.command() -@pass_dev -def ambient_on(dev: miio.PhilipsEyecare): - """Ambient Light on.""" - click.echo("Ambient Light On: %s" % dev.ambient_on()) - - -@cli.command() -@pass_dev -def ambient_off(dev: miio.PhilipsEyecare): - """Ambient Light off.""" - click.echo("Ambient Light Off: %s" % dev.ambient_off()) - - -@cli.command() -@click.argument("level", callback=validate_brightness, required=True) -@pass_dev -def set_ambient_brightness(dev: miio.PhilipsEyecare, level): - """Set Ambient Light brightness level.""" - click.echo("Ambient Light Brightness: %s" % dev.set_ambient_brightness(level)) - - -if __name__ == "__main__": - cli() diff --git a/miio/philips_rwread.py b/miio/philips_rwread.py index 83acc4512..9b1b42a81 100644 --- a/miio/philips_rwread.py +++ b/miio/philips_rwread.py @@ -83,22 +83,6 @@ def child_lock(self) -> bool: class PhilipsRwread(Device): """Main class representing Xiaomi Philips RW Read.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - model: str = MODEL_PHILIPS_LIGHT_RWREAD, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in AVAILABLE_PROPERTIES: - self.model = model - else: - self.model = MODEL_PHILIPS_LIGHT_RWREAD - @command( default_output=format_output( "", @@ -113,7 +97,9 @@ def __init__( ) def status(self) -> PhilipsRwreadStatus: """Retrieve properties.""" - properties = AVAILABLE_PROPERTIES[self.model] + properties = AVAILABLE_PROPERTIES.get( + self.model, AVAILABLE_PROPERTIES[MODEL_PHILIPS_LIGHT_RWREAD] + ) values = self.get_properties(properties) return PhilipsRwreadStatus(defaultdict(lambda: None, zip(properties, values))) diff --git a/miio/plug_cli.py b/miio/plug_cli.py deleted file mode 100644 index d2edd5945..000000000 --- a/miio/plug_cli.py +++ /dev/null @@ -1,96 +0,0 @@ -import ast -import logging -import sys -from typing import Any # noqa: F401 - -import click - -import miio # noqa: E402 -from miio.click_common import ExceptionHandlerGroup, validate_ip, validate_token -from miio.miioprotocol import MiIOProtocol - -_LOGGER = logging.getLogger(__name__) -pass_dev = click.make_pass_decorator(miio.ChuangmiPlug) - - -@click.group(invoke_without_command=True, cls=ExceptionHandlerGroup) -@click.option("--ip", envvar="DEVICE_IP", callback=validate_ip) -@click.option("--token", envvar="DEVICE_TOKEN", callback=validate_token) -@click.option("-d", "--debug", default=False, count=True) -@click.pass_context -def cli(ctx, ip: str, token: str, debug: int): - """A tool to command Xiaomi Smart Plug.""" - if debug: - logging.basicConfig(level=logging.DEBUG) - _LOGGER.info("Debug mode active") - else: - logging.basicConfig(level=logging.INFO) - - _LOGGER.warning( - "This script is deprecated and will be removed soon, use `miiocli chuangmiplug` instead" - ) - - # if we are scanning, we do not try to connect. - if ctx.invoked_subcommand == "discover": - return - - if ip is None or token is None: - click.echo("You have to give ip and token!") - sys.exit(-1) - - dev = miio.ChuangmiPlug(ip, token, debug) - _LOGGER.debug("Connecting to %s with token %s", ip, token) - - ctx.obj = dev - - if ctx.invoked_subcommand is None: - ctx.invoke(status) - - -@cli.command() -def discover(): - """Search for plugs in the network.""" - MiIOProtocol.discover() - - -@cli.command() -@pass_dev -def status(dev: miio.ChuangmiPlug): - """Returns the state information.""" - res = dev.status() - if not res: - return # bail out - - click.echo(click.style("Power: %s" % res.power, bold=True)) - click.echo("Temperature: %s" % res.temperature) - - -@cli.command() -@pass_dev -def on(dev: miio.ChuangmiPlug): - """Power on.""" - click.echo("Power on: %s" % dev.on()) - - -@cli.command() -@pass_dev -def off(dev: miio.ChuangmiPlug): - """Power off.""" - click.echo("Power off: %s" % dev.off()) - - -@cli.command() -@click.argument("cmd", required=True) -@click.argument("parameters", required=False) -@pass_dev -def raw_command(dev: miio.ChuangmiPlug, cmd, parameters): - """Run a raw command.""" - params = [] # type: Any - if parameters: - params = ast.literal_eval(parameters) - click.echo("Sending cmd %s with params %s" % (cmd, params)) - click.echo(dev.raw_command(cmd, params)) - - -if __name__ == "__main__": - cli() diff --git a/miio/powerstrip.py b/miio/powerstrip.py index 052591f31..469c8ddfe 100644 --- a/miio/powerstrip.py +++ b/miio/powerstrip.py @@ -136,21 +136,7 @@ def power_factor(self) -> Optional[float]: class PowerStrip(Device): """Main class representing the smart power strip.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - model: str = MODEL_POWER_STRIP_V1, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in AVAILABLE_PROPERTIES: - self.model = model - else: - self.model = MODEL_POWER_STRIP_V1 + _supported_models = [MODEL_POWER_STRIP_V1, MODEL_POWER_STRIP_V2] @command( default_output=format_output( @@ -169,7 +155,9 @@ def __init__( ) def status(self) -> PowerStripStatus: """Retrieve properties.""" - properties = AVAILABLE_PROPERTIES[self.model] + properties = AVAILABLE_PROPERTIES.get( + self.model, AVAILABLE_PROPERTIES[MODEL_POWER_STRIP_V1] + ) values = self.get_properties(properties) return PowerStripStatus(defaultdict(lambda: None, zip(properties, values))) diff --git a/miio/pwzn_relay.py b/miio/pwzn_relay.py index 2e7625e15..8d268a564 100644 --- a/miio/pwzn_relay.py +++ b/miio/pwzn_relay.py @@ -99,27 +99,13 @@ def on_count(self) -> Optional[int]: class PwznRelay(Device): """Main class representing the PWZN Relay.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - model: str = MODEL_PWZN_RELAY_APPLE, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in AVAILABLE_PROPERTIES: - self.model = model - else: - self.model = MODEL_PWZN_RELAY_APPLE - @command(default_output=format_output("", "on_count: {result.on_count}\n")) def status(self) -> PwznRelayStatus: """Retrieve properties.""" - properties = AVAILABLE_PROPERTIES[self.model].copy() + properties = AVAILABLE_PROPERTIES.get( + self.model, AVAILABLE_PROPERTIES[MODEL_PWZN_RELAY_APPLE] + ).copy() values = self.get_properties(properties) return PwznRelayStatus(defaultdict(lambda: None, zip(properties, values))) diff --git a/miio/py.typed b/miio/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/miio/tests/dummies.py b/miio/tests/dummies.py index 5d4624eca..74009de2d 100644 --- a/miio/tests/dummies.py +++ b/miio/tests/dummies.py @@ -36,6 +36,12 @@ class DummyDevice: def __init__(self, *args, **kwargs): self.start_state = self.state.copy() self._protocol = DummyMiIOProtocol(self) + self._info = None + # TODO: ugly hack to check for pre-existing _model + if getattr(self, "_model", None) is None: + self._model = "dummy.model" + self.token = "ffffffffffffffffffffffffffffffff" + self.ip = "192.0.2.1" def _reset_state(self): """Revert back to the original state.""" diff --git a/miio/tests/test_airconditioner_miot.py b/miio/tests/test_airconditioner_miot.py index b6e61f18d..02ced996c 100644 --- a/miio/tests/test_airconditioner_miot.py +++ b/miio/tests/test_airconditioner_miot.py @@ -36,6 +36,7 @@ class DummyAirConditionerMiot(DummyMiotDevice, AirConditionerMiot): def __init__(self, *args, **kwargs): + self._model = "xiaomi.aircondition.mc1" self.state = _INITIAL_STATE self.return_values = { "get_prop": self._get_state, diff --git a/miio/tests/test_airconditioningcompanion.py b/miio/tests/test_airconditioningcompanion.py index ebd585081..4fe07fab2 100644 --- a/miio/tests/test_airconditioningcompanion.py +++ b/miio/tests/test_airconditioningcompanion.py @@ -67,6 +67,7 @@ class DummyAirConditioningCompanion(DummyDevice, AirConditioningCompanion): def __init__(self, *args, **kwargs): self.state = ["010500978022222102", "01020119A280222221", "2"] self.last_ir_played = None + self._model = "missing.model.airconditioningcompanion" self.return_values = { "get_model_and_state": self._get_state, @@ -222,7 +223,7 @@ class DummyAirConditioningCompanionV3(DummyDevice, AirConditioningCompanionV3): def __init__(self, *args, **kwargs): self.state = ["010507950000257301", "011001160100002573", "807"] self.device_prop = {"lumi.0": {"plug_state": ["on"]}} - self.model = MODEL_ACPARTNER_V3 + self._model = MODEL_ACPARTNER_V3 self.last_ir_played = None self.return_values = { @@ -313,7 +314,7 @@ def test_status(self): class DummyAirConditioningCompanionMcn02(DummyDevice, AirConditioningCompanionMcn02): def __init__(self, *args, **kwargs): self.state = ["on", "cool", 28, "small_fan", "on", 441.0] - self.model = MODEL_ACPARTNER_MCN02 + self._model = MODEL_ACPARTNER_MCN02 self.return_values = {"get_prop": self._get_state} self.start_state = self.state.copy() diff --git a/miio/tests/test_airdehumidifier.py b/miio/tests/test_airdehumidifier.py index 5b8de7be0..52c35c6fc 100644 --- a/miio/tests/test_airdehumidifier.py +++ b/miio/tests/test_airdehumidifier.py @@ -17,7 +17,7 @@ class DummyAirDehumidifierV1(DummyDevice, AirDehumidifier): def __init__(self, *args, **kwargs): - self.model = MODEL_DEHUMIDIFIER_V1 + self._model = MODEL_DEHUMIDIFIER_V1 self.dummy_device_info = { "life": 348202, "uid": 1759530000, diff --git a/miio/tests/test_airfresh.py b/miio/tests/test_airfresh.py index 1bf96e292..3a1c705e8 100644 --- a/miio/tests/test_airfresh.py +++ b/miio/tests/test_airfresh.py @@ -17,7 +17,7 @@ class DummyAirFresh(DummyDevice, AirFresh): def __init__(self, *args, **kwargs): - self.model = MODEL_AIRFRESH_VA2 + self._model = MODEL_AIRFRESH_VA2 self.state = { "power": "on", "ptc_state": None, @@ -213,7 +213,7 @@ def filter_life_remaining(): class DummyAirFreshVA4(DummyDevice, AirFresh): def __init__(self, *args, **kwargs): - self.model = MODEL_AIRFRESH_VA4 + self._model = MODEL_AIRFRESH_VA4 self.state = { "power": "on", "ptc_state": "off", diff --git a/miio/tests/test_airfresh_t2017.py b/miio/tests/test_airfresh_t2017.py index 43614c2d9..83dca3b53 100644 --- a/miio/tests/test_airfresh_t2017.py +++ b/miio/tests/test_airfresh_t2017.py @@ -18,7 +18,7 @@ class DummyAirFreshA1(DummyDevice, AirFreshA1): def __init__(self, *args, **kwargs): - self.model = MODEL_AIRFRESH_A1 + self._model = MODEL_AIRFRESH_A1 self.state = { "power": True, "mode": "auto", @@ -185,7 +185,7 @@ def ptc(): class DummyAirFreshT2017(DummyDevice, AirFreshT2017): def __init__(self, *args, **kwargs): - self.model = MODEL_AIRFRESH_T2017 + self._model = MODEL_AIRFRESH_T2017 self.state = { "power": True, "mode": "favourite", diff --git a/miio/tests/test_airhumidifier.py b/miio/tests/test_airhumidifier.py index 77b7b9fda..71f54235f 100644 --- a/miio/tests/test_airhumidifier.py +++ b/miio/tests/test_airhumidifier.py @@ -1,14 +1,11 @@ -from unittest import TestCase - import pytest -from miio import AirHumidifier +from miio import AirHumidifier, DeviceException from miio.airhumidifier import ( MODEL_HUMIDIFIER_CA1, MODEL_HUMIDIFIER_CB1, MODEL_HUMIDIFIER_V1, AirHumidifierException, - AirHumidifierStatus, LedBrightness, OperationMode, ) @@ -17,11 +14,10 @@ from .dummies import DummyDevice -class DummyAirHumidifierV1(DummyDevice, AirHumidifier): - def __init__(self, *args, **kwargs): - self.model = MODEL_HUMIDIFIER_V1 +class DummyAirHumidifier(DummyDevice, AirHumidifier): + def __init__(self, model, *args, **kwargs): + self._model = model self.dummy_device_info = { - "fw_ver": "1.2.9_5033", "token": "68ffffffffffffffffffffffffffffff", "otu_stat": [101, 74, 5343, 0, 5327, 407], "mmfree": 228248, @@ -40,7 +36,11 @@ def __init__(self, *args, **kwargs): "ot": "otu", "mac": "78:11:FF:FF:FF:FF", } - self.device_info = None + + # Special version handling for CA1 + self.dummy_device_info["fw_ver"] = ( + "1.6.6" if self._model == MODEL_HUMIDIFIER_CA1 else "1.2.9_5033" + ) self.state = { "power": "on", @@ -51,239 +51,47 @@ def __init__(self, *args, **kwargs): "led_b": 2, "child_lock": "on", "limit_hum": 40, - "trans_level": 85, "use_time": 941100, - "button_pressed": "led", "hw_version": 0, } + self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_mode": lambda x: self._set_state("mode", x), - "set_led_b": lambda x: self._set_state("led_b", x), + "set_led_b": lambda x: self._set_state("led_b", [int(x[0])]), "set_buzzer": lambda x: self._set_state("buzzer", x), "set_child_lock": lambda x: self._set_state("child_lock", x), "set_limit_hum": lambda x: self._set_state("limit_hum", x), + "set_dry": lambda x: self._set_state("dry", x), "miIO.info": self._get_device_info, } - super().__init__(args, kwargs) - - def _get_device_info(self, _): - """Return dummy device info.""" - return self.dummy_device_info - - -@pytest.fixture(scope="class") -def airhumidifierv1(request): - request.cls.device = DummyAirHumidifierV1() - # TODO add ability to test on a real device - - -@pytest.mark.usefixtures("airhumidifierv1") -class TestAirHumidifierV1(TestCase): - def is_on(self): - return self.device.status().is_on - - def state(self): - return self.device.status() - - def test_on(self): - self.device.off() # ensure off - assert self.is_on() is False - - self.device.on() - assert self.is_on() is True - - def test_off(self): - self.device.on() # ensure on - assert self.is_on() is True - - self.device.off() - assert self.is_on() is False - - def test_status(self): - self.device._reset_state() - - device_info = DeviceInfo(self.device.dummy_device_info) - - assert repr(self.state()) == repr( - AirHumidifierStatus(self.device.start_state, device_info) - ) - - assert self.is_on() is True - assert self.state().temperature == self.device.start_state["temp_dec"] / 10.0 - assert self.state().humidity == self.device.start_state["humidity"] - assert self.state().mode == OperationMode(self.device.start_state["mode"]) - assert self.state().led_brightness == LedBrightness( - self.device.start_state["led_b"] - ) - assert self.state().buzzer == (self.device.start_state["buzzer"] == "on") - assert self.state().child_lock == ( - self.device.start_state["child_lock"] == "on" - ) - assert self.state().target_humidity == self.device.start_state["limit_hum"] - assert self.state().trans_level == self.device.start_state["trans_level"] - assert self.state().motor_speed is None - assert self.state().depth is None - assert self.state().dry is None - assert self.state().use_time == self.device.start_state["use_time"] - assert self.state().hardware_version == self.device.start_state["hw_version"] - assert self.state().button_pressed == self.device.start_state["button_pressed"] - - assert self.state().firmware_version == device_info.firmware_version - assert ( - self.state().firmware_version_major - == device_info.firmware_version.rsplit("_", 1)[0] - ) - assert self.state().firmware_version_minor == int( - device_info.firmware_version.rsplit("_", 1)[1] - ) - assert self.state().strong_mode_enabled is False - - def test_set_mode(self): - def mode(): - return self.device.status().mode - - self.device.set_mode(OperationMode.Silent) - assert mode() == OperationMode.Silent - - self.device.set_mode(OperationMode.Medium) - assert mode() == OperationMode.Medium - - self.device.set_mode(OperationMode.High) - assert mode() == OperationMode.High - - def test_set_led_brightness(self): - def led_brightness(): - return self.device.status().led_brightness - - self.device.set_led_brightness(LedBrightness.Bright) - assert led_brightness() == LedBrightness.Bright - - self.device.set_led_brightness(LedBrightness.Dim) - assert led_brightness() == LedBrightness.Dim - - self.device.set_led_brightness(LedBrightness.Off) - assert led_brightness() == LedBrightness.Off - - def test_set_led(self): - def led_brightness(): - return self.device.status().led_brightness - - self.device.set_led(True) - assert led_brightness() == LedBrightness.Bright - - self.device.set_led(False) - assert led_brightness() == LedBrightness.Off - - def test_set_buzzer(self): - def buzzer(): - return self.device.status().buzzer - - self.device.set_buzzer(True) - assert buzzer() is True - - self.device.set_buzzer(False) - assert buzzer() is False - - def test_status_without_temperature(self): - self.device._reset_state() - self.device.state["temp_dec"] = None - - assert self.state().temperature is None - def test_status_without_led_brightness(self): - self.device._reset_state() - self.device.state["led_b"] = None + if model == MODEL_HUMIDIFIER_V1: + # V1 has some extra properties that are not currently tested + self.state["trans_level"] = 85 + self.state["button_pressed"] = "led" - assert self.state().led_brightness is None + # V1 doesn't support try, so return an error + def raise_error(): + raise DeviceException("v1 does not support set_dry") - def test_set_target_humidity(self): - def target_humidity(): - return self.device.status().target_humidity + self.return_values["set_dry"] = lambda x: raise_error() - self.device.set_target_humidity(30) - assert target_humidity() == 30 - self.device.set_target_humidity(60) - assert target_humidity() == 60 - self.device.set_target_humidity(80) - assert target_humidity() == 80 + elif model in [MODEL_HUMIDIFIER_CA1, MODEL_HUMIDIFIER_CB1]: + # Additional attributes of the CA1 & CB1 + extra_states = { + "speed": 100, + "depth": 60, + "dry": "off", + } + self.state.update(extra_states) - with pytest.raises(AirHumidifierException): - self.device.set_target_humidity(-1) + # CB1 reports temperature differently + if self._model == MODEL_HUMIDIFIER_CB1: + self.state["temperature"] = self.state["temp_dec"] / 10.0 + del self.state["temp_dec"] - with pytest.raises(AirHumidifierException): - self.device.set_target_humidity(20) - - with pytest.raises(AirHumidifierException): - self.device.set_target_humidity(90) - - with pytest.raises(AirHumidifierException): - self.device.set_target_humidity(110) - - def test_set_child_lock(self): - def child_lock(): - return self.device.status().child_lock - - self.device.set_child_lock(True) - assert child_lock() is True - - self.device.set_child_lock(False) - assert child_lock() is False - - -class DummyAirHumidifierCA1(DummyDevice, AirHumidifier): - def __init__(self, *args, **kwargs): - self.model = MODEL_HUMIDIFIER_CA1 - self.dummy_device_info = { - "fw_ver": "1.6.6", - "token": "68ffffffffffffffffffffffffffffff", - "otu_stat": [101, 74, 5343, 0, 5327, 407], - "mmfree": 228248, - "netif": { - "gw": "192.168.0.1", - "localIp": "192.168.0.25", - "mask": "255.255.255.0", - }, - "ott_stat": [0, 0, 0, 0], - "model": "zhimi.humidifier.v1", - "cfg_time": 0, - "life": 575661, - "ap": {"rssi": -35, "ssid": "ap", "bssid": "FF:FF:FF:FF:FF:FF"}, - "wifi_fw_ver": "SD878x-14.76.36.p84-702.1.0-WM", - "hw_ver": "MW300", - "ot": "otu", - "mac": "78:11:FF:FF:FF:FF", - } - self.device_info = None - - self.state = { - "power": "on", - "mode": "medium", - "temp_dec": 294, - "humidity": 33, - "buzzer": "off", - "led_b": 2, - "child_lock": "on", - "limit_hum": 40, - "use_time": 941100, - "hw_version": 0, - # Additional attributes of the zhimi.humidifier.ca1 - "speed": 100, - "depth": 1, - "dry": "off", - } - self.return_values = { - "get_prop": self._get_state, - "set_power": lambda x: self._set_state("power", x), - "set_mode": lambda x: self._set_state("mode", x), - "set_led_b": lambda x: self._set_state("led_b", [int(x[0])]), - "set_buzzer": lambda x: self._set_state("buzzer", x), - "set_child_lock": lambda x: self._set_state("child_lock", x), - "set_limit_hum": lambda x: self._set_state("limit_hum", x), - "set_dry": lambda x: self._set_state("dry", x), - "miIO.info": self._get_device_info, - } super().__init__(args, kwargs) def _get_device_info(self, _): @@ -291,406 +99,211 @@ def _get_device_info(self, _): return self.dummy_device_info -@pytest.fixture(scope="class") -def airhumidifierca1(request): - request.cls.device = DummyAirHumidifierCA1() +@pytest.fixture( + params=[MODEL_HUMIDIFIER_V1, MODEL_HUMIDIFIER_CA1, MODEL_HUMIDIFIER_CB1] +) +def dev(request): + yield DummyAirHumidifier(model=request.param) # TODO add ability to test on a real device -@pytest.mark.usefixtures("airhumidifierca1") -class TestAirHumidifierCA1(TestCase): - def is_on(self): - return self.device.status().is_on - - def state(self): - return self.device.status() - - def test_on(self): - self.device.off() # ensure off - assert self.is_on() is False - - self.device.on() - assert self.is_on() is True - - def test_off(self): - self.device.on() # ensure on - assert self.is_on() is True - - self.device.off() - assert self.is_on() is False - - def test_status(self): - self.device._reset_state() - - device_info = DeviceInfo(self.device.dummy_device_info) - - assert repr(self.state()) == repr( - AirHumidifierStatus(self.device.start_state, device_info) - ) - - assert self.is_on() is True - assert self.state().temperature == self.device.start_state["temp_dec"] / 10.0 - assert self.state().humidity == self.device.start_state["humidity"] - assert self.state().mode == OperationMode(self.device.start_state["mode"]) - assert self.state().led_brightness == LedBrightness( - self.device.start_state["led_b"] - ) - assert self.state().buzzer == (self.device.start_state["buzzer"] == "on") - assert self.state().child_lock == ( - self.device.start_state["child_lock"] == "on" - ) - assert self.state().target_humidity == self.device.start_state["limit_hum"] - assert self.state().trans_level is None - assert self.state().motor_speed == self.device.start_state["speed"] - assert self.state().depth == self.device.start_state["depth"] - assert self.state().water_level == int(self.device.start_state["depth"] / 1.25) - assert self.state().water_tank_detached == ( - self.device.start_state["depth"] == 127 - ) - assert self.state().dry == (self.device.start_state["dry"] == "on") - assert self.state().use_time == self.device.start_state["use_time"] - assert self.state().hardware_version == self.device.start_state["hw_version"] - assert self.state().button_pressed is None - - assert self.state().firmware_version == device_info.firmware_version - assert ( - self.state().firmware_version_major - == device_info.firmware_version.rsplit("_", 1)[0] - ) - - try: - version_minor = int(device_info.firmware_version.rsplit("_", 1)[1]) - except IndexError: - version_minor = 0 - - assert self.state().firmware_version_minor == version_minor - assert self.state().strong_mode_enabled is False - - def test_set_mode(self): - def mode(): - return self.device.status().mode - - self.device.set_mode(OperationMode.Silent) - assert mode() == OperationMode.Silent +def test_on(dev): + dev.off() # ensure off + assert dev.status().is_on is False - self.device.set_mode(OperationMode.Medium) - assert mode() == OperationMode.Medium + dev.on() + assert dev.status().is_on is True - self.device.set_mode(OperationMode.High) - assert mode() == OperationMode.High - def test_set_led_brightness(self): - def led_brightness(): - return self.device.status().led_brightness +def test_off(dev): + dev.on() # ensure on + assert dev.status().is_on is True - self.device.set_led_brightness(LedBrightness.Bright) - assert led_brightness() == LedBrightness.Bright + dev.off() + assert dev.status().is_on is False - self.device.set_led_brightness(LedBrightness.Dim) - assert led_brightness() == LedBrightness.Dim - self.device.set_led_brightness(LedBrightness.Off) - assert led_brightness() == LedBrightness.Off +def test_set_mode(dev): + def mode(): + return dev.status().mode - def test_set_led(self): - def led_brightness(): - return self.device.status().led_brightness + dev.set_mode(OperationMode.Silent) + assert mode() == OperationMode.Silent - self.device.set_led(True) - assert led_brightness() == LedBrightness.Bright + dev.set_mode(OperationMode.Medium) + assert mode() == OperationMode.Medium - self.device.set_led(False) - assert led_brightness() == LedBrightness.Off + dev.set_mode(OperationMode.High) + assert mode() == OperationMode.High - def test_set_buzzer(self): - def buzzer(): - return self.device.status().buzzer - self.device.set_buzzer(True) - assert buzzer() is True +def test_set_led(dev): + def led_brightness(): + return dev.status().led_brightness - self.device.set_buzzer(False) - assert buzzer() is False + dev.set_led(True) + assert led_brightness() == LedBrightness.Bright - def test_status_without_temperature(self): - self.device._reset_state() - self.device.state["temp_dec"] = None - - assert self.state().temperature is None - - def test_status_without_led_brightness(self): - self.device._reset_state() - self.device.state["led_b"] = None - - assert self.state().led_brightness is None - - def test_set_target_humidity(self): - def target_humidity(): - return self.device.status().target_humidity - - self.device.set_target_humidity(30) - assert target_humidity() == 30 - self.device.set_target_humidity(60) - assert target_humidity() == 60 - self.device.set_target_humidity(80) - assert target_humidity() == 80 - - with pytest.raises(AirHumidifierException): - self.device.set_target_humidity(-1) - - with pytest.raises(AirHumidifierException): - self.device.set_target_humidity(20) - - with pytest.raises(AirHumidifierException): - self.device.set_target_humidity(90) - - with pytest.raises(AirHumidifierException): - self.device.set_target_humidity(110) - - def test_set_child_lock(self): - def child_lock(): - return self.device.status().child_lock - - self.device.set_child_lock(True) - assert child_lock() is True - - self.device.set_child_lock(False) - assert child_lock() is False - - def test_set_dry(self): - def dry(): - return self.device.status().dry - - self.device.set_dry(True) - assert dry() is True - - self.device.set_dry(False) - assert dry() is False - - -class DummyAirHumidifierCB1(DummyDevice, AirHumidifier): - def __init__(self, *args, **kwargs): - self.model = MODEL_HUMIDIFIER_CB1 - self.dummy_device_info = { - "fw_ver": "1.2.9_5033", - "token": "68ffffffffffffffffffffffffffffff", - "otu_stat": [101, 74, 5343, 0, 5327, 407], - "mmfree": 228248, - "netif": { - "gw": "192.168.0.1", - "localIp": "192.168.0.25", - "mask": "255.255.255.0", - }, - "ott_stat": [0, 0, 0, 0], - "model": "zhimi.humidifier.v1", - "cfg_time": 0, - "life": 575661, - "ap": {"rssi": -35, "ssid": "ap", "bssid": "FF:FF:FF:FF:FF:FF"}, - "wifi_fw_ver": "SD878x-14.76.36.p84-702.1.0-WM", - "hw_ver": "MW300", - "ot": "otu", - "mac": "78:11:FF:FF:FF:FF", - } - self.device_info = None + dev.set_led(False) + assert led_brightness() == LedBrightness.Off - self.state = { - "power": "on", - "mode": "medium", - "humidity": 33, - "buzzer": "off", - "led_b": 2, - "child_lock": "on", - "limit_hum": 40, - "use_time": 941100, - "hw_version": 0, - # Additional attributes of the zhimi.humidifier.cb1 - "temperature": 29.4, - "speed": 100, - "depth": 1, - "dry": "off", - } - self.return_values = { - "get_prop": self._get_state, - "set_power": lambda x: self._set_state("power", x), - "set_mode": lambda x: self._set_state("mode", x), - "set_led_b": lambda x: self._set_state("led_b", [int(x[0])]), - "set_buzzer": lambda x: self._set_state("buzzer", x), - "set_child_lock": lambda x: self._set_state("child_lock", x), - "set_limit_hum": lambda x: self._set_state("limit_hum", x), - "set_dry": lambda x: self._set_state("dry", x), - "miIO.info": self._get_device_info, - } - super().__init__(args, kwargs) - def _get_device_info(self, _): - """Return dummy device info.""" - return self.dummy_device_info +def test_set_buzzer(dev): + def buzzer(): + return dev.status().buzzer + dev.set_buzzer(True) + assert buzzer() is True -@pytest.fixture(scope="class") -def airhumidifiercb1(request): - request.cls.device = DummyAirHumidifierCB1() - # TODO add ability to test on a real device + dev.set_buzzer(False) + assert buzzer() is False -@pytest.mark.usefixtures("airhumidifiercb1") -class TestAirHumidifierCB1(TestCase): - def is_on(self): - return self.device.status().is_on +def test_status_without_temperature(dev): + key = "temperature" if dev.model == MODEL_HUMIDIFIER_CB1 else "temp_dec" + dev.state[key] = None - def state(self): - return self.device.status() + assert dev.status().temperature is None - 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_status_without_led_brightness(dev): + dev.state["led_b"] = None - def test_off(self): - self.device.on() # ensure on - assert self.is_on() is True + assert dev.status().led_brightness is None - self.device.off() - assert self.is_on() is False - def test_status(self): - self.device._reset_state() +def test_set_target_humidity(dev): + def target_humidity(): + return dev.status().target_humidity - device_info = DeviceInfo(self.device.dummy_device_info) + dev.set_target_humidity(30) + assert target_humidity() == 30 + dev.set_target_humidity(60) + assert target_humidity() == 60 + dev.set_target_humidity(80) + assert target_humidity() == 80 - assert repr(self.state()) == repr( - AirHumidifierStatus(self.device.start_state, device_info) - ) + with pytest.raises(AirHumidifierException): + dev.set_target_humidity(-1) - assert self.is_on() is True - assert self.state().temperature == self.device.start_state["temperature"] - assert self.state().humidity == self.device.start_state["humidity"] - assert self.state().mode == OperationMode(self.device.start_state["mode"]) - assert self.state().led_brightness == LedBrightness( - self.device.start_state["led_b"] - ) - assert self.state().buzzer == (self.device.start_state["buzzer"] == "on") - assert self.state().child_lock == ( - self.device.start_state["child_lock"] == "on" - ) - assert self.state().target_humidity == self.device.start_state["limit_hum"] - assert self.state().trans_level is None - assert self.state().motor_speed == self.device.start_state["speed"] - assert self.state().depth == self.device.start_state["depth"] - assert self.state().dry == (self.device.start_state["dry"] == "on") - assert self.state().use_time == self.device.start_state["use_time"] - assert self.state().hardware_version == self.device.start_state["hw_version"] - assert self.state().button_pressed is None - - assert self.state().firmware_version == device_info.firmware_version - assert ( - self.state().firmware_version_major - == device_info.firmware_version.rsplit("_", 1)[0] - ) - assert self.state().firmware_version_minor == int( - device_info.firmware_version.rsplit("_", 1)[1] - ) - assert self.state().strong_mode_enabled is False + with pytest.raises(AirHumidifierException): + dev.set_target_humidity(20) - def test_set_mode(self): - def mode(): - return self.device.status().mode + with pytest.raises(AirHumidifierException): + dev.set_target_humidity(90) - self.device.set_mode(OperationMode.Silent) - assert mode() == OperationMode.Silent + with pytest.raises(AirHumidifierException): + dev.set_target_humidity(110) - self.device.set_mode(OperationMode.Medium) - assert mode() == OperationMode.Medium - self.device.set_mode(OperationMode.High) - assert mode() == OperationMode.High +def test_set_child_lock(dev): + def child_lock(): + return dev.status().child_lock - def test_set_led_brightness(self): - def led_brightness(): - return self.device.status().led_brightness + dev.set_child_lock(True) + assert child_lock() is True - self.device.set_led_brightness(LedBrightness.Bright) - assert led_brightness() == LedBrightness.Bright + dev.set_child_lock(False) + assert child_lock() is False - self.device.set_led_brightness(LedBrightness.Dim) - assert led_brightness() == LedBrightness.Dim - self.device.set_led_brightness(LedBrightness.Off) - assert led_brightness() == LedBrightness.Off +def test_status(dev): + assert dev.status().is_on is True + assert dev.status().humidity == dev.start_state["humidity"] + assert dev.status().mode == OperationMode(dev.start_state["mode"]) + assert dev.status().led_brightness == LedBrightness(dev.start_state["led_b"]) + assert dev.status().buzzer == (dev.start_state["buzzer"] == "on") + assert dev.status().child_lock == (dev.start_state["child_lock"] == "on") + assert dev.status().target_humidity == dev.start_state["limit_hum"] - def test_set_led(self): - def led_brightness(): - return self.device.status().led_brightness + if dev.model == MODEL_HUMIDIFIER_CB1: + assert dev.status().temperature == dev.start_state["temperature"] + else: + assert dev.status().temperature == dev.start_state["temp_dec"] / 10.0 - self.device.set_led(True) - assert led_brightness() == LedBrightness.Bright + if dev.model == MODEL_HUMIDIFIER_V1: + # Extra props only on v1 + assert dev.status().trans_level == dev.start_state["trans_level"] + assert dev.status().button_pressed == dev.start_state["button_pressed"] - self.device.set_led(False) - assert led_brightness() == LedBrightness.Off + assert dev.status().motor_speed is None + assert dev.status().depth is None + assert dev.status().dry is None + assert dev.status().water_level is None + assert dev.status().water_tank_detached is None - def test_set_buzzer(self): - def buzzer(): - return self.device.status().buzzer + if dev.model in [MODEL_HUMIDIFIER_CA1, MODEL_HUMIDIFIER_CB1]: + assert dev.status().motor_speed == dev.start_state["speed"] + assert dev.status().depth == dev.start_state["depth"] + assert dev.status().water_level == int(dev.start_state["depth"] / 1.2) + assert dev.status().water_tank_detached == (dev.start_state["depth"] == 127) + assert dev.status().dry == (dev.start_state["dry"] == "on") - self.device.set_buzzer(True) - assert buzzer() is True + # Extra props only on v1 should be none now + assert dev.status().trans_level is None + assert dev.status().button_pressed is None - self.device.set_buzzer(False) - assert buzzer() is False + assert dev.status().use_time == dev.start_state["use_time"] + assert dev.status().hardware_version == dev.start_state["hw_version"] - def test_status_without_temperature(self): - self.device._reset_state() - self.device.state["temperature"] = None + device_info = DeviceInfo(dev.dummy_device_info) + assert dev.status().firmware_version == device_info.firmware_version + assert ( + dev.status().firmware_version_major + == device_info.firmware_version.rsplit("_", 1)[0] + ) - assert self.state().temperature is None + try: + version_minor = int(device_info.firmware_version.rsplit("_", 1)[1]) + except IndexError: + version_minor = 0 - def test_status_without_led_brightness(self): - self.device._reset_state() - self.device.state["led_b"] = None + assert dev.status().firmware_version_minor == version_minor + assert dev.status().strong_mode_enabled is False - assert self.state().led_brightness is None - def test_set_target_humidity(self): - def target_humidity(): - return self.device.status().target_humidity +def test_set_led_brightness(dev): + def led_brightness(): + return dev.status().led_brightness - self.device.set_target_humidity(30) - assert target_humidity() == 30 - self.device.set_target_humidity(60) - assert target_humidity() == 60 - self.device.set_target_humidity(80) - assert target_humidity() == 80 + dev.set_led_brightness(LedBrightness.Bright) + assert led_brightness() == LedBrightness.Bright - with pytest.raises(AirHumidifierException): - self.device.set_target_humidity(-1) + dev.set_led_brightness(LedBrightness.Dim) + assert led_brightness() == LedBrightness.Dim - with pytest.raises(AirHumidifierException): - self.device.set_target_humidity(20) + dev.set_led_brightness(LedBrightness.Off) + assert led_brightness() == LedBrightness.Off - with pytest.raises(AirHumidifierException): - self.device.set_target_humidity(90) - with pytest.raises(AirHumidifierException): - self.device.set_target_humidity(110) +def test_set_dry(dev): + def dry(): + return dev.status().dry - def test_set_child_lock(self): - def child_lock(): - return self.device.status().child_lock + # set_dry is not supported on V1 + if dev.model == MODEL_HUMIDIFIER_V1: + assert dry() is None + with pytest.raises(DeviceException): + dev.set_dry(True) - self.device.set_child_lock(True) - assert child_lock() is True + return - self.device.set_child_lock(False) - assert child_lock() is False + dev.set_dry(True) + assert dry() is True - def test_set_dry(self): - def dry(): - return self.device.status().dry + dev.set_dry(False) + assert dry() is False - self.device.set_dry(True) - assert dry() is True - self.device.set_dry(False) - assert dry() is False +@pytest.mark.parametrize( + "depth,expected", [(-1, 0), (0, 0), (60, 50), (120, 100), (125, 100), (127, None)] +) +def test_water_level(dev, depth, expected): + """Test the water level conversions.""" + if dev.model == MODEL_HUMIDIFIER_V1: + # Water level is always none for v1 + assert dev.status().water_level is None + return + + dev.state["depth"] = depth + assert dev.status().water_level == expected diff --git a/miio/tests/test_airhumidifier_jsq.py b/miio/tests/test_airhumidifier_jsq.py index b57083c8c..60f3e2536 100644 --- a/miio/tests/test_airhumidifier_jsq.py +++ b/miio/tests/test_airhumidifier_jsq.py @@ -17,7 +17,7 @@ class DummyAirHumidifierJsq(DummyDevice, AirHumidifierJsq): def __init__(self, *args, **kwargs): - self.model = MODEL_HUMIDIFIER_JSQ001 + self._model = MODEL_HUMIDIFIER_JSQ001 self.dummy_device_info = { "life": 575661, diff --git a/miio/tests/test_airhumidifier_miot.py b/miio/tests/test_airhumidifier_miot.py index 0993c1d0d..317234eac 100644 --- a/miio/tests/test_airhumidifier_miot.py +++ b/miio/tests/test_airhumidifier_miot.py @@ -1,5 +1,3 @@ -from unittest import TestCase - import pytest from miio import AirHumidifierMiot @@ -53,143 +51,159 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) -@pytest.fixture(scope="function") -def airhumidifier(request): - request.cls.device = DummyAirHumidifierMiot() +@pytest.fixture() +def dev(request): + yield DummyAirHumidifierMiot() + + +def test_on(dev): + dev.off() # ensure off + assert dev.status().is_on is False + + dev.on() + assert dev.status().is_on is True + + +def test_off(dev): + dev.on() # ensure on + assert dev.status().is_on is True + + dev.off() + assert dev.status().is_on is False -@pytest.mark.usefixtures("airhumidifier") -class TestAirHumidifier(TestCase): - def test_on(self): - self.device.off() # ensure off - assert self.device.status().is_on is False +def test_status(dev): + status = dev.status() + assert status.is_on is _INITIAL_STATE["power"] + assert status.error == _INITIAL_STATE["fault"] + assert status.mode == OperationMode(_INITIAL_STATE["mode"]) + assert status.target_humidity == _INITIAL_STATE["target_humidity"] + assert status.water_level == int(_INITIAL_STATE["water_level"] / 1.2) + assert status.water_tank_detached == (_INITIAL_STATE["water_level"] == 127) + assert status.dry == _INITIAL_STATE["dry"] + assert status.use_time == _INITIAL_STATE["use_time"] + assert status.button_pressed == PressedButton(_INITIAL_STATE["button_pressed"]) + assert status.motor_speed == _INITIAL_STATE["speed_level"] + assert status.temperature == _INITIAL_STATE["temperature"] + assert status.fahrenheit == _INITIAL_STATE["fahrenheit"] + assert status.humidity == _INITIAL_STATE["humidity"] + assert status.buzzer == _INITIAL_STATE["buzzer"] + assert status.led_brightness == LedBrightness(_INITIAL_STATE["led_brightness"]) + assert status.child_lock == _INITIAL_STATE["child_lock"] + assert status.actual_speed == _INITIAL_STATE["actual_speed"] + assert status.power_time == _INITIAL_STATE["power_time"] - self.device.on() - assert self.device.status().is_on is True - def test_off(self): - self.device.on() # ensure on - assert self.device.status().is_on is True +def test_set_speed(dev): + def speed_level(): + return dev.status().motor_speed - self.device.off() - assert self.device.status().is_on is False + dev.set_speed(200) + assert speed_level() == 200 + dev.set_speed(2000) + assert speed_level() == 2000 - def test_status(self): - status = self.device.status() - assert status.is_on is _INITIAL_STATE["power"] - assert status.error == _INITIAL_STATE["fault"] - assert status.mode == OperationMode(_INITIAL_STATE["mode"]) - assert status.target_humidity == _INITIAL_STATE["target_humidity"] - assert status.water_level == int(_INITIAL_STATE["water_level"] / 1.25) - assert status.water_tank_detached == (_INITIAL_STATE["water_level"] == 127) - assert status.dry == _INITIAL_STATE["dry"] - assert status.use_time == _INITIAL_STATE["use_time"] - assert status.button_pressed == PressedButton(_INITIAL_STATE["button_pressed"]) - assert status.motor_speed == _INITIAL_STATE["speed_level"] - assert status.temperature == _INITIAL_STATE["temperature"] - assert status.fahrenheit == _INITIAL_STATE["fahrenheit"] - assert status.humidity == _INITIAL_STATE["humidity"] - assert status.buzzer == _INITIAL_STATE["buzzer"] - assert status.led_brightness == LedBrightness(_INITIAL_STATE["led_brightness"]) - assert status.child_lock == _INITIAL_STATE["child_lock"] - assert status.actual_speed == _INITIAL_STATE["actual_speed"] - assert status.power_time == _INITIAL_STATE["power_time"] + with pytest.raises(AirHumidifierMiotException): + dev.set_speed(199) - def test_set_speed(self): - def speed_level(): - return self.device.status().motor_speed + with pytest.raises(AirHumidifierMiotException): + dev.set_speed(2001) - self.device.set_speed(200) - assert speed_level() == 200 - self.device.set_speed(2000) - assert speed_level() == 2000 - with pytest.raises(AirHumidifierMiotException): - self.device.set_speed(199) +def test_set_target_humidity(dev): + def target_humidity(): + return dev.status().target_humidity - with pytest.raises(AirHumidifierMiotException): - self.device.set_speed(2001) + dev.set_target_humidity(30) + assert target_humidity() == 30 + dev.set_target_humidity(80) + assert target_humidity() == 80 - def test_set_target_humidity(self): - def target_humidity(): - return self.device.status().target_humidity + with pytest.raises(AirHumidifierMiotException): + dev.set_target_humidity(29) - self.device.set_target_humidity(30) - assert target_humidity() == 30 - self.device.set_target_humidity(80) - assert target_humidity() == 80 + with pytest.raises(AirHumidifierMiotException): + dev.set_target_humidity(81) - with pytest.raises(AirHumidifierMiotException): - self.device.set_target_humidity(29) - with pytest.raises(AirHumidifierMiotException): - self.device.set_target_humidity(81) +def test_set_mode(dev): + def mode(): + return dev.status().mode - def test_set_mode(self): - def mode(): - return self.device.status().mode + dev.set_mode(OperationMode.Auto) + assert mode() == OperationMode.Auto - self.device.set_mode(OperationMode.Auto) - assert mode() == OperationMode.Auto + dev.set_mode(OperationMode.Low) + assert mode() == OperationMode.Low - self.device.set_mode(OperationMode.Low) - assert mode() == OperationMode.Low + dev.set_mode(OperationMode.Mid) + assert mode() == OperationMode.Mid - self.device.set_mode(OperationMode.Mid) - assert mode() == OperationMode.Mid + dev.set_mode(OperationMode.High) + assert mode() == OperationMode.High - self.device.set_mode(OperationMode.High) - assert mode() == OperationMode.High - def test_set_led_brightness(self): - def led_brightness(): - return self.device.status().led_brightness +def test_set_led_brightness(dev): + def led_brightness(): + return dev.status().led_brightness - self.device.set_led_brightness(LedBrightness.Bright) - assert led_brightness() == LedBrightness.Bright + dev.set_led_brightness(LedBrightness.Bright) + assert led_brightness() == LedBrightness.Bright - self.device.set_led_brightness(LedBrightness.Dim) - assert led_brightness() == LedBrightness.Dim + dev.set_led_brightness(LedBrightness.Dim) + assert led_brightness() == LedBrightness.Dim - self.device.set_led_brightness(LedBrightness.Off) - assert led_brightness() == LedBrightness.Off + dev.set_led_brightness(LedBrightness.Off) + assert led_brightness() == LedBrightness.Off - def test_set_buzzer(self): - def buzzer(): - return self.device.status().buzzer - self.device.set_buzzer(True) - assert buzzer() is True +def test_set_buzzer(dev): + def buzzer(): + return dev.status().buzzer - self.device.set_buzzer(False) - assert buzzer() is False + dev.set_buzzer(True) + assert buzzer() is True - def test_set_child_lock(self): - def child_lock(): - return self.device.status().child_lock + dev.set_buzzer(False) + assert buzzer() is False - self.device.set_child_lock(True) - assert child_lock() is True - self.device.set_child_lock(False) - assert child_lock() is False +def test_set_child_lock(dev): + def child_lock(): + return dev.status().child_lock - def test_set_dry(self): - def dry(): - return self.device.status().dry + dev.set_child_lock(True) + assert child_lock() is True - self.device.set_dry(True) - assert dry() is True + dev.set_child_lock(False) + assert child_lock() is False - self.device.set_dry(False) - assert dry() is False - def test_set_clean_mode(self): - def clean_mode(): - return self.device.status().clean_mode +def test_set_dry(dev): + def dry(): + return dev.status().dry - self.device.set_clean_mode(True) - assert clean_mode() is True + dev.set_dry(True) + assert dry() is True - self.device.set_clean_mode(False) - assert clean_mode() is False + dev.set_dry(False) + assert dry() is False + + +def test_set_clean_mode(dev): + def clean_mode(): + return dev.status().clean_mode + + dev.set_clean_mode(True) + assert clean_mode() is True + + dev.set_clean_mode(False) + assert clean_mode() is False + + +@pytest.mark.parametrize( + "depth,expected", [(-1, 0), (0, 0), (60, 50), (120, 100), (125, 100), (127, None)] +) +def test_water_level(dev, depth, expected): + dev.set_property("water_level", depth) + assert dev.status().water_level == expected diff --git a/miio/tests/test_airhumidifier_mjjsq.py b/miio/tests/test_airhumidifier_mjjsq.py index 871b78235..54f7e3746 100644 --- a/miio/tests/test_airhumidifier_mjjsq.py +++ b/miio/tests/test_airhumidifier_mjjsq.py @@ -15,7 +15,7 @@ class DummyAirHumidifierMjjsq(DummyDevice, AirHumidifierMjjsq): def __init__(self, *args, **kwargs): - self.model = MODEL_HUMIDIFIER_JSQ1 + self._model = MODEL_HUMIDIFIER_JSQ1 self.state = { "Humidifier_Gear": 1, "Humidity_Value": 44, diff --git a/miio/tests/test_airpurifier.py b/miio/tests/test_airpurifier.py index 8691f7b78..ba2f0189e 100644 --- a/miio/tests/test_airpurifier.py +++ b/miio/tests/test_airpurifier.py @@ -17,6 +17,7 @@ class DummyAirPurifier(DummyDevice, AirPurifier): def __init__(self, *args, **kwargs): + self._model = "missing.model.airpurifier" self.state = { "power": "on", "aqi": 10, diff --git a/miio/tests/test_airpurifier_airdog.py b/miio/tests/test_airpurifier_airdog.py index adbdabfe5..998a35310 100644 --- a/miio/tests/test_airpurifier_airdog.py +++ b/miio/tests/test_airpurifier_airdog.py @@ -18,7 +18,7 @@ class DummyAirDogX3(DummyDevice, AirDogX3): def __init__(self, *args, **kwargs): - self.model = MODEL_AIRDOG_X3 + self._model = MODEL_AIRDOG_X3 self.state = { "power": "on", "mode": "manual", @@ -149,7 +149,7 @@ def clean_filters(): class DummyAirDogX5(DummyAirDogX3, AirDogX5): def __init__(self, *args, **kwargs): super().__init__(args, kwargs) - self.model = MODEL_AIRDOG_X5 + self._model = MODEL_AIRDOG_X5 self.state = { "power": "on", "mode": "manual", @@ -170,7 +170,7 @@ def airdogx5(request): class DummyAirDogX7SM(DummyAirDogX5, AirDogX7SM): def __init__(self, *args, **kwargs): super().__init__(args, kwargs) - self.model = MODEL_AIRDOG_X7SM + self._model = MODEL_AIRDOG_X7SM self.state["hcho"] = 2 diff --git a/miio/tests/test_airqualitymonitor.py b/miio/tests/test_airqualitymonitor.py index a78f73cad..d391c074b 100644 --- a/miio/tests/test_airqualitymonitor.py +++ b/miio/tests/test_airqualitymonitor.py @@ -15,7 +15,7 @@ class DummyAirQualityMonitorV1(DummyDevice, AirQualityMonitor): def __init__(self, *args, **kwargs): - self.model = MODEL_AIRQUALITYMONITOR_V1 + self._model = MODEL_AIRQUALITYMONITOR_V1 self.state = { "power": "on", "aqi": 34, @@ -85,7 +85,7 @@ def test_status(self): class DummyAirQualityMonitorS1(DummyDevice, AirQualityMonitor): def __init__(self, *args, **kwargs): - self.model = MODEL_AIRQUALITYMONITOR_S1 + self._model = MODEL_AIRQUALITYMONITOR_S1 self.state = { "battery": 100, "co2": 695, @@ -134,7 +134,7 @@ def test_status(self): class DummyAirQualityMonitorB1(DummyDevice, AirQualityMonitor): def __init__(self, *args, **kwargs): - self.model = MODEL_AIRQUALITYMONITOR_B1 + self._model = MODEL_AIRQUALITYMONITOR_B1 self.state = { "co2e": 1466, "humidity": 59.79999923706055, diff --git a/miio/tests/test_chuangmi_plug.py b/miio/tests/test_chuangmi_plug.py index 5962da97b..6c21576ff 100644 --- a/miio/tests/test_chuangmi_plug.py +++ b/miio/tests/test_chuangmi_plug.py @@ -15,7 +15,7 @@ class DummyChuangmiPlugV1(DummyDevice, ChuangmiPlug): def __init__(self, *args, **kwargs): - self.model = MODEL_CHUANGMI_PLUG_V1 + self._model = MODEL_CHUANGMI_PLUG_V1 self.state = {"on": True, "usb_on": True, "temperature": 32} self.return_values = { "get_prop": self._get_state, @@ -86,7 +86,7 @@ def test_usb_off(self): class DummyChuangmiPlugV3(DummyDevice, ChuangmiPlug): def __init__(self, *args, **kwargs): - self.model = MODEL_CHUANGMI_PLUG_V3 + self._model = MODEL_CHUANGMI_PLUG_V3 self.state = {"on": True, "usb_on": True, "temperature": 32, "wifi_led": "off"} self.return_values = { "get_prop": self._get_state, @@ -177,7 +177,7 @@ def wifi_led(): class DummyChuangmiPlugM1(DummyDevice, ChuangmiPlug): def __init__(self, *args, **kwargs): - self.model = MODEL_CHUANGMI_PLUG_M1 + self._model = MODEL_CHUANGMI_PLUG_M1 self.state = {"power": "on", "temperature": 32} self.return_values = { "get_prop": self._get_state, diff --git a/miio/tests/test_device.py b/miio/tests/test_device.py index cba6afa52..6412cae24 100644 --- a/miio/tests/test_device.py +++ b/miio/tests/test_device.py @@ -47,6 +47,7 @@ class CustomDevice(Device): def test_unavailable_device_info_raises(mocker): + """Make sure custom exception is raised if the info payload is invalid.""" send = mocker.patch("miio.Device.send", side_effect=PayloadDecodeException) d = Device("127.0.0.1", "68ffffffffffffffffffffffffffffff") @@ -54,3 +55,52 @@ def test_unavailable_device_info_raises(mocker): d.info() assert send.call_count == 1 + + +def test_model_autodetection(mocker): + """Make sure info() gets called if the model is unknown.""" + info = mocker.patch("miio.Device.info") + _ = mocker.patch("miio.Device.send") + + d = Device("127.0.0.1", "68ffffffffffffffffffffffffffffff") + + d.raw_command("cmd", {}) + + info.assert_called() + + +def test_forced_model(mocker): + """Make sure info() does not get called automatically if model is given.""" + info = mocker.patch("miio.Device.info") + _ = mocker.patch("miio.Device.send") + + DUMMY_MODEL = "dummy.model" + + d = Device("127.0.0.1", "68ffffffffffffffffffffffffffffff", model=DUMMY_MODEL) + d.raw_command("dummy", {}) + + assert d.model == DUMMY_MODEL + info.assert_not_called() + + +def test_missing_supported(mocker, caplog): + """Make sure warning is logged if the device is unsupported for the class.""" + _ = mocker.patch("miio.Device.send") + + d = Device("127.0.0.1", "68ffffffffffffffffffffffffffffff") + d._fetch_info() + + assert "Found an unsupported model" in caplog.text + assert "for class 'Device'" in caplog.text + + +@pytest.mark.parametrize("cls", Device.__subclasses__()) +def test_device_ctor_model(cls): + """Make sure that every device subclass ctor accepts model kwarg.""" + ignore_classes = ["GatewayDevice", "CustomDevice"] + if cls.__name__ in ignore_classes: + return + + dummy_model = "dummy" + dev = cls("127.0.0.1", "68ffffffffffffffffffffffffffffff", model=dummy_model) + assert dev.model == dummy_model diff --git a/miio/tests/test_deviceinfo.py b/miio/tests/test_deviceinfo.py new file mode 100644 index 000000000..d577078d7 --- /dev/null +++ b/miio/tests/test_deviceinfo.py @@ -0,0 +1,64 @@ +import pytest + +from miio.deviceinfo import DeviceInfo + + +@pytest.fixture() +def info(): + """Example response from Xiaomi Smart WiFi Plug (c&p from deviceinfo ctor).""" + return DeviceInfo( + { + "ap": {"bssid": "FF:FF:FF:FF:FF:FF", "rssi": -68, "ssid": "network"}, + "cfg_time": 0, + "fw_ver": "1.2.4_16", + "hw_ver": "MW300", + "life": 24, + "mac": "28:FF:FF:FF:FF:FF", + "mmfree": 30312, + "model": "chuangmi.plug.m1", + "netif": { + "gw": "192.168.xxx.x", + "localIp": "192.168.xxx.x", + "mask": "255.255.255.0", + }, + "ot": "otu", + "ott_stat": [0, 0, 0, 0], + "otu_stat": [320, 267, 3, 0, 3, 742], + "token": "2b00042f7481c7b056c4b410d28f33cf", + "wifi_fw_ver": "SD878x-14.76.36.p84-702.1.0-WM", + } + ) + + +def test_properties(info): + """Test that all deviceinfo properties are accessible.""" + + assert info.raw == info.data + + assert isinstance(info.accesspoint, dict) + assert isinstance(info.network_interface, dict) + + ap_props = ["bssid", "ssid", "rssi"] + for prop in ap_props: + assert prop in info.accesspoint + + if_props = ["gw", "localIp", "mask"] + for prop in if_props: + assert prop in info.network_interface + + assert info.model is not None + assert info.firmware_version is not None + assert info.hardware_version is not None + assert info.mac_address is not None + + +def test_missing_fields(info): + """Test that missing keys do not cause exceptions.""" + for k in ["fw_ver", "hw_ver", "model", "token", "mac"]: + del info.raw[k] + + assert info.model is None + assert info.firmware_version is None + assert info.hardware_version is None + assert info.mac_address is None + assert info.token is None diff --git a/miio/tests/test_fan.py b/miio/tests/test_fan.py index dd29d1562..0ee925b01 100644 --- a/miio/tests/test_fan.py +++ b/miio/tests/test_fan.py @@ -21,7 +21,7 @@ class DummyFanV2(DummyDevice, Fan): def __init__(self, *args, **kwargs): - self.model = MODEL_FAN_V2 + self._model = MODEL_FAN_V2 # This example response is just a guess. Please update! self.state = { "temp_dec": 232, @@ -271,7 +271,7 @@ def delay_off_countdown(): class DummyFanV3(DummyDevice, Fan): def __init__(self, *args, **kwargs): - self.model = MODEL_FAN_V3 + self._model = MODEL_FAN_V3 self.state = { "temp_dec": 232, "humidity": 46, @@ -527,7 +527,7 @@ def delay_off_countdown(): class DummyFanSA1(DummyDevice, Fan): def __init__(self, *args, **kwargs): - self.model = MODEL_FAN_SA1 + self._model = MODEL_FAN_SA1 self.state = { "angle": 120, "speed": 277, @@ -745,7 +745,7 @@ def delay_off_countdown(): class DummyFanP5(DummyDevice, FanP5): def __init__(self, *args, **kwargs): - self.model = MODEL_FAN_P5 + self._model = MODEL_FAN_P5 self.state = { "power": True, "mode": "normal", diff --git a/miio/tests/test_fan_leshow.py b/miio/tests/test_fan_leshow.py index 8abd703f7..2f767137c 100644 --- a/miio/tests/test_fan_leshow.py +++ b/miio/tests/test_fan_leshow.py @@ -15,7 +15,7 @@ class DummyFanLeshow(DummyDevice, FanLeshow): def __init__(self, *args, **kwargs): - self.model = MODEL_FAN_LESHOW_SS4 + self._model = MODEL_FAN_LESHOW_SS4 self.state = { "power": 1, "mode": 2, diff --git a/miio/tests/test_fan_miot.py b/miio/tests/test_fan_miot.py index f071b87f6..6955eb18c 100644 --- a/miio/tests/test_fan_miot.py +++ b/miio/tests/test_fan_miot.py @@ -19,7 +19,7 @@ class DummyFanMiot(DummyMiotDevice, FanMiot): def __init__(self, *args, **kwargs): - self.model = MODEL_FAN_P9 + self._model = MODEL_FAN_P9 self.state = { "power": True, "mode": 0, @@ -178,7 +178,7 @@ def delay_off_countdown(): class DummyFanMiotP10(DummyFanMiot, FanMiot): def __init__(self, *args, **kwargs): super().__init__(args, kwargs) - self.model = MODEL_FAN_P10 + self._model = MODEL_FAN_P10 @pytest.fixture(scope="class") @@ -222,7 +222,7 @@ def angle(): class DummyFanMiotP11(DummyFanMiot, FanMiot): def __init__(self, *args, **kwargs): super().__init__(args, kwargs) - self.model = MODEL_FAN_P11 + self._model = MODEL_FAN_P11 @pytest.fixture(scope="class") @@ -237,7 +237,7 @@ class TestFanMiotP11(TestFanMiotP10, TestCase): class DummyFan1C(DummyMiotDevice, Fan1C): def __init__(self, *args, **kwargs): - self.model = MODEL_FAN_1C + self._model = MODEL_FAN_1C self.state = { "power": True, "mode": 0, @@ -364,7 +364,7 @@ def delay_off_countdown(): class DummyFanZA5(DummyMiotDevice, FanZA5): def __init__(self, *args, **kwargs): - self.model = MODEL_FAN_ZA5 + self._model = MODEL_FAN_ZA5 self.state = { "anion": True, "buzzer": False, diff --git a/miio/tests/test_heater.py b/miio/tests/test_heater.py index 8eb72efe0..2bd0d6402 100644 --- a/miio/tests/test_heater.py +++ b/miio/tests/test_heater.py @@ -10,7 +10,7 @@ class DummyHeater(DummyDevice, Heater): def __init__(self, *args, **kwargs): - self.model = MODEL_HEATER_ZA1 + self._model = MODEL_HEATER_ZA1 # This example response is just a guess. Please update! self.state = { "target_temperature": 24, diff --git a/miio/tests/test_huizuo.py b/miio/tests/test_huizuo.py index 74a1f93b3..3c2c20040 100644 --- a/miio/tests/test_huizuo.py +++ b/miio/tests/test_huizuo.py @@ -39,28 +39,28 @@ class DummyHuizuo(DummyMiotDevice, Huizuo): def __init__(self, *args, **kwargs): self.state = _INITIAL_STATE - self.model = MODEL_HUIZUO_PIS123 + self._model = MODEL_HUIZUO_PIS123 super().__init__(*args, **kwargs) class DummyHuizuoFan(DummyMiotDevice, HuizuoLampFan): def __init__(self, *args, **kwargs): self.state = _INITIAL_STATE_FAN - self.model = MODEL_HUIZUO_FANWY + self._model = MODEL_HUIZUO_FANWY super().__init__(*args, **kwargs) class DummyHuizuoFan2(DummyMiotDevice, HuizuoLampFan): def __init__(self, *args, **kwargs): self.state = _INITIAL_STATE_FAN - self.model = MODEL_HUIZUO_FANWY2 + self._model = MODEL_HUIZUO_FANWY2 super().__init__(*args, **kwargs) class DummyHuizuoHeater(DummyMiotDevice, HuizuoLampHeater): def __init__(self, *args, **kwargs): self.state = _INITIAL_STATE_HEATER - self.model = MODEL_HUIZUO_WYHEAT + self._model = MODEL_HUIZUO_WYHEAT super().__init__(*args, **kwargs) diff --git a/miio/tests/test_miotdevice.py b/miio/tests/test_miotdevice.py index ec3c71b99..429e85d40 100644 --- a/miio/tests/test_miotdevice.py +++ b/miio/tests/test_miotdevice.py @@ -1,6 +1,6 @@ import pytest -from miio import DeviceException, MiotDevice +from miio import MiotDevice from miio.miot_device import MiotValueType @@ -14,11 +14,11 @@ def dev(module_mocker): return device -def test_missing_mapping(): +def test_missing_mapping(caplog): """Make sure ctor raises exception if neither class nor parameter defines the mapping.""" - with pytest.raises(DeviceException): - _ = MiotDevice("127.0.0.1", "68ffffffffffffffffffffffffffffff") + _ = MiotDevice("127.0.0.1", "68ffffffffffffffffffffffffffffff") + assert "Neither the class nor the parameter defines the mapping" in caplog.text def test_ctor_mapping(): diff --git a/miio/tests/test_philips_bulb.py b/miio/tests/test_philips_bulb.py index 38a9b306d..bac6a2e3e 100644 --- a/miio/tests/test_philips_bulb.py +++ b/miio/tests/test_philips_bulb.py @@ -15,7 +15,7 @@ class DummyPhilipsBulb(DummyDevice, PhilipsBulb): def __init__(self, *args, **kwargs): - self.model = MODEL_PHILIPS_LIGHT_BULB + self._model = MODEL_PHILIPS_LIGHT_BULB self.state = {"power": "on", "bright": 100, "cct": 10, "snm": 0, "dv": 0} self.return_values = { "get_prop": self._get_state, @@ -180,7 +180,7 @@ def scene(): class DummyPhilipsWhiteBulb(DummyDevice, PhilipsWhiteBulb): def __init__(self, *args, **kwargs): - self.model = MODEL_PHILIPS_LIGHT_HBULB + self._model = MODEL_PHILIPS_LIGHT_HBULB self.state = {"power": "on", "bri": 100, "dv": 0} self.return_values = { "get_prop": self._get_state, diff --git a/miio/tests/test_philips_rwread.py b/miio/tests/test_philips_rwread.py index 9cc90912a..3358c93d5 100644 --- a/miio/tests/test_philips_rwread.py +++ b/miio/tests/test_philips_rwread.py @@ -15,7 +15,7 @@ class DummyPhilipsRwread(DummyDevice, PhilipsRwread): def __init__(self, *args, **kwargs): - self.model = MODEL_PHILIPS_LIGHT_RWREAD + self._model = MODEL_PHILIPS_LIGHT_RWREAD self.state = { "power": "on", "bright": 53, diff --git a/miio/tests/test_powerstrip.py b/miio/tests/test_powerstrip.py index ebba6e39a..129c680c8 100644 --- a/miio/tests/test_powerstrip.py +++ b/miio/tests/test_powerstrip.py @@ -16,7 +16,7 @@ class DummyPowerStripV1(DummyDevice, PowerStrip): def __init__(self, *args, **kwargs): - self.model = MODEL_POWER_STRIP_V1 + self._model = MODEL_POWER_STRIP_V1 self.state = { "power": "on", "mode": "normal", @@ -108,7 +108,7 @@ def mode(): class DummyPowerStripV2(DummyDevice, PowerStrip): def __init__(self, *args, **kwargs): - self.model = MODEL_POWER_STRIP_V2 + self._model = MODEL_POWER_STRIP_V2 self.state = { "power": "on", "mode": "normal", diff --git a/miio/tests/test_toiletlid.py b/miio/tests/test_toiletlid.py index 70ae4acae..e80cc2d19 100644 --- a/miio/tests/test_toiletlid.py +++ b/miio/tests/test_toiletlid.py @@ -25,7 +25,7 @@ class DummyToiletlidV1(DummyDevice, Toiletlid): def __init__(self, *args, **kwargs): - self.model = MODEL_TOILETLID_V1 + self._model = MODEL_TOILETLID_V1 self.state = { "is_on": False, "work_state": 1, diff --git a/miio/tests/test_wifirepeater.py b/miio/tests/test_wifirepeater.py index 236c39ef8..5fca85636 100644 --- a/miio/tests/test_wifirepeater.py +++ b/miio/tests/test_wifirepeater.py @@ -9,6 +9,7 @@ class DummyWifiRepeater(DummyDevice, WifiRepeater): def __init__(self, *args, **kwargs): + self._model = "xiaomi.repeater.v2" self.state = { "sta": {"count": 2, "access_policy": 0}, "mat": [ @@ -76,6 +77,12 @@ def __init__(self, *args, **kwargs): self.start_device_info = self.device_info.copy() super().__init__(args, kwargs) + def info(self): + """This device has custom miIO.info response.""" + from miio.deviceinfo import DeviceInfo + + return DeviceInfo(self.device_info) + def _reset_state(self): """Revert back to the original state.""" self.state = self.start_state.copy() diff --git a/miio/toiletlid.py b/miio/toiletlid.py index acb13c7da..4f78927e6 100644 --- a/miio/toiletlid.py +++ b/miio/toiletlid.py @@ -71,22 +71,6 @@ def ambient_light(self) -> str: class Toiletlid(Device): - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - model: str = MODEL_TOILETLID_V1, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover) - - if model in AVAILABLE_PROPERTIES: - self.model = model - else: - self.model = MODEL_TOILETLID_V1 - @command( default_output=format_output( "", @@ -100,7 +84,9 @@ def __init__( ) def status(self) -> ToiletlidStatus: """Retrieve properties.""" - properties = AVAILABLE_PROPERTIES[self.model] + properties = AVAILABLE_PROPERTIES.get( + self.model, AVAILABLE_PROPERTIES[MODEL_TOILETLID_V1] + ) values = self.get_properties(properties) color = self.get_ambient_light() diff --git a/miio/utils.py b/miio/utils.py index cef4386ea..9a16cdafe 100644 --- a/miio/utils.py +++ b/miio/utils.py @@ -103,7 +103,7 @@ def rgb_to_int(x: Tuple[int, int, int]) -> int: def int_to_brightness(x: int) -> int: - """"Return brightness (0-100) from integer.""" + """Return brightness (0-100) from integer.""" return x >> 24 diff --git a/miio/vacuum.py b/miio/vacuum.py index e1aeba892..fd993ee9f 100644 --- a/miio/vacuum.py +++ b/miio/vacuum.py @@ -1,855 +1,10 @@ -import contextlib -import datetime -import enum -import json -import logging -import math -import os -import pathlib -import time -from typing import Dict, List, Optional, Union +"""This file is just for compat reasons and prints out a deprecated warning when +executed.""" +import warnings -import click -import pytz -from appdirs import user_cache_dir +from .integrations.vacuum.roborock.vacuum import * # noqa: F403,F401 -from .click_common import ( - DeviceGroup, - EnumType, - GlobalContextObject, - LiteralParamType, - command, +warnings.warn( + "miio.vacuum module has been renamed to miio.integrations.vacuum.roborock.vacuum", + DeprecationWarning, ) -from .device import Device -from .exceptions import DeviceException, DeviceInfoUnavailableException -from .vacuumcontainers import ( - CarpetModeStatus, - CleaningDetails, - CleaningSummary, - ConsumableStatus, - DNDStatus, - SoundInstallStatus, - SoundStatus, - Timer, - VacuumStatus, -) - -_LOGGER = logging.getLogger(__name__) - - -class VacuumException(DeviceException): - pass - - -class TimerState(enum.Enum): - On = "on" - Off = "off" - - -class Consumable(enum.Enum): - MainBrush = "main_brush_work_time" - SideBrush = "side_brush_work_time" - Filter = "filter_work_time" - SensorDirty = "sensor_dirty_time" - - -class FanspeedV1(enum.Enum): - Silent = 38 - Standard = 60 - Medium = 77 - Turbo = 90 - - -class FanspeedV2(enum.Enum): - Silent = 101 - Standard = 102 - Medium = 103 - Turbo = 104 - Gentle = 105 - Auto = 106 - - -class FanspeedV3(enum.Enum): - Silent = 38 - Standard = 60 - Medium = 75 - Turbo = 100 - - -class FanspeedE2(enum.Enum): - # Original names from the app: Gentle, Silent, Standard, Strong, Max - Gentle = 41 - Silent = 50 - Standard = 68 - Medium = 79 - Turbo = 100 - - -class FanspeedS7(enum.Enum): - Silent = 101 - Standard = 102 - Medium = 103 - Turbo = 104 - - -class WaterFlow(enum.Enum): - """Water flow strength on s5 max.""" - - Minimum = 200 - Low = 201 - High = 202 - Maximum = 203 - - -class MopMode(enum.Enum): - """Mop routing on S7.""" - - Standard = 300 - Deep = 301 - - -class CarpetCleaningMode(enum.Enum): - """Type of carpet cleaning/avoidance.""" - - Avoid = 0 - Rise = 1 - Ignore = 2 - - -ROCKROBO_V1 = "rockrobo.vacuum.v1" -ROCKROBO_S5 = "roborock.vacuum.s5" -ROCKROBO_S6 = "roborock.vacuum.s6" -ROCKROBO_S6_MAXV = "roborock.vacuum.a10" -ROCKROBO_S7 = "roborock.vacuum.a15" - - -class Vacuum(Device): - """Main class representing the vacuum.""" - - def __init__( - self, ip: str, token: str = None, start_id: int = 0, debug: int = 0 - ) -> None: - super().__init__(ip, token, start_id, debug) - self.manual_seqnum = -1 - self.model = None - self._fanspeeds = FanspeedV1 - - @command() - def start(self): - """Start cleaning.""" - return self.send("app_start") - - @command() - def stop(self): - """Stop cleaning. - - Note, prefer 'pause' instead of this for wider support. Some newer vacuum models - do not support this command. - """ - 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 resume_or_start(self): - """A shortcut for resuming or starting cleaning.""" - status = self.status() - if status.in_zone_cleaning and (status.is_paused or status.got_error): - return self.resume_zoned_clean() - if status.in_segment_cleaning and (status.is_paused or status.got_error): - return self.resume_segment_clean() - - return self.start() - - @command() - def home(self): - """Stop cleaning and return home.""" - if self.model is None: - self._autodetect_model() - - PAUSE_BEFORE_HOME = [ - ROCKROBO_V1, - ] - - if self.model in PAUSE_BEFORE_HOME: - self.send("app_pause") - - return self.send("app_charge") - - @command(click.argument("x_coord", type=int), click.argument("y_coord", type=int)) - def goto(self, x_coord: int, y_coord: int): - """Go to specific target. - - :param int x_coord: x coordinate - :param int y_coord: y coordinate - """ - return self.send("app_goto_target", [x_coord, y_coord]) - - @command(click.argument("zones", type=LiteralParamType(), required=True)) - def zoned_clean(self, zones: List): - """Clean zones. - - :param List zones: List of zones to clean: [[x1,y1,x2,y2, iterations],[x1,y1,x2,y2, iterations]] - """ - return self.send("app_zoned_clean", zones) - - @command() - def resume_zoned_clean(self): - """Resume zone cleaning after being paused.""" - return self.send("resume_zoned_clean") - - @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") - - MANUAL_ROTATION_MAX = 180 - MANUAL_ROTATION_MIN = -MANUAL_ROTATION_MAX - MANUAL_VELOCITY_MAX = 0.3 - MANUAL_VELOCITY_MIN = -MANUAL_VELOCITY_MAX - MANUAL_DURATION_DEFAULT = 1500 - - @command( - click.argument("rotation", type=int), - click.argument("velocity", type=float), - click.argument( - "duration", type=int, required=False, default=MANUAL_DURATION_DEFAULT - ), - ) - def manual_control_once( - self, rotation: int, velocity: float, duration: int = MANUAL_DURATION_DEFAULT - ): - """Starts the remote control mode and executes the action once before - deactivating the mode.""" - number_of_tries = 3 - self.manual_start() - while number_of_tries > 0: - if self.status().state_code == 7: - time.sleep(5) - self.manual_control(rotation, velocity, duration) - time.sleep(5) - return self.manual_stop() - - 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=MANUAL_DURATION_DEFAULT - ), - ) - def manual_control( - self, rotation: int, velocity: float, duration: int = MANUAL_DURATION_DEFAULT - ): - """Give a command over manual control interface.""" - if rotation < self.MANUAL_ROTATION_MIN or rotation > self.MANUAL_ROTATION_MAX: - raise DeviceException( - "Given rotation is invalid, should be ]%s, %s[, was %s" - % (self.MANUAL_ROTATION_MIN, self.MANUAL_ROTATION_MAX, rotation) - ) - if velocity < self.MANUAL_VELOCITY_MIN or velocity > self.MANUAL_VELOCITY_MAX: - raise DeviceException( - "Given velocity is invalid, should be ]%s, %s[, was: %s" - % (self.MANUAL_VELOCITY_MIN, self.MANUAL_VELOCITY_MAX, velocity) - ) - - self.manual_seqnum += 1 - params = { - "omega": round(math.radians(rotation), 1), - "velocity": velocity, - "duration": duration, - "seqnum": self.manual_seqnum, - } - - self.send("app_rc_move", [params]) - - @command() - def status(self) -> VacuumStatus: - """Return status of the vacuum.""" - return VacuumStatus(self.send("get_status")[0]) - - 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(click.argument("start", type=bool)) - def edit_map(self, start): - """Start map editing?""" - if start: - return self.send("start_edit_map")[0] == "ok" - else: - return self.send("end_edit_map")[0] == "ok" - - @command(click.option("--version", default=1)) - def fresh_map(self, version): - """Return fresh map?""" - if version not in [1, 2]: - raise VacuumException("Unknown map version: %s" % version) - - if version == 1: - return self.send("get_fresh_map") - elif version == 2: - return self.send("get_fresh_map_v2") - - @command(click.option("--version", default=1)) - def persist_map(self, version): - """Return fresh map?""" - if version not in [1, 2]: - raise VacuumException("Unknown map version: %s" % version) - - if version == 1: - return self.send("get_persist_map") - elif version == 2: - return self.send("get_persist_map_v2") - - @command( - click.argument("x1", type=int), - click.argument("y1", type=int), - click.argument("x2", type=int), - click.argument("y2", type=int), - ) - def create_software_barrier(self, x1, y1, x2, y2): - """Create software barrier (gen2 only?). - - NOTE: Multiple nogo zones and barriers could be added by passing - a list of them to save_map. - - Requires new fw version. - 3.3.9_001633+? - """ - # First parameter indicates the type, 1 = barrier - payload = [1, x1, y1, x2, y2] - return self.send("save_map", payload)[0] == "ok" - - @command( - click.argument("x1", type=int), - click.argument("y1", type=int), - click.argument("x2", type=int), - click.argument("y2", type=int), - click.argument("x3", type=int), - click.argument("y3", type=int), - click.argument("x4", type=int), - click.argument("y4", type=int), - ) - def create_nogo_zone(self, x1, y1, x2, y2, x3, y3, x4, y4): - """Create a rectangular no-go zone (gen2 only?). - - NOTE: Multiple nogo zones and barriers could be added by passing - a list of them to save_map. - - Requires new fw version. - 3.3.9_001633+? - """ - # First parameter indicates the type, 0 = zone - payload = [0, x1, y1, x2, y2, x3, y3, x4, y4] - return self.send("save_map", payload)[0] == "ok" - - @command(click.argument("enable", type=bool)) - def enable_lab_mode(self, enable): - """Enable persistent maps and software barriers. - - This is required to use create_nogo_zone and create_software_barrier commands. - """ - return self.send("set_lab_status", int(enable))["ok"] - - @command() - def clean_history(self) -> CleaningSummary: - """Return generic cleaning history.""" - return CleaningSummary(self.send("get_clean_summary")) - - @command() - def last_clean_details(self) -> Optional[CleaningDetails]: - """Return details from the last cleaning. - - Returns None if there has been no cleanups. - """ - history = self.clean_history() - if not history.ids: - return None - - last_clean_id = history.ids.pop(0) - return self.clean_details(last_clean_id) - - @command( - click.argument("id_", type=int, metavar="ID"), - ) - def clean_details( - self, id_: int - ) -> Union[List[CleaningDetails], Optional[CleaningDetails]]: - """Return details about specific cleaning.""" - details = self.send("get_clean_record", [id_]) - - if not details: - _LOGGER.warning("No cleaning record found for id %s", id_) - return None - - res = CleaningDetails(details.pop()) - 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[Timer] = list() - res = self.send("get_timer", [""]) - if not res: - return timers - - timezone = pytz.timezone(self.timezone()) - for rec in res: - try: - timers.append(Timer(rec, timezone=timezone)) - except Exception as ex: - _LOGGER.warning("Unable to add timer for %s: %s", rec, ex) - - 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. - - :param cron: schedule in cron format - :param command: ignored by the vacuum. - :param parameters: ignored by the vacuum. - """ - import time - - ts = int(round(time.time() * 1000)) - return self.send("set_timer", [[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. - - :param int timer_id: Timer ID - :param TimerStae mode: either On or Off - """ - if mode != TimerState.On and mode != TimerState.Off: - 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. - - :param int start_hr: Start hour - :param int start_min: Start minute - :param int end_hr: End hour - :param int end_min: End minute - """ - 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. - - :param int speed: Fan speed to set - """ - # 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] - - def _autodetect_model(self): - """Detect the model of the vacuum. - - For the moment this is used only for the fanspeeds, but that could be extended - to cover other supported features. - """ - try: - info = self.info() - self.model = info.model - except (TypeError, DeviceInfoUnavailableException): - # cloud-blocked vacuums will not return proper payloads - self._fanspeeds = FanspeedV1 - self.model = ROCKROBO_V1 - _LOGGER.warning("Unable to query model, falling back to %s", self.model) - return - finally: - _LOGGER.debug("Model: %s", self.model) - - if self.model == ROCKROBO_V1: - _LOGGER.debug("Got robov1, checking for firmware version") - fw_version = info.firmware_version - version, build = fw_version.split("_") - version = tuple(map(int, version.split("."))) - if version >= (3, 5, 8): - self._fanspeeds = FanspeedV3 - elif version == (3, 5, 7): - self._fanspeeds = FanspeedV2 - else: - self._fanspeeds = FanspeedV1 - elif self.model == "roborock.vacuum.e2": - self._fanspeeds = FanspeedE2 - elif self.model == ROCKROBO_S7: - self._fanspeeds = FanspeedS7 - else: - self._fanspeeds = FanspeedV2 - - _LOGGER.debug( - "Using new fanspeed mapping %s for %s", self._fanspeeds, info.model - ) - - @command() - def fan_speed_presets(self) -> Dict[str, int]: - """Return dictionary containing supported fan speeds.""" - if self.model is None: - self._autodetect_model() - - return {x.name: x.value for x in list(self._fanspeeds)} - - @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 = {"url": url, "md5": md5sum, "sid": int(sound_id)} - 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.""" - serial = self.send("get_serial_number") - if isinstance(serial, list): - return serial[0]["serial_number"] - return serial - - @command() - def locale(self): - """Return locale information.""" - return self.send("app_get_locale") - - @command() - def timezone(self): - """Get the timezone.""" - res = self.send("get_timezone") - - def _fallback_timezone(data): - fallback = "UTC" - _LOGGER.error( - "Unsupported timezone format (%s), falling back to %s", data, fallback - ) - return fallback - - if isinstance(res, int): - return _fallback_timezone(res) - - res = res[0] - if isinstance(res, dict): - # Xiaowa E25 example - # {'olson': 'Europe/Berlin', 'posix': 'CET-1CEST,M3.5.0,M10.5.0/3'} - if "olson" not in res: - return _fallback_timezone(res) - - return res["olson"] - - return res - - def set_timezone(self, new_zone): - """Set the timezone.""" - return self.send("set_timezone", [new_zone])[0] == "ok" - - def configure_wifi(self, ssid, password, uid=0, timezone=None): - """Configure the wifi settings.""" - extra_params = {} - if timezone is not None: - now = datetime.datetime.now(pytz.timezone(timezone)) - offset_as_float = now.utcoffset().total_seconds() / 60 / 60 - extra_params["tz"] = timezone - extra_params["gmt_offset"] = offset_as_float - - return super().configure_wifi(ssid, password, uid, extra_params) - - @command() - def carpet_mode(self): - """Get carpet mode settings.""" - return CarpetModeStatus(self.send("get_carpet_mode")[0]) - - @command( - click.argument("enabled", required=True, type=bool), - click.argument("stall_time", required=False, default=10, type=int), - click.argument("low", required=False, default=400, type=int), - click.argument("high", required=False, default=500, type=int), - click.argument("integral", required=False, default=450, type=int), - ) - def set_carpet_mode( - self, - enabled: bool, - stall_time: int = 10, - low: int = 400, - high: int = 500, - integral: int = 450, - ): - """Set the carpet mode.""" - click.echo("Setting carpet mode: %s" % enabled) - data = { - "enable": int(enabled), - "stall_time": stall_time, - "current_low": low, - "current_high": high, - "current_integral": integral, - } - return self.send("set_carpet_mode", [data])[0] == "ok" - - @command() - def carpet_cleaning_mode(self) -> Optional[CarpetCleaningMode]: - """Get carpet cleaning mode/avoidance setting.""" - try: - return CarpetCleaningMode( - self.send("get_carpet_clean_mode")[0]["carpet_clean_mode"] - ) - except Exception as err: - _LOGGER.warning("Error while requesting carpet clean mode: %s", err) - return None - - @command(click.argument("mode", type=EnumType(CarpetCleaningMode))) - def set_carpet_cleaning_mode(self, mode: CarpetCleaningMode): - """Set carpet cleaning mode/avoidance setting.""" - return ( - self.send("set_carpet_clean_mode", {"carpet_clean_mode": mode.value})[0] - == "ok" - ) - - @command() - def stop_zoned_clean(self): - """Stop cleaning a zone.""" - return self.send("stop_zoned_clean") - - @command() - def stop_segment_clean(self): - """Stop cleaning a segment.""" - return self.send("stop_segment_clean") - - @command() - def resume_segment_clean(self): - """Resuming cleaning a segment.""" - return self.send("resume_segment_clean") - - @command(click.argument("segments", type=LiteralParamType(), required=True)) - def segment_clean(self, segments: List): - """Clean segments. - - :param List segments: List of segments to clean: [16,17,18] - """ - return self.send("app_segment_clean", segments) - - @command() - def get_room_mapping(self): - """Retrieves a list of segments.""" - return self.send("get_room_mapping") - - @command() - def get_backup_maps(self): - """Get backup maps.""" - return self.send("get_recover_maps") - - @command(click.argument("id", type=int)) - def use_backup_map(self, id: int): - """Set backup map.""" - click.echo("Setting the map %s as active" % id) - return self.send("recover_map", [id]) - - @command() - def get_segment_status(self): - """Get the status of a segment.""" - return self.send("get_segment_status") - - def name_segment(self): - raise NotImplementedError("unknown parameters") - # return self.send("name_segment") - - def merge_segment(self): - raise NotImplementedError("unknown parameters") - # return self.send("merge_segment") - - def split_segment(self): - raise NotImplementedError("unknown parameters") - # return self.send("split_segment") - - @command() - def waterflow(self) -> WaterFlow: - """Get water flow setting.""" - return WaterFlow(self.send("get_water_box_custom_mode")[0]) - - @command(click.argument("waterflow", type=EnumType(WaterFlow))) - def set_waterflow(self, waterflow: WaterFlow): - """Set water flow setting.""" - return self.send("set_water_box_custom_mode", [waterflow.value]) - - @command() - def mop_mode(self) -> Optional[MopMode]: - """Get mop mode setting.""" - try: - return MopMode(self.send("get_mop_mode")[0]) - except ValueError as err: - _LOGGER.warning("Device returned unknown MopMode: %s", err) - return None - - @command(click.argument("mop_mode", type=EnumType(MopMode))) - def set_mop_mode(self, mop_mode: MopMode): - """Set mop mode setting.""" - return self.send("set_mop_mode", [mop_mode.value])[0] == "ok" - - @command() - def child_lock(self) -> bool: - """Get child lock setting.""" - return self.send("get_child_lock_status")["lock_status"] == 1 - - @command(click.argument("lock", type=bool)) - def set_child_lock(self, lock: bool) -> bool: - """Set child lock setting.""" - return self.send("set_child_lock_status", {"lock_status": int(lock)})[0] == "ok" - - @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 - with contextlib.suppress(FileNotFoundError, TypeError, ValueError), 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) - - 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, *args, **kwargs): - if vac.ip is None: # dummy Device for discovery, skip teardown - return - id_file = kwargs["id_file"] - seqs = {"seq": vac._protocol.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] - cache_dir.mkdir(parents=True, exist_ok=True) - with open(id_file, "w") as f: - json.dump(seqs, f) - - return dg diff --git a/miio/waterpurifier_yunmi.py b/miio/waterpurifier_yunmi.py index 193026560..c50b7ea8a 100644 --- a/miio/waterpurifier_yunmi.py +++ b/miio/waterpurifier_yunmi.py @@ -299,7 +299,7 @@ def status(self) -> WaterPurifierYunmiStatus: time. Key "mode" (always 'purifying') and key "tds_out_avg" (always 0) are not included in return values. - """ + """ # noqa: B018 values = self.send("get_prop", ["all"]) prop_count = len(properties) diff --git a/miio/wifispeaker.py b/miio/wifispeaker.py index b7e274063..0cc1af45c 100644 --- a/miio/wifispeaker.py +++ b/miio/wifispeaker.py @@ -1,6 +1,5 @@ import enum import logging -import warnings import click @@ -95,15 +94,6 @@ def transport_channel(self) -> TransportChannel: class WifiSpeaker(Device): """Device class for Xiaomi Smart Wifi Speaker.""" - def __init__(self, *args, **kwargs): - warnings.warn( - "Please help to complete this by providing more " - "information about possible values for `state`, " - "`play_mode` and `transport_channel`.", - stacklevel=2, - ) - super().__init__(*args, **kwargs) - @command( default_output=format_output( "", diff --git a/miio/yeelight_dual_switch.py b/miio/yeelight_dual_switch.py index 45be97d12..6bc611e43 100644 --- a/miio/yeelight_dual_switch.py +++ b/miio/yeelight_dual_switch.py @@ -136,7 +136,7 @@ def status(self) -> DualControlModuleStatus: "flex_mode", "rc_list", ] - """Filter only readable properties for status""" + # Filter only readable properties for status properties = [ {"did": k, **v} for k, v in filter(lambda item: item[0] in p, _MAPPING.items()) diff --git a/poetry.lock b/poetry.lock index 70f6bc86e..c7f724952 100644 --- a/poetry.lock +++ b/poetry.lock @@ -55,9 +55,24 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.dependencies] pytz = ">=2015.7" +[[package]] +name = "backports.entry-points-selectable" +version = "1.1.1" +description = "Compatibility shim providing selectable entry points for older implementations" +category = "dev" +optional = false +python-versions = ">=2.7" + +[package.dependencies] +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] + [[package]] name = "certifi" -version = "2021.5.30" +version = "2021.10.8" description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = true @@ -65,7 +80,7 @@ python-versions = "*" [[package]] name = "cffi" -version = "1.14.5" +version = "1.15.0" description = "Foreign Function Interface for Python calling C code." category = "main" optional = false @@ -76,19 +91,22 @@ pycparser = "*" [[package]] name = "cfgv" -version = "3.3.0" +version = "3.3.1" description = "Validate configuration and produce human readable error messages." category = "dev" optional = false python-versions = ">=3.6.1" [[package]] -name = "chardet" -version = "4.0.0" -description = "Universal encoding detector for Python 2 and 3" +name = "charset-normalizer" +version = "2.0.8" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +optional = true +python-versions = ">=3.5.0" + +[package.extras] +unicode_backport = ["unicodedata2"] [[package]] name = "click" @@ -119,30 +137,32 @@ extras = ["arrow", "cloudpickle", "enum34", "lz4", "numpy", "ruamel.yaml"] [[package]] name = "coverage" -version = "5.5" +version = "6.2" description = "Code coverage measurement for Python" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +python-versions = ">=3.6" + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "extra == \"toml\""} [package.extras] -toml = ["toml"] +toml = ["tomli"] [[package]] name = "croniter" -version = "0.3.37" +version = "1.0.15" description = "croniter provides iteration for datetime object with cron like format" category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.dependencies] -natsort = "*" python-dateutil = "*" [[package]] name = "cryptography" -version = "3.4.7" +version = "36.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." category = "main" optional = false @@ -153,11 +173,11 @@ cffi = ">=1.12" [package.extras] docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] -docstest = ["doc8", "pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] +docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] -sdist = ["setuptools-rust (>=0.11.4)"] +sdist = ["setuptools_rust (>=0.11.4)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["pytest (>=6.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] +test = ["pytest (>=6.2.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] [[package]] name = "defusedxml" @@ -169,7 +189,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "distlib" -version = "0.3.2" +version = "0.3.3" description = "Distribution utilities" category = "dev" optional = false @@ -177,18 +197,16 @@ python-versions = "*" [[package]] name = "doc8" -version = "0.8.1" +version = "0.10.1" description = "Style checker for Sphinx (or other) RST documentation" category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.6" [package.dependencies] -chardet = "*" docutils = "*" Pygments = "*" restructuredtext-lint = ">=0.7" -six = "*" stevedore = "*" [[package]] @@ -212,30 +230,34 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "filelock" -version = "3.0.12" +version = "3.4.0" description = "A platform independent file lock." category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.6" + +[package.extras] +docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] +testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] [[package]] name = "identify" -version = "2.2.10" +version = "2.4.0" description = "File identification library for Python" category = "dev" optional = false python-versions = ">=3.6.1" [package.extras] -license = ["editdistance-s"] +license = ["ukkonen"] [[package]] name = "idna" -version = "2.10" +version = "3.3" description = "Internationalized Domain Names in Applications (IDNA)" category = "main" optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.5" [[package]] name = "ifaddr" @@ -247,7 +269,7 @@ python-versions = "*" [[package]] name = "imagesize" -version = "1.2.0" +version = "1.3.0" description = "Getting image size from png/jpeg/jpeg2000/gif file" category = "main" optional = true @@ -270,7 +292,7 @@ testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] [[package]] name = "importlib-resources" -version = "5.1.4" +version = "5.4.0" description = "Read resources from Python packages" category = "dev" optional = false @@ -281,7 +303,7 @@ zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-black (>=0.3.7)", "pytest-mypy"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-black (>=0.3.7)", "pytest-mypy"] [[package]] name = "isort" @@ -299,7 +321,7 @@ xdg_home = ["appdirs (>=1.4.0)"] [[package]] name = "jinja2" -version = "3.0.1" +version = "3.0.3" description = "A very fast and expressive template engine." category = "main" optional = true @@ -321,7 +343,7 @@ python-versions = ">=3.6" [[package]] name = "more-itertools" -version = "8.8.0" +version = "8.12.0" description = "More routines for operating on iterables, beyond itertools" category = "dev" optional = false @@ -329,7 +351,7 @@ python-versions = ">=3.5" [[package]] name = "mypy" -version = "0.902" +version = "0.910" description = "Optional static typing for Python" category = "dev" optional = false @@ -353,18 +375,6 @@ category = "dev" optional = false python-versions = "*" -[[package]] -name = "natsort" -version = "7.1.1" -description = "Simple yet flexible natural sorting in Python." -category = "main" -optional = false -python-versions = ">=3.4" - -[package.extras] -fast = ["fastnumbers (>=2.0.0)"] -icu = ["PyICU (>=1.0.0)"] - [[package]] name = "netifaces" version = "0.11.0" @@ -383,23 +393,35 @@ python-versions = "*" [[package]] name = "packaging" -version = "20.9" +version = "21.3" description = "Core utilities for Python packages" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" [package.dependencies] -pyparsing = ">=2.0.2" +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" [[package]] name = "pbr" -version = "5.6.0" +version = "5.8.0" description = "Python Build Reasonableness" category = "main" optional = false python-versions = ">=2.6" +[[package]] +name = "platformdirs" +version = "2.4.0" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] + [[package]] name = "pluggy" version = "0.13.1" @@ -416,7 +438,7 @@ dev = ["pre-commit", "tox"] [[package]] name = "pre-commit" -version = "2.13.0" +version = "2.15.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -434,15 +456,15 @@ virtualenv = ">=20.0.8" [[package]] name = "py" -version = "1.10.0" +version = "1.11.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "pycparser" -version = "2.20" +version = "2.21" description = "C parser in Python" category = "main" optional = false @@ -450,7 +472,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pygments" -version = "2.9.0" +version = "2.10.0" description = "Pygments is a syntax highlighting package written in Python." category = "main" optional = false @@ -458,11 +480,14 @@ python-versions = ">=3.5" [[package]] name = "pyparsing" -version = "2.4.7" +version = "3.0.6" description = "Python parsing module" category = "main" optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = ">=3.6" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" @@ -519,7 +544,7 @@ dev = ["pre-commit", "tox", "pytest-asyncio"] [[package]] name = "python-dateutil" -version = "2.8.1" +version = "2.8.2" description = "Extensions to the standard Python datetime module" category = "main" optional = false @@ -530,7 +555,7 @@ six = ">=1.5" [[package]] name = "pytz" -version = "2021.1" +version = "2021.3" description = "World timezone definitions, modern and historical" category = "main" optional = false @@ -538,29 +563,29 @@ python-versions = "*" [[package]] name = "pyyaml" -version = "5.4.1" +version = "6.0" description = "YAML parser and emitter for Python" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = ">=3.6" [[package]] name = "requests" -version = "2.25.1" +version = "2.26.0" description = "Python HTTP for Humans." category = "main" optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [package.dependencies] certifi = ">=2017.4.17" -chardet = ">=3.0.2,<5" -idna = ">=2.5,<3" +charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} +idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} urllib3 = ">=1.21.1,<1.27" [package.extras] -security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] [[package]] name = "restructuredtext-lint" @@ -583,7 +608,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "snowballstemmer" -version = "2.1.0" +version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." category = "main" optional = true @@ -733,7 +758,7 @@ test = ["pytest"] [[package]] name = "stevedore" -version = "3.3.0" +version = "3.5.0" description = "Manage dynamic plugins for Python applications" category = "dev" optional = false @@ -751,9 +776,17 @@ category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "tomli" +version = "1.2.2" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.6" + [[package]] name = "tox" -version = "3.23.1" +version = "3.24.4" description = "tox is a generic virtualenv management and test command line tool" category = "dev" optional = false @@ -776,12 +809,15 @@ testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "psutil (>=5.6.1)", "pytes [[package]] name = "tqdm" -version = "4.61.1" +version = "4.62.3" description = "Fast, Extensible Progress Meter" category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + [package.extras] dev = ["py-make (>=0.1.0)", "twine", "wheel"] notebook = ["ipywidgets (>=6)"] @@ -797,11 +833,11 @@ python-versions = "*" [[package]] name = "typing-extensions" -version = "3.10.0.0" -description = "Backported and Experimental Type Hints for Python 3.5+" +version = "4.0.0" +description = "Backported and Experimental Type Hints for Python 3.6+" category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.6" [[package]] name = "untokenize" @@ -813,7 +849,7 @@ python-versions = "*" [[package]] name = "urllib3" -version = "1.26.5" +version = "1.26.7" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = true @@ -826,27 +862,28 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.4.7" +version = "20.10.0" description = "Virtual Python Environment builder" category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] -appdirs = ">=1.4.3,<2" +"backports.entry-points-selectable" = ">=1.0.4" distlib = ">=0.3.1,<1" -filelock = ">=3.0.0,<4" +filelock = ">=3.2,<4" importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} importlib-resources = {version = ">=1.0", markers = "python_version < \"3.7\""} +platformdirs = ">=2,<3" six = ">=1.9.0,<2" [package.extras] -docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] -testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] +docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] [[package]] name = "voluptuous" -version = "0.12.1" +version = "0.12.2" description = "" category = "dev" optional = false @@ -862,7 +899,7 @@ python-versions = "*" [[package]] name = "zeroconf" -version = "0.31.0" +version = "0.37.0" description = "Pure Python Multicast DNS Service Discovery Library (Bonjour/Avahi compatible)" category = "main" optional = false @@ -873,7 +910,7 @@ ifaddr = ">=0.1.7" [[package]] name = "zipp" -version = "3.4.1" +version = "3.6.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "main" optional = false @@ -881,7 +918,7 @@ python-versions = ">=3.6" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] [extras] docs = ["sphinx", "sphinx_click", "sphinxcontrib-apidoc", "sphinx_rtd_theme"] @@ -889,7 +926,7 @@ docs = ["sphinx", "sphinx_click", "sphinxcontrib-apidoc", "sphinx_rtd_theme"] [metadata] lock-version = "1.1" python-versions = "^3.6.5" -content-hash = "aca59527967e96a2d85a96feaa1ba70fb1c2eb7a1dcd8e367c090e227a9a575b" +content-hash = "d5c3591867e42ee952a34ff4f2350d7c8efdcc11ce41cdada9abad2ff3c79cce" [metadata.files] alabaster = [ @@ -915,56 +952,73 @@ babel = [ {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, ] +"backports.entry-points-selectable" = [ + {file = "backports.entry_points_selectable-1.1.1-py2.py3-none-any.whl", hash = "sha256:7fceed9532a7aa2bd888654a7314f864a3c16a4e710b34a58cfc0f08114c663b"}, + {file = "backports.entry_points_selectable-1.1.1.tar.gz", hash = "sha256:914b21a479fde881635f7af5adc7f6e38d6b274be32269070c53b698c60d5386"}, +] certifi = [ - {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, - {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, + {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, + {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, ] cffi = [ - {file = "cffi-1.14.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991"}, - {file = "cffi-1.14.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1"}, - {file = "cffi-1.14.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa"}, - {file = "cffi-1.14.5-cp27-cp27m-win32.whl", hash = "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3"}, - {file = "cffi-1.14.5-cp27-cp27m-win_amd64.whl", hash = "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5"}, - {file = "cffi-1.14.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482"}, - {file = "cffi-1.14.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6"}, - {file = "cffi-1.14.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045"}, - {file = "cffi-1.14.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa"}, - {file = "cffi-1.14.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406"}, - {file = "cffi-1.14.5-cp35-cp35m-win32.whl", hash = "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369"}, - {file = "cffi-1.14.5-cp35-cp35m-win_amd64.whl", hash = "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315"}, - {file = "cffi-1.14.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892"}, - {file = "cffi-1.14.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058"}, - {file = "cffi-1.14.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5"}, - {file = "cffi-1.14.5-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132"}, - {file = "cffi-1.14.5-cp36-cp36m-win32.whl", hash = "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53"}, - {file = "cffi-1.14.5-cp36-cp36m-win_amd64.whl", hash = "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813"}, - {file = "cffi-1.14.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73"}, - {file = "cffi-1.14.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06"}, - {file = "cffi-1.14.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1"}, - {file = "cffi-1.14.5-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49"}, - {file = "cffi-1.14.5-cp37-cp37m-win32.whl", hash = "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62"}, - {file = "cffi-1.14.5-cp37-cp37m-win_amd64.whl", hash = "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4"}, - {file = "cffi-1.14.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053"}, - {file = "cffi-1.14.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0"}, - {file = "cffi-1.14.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e"}, - {file = "cffi-1.14.5-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827"}, - {file = "cffi-1.14.5-cp38-cp38-win32.whl", hash = "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e"}, - {file = "cffi-1.14.5-cp38-cp38-win_amd64.whl", hash = "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396"}, - {file = "cffi-1.14.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea"}, - {file = "cffi-1.14.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322"}, - {file = "cffi-1.14.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c"}, - {file = "cffi-1.14.5-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee"}, - {file = "cffi-1.14.5-cp39-cp39-win32.whl", hash = "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396"}, - {file = "cffi-1.14.5-cp39-cp39-win_amd64.whl", hash = "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d"}, - {file = "cffi-1.14.5.tar.gz", hash = "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c"}, + {file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"}, + {file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"}, + {file = "cffi-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14"}, + {file = "cffi-1.15.0-cp27-cp27m-win32.whl", hash = "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474"}, + {file = "cffi-1.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6"}, + {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27"}, + {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023"}, + {file = "cffi-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2"}, + {file = "cffi-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382"}, + {file = "cffi-1.15.0-cp310-cp310-win32.whl", hash = "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55"}, + {file = "cffi-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0"}, + {file = "cffi-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605"}, + {file = "cffi-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e"}, + {file = "cffi-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc"}, + {file = "cffi-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7"}, + {file = "cffi-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66"}, + {file = "cffi-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029"}, + {file = "cffi-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6"}, + {file = "cffi-1.15.0-cp38-cp38-win32.whl", hash = "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c"}, + {file = "cffi-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443"}, + {file = "cffi-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a"}, + {file = "cffi-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8"}, + {file = "cffi-1.15.0-cp39-cp39-win32.whl", hash = "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a"}, + {file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"}, + {file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"}, ] cfgv = [ - {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"}, - {file = "cfgv-3.3.0.tar.gz", hash = "sha256:9e600479b3b99e8af981ecdfc80a0296104ee610cab48a5ae4ffd0b668650eb1"}, + {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, + {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] -chardet = [ - {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, - {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, +charset-normalizer = [ + {file = "charset-normalizer-2.0.8.tar.gz", hash = "sha256:735e240d9a8506778cd7a453d97e817e536bb1fc29f4f6961ce297b9c7a917b0"}, + {file = "charset_normalizer-2.0.8-py3-none-any.whl", hash = "sha256:83fcdeb225499d6344c8f7f34684c2981270beacc32ede2e669e94f7fa544405"}, ] click = [ {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, @@ -978,88 +1032,92 @@ construct = [ {file = "construct-2.10.67.tar.gz", hash = "sha256:730235fedf4f2fee5cfadda1d14b83ef1bf23790fb1cc579073e10f70a050883"}, ] coverage = [ - {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, - {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"}, - {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"}, - {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"}, - {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"}, - {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"}, - {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"}, - {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"}, - {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"}, - {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"}, - {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"}, - {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"}, - {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"}, - {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"}, - {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"}, - {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"}, - {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"}, - {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"}, - {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"}, - {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"}, - {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"}, - {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"}, - {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"}, - {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"}, - {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"}, - {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"}, - {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"}, - {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"}, - {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"}, - {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"}, - {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"}, - {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"}, - {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"}, - {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"}, - {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"}, - {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"}, - {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"}, - {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"}, - {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"}, - {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"}, - {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"}, - {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"}, - {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"}, - {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"}, - {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"}, - {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"}, - {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, - {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, + {file = "coverage-6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6dbc1536e105adda7a6312c778f15aaabe583b0e9a0b0a324990334fd458c94b"}, + {file = "coverage-6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:174cf9b4bef0db2e8244f82059a5a72bd47e1d40e71c68ab055425172b16b7d0"}, + {file = "coverage-6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:92b8c845527eae547a2a6617d336adc56394050c3ed8a6918683646328fbb6da"}, + {file = "coverage-6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c7912d1526299cb04c88288e148c6c87c0df600eca76efd99d84396cfe00ef1d"}, + {file = "coverage-6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5d2033d5db1d58ae2d62f095e1aefb6988af65b4b12cb8987af409587cc0739"}, + {file = "coverage-6.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3feac4084291642165c3a0d9eaebedf19ffa505016c4d3db15bfe235718d4971"}, + {file = "coverage-6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:276651978c94a8c5672ea60a2656e95a3cce2a3f31e9fb2d5ebd4c215d095840"}, + {file = "coverage-6.2-cp310-cp310-win32.whl", hash = "sha256:f506af4f27def639ba45789fa6fde45f9a217da0be05f8910458e4557eed020c"}, + {file = "coverage-6.2-cp310-cp310-win_amd64.whl", hash = "sha256:3f7c17209eef285c86f819ff04a6d4cbee9b33ef05cbcaae4c0b4e8e06b3ec8f"}, + {file = "coverage-6.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:13362889b2d46e8d9f97c421539c97c963e34031ab0cb89e8ca83a10cc71ac76"}, + {file = "coverage-6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22e60a3ca5acba37d1d4a2ee66e051f5b0e1b9ac950b5b0cf4aa5366eda41d47"}, + {file = "coverage-6.2-cp311-cp311-win_amd64.whl", hash = "sha256:b637c57fdb8be84e91fac60d9325a66a5981f8086c954ea2772efe28425eaf64"}, + {file = "coverage-6.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f467bbb837691ab5a8ca359199d3429a11a01e6dfb3d9dcc676dc035ca93c0a9"}, + {file = "coverage-6.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2641f803ee9f95b1f387f3e8f3bf28d83d9b69a39e9911e5bfee832bea75240d"}, + {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1219d760ccfafc03c0822ae2e06e3b1248a8e6d1a70928966bafc6838d3c9e48"}, + {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9a2b5b52be0a8626fcbffd7e689781bf8c2ac01613e77feda93d96184949a98e"}, + {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8e2c35a4c1f269704e90888e56f794e2d9c0262fb0c1b1c8c4ee44d9b9e77b5d"}, + {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5d6b09c972ce9200264c35a1d53d43ca55ef61836d9ec60f0d44273a31aa9f17"}, + {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e3db840a4dee542e37e09f30859f1612da90e1c5239a6a2498c473183a50e781"}, + {file = "coverage-6.2-cp36-cp36m-win32.whl", hash = "sha256:4e547122ca2d244f7c090fe3f4b5a5861255ff66b7ab6d98f44a0222aaf8671a"}, + {file = "coverage-6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:01774a2c2c729619760320270e42cd9e797427ecfddd32c2a7b639cdc481f3c0"}, + {file = "coverage-6.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb8b8ee99b3fffe4fd86f4c81b35a6bf7e4462cba019997af2fe679365db0c49"}, + {file = "coverage-6.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:619346d57c7126ae49ac95b11b0dc8e36c1dd49d148477461bb66c8cf13bb521"}, + {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0a7726f74ff63f41e95ed3a89fef002916c828bb5fcae83b505b49d81a066884"}, + {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cfd9386c1d6f13b37e05a91a8583e802f8059bebfccde61a418c5808dea6bbfa"}, + {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:17e6c11038d4ed6e8af1407d9e89a2904d573be29d51515f14262d7f10ef0a64"}, + {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c254b03032d5a06de049ce8bca8338a5185f07fb76600afff3c161e053d88617"}, + {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dca38a21e4423f3edb821292e97cec7ad38086f84313462098568baedf4331f8"}, + {file = "coverage-6.2-cp37-cp37m-win32.whl", hash = "sha256:600617008aa82032ddeace2535626d1bc212dfff32b43989539deda63b3f36e4"}, + {file = "coverage-6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:bf154ba7ee2fd613eb541c2bc03d3d9ac667080a737449d1a3fb342740eb1a74"}, + {file = "coverage-6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f9afb5b746781fc2abce26193d1c817b7eb0e11459510fba65d2bd77fe161d9e"}, + {file = "coverage-6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edcada2e24ed68f019175c2b2af2a8b481d3d084798b8c20d15d34f5c733fa58"}, + {file = "coverage-6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a9c8c4283e17690ff1a7427123ffb428ad6a52ed720d550e299e8291e33184dc"}, + {file = "coverage-6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f614fc9956d76d8a88a88bb41ddc12709caa755666f580af3a688899721efecd"}, + {file = "coverage-6.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9365ed5cce5d0cf2c10afc6add145c5037d3148585b8ae0e77cc1efdd6aa2953"}, + {file = "coverage-6.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8bdfe9ff3a4ea37d17f172ac0dff1e1c383aec17a636b9b35906babc9f0f5475"}, + {file = "coverage-6.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:63c424e6f5b4ab1cf1e23a43b12f542b0ec2e54f99ec9f11b75382152981df57"}, + {file = "coverage-6.2-cp38-cp38-win32.whl", hash = "sha256:49dbff64961bc9bdd2289a2bda6a3a5a331964ba5497f694e2cbd540d656dc1c"}, + {file = "coverage-6.2-cp38-cp38-win_amd64.whl", hash = "sha256:9a29311bd6429be317c1f3fe4bc06c4c5ee45e2fa61b2a19d4d1d6111cb94af2"}, + {file = "coverage-6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03b20e52b7d31be571c9c06b74746746d4eb82fc260e594dc662ed48145e9efd"}, + {file = "coverage-6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:215f8afcc02a24c2d9a10d3790b21054b58d71f4b3c6f055d4bb1b15cecce685"}, + {file = "coverage-6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a4bdeb0a52d1d04123b41d90a4390b096f3ef38eee35e11f0b22c2d031222c6c"}, + {file = "coverage-6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c332d8f8d448ded473b97fefe4a0983265af21917d8b0cdcb8bb06b2afe632c3"}, + {file = "coverage-6.2-cp39-cp39-win32.whl", hash = "sha256:6e1394d24d5938e561fbeaa0cd3d356207579c28bd1792f25a068743f2d5b282"}, + {file = "coverage-6.2-cp39-cp39-win_amd64.whl", hash = "sha256:86f2e78b1eff847609b1ca8050c9e1fa3bd44ce755b2ec30e70f2d3ba3844644"}, + {file = "coverage-6.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:5829192582c0ec8ca4a2532407bc14c2f338d9878a10442f5d03804a95fac9de"}, + {file = "coverage-6.2.tar.gz", hash = "sha256:e2cad8093172b7d1595b4ad66f24270808658e11acf43a8f95b41276162eb5b8"}, ] croniter = [ - {file = "croniter-0.3.37-py2.py3-none-any.whl", hash = "sha256:8f573a889ca9379e08c336193435c57c02698c2dd22659cdbe04fee57426d79b"}, - {file = "croniter-0.3.37.tar.gz", hash = "sha256:12ced475dfc107bf7c6c1440af031f34be14cd97bbbfaf0f62221a9c11e86404"}, + {file = "croniter-1.0.15-py2.py3-none-any.whl", hash = "sha256:0f97b361fe343301a8f66f852e7d84e4fb7f21379948f71e1bbfe10f5d015fbd"}, + {file = "croniter-1.0.15.tar.gz", hash = "sha256:a70dfc9d52de9fc1a886128b9148c89dd9e76b67d55f46516ca94d2d73d58219"}, ] cryptography = [ - {file = "cryptography-3.4.7-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:3d8427734c781ea5f1b41d6589c293089704d4759e34597dce91014ac125aad1"}, - {file = "cryptography-3.4.7-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e56e16617872b0957d1c9742a3f94b43533447fd78321514abbe7db216aa250"}, - {file = "cryptography-3.4.7-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:37340614f8a5d2fb9aeea67fd159bfe4f5f4ed535b1090ce8ec428b2f15a11f2"}, - {file = "cryptography-3.4.7-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:240f5c21aef0b73f40bb9f78d2caff73186700bf1bc6b94285699aff98cc16c6"}, - {file = "cryptography-3.4.7-cp36-abi3-manylinux2014_x86_64.whl", hash = "sha256:1e056c28420c072c5e3cb36e2b23ee55e260cb04eee08f702e0edfec3fb51959"}, - {file = "cryptography-3.4.7-cp36-abi3-win32.whl", hash = "sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d"}, - {file = "cryptography-3.4.7-cp36-abi3-win_amd64.whl", hash = "sha256:de4e5f7f68220d92b7637fc99847475b59154b7a1b3868fb7385337af54ac9ca"}, - {file = "cryptography-3.4.7-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:26965837447f9c82f1855e0bc8bc4fb910240b6e0d16a664bb722df3b5b06873"}, - {file = "cryptography-3.4.7-pp36-pypy36_pp73-manylinux2014_x86_64.whl", hash = "sha256:eb8cc2afe8b05acbd84a43905832ec78e7b3873fb124ca190f574dca7389a87d"}, - {file = "cryptography-3.4.7-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:7ec5d3b029f5fa2b179325908b9cd93db28ab7b85bb6c1db56b10e0b54235177"}, - {file = "cryptography-3.4.7-pp37-pypy37_pp73-manylinux2014_x86_64.whl", hash = "sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9"}, - {file = "cryptography-3.4.7.tar.gz", hash = "sha256:3d10de8116d25649631977cb37da6cbdd2d6fa0e0281d014a5b7d337255ca713"}, + {file = "cryptography-36.0.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:9511416e85e449fe1de73f7f99b21b3aa04fba4c4d335d30c486ba3756e3a2a6"}, + {file = "cryptography-36.0.0-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:97199a13b772e74cdcdb03760c32109c808aff7cd49c29e9cf4b7754bb725d1d"}, + {file = "cryptography-36.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:494106e9cd945c2cadfce5374fa44c94cfadf01d4566a3b13bb487d2e6c7959e"}, + {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6fbbbb8aab4053fa018984bb0e95a16faeb051dd8cca15add2a27e267ba02b58"}, + {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:684993ff6f67000a56454b41bdc7e015429732d65a52d06385b6e9de6181c71e"}, + {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c702855cd3174666ef0d2d13dcc879090aa9c6c38f5578896407a7028f75b9f"}, + {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d91bc9f535599bed58f6d2e21a2724cb0c3895bf41c6403fe881391d29096f1d"}, + {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:b17d83b3d1610e571fedac21b2eb36b816654d6f7496004d6a0d32f99d1d8120"}, + {file = "cryptography-36.0.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:8982c19bb90a4fa2aad3d635c6d71814e38b643649b4000a8419f8691f20ac44"}, + {file = "cryptography-36.0.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:24469d9d33217ffd0ce4582dfcf2a76671af115663a95328f63c99ec7ece61a4"}, + {file = "cryptography-36.0.0-cp36-abi3-win32.whl", hash = "sha256:f6a5a85beb33e57998dc605b9dbe7deaa806385fdf5c4810fb849fcd04640c81"}, + {file = "cryptography-36.0.0-cp36-abi3-win_amd64.whl", hash = "sha256:2deab5ec05d83ddcf9b0916319674d3dae88b0e7ee18f8962642d3cde0496568"}, + {file = "cryptography-36.0.0-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2049f8b87f449fc6190350de443ee0c1dd631f2ce4fa99efad2984de81031681"}, + {file = "cryptography-36.0.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a776bae1629c8d7198396fd93ec0265f8dd2341c553dc32b976168aaf0e6a636"}, + {file = "cryptography-36.0.0-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:aa94d617a4cd4cdf4af9b5af65100c036bce22280ebb15d8b5262e8273ebc6ba"}, + {file = "cryptography-36.0.0-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:5c49c9e8fb26a567a2b3fa0343c89f5d325447956cc2fc7231c943b29a973712"}, + {file = "cryptography-36.0.0-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ef216d13ac8d24d9cd851776662f75f8d29c9f2d05cdcc2d34a18d32463a9b0b"}, + {file = "cryptography-36.0.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:231c4a69b11f6af79c1495a0e5a85909686ea8db946935224b7825cfb53827ed"}, + {file = "cryptography-36.0.0-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:f92556f94e476c1b616e6daec5f7ddded2c082efa7cee7f31c7aeda615906ed8"}, + {file = "cryptography-36.0.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d73e3a96c38173e0aa5646c31bf8473bc3564837977dd480f5cbeacf1d7ef3a3"}, + {file = "cryptography-36.0.0.tar.gz", hash = "sha256:52f769ecb4ef39865719aedc67b4b7eae167bafa48dbc2a26dd36fa56460507f"}, ] defusedxml = [ {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, ] distlib = [ - {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, - {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, + {file = "distlib-0.3.3-py2.py3-none-any.whl", hash = "sha256:c8b54e8454e5bf6237cc84c20e8264c3e991e824ef27e8f1e81049867d861e31"}, + {file = "distlib-0.3.3.zip", hash = "sha256:d982d0751ff6eaaab5e2ec8e691d949ee80eddf01a62eaa96ddb11531fe16b05"}, ] doc8 = [ - {file = "doc8-0.8.1-py2.py3-none-any.whl", hash = "sha256:4d58a5c8c56cedd2b2c9d6e3153be5d956cf72f6051128f0f2255c66227df721"}, - {file = "doc8-0.8.1.tar.gz", hash = "sha256:4d1df12598807cf08ffa9a1d5ef42d229ee0de42519da01b768ff27211082c12"}, + {file = "doc8-0.10.1-py3-none-any.whl", hash = "sha256:551a61df5915f0107e518d582fead47a0a56df7d4a9374feab955ea14dedea84"}, + {file = "doc8-0.10.1.tar.gz", hash = "sha256:376e50f4e70a1ae935416ddfcf93db35dd5d4cc0e557f2ec72f0667d0ace4548"}, ] docformatter = [ {file = "docformatter-1.4.tar.gz", hash = "sha256:064e6d81f04ac96bc0d176cbaae953a0332482b22d3ad70d47c8a7f2732eef6f"}, @@ -1069,40 +1127,40 @@ docutils = [ {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, ] filelock = [ - {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, - {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, + {file = "filelock-3.4.0-py3-none-any.whl", hash = "sha256:2e139a228bcf56dd8b2274a65174d005c4a6b68540ee0bdbb92c76f43f29f7e8"}, + {file = "filelock-3.4.0.tar.gz", hash = "sha256:93d512b32a23baf4cac44ffd72ccf70732aeff7b8050fcaf6d3ec406d954baf4"}, ] identify = [ - {file = "identify-2.2.10-py2.py3-none-any.whl", hash = "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421"}, - {file = "identify-2.2.10.tar.gz", hash = "sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306"}, + {file = "identify-2.4.0-py2.py3-none-any.whl", hash = "sha256:eba31ca80258de6bb51453084bff4a923187cd2193b9c13710f2516ab30732cc"}, + {file = "identify-2.4.0.tar.gz", hash = "sha256:a33ae873287e81651c7800ca309dc1f84679b763c9c8b30680e16fbfa82f0107"}, ] idna = [ - {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, - {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, ] ifaddr = [ {file = "ifaddr-0.1.7-py2.py3-none-any.whl", hash = "sha256:d1f603952f0a71c9ab4e705754511e4e03b02565bc4cec7188ad6415ff534cd3"}, {file = "ifaddr-0.1.7.tar.gz", hash = "sha256:1f9e8a6ca6f16db5a37d3356f07b6e52344f6f9f7e806d618537731669eb1a94"}, ] imagesize = [ - {file = "imagesize-1.2.0-py2.py3-none-any.whl", hash = "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1"}, - {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, + {file = "imagesize-1.3.0-py2.py3-none-any.whl", hash = "sha256:1db2f82529e53c3e929e8926a1fa9235aa82d0bd0c580359c67ec31b2fddaa8c"}, + {file = "imagesize-1.3.0.tar.gz", hash = "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d"}, ] importlib-metadata = [ {file = "importlib_metadata-1.7.0-py2.py3-none-any.whl", hash = "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"}, {file = "importlib_metadata-1.7.0.tar.gz", hash = "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83"}, ] importlib-resources = [ - {file = "importlib_resources-5.1.4-py3-none-any.whl", hash = "sha256:e962bff7440364183203d179d7ae9ad90cb1f2b74dcb84300e88ecc42dca3351"}, - {file = "importlib_resources-5.1.4.tar.gz", hash = "sha256:54161657e8ffc76596c4ede7080ca68cb02962a2e074a2586b695a93a925d36e"}, + {file = "importlib_resources-5.4.0-py3-none-any.whl", hash = "sha256:33a95faed5fc19b4bc16b29a6eeae248a3fe69dd55d4d229d2b480e23eeaad45"}, + {file = "importlib_resources-5.4.0.tar.gz", hash = "sha256:d756e2f85dd4de2ba89be0b21dba2a3bbec2e871a42a3a16719258a11f87506b"}, ] isort = [ {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"}, {file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"}, ] jinja2 = [ - {file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"}, - {file = "Jinja2-3.0.1.tar.gz", hash = "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"}, + {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, + {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"}, ] markupsafe = [ {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, @@ -1141,42 +1199,38 @@ markupsafe = [ {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, ] more-itertools = [ - {file = "more-itertools-8.8.0.tar.gz", hash = "sha256:83f0308e05477c68f56ea3a888172c78ed5d5b3c282addb67508e7ba6c8f813a"}, - {file = "more_itertools-8.8.0-py3-none-any.whl", hash = "sha256:2cf89ec599962f2ddc4d568a05defc40e0a587fbc10d5989713638864c36be4d"}, + {file = "more-itertools-8.12.0.tar.gz", hash = "sha256:7dc6ad46f05f545f900dd59e8dfb4e84a4827b97b3cfecb175ea0c7d247f6064"}, + {file = "more_itertools-8.12.0-py3-none-any.whl", hash = "sha256:43e6dd9942dffd72661a2c4ef383ad7da1e6a3e968a927ad7a6083ab410a688b"}, ] mypy = [ - {file = "mypy-0.902-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3f12705eabdd274b98f676e3e5a89f247ea86dc1af48a2d5a2b080abac4e1243"}, - {file = "mypy-0.902-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:2f9fedc1f186697fda191e634ac1d02f03d4c260212ccb018fabbb6d4b03eee8"}, - {file = "mypy-0.902-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:0756529da2dd4d53d26096b7969ce0a47997123261a5432b48cc6848a2cb0bd4"}, - {file = "mypy-0.902-cp35-cp35m-win_amd64.whl", hash = "sha256:68a098c104ae2b75e946b107ef69dd8398d54cb52ad57580dfb9fc78f7f997f0"}, - {file = "mypy-0.902-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cd01c599cf9f897b6b6c6b5d8b182557fb7d99326bcdf5d449a0fbbb4ccee4b9"}, - {file = "mypy-0.902-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e89880168c67cf4fde4506b80ee42f1537ad66ad366c101d388b3fd7d7ce2afd"}, - {file = "mypy-0.902-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:ebe2bc9cb638475f5d39068d2dbe8ae1d605bb8d8d3ff281c695df1670ab3987"}, - {file = "mypy-0.902-cp36-cp36m-win_amd64.whl", hash = "sha256:f89bfda7f0f66b789792ab64ce0978e4a991a0e4dd6197349d0767b0f1095b21"}, - {file = "mypy-0.902-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:746e0b0101b8efec34902810047f26a8c80e1efbb4fc554956d848c05ef85d76"}, - {file = "mypy-0.902-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:0190fb77e93ce971954c9e54ea61de2802065174e5e990c9d4c1d0f54fbeeca2"}, - {file = "mypy-0.902-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:b5dfcd22c6bab08dfeded8d5b44bdcb68c6f1ab261861e35c470b89074f78a70"}, - {file = "mypy-0.902-cp37-cp37m-win_amd64.whl", hash = "sha256:b5ba1f0d5f9087e03bf5958c28d421a03a4c1ad260bf81556195dffeccd979c4"}, - {file = "mypy-0.902-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9ef5355eaaf7a23ab157c21a44c614365238a7bdb3552ec3b80c393697d974e1"}, - {file = "mypy-0.902-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:517e7528d1be7e187a5db7f0a3e479747307c1b897d9706b1c662014faba3116"}, - {file = "mypy-0.902-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:fd634bc17b1e2d6ce716f0e43446d0d61cdadb1efcad5c56ca211c22b246ebc8"}, - {file = "mypy-0.902-cp38-cp38-win_amd64.whl", hash = "sha256:fc4d63da57ef0e8cd4ab45131f3fe5c286ce7dd7f032650d0fbc239c6190e167"}, - {file = "mypy-0.902-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:353aac2ce41ddeaf7599f1c73fed2b75750bef3b44b6ad12985a991bc002a0da"}, - {file = "mypy-0.902-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ae94c31bb556ddb2310e4f913b706696ccbd43c62d3331cd3511caef466871d2"}, - {file = "mypy-0.902-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:8be7bbd091886bde9fcafed8dd089a766fa76eb223135fe5c9e9798f78023a20"}, - {file = "mypy-0.902-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:4efc67b9b3e2fddbe395700f91d5b8deb5980bfaaccb77b306310bd0b9e002eb"}, - {file = "mypy-0.902-cp39-cp39-win_amd64.whl", hash = "sha256:9f1d74eeb3f58c7bd3f3f92b8f63cb1678466a55e2c4612bf36909105d0724ab"}, - {file = "mypy-0.902-py3-none-any.whl", hash = "sha256:a26d0e53e90815c765f91966442775cf03b8a7514a4e960de7b5320208b07269"}, - {file = "mypy-0.902.tar.gz", hash = "sha256:9236c21194fde5df1b4d8ebc2ef2c1f2a5dc7f18bcbea54274937cae2e20a01c"}, + {file = "mypy-0.910-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a155d80ea6cee511a3694b108c4494a39f42de11ee4e61e72bc424c490e46457"}, + {file = "mypy-0.910-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b94e4b785e304a04ea0828759172a15add27088520dc7e49ceade7834275bedb"}, + {file = "mypy-0.910-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:088cd9c7904b4ad80bec811053272986611b84221835e079be5bcad029e79dd9"}, + {file = "mypy-0.910-cp35-cp35m-win_amd64.whl", hash = "sha256:adaeee09bfde366d2c13fe6093a7df5df83c9a2ba98638c7d76b010694db760e"}, + {file = "mypy-0.910-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ecd2c3fe726758037234c93df7e98deb257fd15c24c9180dacf1ef829da5f921"}, + {file = "mypy-0.910-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d9dd839eb0dc1bbe866a288ba3c1afc33a202015d2ad83b31e875b5905a079b6"}, + {file = "mypy-0.910-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:3e382b29f8e0ccf19a2df2b29a167591245df90c0b5a2542249873b5c1d78212"}, + {file = "mypy-0.910-cp36-cp36m-win_amd64.whl", hash = "sha256:53fd2eb27a8ee2892614370896956af2ff61254c275aaee4c230ae771cadd885"}, + {file = "mypy-0.910-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b6fb13123aeef4a3abbcfd7e71773ff3ff1526a7d3dc538f3929a49b42be03f0"}, + {file = "mypy-0.910-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e4dab234478e3bd3ce83bac4193b2ecd9cf94e720ddd95ce69840273bf44f6de"}, + {file = "mypy-0.910-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:7df1ead20c81371ccd6091fa3e2878559b5c4d4caadaf1a484cf88d93ca06703"}, + {file = "mypy-0.910-cp37-cp37m-win_amd64.whl", hash = "sha256:0aadfb2d3935988ec3815952e44058a3100499f5be5b28c34ac9d79f002a4a9a"}, + {file = "mypy-0.910-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ec4e0cd079db280b6bdabdc807047ff3e199f334050db5cbb91ba3e959a67504"}, + {file = "mypy-0.910-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:119bed3832d961f3a880787bf621634ba042cb8dc850a7429f643508eeac97b9"}, + {file = "mypy-0.910-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:866c41f28cee548475f146aa4d39a51cf3b6a84246969f3759cb3e9c742fc072"}, + {file = "mypy-0.910-cp38-cp38-win_amd64.whl", hash = "sha256:ceb6e0a6e27fb364fb3853389607cf7eb3a126ad335790fa1e14ed02fba50811"}, + {file = "mypy-0.910-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a85e280d4d217150ce8cb1a6dddffd14e753a4e0c3cf90baabb32cefa41b59e"}, + {file = "mypy-0.910-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42c266ced41b65ed40a282c575705325fa7991af370036d3f134518336636f5b"}, + {file = "mypy-0.910-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:3c4b8ca36877fc75339253721f69603a9c7fdb5d4d5a95a1a1b899d8b86a4de2"}, + {file = "mypy-0.910-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:c0df2d30ed496a08de5daed2a9ea807d07c21ae0ab23acf541ab88c24b26ab97"}, + {file = "mypy-0.910-cp39-cp39-win_amd64.whl", hash = "sha256:c6c2602dffb74867498f86e6129fd52a2770c48b7cd3ece77ada4fa38f94eba8"}, + {file = "mypy-0.910-py3-none-any.whl", hash = "sha256:ef565033fa5a958e62796867b1df10c40263ea9ded87164d67572834e57a174d"}, + {file = "mypy-0.910.tar.gz", hash = "sha256:704098302473cb31a218f1775a873b376b30b4c18229421e9e9dc8916fd16150"}, ] mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] -natsort = [ - {file = "natsort-7.1.1-py3-none-any.whl", hash = "sha256:d0f4fc06ca163fa4a5ef638d9bf111c67f65eedcc7920f98dec08e489045b67e"}, - {file = "natsort-7.1.1.tar.gz", hash = "sha256:00c603a42365830c4722a2eb7663a25919551217ec09a243d3399fa8dd4ac403"}, -] netifaces = [ {file = "netifaces-0.11.0-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eb4813b77d5df99903af4757ce980a98c4d702bbcb81f32a0b305a1537bdf0b1"}, {file = "netifaces-0.11.0-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:5f9ca13babe4d845e400921973f6165a4c2f9f3379c7abfc7478160e25d196a4"}, @@ -1214,36 +1268,40 @@ nodeenv = [ {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, ] packaging = [ - {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, - {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] pbr = [ - {file = "pbr-5.6.0-py2.py3-none-any.whl", hash = "sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4"}, - {file = "pbr-5.6.0.tar.gz", hash = "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd"}, + {file = "pbr-5.8.0-py2.py3-none-any.whl", hash = "sha256:176e8560eaf61e127817ef93d8a844803abb27a4d4637f0ff3bb783129be2e0a"}, + {file = "pbr-5.8.0.tar.gz", hash = "sha256:672d8ebee84921862110f23fcec2acea191ef58543d34dfe9ef3d9f13c31cddf"}, +] +platformdirs = [ + {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, + {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"}, ] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] pre-commit = [ - {file = "pre_commit-2.13.0-py2.py3-none-any.whl", hash = "sha256:b679d0fddd5b9d6d98783ae5f10fd0c4c59954f375b70a58cbe1ce9bcf9809a4"}, - {file = "pre_commit-2.13.0.tar.gz", hash = "sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378"}, + {file = "pre_commit-2.15.0-py2.py3-none-any.whl", hash = "sha256:a4ed01000afcb484d9eb8d504272e642c4c4099bbad3a6b27e519bd6a3e928a6"}, + {file = "pre_commit-2.15.0.tar.gz", hash = "sha256:3c25add78dbdfb6a28a651780d5c311ac40dd17f160eb3954a0c59da40a505a7"}, ] py = [ - {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, - {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, ] pycparser = [ - {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"}, - {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, ] pygments = [ - {file = "Pygments-2.9.0-py3-none-any.whl", hash = "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e"}, - {file = "Pygments-2.9.0.tar.gz", hash = "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f"}, + {file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"}, + {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"}, ] pyparsing = [ - {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, - {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, + {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, + {file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"}, ] pytest = [ {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, @@ -1258,39 +1316,51 @@ pytest-mock = [ {file = "pytest_mock-3.6.1-py3-none-any.whl", hash = "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3"}, ] python-dateutil = [ - {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, - {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, ] pytz = [ - {file = "pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"}, - {file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"}, + {file = "pytz-2021.3-py2.py3-none-any.whl", hash = "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c"}, + {file = "pytz-2021.3.tar.gz", hash = "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326"}, ] pyyaml = [ - {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, - {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"}, - {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"}, - {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, - {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, - {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, - {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, - {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, - {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, - {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, - {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, - {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, - {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, - {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, - {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, - {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, - {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, + {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, + {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, + {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, + {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, + {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, + {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, + {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, + {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, + {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, + {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, + {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, + {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, + {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, + {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, + {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, + {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, ] requests = [ - {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, - {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, + {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, + {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"}, ] restructuredtext-lint = [ {file = "restructuredtext_lint-1.3.2.tar.gz", hash = "sha256:d3b10a1fe2ecac537e51ae6d151b223b78de9fafdd50e5eb6b08c243df173c80"}, @@ -1300,8 +1370,8 @@ six = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] snowballstemmer = [ - {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"}, - {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"}, + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, ] sphinx = [ {file = "Sphinx-3.5.4-py3-none-any.whl", hash = "sha256:2320d4e994a191f4b4be27da514e46b3d6b420f2ff895d064f52415d342461e8"}, @@ -1344,20 +1414,24 @@ sphinxcontrib-serializinghtml = [ {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, ] stevedore = [ - {file = "stevedore-3.3.0-py3-none-any.whl", hash = "sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a"}, - {file = "stevedore-3.3.0.tar.gz", hash = "sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee"}, + {file = "stevedore-3.5.0-py3-none-any.whl", hash = "sha256:a547de73308fd7e90075bb4d301405bebf705292fa90a90fc3bcf9133f58616c"}, + {file = "stevedore-3.5.0.tar.gz", hash = "sha256:f40253887d8712eaa2bb0ea3830374416736dc8ec0e22f5a65092c1174c44335"}, ] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] +tomli = [ + {file = "tomli-1.2.2-py3-none-any.whl", hash = "sha256:f04066f68f5554911363063a30b108d2b5a5b1a010aa8b6132af78489fe3aade"}, + {file = "tomli-1.2.2.tar.gz", hash = "sha256:c6ce0015eb38820eaf32b5db832dbc26deb3dd427bd5f6556cf0acac2c214fee"}, +] tox = [ - {file = "tox-3.23.1-py2.py3-none-any.whl", hash = "sha256:b0b5818049a1c1997599d42012a637a33f24c62ab8187223fdd318fa8522637b"}, - {file = "tox-3.23.1.tar.gz", hash = "sha256:307a81ddb82bd463971a273f33e9533a24ed22185f27db8ce3386bff27d324e3"}, + {file = "tox-3.24.4-py2.py3-none-any.whl", hash = "sha256:5e274227a53dc9ef856767c21867377ba395992549f02ce55eb549f9fb9a8d10"}, + {file = "tox-3.24.4.tar.gz", hash = "sha256:c30b57fa2477f1fb7c36aa1d83292d5c2336cd0018119e1b1c17340e2c2708ca"}, ] tqdm = [ - {file = "tqdm-4.61.1-py2.py3-none-any.whl", hash = "sha256:aa0c29f03f298951ac6318f7c8ce584e48fa22ec26396e6411e43d038243bdb2"}, - {file = "tqdm-4.61.1.tar.gz", hash = "sha256:24be966933e942be5f074c29755a95b315c69a91f839a29139bf26ffffe2d3fd"}, + {file = "tqdm-4.62.3-py2.py3-none-any.whl", hash = "sha256:8dd278a422499cd6b727e6ae4061c40b48fce8b76d1ccbf5d34fca9b7f925b0c"}, + {file = "tqdm-4.62.3.tar.gz", hash = "sha256:d359de7217506c9851b7869f3708d8ee53ed70a1b8edbba4dbcb47442592920d"}, ] typed-ast = [ {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, @@ -1392,34 +1466,32 @@ typed-ast = [ {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, ] typing-extensions = [ - {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, - {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, - {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, + {file = "typing_extensions-4.0.0-py3-none-any.whl", hash = "sha256:829704698b22e13ec9eaf959122315eabb370b0884400e9818334d8b677023d9"}, + {file = "typing_extensions-4.0.0.tar.gz", hash = "sha256:2cdf80e4e04866a9b3689a51869016d36db0814d84b8d8a568d22781d45d27ed"}, ] untokenize = [ {file = "untokenize-0.1.1.tar.gz", hash = "sha256:3865dbbbb8efb4bb5eaa72f1be7f3e0be00ea8b7f125c69cbd1f5fda926f37a2"}, ] urllib3 = [ - {file = "urllib3-1.26.5-py2.py3-none-any.whl", hash = "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c"}, - {file = "urllib3-1.26.5.tar.gz", hash = "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098"}, + {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"}, + {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"}, ] virtualenv = [ - {file = "virtualenv-20.4.7-py2.py3-none-any.whl", hash = "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"}, - {file = "virtualenv-20.4.7.tar.gz", hash = "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467"}, + {file = "virtualenv-20.10.0-py2.py3-none-any.whl", hash = "sha256:4b02e52a624336eece99c96e3ab7111f469c24ba226a53ec474e8e787b365814"}, + {file = "virtualenv-20.10.0.tar.gz", hash = "sha256:576d05b46eace16a9c348085f7d0dc8ef28713a2cabaa1cf0aea41e8f12c9218"}, ] voluptuous = [ - {file = "voluptuous-0.12.1-py3-none-any.whl", hash = "sha256:8ace33fcf9e6b1f59406bfaf6b8ec7bcc44266a9f29080b4deb4fe6ff2492386"}, - {file = "voluptuous-0.12.1.tar.gz", hash = "sha256:663572419281ddfaf4b4197fd4942d181630120fb39b333e3adad70aeb56444b"}, + {file = "voluptuous-0.12.2.tar.gz", hash = "sha256:4db1ac5079db9249820d49c891cb4660a6f8cae350491210abce741fabf56513"}, ] wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, ] zeroconf = [ - {file = "zeroconf-0.31.0-py3-none-any.whl", hash = "sha256:5a468da018bc3f04bbce77ae247924d802df7aeb4c291bbbb5a9616d128800b0"}, - {file = "zeroconf-0.31.0.tar.gz", hash = "sha256:53a180248471c6f81bd1fffcbce03ed93d7d8eaf10905c9121ac1ea996d19844"}, + {file = "zeroconf-0.37.0-py3-none-any.whl", hash = "sha256:1de8e4274ff0af35bab098ec596f9448b26db9c4d90dc61a861f1cf4f435bc75"}, + {file = "zeroconf-0.37.0.tar.gz", hash = "sha256:f901eda390160bc270aeba95ef2d6aa0a736503301dac393e7d5fd95fa17043a"}, ] zipp = [ - {file = "zipp-3.4.1-py3-none-any.whl", hash = "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098"}, - {file = "zipp-3.4.1.tar.gz", hash = "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76"}, + {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"}, + {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"}, ] diff --git a/pyproject.toml b/pyproject.toml index ac16ed469..b3aae0669 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-miio" -version = "0.5.8" +version = "0.5.9" description = "Python library for interfacing with Xiaomi smart appliances" authors = ["Teemu R "] repository = "https://github.com/rytilahti/python-miio" @@ -13,17 +13,14 @@ packages = [ keywords = ["xiaomi", "miio", "miot", "smart home"] [tool.poetry.scripts] -mirobo = "miio.vacuum_cli:cli" -miplug = "miio.plug_cli:cli" -miceil = "miio.ceil_cli:cli" -mieye = "miio.philips_eyecare_cli:cli" +mirobo = "miio.integrations.vacuum.roborock.vacuum_cli:cli" miio-extract-tokens = "miio.extract_tokens:main" miiocli = "miio.cli:create_cli" [tool.poetry.dependencies] python = "^3.6.5" -click = "^7" -cryptography = "^3" +click = ">=7" +cryptography = ">=35" construct = "^2.10.56" zeroconf = "^0" attrs = "*" @@ -33,14 +30,14 @@ tqdm = "^4" netifaces = { version = "^0", optional = true } android_backup = { version = "^0", optional = true } importlib_metadata = { version = "^1", markers = "python_version <= '3.7'" } -croniter = "^0" +croniter = ">=1" defusedxml = "^0" sphinx = { version = "^3", optional = true } sphinx_click = { version = "^2", optional = true } sphinxcontrib-apidoc = { version = "^0", optional = true } sphinx_rtd_theme = { version = "^0", optional = true } -PyYAML = "^5" +PyYAML = ">=5,<7" [tool.poetry.extras] docs = ["sphinx", "sphinx_click", "sphinxcontrib-apidoc", "sphinx_rtd_theme"] @@ -58,6 +55,7 @@ isort = "^4" cffi = "^1" docformatter = "^1" mypy = {version = "^0", markers = "platform_python_implementation == 'CPython'"} +coverage = {extras = ["toml"], version = "^6"} [tool.isort] multi_line_output = 3 @@ -102,5 +100,5 @@ exclude_lines = [ ignore = ["devtools/*"] [build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api"