Repository: CharlesBlonde/libpurecoollink Branch: master Commit: a91362c57a0b Files: 46 Total size: 229.8 KB Directory structure: gitextract_scq8z675/ ├── .coveragerc ├── .gitignore ├── .travis.yml ├── AUTHORS.rst ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── RELEASES.rst ├── docs/ │ ├── Makefile │ ├── _templates/ │ │ └── sidebarintro.html │ ├── api.rst │ ├── conf.py │ ├── index.rst │ └── versions.rst ├── libpurecoollink/ │ ├── __init__.py │ ├── const.py │ ├── dyson.py │ ├── dyson_360_eye.py │ ├── dyson_device.py │ ├── dyson_pure_cool_link.py │ ├── dyson_pure_hotcool_link.py │ ├── dyson_pure_state.py │ ├── exceptions.py │ ├── utils.py │ └── zeroconf.py ├── requirements.txt ├── requirements_test.txt ├── setup.cfg ├── setup.py ├── tests/ │ ├── data/ │ │ ├── sensor.json │ │ ├── sensor_sltm_off.json │ │ ├── state.json │ │ ├── state_hot.json │ │ └── vacuum/ │ │ ├── goodbye.json │ │ ├── map-data.json │ │ ├── map-global.json │ │ ├── map-grid.json │ │ ├── state-change.json │ │ ├── state-unknown-values.json │ │ ├── state.json │ │ └── telemetry-data.json │ ├── test_360_eye.py │ ├── test_dyson_account.py │ ├── test_libpurecoollink.py │ └── test_utils.py └── tox.ini ================================================ FILE CONTENTS ================================================ ================================================ FILE: .coveragerc ================================================ [run] source = libpurecoollink omit = libpurecoollink/zeroconf.py ================================================ FILE: .gitignore ================================================ *.iml runtime/* .coverage .tox/ .cache/ libpurecoollink.egg-info/ libpurecoollink/__pycache__/ tests/__pycache__/ dist/* build/ .SVN/ main/ .project .pydevproject docs/_build README.rst ================================================ FILE: .travis.yml ================================================ language: python matrix: include: - python: "3.4.2" env: TOXENV=lint - python: "3.4.2" env: TOXENV=py34 - python: "3.5" env: TOXENV=py35 - python: "3.6" env: TOXENV=py36 install: "pip install -U tox coveralls" script: tox after_success: coveralls ================================================ FILE: AUTHORS.rst ================================================ Thanks to all the wonderful folks who have contributed to Libpurecoollink: - ThomasHoussin (add parameters) - Soraxas Add Cool+Hot devices support ================================================ FILE: LICENSE.md ================================================ Copyright 2017 Charles Blonde. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` ------------------------------------------------------------------------- Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS ``` ================================================ FILE: MANIFEST.in ================================================ include README.rst include LICENSE.md ================================================ FILE: README.md ================================================ # Dyson Pure Cool Link Python library [![Build Status](https://travis-ci.org/CharlesBlonde/libpurecoollink.svg?branch=master)](https://travis-ci.org/CharlesBlonde/libpurecoollink) [![Coverage Status](https://coveralls.io/repos/github/CharlesBlonde/libpurecoollink/badge.svg?branch=master)](https://coveralls.io/github/CharlesBlonde/libpurecoollink?branch=master)[![PyPI](https://img.shields.io/pypi/v/libpurecoollink.svg)](https://pypi.python.org/pypi/libpurecoollink) [![Documentation Status](https://readthedocs.org/projects/libpurecoollink/badge/?version=latest)](http://libpurecoollink.readthedocs.io/en/latest/?badge=latest) This Python 3.4+ library allow you to control [Dyson fan/purifier devices](http://www.dyson.com/air-treatment/purifiers/dyson-pure-hot-cool-link.aspx) and [Dyson 360 Eye robot vacuum device](http://www.dyson.com/vacuum-cleaners/robot/dyson-360-eye.aspx). [official documentation](http://libpurecoollink.readthedocs.io) ## Status This library is becoming quite stable but backward compatibility is not yet guaranteed. ## Full documentation http://libpurecoollink.readthedocs.io ### Supported devices * Dyson pure cool link devices (Tower and Desk) * Dyson pure cool+hot devices * Dyson 360 Eye robot vacuum ## Features The following feature are supported: * Purifier/fan devices * Connect to the device using discovery or manually with IP Address * Turn on/off * Set speed * Turn on/off oscillation * Set Auto mode * Set night mode * Set sleep timer * Set Air Quality target (Normal, High, Better) * Enable/disable standby monitoring (the device continue to update sensors when in standby) * Reset filter life * Cool+Hot purifier/fan devices * Set heat mode * Set heat target * Set fan focus mode * 360 Eye device (robot vacuum) * Set power mode (Quiet/Max) * Start cleaning * Pause cleaning * Resume cleaning * Abort cleaning The following sensors are available for fan/purifier devices: * Humidity * Temperature in Kelvin * Dust (unknown metric) * Air quality (unknown metric) ## Quick start Please read [official documentation](http://libpurecoollink.readthedocs.io) ## How it's work Dyson devices use many different protocols in order to work: * HTTPS to Dyson API in order to get devices informations (credentials, historical data, etc ...) * MDNS to discover devices on the local network * MQTT (with auth) to get device status and send commands To my knowledge, no public technical information about API/MQTT are available so all the work is done by testing and a lot of properties are unknown to me at this time. This library come with a modified version of [Zeroconf](https://github.com/jstasiak/python-zeroconf) because Dyson MDNS implementation is not valid. This [documentation](https://github.com/shadowwa/Dyson-MQTT2RRD) help me to understand some of return values. ## Work to do * Better protocol understanding * Better technical documentation on how it is working * Get historical data from the API (air quality, etc ...) ================================================ FILE: RELEASES.rst ================================================ Version 0.4.1 ~~~~~~~~~~~~~ :Date: 2017/08/05 - Add new Dyson 360 eye state - Refactor connection (auto_connect() for mDNS, connect() for manual connection) Version 0.4.1 ~~~~~~~~~~~~~ :Date: 2017/08/03 - Add new Dyson 360 eye states and messages - Remove enum34 dependency Version 0.4.0 ~~~~~~~~~~~~~ :Date: 2017/07/16 - Add Dyson 360 eye device support (robot vacuum) Version 0.3.0 ~~~~~~~~~~~~~ :Date: 2017/07/08 - Add support for heating devices - Add reset filter life feature Version 0.2.0 ~~~~~~~~~~~~~ :Date: 2017/06/18 - First official Pypi release ================================================ FILE: docs/Makefile ================================================ # Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Libpurecoollink.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Libpurecoollink.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/Libpurecoollink" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Libpurecoollink" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." ================================================ FILE: docs/_templates/sidebarintro.html ================================================

📰 Useful Links

================================================ FILE: docs/api.rst ================================================ .. _api: Developer Interface =================== .. module:: libpurecoollink.dyson .. module:: libpurecoollink.dyson_device .. module:: libpurecoollink.dyson_360_eye .. module:: libpurecoollink.dyson_pure_cool_link .. module:: libpurecoollink.dyson_pure_hotcool_link .. module:: libpurecoollink.dyson_pure_state This part of the documentation covers all the interfaces of Libpurecoollink. Classes ------- Common ~~~~~~ DysonAccount ############ .. autoclass:: libpurecoollink.dyson.DysonAccount :members: NetworkDevice ############# .. autoclass:: libpurecoollink.dyson_device.NetworkDevice :members: Fan/Purifier devices ~~~~~~~~~~~~~~~~~~~~ DysonPureCoolLink ################# .. autoclass:: libpurecoollink.dyson_pure_cool_link.DysonPureCoolLink :members: :inherited-members: DysonPureHotCoolLink #################### .. autoclass:: libpurecoollink.dyson_pure_hotcool_link.DysonPureHotCoolLink :members: :inherited-members: DysonPureCoolState ################## .. autoclass:: libpurecoollink.dyson_pure_state.DysonPureCoolState :members: DysonEnvironmentalSensorState ############################# .. autoclass:: libpurecoollink.dyson_pure_state.DysonEnvironmentalSensorState :members: DysonPureHotCoolState ##################### .. autoclass:: libpurecoollink.dyson_pure_state.DysonPureHotCoolState :members: :inherited-members: Eye 360 robot vacuum device ~~~~~~~~~~~~~~~~~~~~~~~~~~~ Dyson360Eye ########### .. autoclass:: libpurecoollink.dyson_360_eye.Dyson360Eye :members: :inherited-members: Dyson360EyeState ################ .. autoclass:: libpurecoollink.dyson_360_eye.Dyson360EyeState :members: Dyson360EyeTelemetryData ######################## .. autoclass:: libpurecoollink.dyson_360_eye.Dyson360EyeTelemetryData :members: Dyson360EyeMapData ################## .. autoclass:: libpurecoollink.dyson_360_eye.Dyson360EyeMapData :members: Dyson360EyeMapGrid ################## .. autoclass:: libpurecoollink.dyson_360_eye.Dyson360EyeMapGrid :members: Dyson360EyeMapGlobal #################### .. autoclass:: libpurecoollink.dyson_360_eye.Dyson360EyeMapGlobal :members: Exceptions ---------- DysonNotLoggedException ~~~~~~~~~~~~~~~~~~~~~~~ .. autoexception:: libpurecoollink.exceptions.DysonNotLoggedException DysonInvalidTargetTemperatureException ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. autoexception:: libpurecoollink.exceptions.DysonInvalidTargetTemperatureException ================================================ FILE: docs/conf.py ================================================ #!/usr/bin/env python3 # -*- coding: utf-8 -*- # # Libpurecoollink documentation build configuration file, created by # sphinx-quickstart on Sun Jun 18 08:28:58 2017. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import sys import os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. #sys.path.insert(0, os.path.abspath('.')) sys.path.insert(0, '..') # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. #needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.doctest', 'sphinx.ext.coverage', 'sphinx.ext.viewcode', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. #source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = 'Libpurecoollink' copyright = '2017, Charles Blonde' author = 'Charles Blonde' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = '0.1.0' # The full version, including alpha/beta/rc tags. release = '0.1.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. #language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: #today = '' # Else, today_fmt is used as the format for a strftime call. #today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['_build'] # The reST default role (used for this markup: `text`) to use for all # documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. #add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). #add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. #show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. #keep_warnings = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'alabaster' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. #html_theme_options = {} html_theme_options = { 'show_powered_by': False, 'github_user': 'CharlesBlonde', 'github_repo': 'libpurecoollink', 'github_banner': True, 'show_related': False } # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". html_title = 'Libpurecoollink' # A shorter title for the navigation bar. Default is the same as html_title. html_short_title = 'Libpurecoollink' # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. #html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. #html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. #html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. #html_sidebars = {} html_sidebars = { 'index': ['sidebarintro.html', 'localtoc.html', 'sourcelink.html', 'searchbox.html'], } # Additional templates that should be rendered to pages, maps page names to # template names. #html_additional_pages = {} # If false, no module index is generated. #html_domain_indices = True # If false, no index is generated. #html_use_index = True # If true, the index is split into individual pages for each letter. #html_split_index = False # If true, links to the reST sources are added to the pages. html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. #html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. #html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. #html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). #html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'Libpurecoollinkdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt', # Additional stuff for the LaTeX preamble. #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ ('index', 'Libpurecoollink.tex', 'Libpurecoollink Documentation', 'Charles Blonde', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. #latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. #latex_use_parts = False # If true, show page references after internal links. #latex_show_pagerefs = False # If true, show URL addresses after external links. #latex_show_urls = False # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. #latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'libpurecoollink', 'Libpurecoollink Documentation', ['Charles Blonde'], 1) ] # If true, show URL addresses after external links. #man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'Libpurecoollink', 'Libpurecoollink Documentation', 'Charles Blonde', 'Libpurecoollink', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. #texinfo_appendices = [] # If false, no module index is generated. #texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. #texinfo_no_detailmenu = False ================================================ FILE: docs/index.rst ================================================ .. Libpurecoollink documentation master file, created by sphinx-quickstart on Sun Jun 18 08:28:58 2017. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Libpurecoollink's documentation =============================== .. image:: https://api.travis-ci.org/CharlesBlonde/libpurecoollink.svg?branch=master :target: https://travis-ci.org/CharlesBlonde/libpurecoollink .. image:: https://coveralls.io/repos/github/CharlesBlonde/libpurecoollink/badge.svg?branch=master :target: https://coveralls.io/github/CharlesBlonde/libpurecoollink?branch=master .. image:: https://img.shields.io/pypi/v/libpurecoollink.svg :target: https://pypi.python.org/pypi/libpurecoollink This Python 3.4+ library allow you to control `Dyson fan/purifier devices `_ and `Dyson 360 Eye robot vacuum device `_. Status ------ Backward compatibility is a goal but breaking changes can still happen. Discovery is not fully reliable yet. It's working most of the time but sometimes discovery will not work. Manual configuration is available to bypass this limitation. Supported devices ~~~~~~~~~~~~~~~~~ - Dyson pure cool link devices (Tower and Desk) - Dyson Cool+Hot devices - Dyson 360 Eye robot vacuum Features -------- Commands ~~~~~~~~ The following commands are supported: - Purifier/fan devices - Connect to the device using discovery or manually with IP Address - Turn on/off - Set speed - Turn on/off oscillation - Set Auto mode - Set night mode - Set sleep timer - Set Air Quality target (Normal, High, Better) - Enable/disable standby monitoring (the device continue to update sensors when in standby) - Reset filter life - Cool+Hot purifier/fan devices - Set heat mode - Set heat target - Set fan focus mode - 360 Eye device - Set power mode (Quiet/Max) - Start cleaning - Pause cleaning - Resume cleaning - Abort cleaning Sensors ~~~~~~~ The following sensors are available for fan/purifier devices: - Humidity - Temperature in Kelvin - Dust (unknown metric) - Air Quality (unknown metric) Usage ----- Installation ~~~~~~~~~~~~ .. code:: shell pip install libpurecoollink Dyson account ~~~~~~~~~~~~~ In order to access the devices, you need to have access to a valid Dyson account. .. code:: python from libpurecoollink.dyson import DysonAccount # Log to Dyson account # Language is a two characters code (eg: FR) dyson_account = DysonAccount("","","") logged = dyson_account.login() Fan/Purifier devices ~~~~~~~~~~~~~~~~~~~~ Connect to devices ################## After login to the Dyson account, known devices are available. Connections to the devices can been done automatically using mDNS or manually with specifying IP address Automatic connection (mDNS) +++++++++++++++++++++++++++ .. code:: python from libpurecoollink.dyson import DysonAccount # Log to Dyson account # Language is a two characters code (eg: FR) dyson_account = DysonAccount("","","") logged = dyson_account.login() if not logged: print('Unable to login to Dyson account') exit(1) # List devices available on the Dyson account devices = dyson_account.devices() # Connect using discovery to the first device connected = devices[0].auto_connect() # connected == device available, state values are available, sensor values are available Manual connection +++++++++++++++++ .. code:: python from libpurecoollink.dyson import DysonAccount # Log to Dyson account # Language is a two characters code (eg: FR) dyson_account = DysonAccount("","","") logged = dyson_account.login() if not logged: print('Unable to login to Dyson account') exit(1) # List devices available on the Dyson account devices = dyson_account.devices() # Connect using discovery to the first device connected = devices[0].connect("192.168.1.2") # connected == device available, state values are available, sensor values are available Disconnect from the device ########################## Disconnection is required for fan/purifier devices in order to release resources (an internal thread is started to request update notifications) .. code:: python from libpurecoollink.dyson import DysonAccount # ... connection do dyson account and to device ... # # Disconnect devices[0].disconnect() Send commands ############# After connected to the device, commands cand be send in order to update the device configuration .. code:: python from libpurecoollink.dyson import DysonAccount from libpurecoollink.const import FanSpeed, FanMode, NightMode, Oscillation, \ FanState, StandbyMonitoring, QualityTarget, ResetFilter, HeatMode, \ FocusMode, HeatTarget # ... connection do dyson account and to device ... # # Turn on the fan to speed 2 devices[0].set_configuration(fan_mode=FanMode.FAN, fan_speed=FanSpeed.FAN_SPEED_2) # Turn on oscillation devices[0].set_configuration(oscillation=Oscillation.OSCILLATION_ON) # Turn on night mode devices[0].set_configuration(night_mode=NightMode.NIGHT_MODE_ON) # Set 10 minutes sleep timer devices[0].set_configuration(sleep_timer=10) # Disable sleep timer devices[0].set_configuration(sleep_timer=0) # Set quality target (for auto mode) devices[0].set_configuration(quality_target=QualityTarget.QUALITY_NORMAL) # Disable standby monitoring devices[0].set_configuration(standby_monitoring=StandbyMonitoring.STANDBY_MONITORING_OFF) # Reset filter life devices[0].set_configuration(reset_filter=ResetFilter.RESET_FILTER) ## Cool+Hot devices only # Set Heat mode devices[0].set_configuration(heat_mode=HeatMode.HEAT_ON) # Set heat target devices[0].set_configuration(heat_target=HeatTarget.celsius(25)) devices[0].set_configuration(heat_target=HeatTarget.fahrenheit(70)) # Set fan focus mode devices[0].set_configuration(focus_mode=FocusMode.FOCUS_ON) # Everything can be mixed in one call devices[0].set_configuration(sleep_timer=10, fan_mode=FanMode.FAN, fan_speed=FanSpeed.FAN_SPEED_5, night_mode=NightMode.NIGHT_MODE_OFF, standby_monitoring=StandbyMonitoring.STANDBY_MONITORING_ON, quality_target=QualityTarget.QUALITY_HIGH) States and sensors ################## States and sensors values are available using *state* and *environment_state* properties States values .. code:: python # ... imports ... # # ... connection do dyson account and to device ... # print(devices[0].state.speed) print(devices[0].state.oscillation) # ... # Environmental values .. code:: python # ... imports ... # # ... connection do dyson account and to device ... # print(devices[0].environment_state.humidity) print(devices[0].environment_state.sleep_timer) # ... # All properties are available in the sources. Notifications ############# You can register to any values changed by using a callback function .. code:: python # ... imports ... # from libpurecoollink.dyson_pure_state import DysonPureHotCoolState, \ DysonPureCoolState, DysonEnvironmentalSensorState # ... connection do dyson account and to device ... # def on_message(msg): # Message received if isinstance(msg, DysonPureCoolState): # Will be true for DysonPureHotCoolState too. print("DysonPureCoolState message received") if isinstance(msg, DysonPureHotCoolState): print("DysonPureHotCoolState message received") if isinstance(msg, DysonEnvironmentalSensorState) print("DysonEnvironmentalSensorState received") print(msg) devices[0].connect() devices[0].add_message_listener(on_message) 360 Eye robot vacuum ~~~~~~~~~~~~~~~~~~~~ Connect to devices ################## After login to the Dyson account, known devices are available. Auto discovery is not yet supported. Manual connection +++++++++++++++++ .. code:: python from libpurecoollink.dyson import DysonAccount # Log to Dyson account # Language is a two characters code (eg: FR) dyson_account = DysonAccount("","","") logged = dyson_account.login() if not logged: print('Unable to login to Dyson account') exit(1) # List devices available on the Dyson account devices = dyson_account.devices() # Connect using discovery to the first device connected = devices[0].connect("192.168.1.2") # connected == device available, state values are available, sensor values are available Send commands ############# After connected to the device, commands cand be send in order to update the device configuration. .. code:: python import time from libpurecoollink.dyson import DysonAccount from libpurecoollink.const import PowerMode # ... connection do dyson account and to device ... # # Set power mode devices[0].set_power_mode(PowerMode.QUIET) devices[0].set_power_mode(PowerMode.MAX) # Start cleaning devices[0].start time.sleep(30) # Pause cleaning devices[0].pause() time.sleep(30) # Resume cleaning devices[0].resume() time.sleep(30) # Abort cleaning (device return to the base) devices[0].abort() States ###### State values are available using *state* property. .. code:: python # ... imports ... # # ... connection do dyson account and to device ... # print(devices[0].state.state) print(devices[0].state.full_clean_type) print(devices[0].state.position) print(devices[0].state.power_mode) print(devices[0].state.battery_level) print(devices[0].state.clean_id) # ... # All properties are available in the sources. Notifications ############# You can register to any values changed by using a callback function .. code:: python # ... imports ... # from libpurecoollink.dyson_360_eye import Dyson360EyeState, \ Dyson360EyeTelemetryData, Dyson360EyeMapData, Dyson360EyeMapGrid, \ Dyson360EyeMapGlobal # ... connection do dyson account and to device ... # def on_message(msg): # Message received if isinstance(msg, Dyson360EyeState): print("Dyson360EyeState message received") if isinstance(msg, Dyson360EyeTelemetryData) print("Dyson360EyeTelemetryData received") if isinstance(msg, Dyson360EyeMapData) print("Dyson360EyeMapData received") if isinstance(msg, Dyson360EyeMapGrid) print("Dyson360EyeMapGrid received") if isinstance(msg, Dyson360EyeMapGlobal) print("Dyson360EyeMapGlobal received") print(msg) devices[0].connect() devices[0].add_message_listener(on_message) API Documentation ----------------- If you are looking for information on a specific function, class, or method, this part of the documentation is for you. .. toctree:: api How it's working ---------------- Dyson devices use many different protocols in order to work: - HTTPS to Dyson API in order to get devices informations (credentials, historical data, etc ...) - MDNS to discover devices on the local network - MQTT (with auth) to get device status and send commands To my knowledge, no public technical information about API/MQTT are available so all the work is done by testing and a lot of properties are unknown to me at this time. This library come with a modified version of `Zeroconf `_ because Dyson MDNS implementation is not valid. This `documentation `_ help me to understand some of return values. Work to do ---------- - Better protocol understanding - Better technical documentation on how it is working - Get historical data from the API (air quality, etc ...) Releases -------- .. toctree:: versions Contributors ------------ .. include:: ../AUTHORS.rst ================================================ FILE: docs/versions.rst ================================================ Versions ======== .. include:: ../RELEASES.rst ================================================ FILE: libpurecoollink/__init__.py ================================================ """Dyson Pure Cool Link package.""" ================================================ FILE: libpurecoollink/const.py ================================================ """Dyson Pure Cool Link constants.""" from enum import Enum from .exceptions import DysonInvalidTargetTemperatureException as DITTE DYSON_PURE_COOL_LINK_TOUR = "475" DYSON_PURE_COOL_LINK_DESK = "469" DYSON_PURE_HOT_COOL_LINK_TOUR = "455" DYSON_360_EYE = "N223" class FanMode(Enum): """Fan mode.""" OFF = 'OFF' FAN = 'FAN' AUTO = 'AUTO' class Oscillation(Enum): """Oscillation.""" OSCILLATION_ON = 'ON' OSCILLATION_OFF = 'OFF' class NightMode(Enum): """Night mode.""" NIGHT_MODE_ON = 'ON' NIGHT_MODE_OFF = 'OFF' class FanSpeed(Enum): """Fan Speed.""" FAN_SPEED_1 = '0001' FAN_SPEED_2 = '0002' FAN_SPEED_3 = '0003' FAN_SPEED_4 = '0004' FAN_SPEED_5 = '0005' FAN_SPEED_6 = '0006' FAN_SPEED_7 = '0007' FAN_SPEED_8 = '0008' FAN_SPEED_9 = '0009' FAN_SPEED_10 = '0010' FAN_SPEED_AUTO = 'AUTO' class FanState(Enum): """Fan State.""" FAN_OFF = "OFF" FAN_ON = "FAN" class QualityTarget(Enum): """Quality Target for air.""" QUALITY_NORMAL = "0004" QUALITY_HIGH = "0003" QUALITY_BETTER = "0001" class StandbyMonitoring(Enum): """Monitor air quality when on standby.""" STANDBY_MONITORING_ON = "ON" STANDBY_MONITORING_OFF = "OFF" class FocusMode(Enum): """Fan operates in a focused stream or wide spread.""" FOCUS_OFF = "OFF" FOCUS_ON = "ON" class TiltState(Enum): """Indicates if device is tilted.""" TILT_TRUE = "TILT" TILT_FALSE = "OK" class HeatMode(Enum): """Heat mode for the fan.""" HEAT_OFF = "OFF" HEAT_ON = "HEAT" class HeatState(Enum): """Heating State.""" HEAT_STATE_OFF = "OFF" HEAT_STATE_ON = "HEAT" class HeatTarget: """Heat Target for fan. Note dyson uses kelvin as the temperature unit.""" @staticmethod def celsius(temperature): """Convert the given int celsius temperature to string in Kelvin. :param temperature temperature in celsius between 1 to 37 inclusive. """ if temperature < 1 or temperature > 37: raise DITTE(DITTE.CELSIUS, temperature) return str((int(temperature) + 273) * 10) @staticmethod def fahrenheit(temperature): """Convert the given int fahrenheit temperature to string in Kelvin. :param temperature temperature in fahrenheit between 34 to 98 inclusive. """ if temperature < 34 or temperature > 98: raise DITTE(DITTE.FAHRENHEIT, temperature) return str(int((int(temperature) + 459.67) * 5/9) * 10) class ResetFilter(Enum): """Reset the filter status / new filter.""" RESET_FILTER = "RSTF" DO_NOTHING = "STET" class PowerMode(Enum): """360 Eye power mode.""" QUIET = "halfPower" MAX = "fullPower" class Dyson360EyeMode(Enum): """360 Eye state.""" INACTIVE_CHARGED = "INACTIVE_CHARGED" FULL_CLEAN_INITIATED = "FULL_CLEAN_INITIATED" FULL_CLEAN_RUNNING = "FULL_CLEAN_RUNNING" FULL_CLEAN_PAUSED = "FULL_CLEAN_PAUSED" FULL_CLEAN_ABORTED = "FULL_CLEAN_ABORTED" FULL_CLEAN_FINISHED = "FULL_CLEAN_FINISHED" INACTIVE_CHARGING = "INACTIVE_CHARGING" FAULT_USER_RECOVERABLE = "FAULT_USER_RECOVERABLE" FULL_CLEAN_NEEDS_CHARGE = "FULL_CLEAN_NEEDS_CHARGE" FAULT_REPLACE_ON_DOCK = "FAULT_REPLACE_ON_DOCK" class Dyson360EyeCommand(Enum): """360 Eye commands.""" STATE_SET = "STATE-SET" START = "START" PAUSE = "PAUSE" RESUME = "RESUME" ABORT = "ABORT" ================================================ FILE: libpurecoollink/dyson.py ================================================ """Dyson Pure Cool Link library.""" # pylint: disable=too-many-public-methods,too-many-instance-attributes import logging import requests from requests.auth import HTTPBasicAuth from .utils import is_360_eye_device, is_heating_device from .dyson_360_eye import Dyson360Eye from .dyson_pure_cool_link import DysonPureCoolLink from .dyson_pure_hotcool_link import DysonPureHotCoolLink from .exceptions import DysonNotLoggedException _LOGGER = logging.getLogger(__name__) DYSON_API_URL = "api.cp.dyson.com" class DysonAccount: """Dyson account.""" def __init__(self, email, password, country): """Create a new Dyson account. :param email: User email :param password: User password :param country: 2 characters language code """ self._email = email self._password = password self._country = country self._logged = False self._auth = None def login(self): """Login to dyson web services.""" request_body = { "Email": self._email, "Password": self._password } login = requests.post( "https://{0}/v1/userregistration/authenticate?country={1}".format( DYSON_API_URL, self._country), request_body, verify=False) # pylint: disable=no-member if login.status_code == requests.codes.ok: json_response = login.json() self._auth = HTTPBasicAuth(json_response["Account"], json_response["Password"]) self._logged = True else: self._logged = False return self._logged def devices(self): """Return all devices linked to the account.""" if self._logged: device_response = requests.get( "https://{0}/v1/provisioningservice/manifest".format( DYSON_API_URL), verify=False, auth=self._auth) devices = [] for device in device_response.json(): if is_360_eye_device(device): dyson_device = Dyson360Eye(device) elif is_heating_device(device): dyson_device = DysonPureHotCoolLink(device) else: dyson_device = DysonPureCoolLink(device) devices.append(dyson_device) return devices else: _LOGGER.warning("Not logged to Dyson Web Services.") raise DysonNotLoggedException() @property def logged(self): """Return True if user is logged, else False.""" return self._logged ================================================ FILE: libpurecoollink/dyson_360_eye.py ================================================ """Dyson 360 eye device.""" import logging import json import time import datetime import paho.mqtt.client as mqtt from .dyson_device import DysonDevice, NetworkDevice, DEFAULT_PORT from .utils import printable_fields from .const import PowerMode, Dyson360EyeMode, Dyson360EyeCommand _LOGGER = logging.getLogger(__name__) class Dyson360Eye(DysonDevice): """Dyson 360 Eye device.""" def connect(self, device_ip, device_port=DEFAULT_PORT): """Try to connect to device. :param device_ip: Device IP address :param device_port: Device Port (default: 1883) :return: True if connected, else False """ self._network_device = NetworkDevice(self._name, device_ip, device_port) self._mqtt = mqtt.Client(userdata=self, protocol=3) self._mqtt.username_pw_set(self._serial, self._credentials) self._mqtt.on_message = self.on_message self._mqtt.on_connect = self.on_connect self._mqtt.connect(self._network_device.address, self._network_device.port) self._mqtt.loop_start() if self._connection_queue.get(timeout=10): self._connected = True _LOGGER.info("Connected to device %s", self.serial) self.request_current_state() # Wait for first data self._state_data_available.get() self._device_available = True else: self._mqtt.loop_stop() return self._device_available @property def status_topic(self): """MQTT status topic.""" return "{0}/{1}/status".format(self.product_type, self.serial) def _send_command(self, command, data=None): """Send command to the device. :param command Command to send (const.Dyson360EyeCommand) :param data Data dictionary to send. Can be empty """ if data is None: data = {} if self._connected: payload = { "msg": "{0}".format(command), "time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), } payload.update(data) _LOGGER.debug("Sending command to the device: %s", json.dumps(payload)) self._mqtt.publish(self.command_topic, json.dumps(payload), 1) else: _LOGGER.warning( "Not connected, can not send commands: %s", self.serial) def set_power_mode(self, power_mode): """Set power mode. :param power_mode Power mode (const.PowerMode) """ self._send_command(Dyson360EyeCommand.STATE_SET.value, { "data": {"defaultVacuumPowerMode": power_mode.value}}) def start(self): """Start cleaning.""" self._send_command(Dyson360EyeCommand.START.value, {"fullCleanType": "immediate"}) def pause(self): """Pause cleaning.""" self._send_command(Dyson360EyeCommand.PAUSE.value) def resume(self): """Resume cleaning.""" self._send_command(Dyson360EyeCommand.RESUME.value) def abort(self): """Abort cleaning.""" self._send_command(Dyson360EyeCommand.ABORT.value) @staticmethod def call_callback_functions(functions, message): """Call callback functions.""" for func in functions: func(message) @staticmethod def on_message(client, userdata, msg): # pylint: disable=unused-argument """Set function Callback when message received.""" payload = msg.payload.decode("utf-8") device_msg = None if Dyson360EyeState.is_state_message(payload): device_msg = Dyson360EyeState(payload) if not userdata.device_available: userdata.state_data_available() userdata.state = device_msg elif Dyson360EyeMapGlobal.is_map_global(payload): device_msg = Dyson360EyeMapGlobal(payload) elif Dyson360EyeTelemetryData.is_telemetry_data(payload): device_msg = Dyson360EyeTelemetryData(payload) elif Dyson360EyeMapGrid.is_map_grid(payload): device_msg = Dyson360EyeMapGrid(payload) elif Dyson360EyeMapData.is_map_data(payload): device_msg = Dyson360EyeMapData(payload) elif Dyson360Goodbye.is_goodbye_message(payload): device_msg = Dyson360Goodbye(payload) else: _LOGGER.warning(payload) if device_msg: Dyson360Eye.call_callback_functions(userdata.callback_message, device_msg) def __repr__(self): """Return a String representation.""" fields = self._fields() return 'Dyson360Eye(' + ",".join(printable_fields(fields)) + ')' class Dyson360EyeState: """Dyson 360 Eye state.""" @staticmethod def is_state_message(payload): """Return true if this message is a Dyson 360 Eye state message.""" return json.loads(payload)['msg'] in ["CURRENT-STATE", "STATE-CHANGE"] def __init__(self, json_body): """Create a new Dyson 360 Eye state.""" data = json.loads(json_body) try: self._state = Dyson360EyeMode( data["state"] if "state" in data else data["newstate"]) except ValueError: _LOGGER.error("Unknown state value %s", data["state"] if "state" in data else data[ "newstate"]) self._state = data["state"] if "state" in data else data[ "newstate"] self._full_clean_type = data["fullCleanType"] if "globalPosition" in data and len(data["globalPosition"]) == 2: self._position = (int(data["globalPosition"][0]), int(data["globalPosition"][1])) try: self._power_mode = PowerMode(data["currentVacuumPowerMode"]) except ValueError: _LOGGER.error("Unknown power mode value %s", data["currentVacuumPowerMode"]) self._power_mode = data["currentVacuumPowerMode"] self._clean_id = data["cleanId"] self._battery_level = int(data["batteryChargeLevel"]) @property def state(self): """Return state status.""" return self._state @property def full_clean_type(self): """Return full clean type.""" return self._full_clean_type @property def position(self): """Return position.""" return self._position @property def power_mode(self): """Return power mode.""" return self._power_mode @property def battery_level(self): """Return battery level.""" return self._battery_level @property def clean_id(self): """Return clean id.""" return self._clean_id def __repr__(self): """Return a String representation.""" fields = [("state", str(self.state)), ("clean_id", str(self.clean_id)), ("full_clean_type", str(self.full_clean_type)), ("power_mode", str(self.power_mode)), ("battery_level", str(self.battery_level)), ("position", str(self.position))] return 'Dyson360EyeState(' + ",".join(printable_fields(fields)) + ')' class Dyson360EyeTelemetryData: """Dyson 360 Eye Telemetry Data.""" @staticmethod def is_telemetry_data(payload): """Return true if this message is a telemetry data message.""" json_message = json.loads(payload) return json_message['msg'] in ["TELEMETRY-DATA"] def __init__(self, json_body): """Create a new Telemetry Data.""" data = json.loads(json_body) self._telemetry_data_id = data["id"] self._field1 = data["field1"] self._field2 = data["field2"] self._field3 = data["field3"] self._field4 = data["field4"] self._time = datetime.datetime.strptime(data["time"], "%Y-%m-%dT%H:%M:%SZ") @property def telemetry_data_id(self): """Return Telemetry data id.""" return self._telemetry_data_id @property def field1(self): """Return field 1.""" return self._field1 @property def field2(self): """Return field 2.""" return self._field2 @property def field3(self): """Return field 3.""" return self._field3 @property def field4(self): """Return field 4.""" return self._field4 @property def time(self): """Return time.""" return self._time def __repr__(self): """Return a String representation.""" fields = [("telemetry_data_id", str(self.telemetry_data_id)), ("field1", str(self.field1)), ("field2", str(self.field2)), ("field3", str(self.field3)), ("field4", str(self.field4)), ("time", str(self.time))] return 'Dyson360EyeTelemetryData(' + ",".join( printable_fields(fields)) + ')' class Dyson360EyeMapData: """Dyson 360 Eye map data.""" @staticmethod def is_map_data(payload): """Return true if this message is a map data message.""" json_message = json.loads(payload) return json_message['msg'] in ["MAP-DATA"] def __init__(self, json_body): """Create a new Map Data.""" data = json.loads(json_body) self._grid_id = data["gridID"] self._clean_id = data["cleanId"] self._content_type = data["data"]["content-type"] self._content_encoding = data["data"]["content-encoding"] self._content = data["data"]["content"] self._time = datetime.datetime.strptime(data["time"], "%Y-%m-%dT%H:%M:%SZ") @property def grid_id(self): """Return Grid id.""" return self._grid_id @property def clean_id(self): """Return Clean Id.""" return self._clean_id @property def content_type(self): """Return content type.""" return self._content_type @property def content_encoding(self): """Return content encoding.""" return self._content_encoding @property def content(self): """Return content.""" return self._content @property def time(self): """Return time.""" return self._time def __repr__(self): """Return a String representation.""" fields = [("grid_id", str(self.grid_id)), ("clean_id", str(self.clean_id)), ("content_type", str(self.content_type)), ("content_encoding", str(self.content_encoding)), ("content", str(self.content)), ("time", str(self.time))] return 'Dyson360EyeMapData(' + ",".join(printable_fields(fields)) + ')' class Dyson360EyeMapGrid: """Dyson 360 Eye map grid.""" @staticmethod def is_map_grid(payload): """Return true if this message is a map grid message.""" json_message = json.loads(payload) return json_message['msg'] in ["MAP-GRID"] def __init__(self, json_body): """Create a new Map Grid.""" data = json.loads(json_body) self._grid_id = data["gridID"] self._resolution = data["resolution"] self._width = data["width"] self._height = data["height"] self._clean_id = data["cleanId"] if "anchor" in data and len(data["anchor"]) == 2: self._anchor = (int(data["anchor"][0]), int(data["anchor"][1])) self._time = datetime.datetime.strptime(data["time"], "%Y-%m-%dT%H:%M:%SZ") @property def grid_id(self): """Return grid id.""" return self._grid_id @property def clean_id(self): """Return clean id.""" return self._clean_id @property def resolution(self): """Return resolution.""" return self._resolution @property def width(self): """Return width.""" return self._width @property def height(self): """Return height.""" return self._height @property def anchor(self): """Return Anchor.""" return self._anchor @property def time(self): """Return time.""" return self._time def __repr__(self): """Return a String representation.""" fields = [("grid_id", str(self.grid_id)), ("clean_id", str(self.clean_id)), ("resolution", str(self.resolution)), ("width", str(self.width)), ("height", str(self.height)), ("anchor", str(self.anchor)), ("time", str(self.time))] return 'Dyson360EyeMapGrid(' + ",".join(printable_fields(fields)) + ')' class Dyson360EyeMapGlobal: """Dyson 360Eye map global.""" @staticmethod def is_map_global(payload): """Return true if this message is a map global message.""" json_message = json.loads(payload) return json_message['msg'] in ["MAP-GLOBAL"] def __init__(self, json_body): """Create a new Map Global.""" data = json.loads(json_body) self._grid_id = data["gridID"] self._x = data["x"] self._y = data["y"] self._angle = data["angle"] self._clean_id = data["cleanId"] self._time = datetime.datetime.strptime(data["time"], "%Y-%m-%dT%H:%M:%SZ") @property def grid_id(self): """Return grid id.""" return self._grid_id @property def clean_id(self): """Return clean id.""" return self._clean_id @property def position_x(self): """Return x.""" return self._x @property def position_y(self): """Return y.""" return self._y @property def angle(self): """Return angle.""" return self._angle @property def time(self): """Return time.""" return self._time def __repr__(self): """Return a String representation.""" fields = [("grid_id", str(self.grid_id)), ("clean_id", str(self.clean_id)), ("x", str(self.position_x)), ("y", str(self.position_y)), ("angle", str(self.angle)), ("time", str(self.time))] return 'Dyson360EyeMapGlobal(' + ",".join( printable_fields(fields)) + ')' class Dyson360Goodbye: """Dyson 360 Eye goodbye message.""" @staticmethod def is_goodbye_message(payload): """Return true if this message is a goodbye message.""" json_message = json.loads(payload) return json_message['msg'] in ["GOODBYE"] def __init__(self, json_body): """Create a new Map Global.""" data = json.loads(json_body) self._reason = data["reason"] self._time = datetime.datetime.strptime(data["time"], "%Y-%m-%dT%H:%M:%SZ") @property def reason(self): """Return reason.""" return self._reason @property def time(self): """Return time.""" return self._time def __repr__(self): """Return a String representation.""" fields = [("reason", str(self.reason)), ("time", str(self.time))] return 'Dyson360EyeGoodbye(' + ",".join(printable_fields(fields)) + ')' ================================================ FILE: libpurecoollink/dyson_device.py ================================================ """Base Dyson devices.""" # pylint: disable=too-many-public-methods,too-many-instance-attributes from queue import Queue import logging import json import abc import time from .utils import printable_fields from .utils import decrypt_password _LOGGER = logging.getLogger(__name__) MQTT_RETURN_CODES = { 0: "Connection successful", 1: "Connection refused - incorrect protocol version", 2: "Connection refused - invalid client identifier", 3: "Connection refused - server unavailable", 4: "Connection refused - bad username or password", 5: "Connection refused - not authorised" } DEFAULT_PORT = 1883 class NetworkDevice: """Network device.""" def __init__(self, name, address, port): """Create a new network device. :param name: Device name :param address: Device address :param port: Device port """ self._name = name self._address = address self._port = port @property def name(self): """Device name.""" return self._name @property def address(self): """Device address.""" return self._address @property def port(self): """Device port.""" return self._port def __repr__(self): """Return a String representation.""" fields = [("name", self.name), ("address", self.address), ("port", str(self.port))] return 'NetworkDevice(' + ",".join(printable_fields(fields)) + ')' class DysonDevice: """Abstract Dyson device.""" @staticmethod def on_connect(client, userdata, flags, return_code): # pylint: disable=unused-argument """Set function callback when connected.""" if return_code == 0: _LOGGER.debug("Connected with result code: %s", return_code) client.subscribe(userdata.status_topic) userdata.connection_callback(True) else: _LOGGER.error("Connection error: %s", MQTT_RETURN_CODES[return_code]) userdata.connection_callback(False) def __init__(self, json_body): """Create a new Dyson device. :param json_body: JSON message returned by the HTTPS API """ self._active = json_body['Active'] self._serial = json_body['Serial'] self._name = json_body['Name'] self._version = json_body['Version'] self._credentials = decrypt_password(json_body['LocalCredentials']) self._auto_update = json_body['AutoUpdate'] self._new_version_available = json_body['NewVersionAvailable'] self._product_type = json_body['ProductType'] self._network_device = None self._connected = False self._mqtt = None self._callback_message = [] self._device_available = False self._current_state = None self._state_data_available = Queue() self._search_device_queue = Queue() self._connection_queue = Queue() def connection_callback(self, connected): """Set function called when device is connected.""" self._connection_queue.put_nowait(connected) @abc.abstractmethod def connect(self, device_ip, device_port=DEFAULT_PORT): """Connect to the device using ip address. :param device_ip: Device IP address :param device_port: Device Port (default: 1883) :return: True if connected, else False """ return @property @abc.abstractmethod def status_topic(self): """MQTT status topic.""" return @property def command_topic(self): """MQTT command topic.""" return "{0}/{1}/command".format(self._product_type, self._serial) def request_current_state(self): """Request new state message.""" if self._connected: payload = { "msg": "REQUEST-CURRENT-STATE", "time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) } self._mqtt.publish(self.command_topic, json.dumps(payload)) else: _LOGGER.warning( "Unable to send commands because device %s is not connected", self.serial) @property def state(self): """Device state.""" return self._current_state @state.setter def state(self, value): """Set current state.""" self._current_state = value @property def active(self): """Active status.""" return self._active @property def serial(self): """Device serial.""" return self._serial @property def name(self): """Device name.""" return self._name @property def version(self): """Device version.""" return self._version @property def credentials(self): """Device encrypted credentials.""" return self._credentials @property def auto_update(self): """Auto update configuration.""" return self._auto_update @property def new_version_available(self): """Return if new version available.""" return self._new_version_available @property def product_type(self): """Product type.""" return self._product_type @property def network_device(self): """Network device.""" return self._network_device def _add_network_device(self, network_device): """Add network device. :param network_device: Network device """ self._search_device_queue.put_nowait(network_device) @property def callback_message(self): """Return callback functions when message are received.""" return self._callback_message def add_message_listener(self, callback_message): """Add message listener.""" self._callback_message.append(callback_message) def remove_message_listener(self, callback_message): """Remove a message listener.""" if callback_message in self._callback_message: self.callback_message.remove(callback_message) def clear_message_listener(self): """Clear all message listener.""" self.callback_message.clear() @property def device_available(self): """Return True if device is fully available, else false.""" return self._device_available def state_data_available(self): """Call when first state data are available. Internal method.""" _LOGGER.debug("State data available for device %s", self._serial) self._state_data_available.put_nowait(True) def _fields(self): """Return list of field tuples.""" fields = [("serial", self.serial), ("active", str(self.active)), ("name", self.name), ("version", self.version), ("auto_update", str(self.auto_update)), ("new_version_available", str(self.new_version_available)), ("product_type", self.product_type), ("network_device", str(self.network_device))] return fields ================================================ FILE: libpurecoollink/dyson_pure_cool_link.py ================================================ """Dyson pure cool link device.""" # pylint: disable=too-many-locals import json import logging import time import socket from threading import Thread from queue import Queue, Empty import paho.mqtt.client as mqtt from .dyson_device import DysonDevice, NetworkDevice, DEFAULT_PORT from .utils import printable_fields, support_heating from .dyson_pure_state import DysonPureHotCoolState, DysonPureCoolState, \ DysonEnvironmentalSensorState from .zeroconf import ServiceBrowser, Zeroconf _LOGGER = logging.getLogger(__name__) class DysonPureCoolLink(DysonDevice): """Dyson device (fan).""" class DysonDeviceListener(object): """Message listener.""" def __init__(self, serial, add_device_function): """Create a new message listener. :param serial: Device serial :param add_device_function: Callback function """ self._serial = serial self.add_device_function = add_device_function def remove_service(self, zeroconf, device_type, name): # pylint: disable=unused-argument,no-self-use """Remove listener.""" _LOGGER.info("Service %s removed", name) def add_service(self, zeroconf, device_type, name): """Add device. :param zeroconf: MSDNS object :param device_type: Service type :param name: Device name """ device_serial = (name.split(".")[0]).split("_")[1] if device_serial == self._serial: # Find searched device info = zeroconf.get_service_info(device_type, name) address = socket.inet_ntoa(info.address) network_device = NetworkDevice(device_serial, address, info.port) self.add_device_function(network_device) zeroconf.close() def __init__(self, json_body): """Create a new Pure Cool Link device. :param json_body: JSON message returned by the HTTPS API """ super().__init__(json_body) self._sensor_data_available = Queue() self._environmental_state = None self._request_thread = None @property def status_topic(self): """MQTT status topic.""" return "{0}/{1}/status/current".format(self.product_type, self.serial) @staticmethod def on_message(client, userdata, msg): # pylint: disable=unused-argument """Set function Callback when message received.""" payload = msg.payload.decode("utf-8") if DysonPureCoolState.is_state_message(payload): if support_heating(userdata.product_type): device_msg = DysonPureHotCoolState(payload) else: device_msg = DysonPureCoolState(payload) if not userdata.device_available: userdata.state_data_available() userdata.state = device_msg for function in userdata.callback_message: function(device_msg) elif DysonEnvironmentalSensorState.is_environmental_state_message( payload): device_msg = DysonEnvironmentalSensorState(payload) if not userdata.device_available: userdata.sensor_data_available() userdata.environmental_state = device_msg for function in userdata.callback_message: function(device_msg) else: _LOGGER.warning("Unknown message: %s", payload) def auto_connect(self, timeout=5, retry=15): """Try to connect to device using mDNS. :param timeout: Timeout :param retry: Max retry :return: True if connected, else False """ for i in range(retry): zeroconf = Zeroconf() listener = self.DysonDeviceListener(self._serial, self._add_network_device) ServiceBrowser(zeroconf, "_dyson_mqtt._tcp.local.", listener) try: self._network_device = self._search_device_queue.get( timeout=timeout) except Empty: # Unable to find device _LOGGER.warning("Unable to find device %s, try %s", self._serial, i) zeroconf.close() else: break if self._network_device is None: _LOGGER.error("Unable to connect to device %s", self._serial) return False return self._mqtt_connect() def connect(self, device_ip, device_port=DEFAULT_PORT): """Connect to the device using ip address. :param device_ip: Device IP address :param device_port: Device Port (default: 1883) :return: True if connected, else False """ self._network_device = NetworkDevice(self._name, device_ip, device_port) return self._mqtt_connect() def _mqtt_connect(self): """Connect to the MQTT broker.""" self._mqtt = mqtt.Client(userdata=self) self._mqtt.on_message = self.on_message self._mqtt.on_connect = self.on_connect self._mqtt.username_pw_set(self._serial, self._credentials) self._mqtt.connect(self._network_device.address, self._network_device.port) self._mqtt.loop_start() self._connected = self._connection_queue.get(timeout=10) if self._connected: self.request_current_state() # Start Environmental thread self._request_thread = EnvironmentalSensorThread( self.request_environmental_state) self._request_thread.start() # Wait for first data self._state_data_available.get() self._sensor_data_available.get() self._device_available = True else: self._mqtt.loop_stop() return self._connected def sensor_data_available(self): """Call when first sensor data are available. Internal method.""" _LOGGER.debug("Sensor data available for device %s", self._serial) self._sensor_data_available.put_nowait(True) def disconnect(self): """Disconnect from the device.""" self._request_thread.stop() self._connected = False def request_environmental_state(self): """Request new state message.""" if self._connected: payload = { "msg": "REQUEST-PRODUCT-ENVIRONMENT-CURRENT-SENSOR-DATA", "time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) } self._mqtt.publish( self._product_type + "/" + self._serial + "/command", json.dumps(payload)) else: _LOGGER.warning( "Unable to send commands because device %s is not connected", self.serial) def set_fan_configuration(self, data): # pylint: disable=too-many-arguments,too-many-locals """Configure Fan. :param data: Data to send """ if self._connected: payload = { "msg": "STATE-SET", "time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "mode-reason": "LAPP", "data": data } self._mqtt.publish(self.command_topic, json.dumps(payload), 1) else: _LOGGER.warning("Not connected, can not set configuration: %s", self.serial) def _parse_command_args(self, **kwargs): """Parse command arguments. :param kwargs Arguments :return payload dictionary """ fan_mode = kwargs.get('fan_mode') oscillation = kwargs.get('oscillation') fan_speed = kwargs.get('fan_speed') night_mode = kwargs.get('night_mode') quality_target = kwargs.get('quality_target') standby_monitoring = kwargs.get('standby_monitoring') sleep_timer = kwargs.get('sleep_timer') reset_filter = kwargs.get('reset_filter') f_mode = fan_mode.value if fan_mode \ else self._current_state.fan_mode f_speed = fan_speed.value if fan_speed \ else self._current_state.speed f_oscillation = oscillation.value if oscillation \ else self._current_state.oscillation f_night_mode = night_mode.value if night_mode \ else self._current_state.night_mode f_quality_target = quality_target.value if quality_target \ else self._current_state.quality_target f_standby_monitoring = standby_monitoring.value if \ standby_monitoring else self._current_state.standby_monitoring f_sleep_timer = sleep_timer if sleep_timer or isinstance( sleep_timer, int) else "STET" f_reset_filter = reset_filter.value if reset_filter \ else "STET" return { "fmod": f_mode, "fnsp": f_speed, "oson": f_oscillation, "sltm": f_sleep_timer, # sleep timer "rhtm": f_standby_monitoring, # monitor air quality # when inactive "rstf": f_reset_filter, # reset filter lifecycle "qtar": f_quality_target, "nmod": f_night_mode } def set_configuration(self, **kwargs): """Configure fan. :param kwargs: Parameters """ data = self._parse_command_args(**kwargs) self.set_fan_configuration(data) @property def environmental_state(self): """Environmental Device state.""" return self._environmental_state @environmental_state.setter def environmental_state(self, value): """Set Environmental Device state.""" self._environmental_state = value @property def connected(self): """Device connected.""" return self._connected @connected.setter def connected(self, value): """Set device connected.""" self._connected = value def __repr__(self): """Return a String representation.""" fields = self._fields() return 'DysonPureCoolLink(' + ",".join(printable_fields(fields)) + ')' class EnvironmentalSensorThread(Thread): """Environmental Sensor thread. The device don't send environmental data if not asked. """ def __init__(self, request_data_method, interval=30): """Create new Environmental Sensor thread.""" Thread.__init__(self) self._interval = interval self._request_data_method = request_data_method self._stop_queue = Queue() def stop(self): """Stop the thread.""" self._stop_queue.put_nowait(True) def run(self): """Start Refresh sensor state thread.""" stopped = False while not stopped: self._request_data_method() try: stopped = self._stop_queue.get(timeout=self._interval) except Empty: # Thread has not been stopped pass ================================================ FILE: libpurecoollink/dyson_pure_hotcool_link.py ================================================ """Dyson pure Hot+Cool link device.""" import logging from .dyson_pure_cool_link import DysonPureCoolLink from .utils import printable_fields _LOGGER = logging.getLogger(__name__) class DysonPureHotCoolLink(DysonPureCoolLink): """Dyson Pure Hot+Cool device.""" def _parse_command_args(self, **kwargs): """Parse command arguments. :param kwargs Arguments :return payload dictionary """ data = super()._parse_command_args(**kwargs) heat_mode = kwargs.get('heat_mode') heat_target = kwargs.get('heat_target') focus_mode = kwargs.get('focus_mode') f_heat_mode = heat_mode.value if heat_mode \ else self._current_state.heat_mode f_heat_target = heat_target if heat_target \ else self._current_state.heat_target f_fan_focus = focus_mode.value if focus_mode \ else self._current_state.focus_mode data["hmod"] = f_heat_mode data["ffoc"] = f_fan_focus data["hmax"] = f_heat_target return data def set_configuration(self, **kwargs): """Configure fan. :param kwargs: Parameters """ data = self._parse_command_args(**kwargs) self.set_fan_configuration(data) def __repr__(self): """Return a String representation.""" fields = self._fields() return 'DysonPureHotCoolLink(' + ",".join( printable_fields(fields)) + ')' ================================================ FILE: libpurecoollink/dyson_pure_state.py ================================================ """Dyson Pure link devices (Cool and Hot+Cool) states.""" # pylint: disable=too-many-public-methods,too-many-instance-attributes import json from .utils import printable_fields class DysonPureCoolState: """Dyson device state.""" @staticmethod def is_state_message(payload): """Return true if this message is a Dyson Pure state message.""" return json.loads(payload)['msg'] in ["CURRENT-STATE", "STATE-CHANGE"] @staticmethod def _get_field_value(state, field): """Get field value.""" return state[field][1] if isinstance(state[field], list) else state[ field] def __init__(self, payload): """Create a new state. :param product_type: Product type :param payload: Message payload """ json_message = json.loads(payload) self._state = json_message['product-state'] self._fan_mode = self._get_field_value(self._state, 'fmod') self._fan_state = self._get_field_value(self._state, 'fnst') self._night_mode = self._get_field_value(self._state, 'nmod') self._speed = self._get_field_value(self._state, 'fnsp') self._oscilation = self._get_field_value(self._state, 'oson') self._filter_life = self._get_field_value(self._state, 'filf') self._quality_target = self._get_field_value(self._state, 'qtar') self._standby_monitoring = self._get_field_value(self._state, 'rhtm') @property def fan_mode(self): """Fan mode.""" return self._fan_mode @property def fan_state(self): """Fan state.""" return self._fan_state @property def night_mode(self): """Night mode.""" return self._night_mode @property def speed(self): """Fan speed.""" return self._speed @property def oscillation(self): """Oscillation mode.""" return self._oscilation @property def filter_life(self): """Filter life.""" return self._filter_life @property def quality_target(self): """Air quality target.""" return self._quality_target @property def standby_monitoring(self): """Monitor when inactive (standby).""" return self._standby_monitoring def __repr__(self): """Return a String representation.""" fields = [("fan_mode", self.fan_mode), ("fan_state", self.fan_state), ("night_mode", self.night_mode), ("speed", self.speed), ("oscillation", self.oscillation), ("filter_life", self.filter_life), ("quality_target", self.quality_target), ("standby_monitoring", self.standby_monitoring)] return 'DysonPureCoolState(' + ",".join(printable_fields(fields)) + ')' class DysonEnvironmentalSensorState: """Environmental sensor state.""" @staticmethod def is_environmental_state_message(payload): """Return true if this message is a state message.""" json_message = json.loads(payload) return json_message['msg'] in ["ENVIRONMENTAL-CURRENT-SENSOR-DATA"] @staticmethod def __get_field_value(state, field): """Get field value.""" return state[field][1] if isinstance(state[field], list) else state[ field] def __init__(self, payload): """Create a new Environmental sensor state. :param payload: Message payload """ json_message = json.loads(payload) data = json_message['data'] humidity = self.__get_field_value(data, 'hact') self._humidity = 0 if humidity == 'OFF' else int(humidity) volatil_copounds = self.__get_field_value(data, 'vact') self._volatil_compounds = 0 if volatil_copounds == 'INIT' else int( volatil_copounds) temperature = self.__get_field_value(data, 'tact') self._temperature = 0 if temperature == 'OFF' else float( temperature) / 10 self._dust = int(self.__get_field_value(data, 'pact')) sltm = self.__get_field_value(data, 'sltm') self._sleep_timer = 0 if sltm == 'OFF' else int(sltm) @property def humidity(self): """Humidity in percent.""" return self._humidity @property def volatil_organic_compounds(self): """Volatil organic compounds level.""" return self._volatil_compounds @property def temperature(self): """Temperature in Kelvin.""" return self._temperature @property def dust(self): """Dust level.""" return self._dust @property def sleep_timer(self): """Sleep timer.""" return self._sleep_timer def __repr__(self): """Return a String representation.""" fields = [("humidity", str(self.humidity)), ("air quality", str(self.volatil_organic_compounds)), ("temperature", str(self.temperature)), ("dust", str(self.dust)), ("sleep_timer", str(self._sleep_timer))] return 'DysonEnvironmentalSensorState(' + ",".join( printable_fields(fields)) + ')' class DysonPureHotCoolState(DysonPureCoolState): """Dyson device state.""" def __init__(self, payload): """Create a new Dyson Hot+Cool state. :param product_type: Product type :param payload: Message payload """ super().__init__(payload) self._tilt = DysonPureCoolState._get_field_value(self._state, 'tilt') self._fan_focus = DysonPureCoolState._get_field_value(self._state, 'ffoc') self._heat_target = DysonPureCoolState._get_field_value(self._state, 'hmax') self._heat_mode = DysonPureCoolState._get_field_value(self._state, 'hmod') self._heat_state = DysonPureCoolState._get_field_value(self._state, 'hsta') @property def tilt(self): """Return tilt status.""" return self._tilt @property def focus_mode(self): """Focus the fan on one stream or spread.""" return self._fan_focus @property def heat_target(self): """Heat target of the temperature.""" return self._heat_target @property def heat_mode(self): """Heat mode on or off.""" return self._heat_mode @property def heat_state(self): """Return heat state.""" return self._heat_state def __repr__(self): """Return a String representation.""" fields = [("fan_mode", self.fan_mode), ("fan_state", self.fan_state), ("night_mode", self.night_mode), ("speed", self.speed), ("oscillation", self.oscillation), ("filter_life", self.filter_life), ("quality_target", self.quality_target), ("standby_monitoring", self.standby_monitoring), ("tilt", self.tilt), ("focus_mode", self.focus_mode), ("heat_mode", self.heat_mode), ("heat_target", self.heat_target), ("heat_state", self.heat_state)] return 'DysonHotCoolState(' + ",".join(printable_fields(fields)) + ')' ================================================ FILE: libpurecoollink/exceptions.py ================================================ """Dyson exceptions.""" # pylint: disable=useless-super-delegation class DysonInvalidTargetTemperatureException(Exception): """Invalid target temperature Exception.""" CELSIUS = "Celsius" FAHRENHEIT = "Fahrenheit" def __init__(self, temperature_unit, current_value): """Dyson invalid target temperature. :param temperature_unit Celsius/Fahrenheit :param current_value invalid value """ super(DysonInvalidTargetTemperatureException, self).__init__() self._temperature_unit = temperature_unit self._current_value = current_value @property def temperature_unit(self): """Temperature unit: Celsius or Fahrenheit.""" return self._temperature_unit @property def current_value(self): """Return Current value.""" return self._current_value def __repr__(self): """Return a String representation.""" if self.temperature_unit == self.CELSIUS: return "{0} is not a valid temperature target. It must be " \ "between 1 to 37 inclusive.".format(self._current_value) if self.temperature_unit == self.FAHRENHEIT: return "{0} is not a valid temperature target. It must be " \ "between 34 to 98 inclusive.".format(self._current_value) class DysonNotLoggedException(Exception): """Not logged to Dyson Web Services Exception.""" def __init__(self): """Dyson Not Logged Exception.""" super(DysonNotLoggedException, self).__init__() ================================================ FILE: libpurecoollink/utils.py ================================================ """Utilities for Dyson Pure Hot+Cool link devices.""" import json import base64 from Crypto.Cipher import AES from .const import DYSON_PURE_HOT_COOL_LINK_TOUR, DYSON_360_EYE def support_heating(product_type): """Return True if device_model support heating mode, else False. :param product_type Dyson device model """ if product_type in [DYSON_PURE_HOT_COOL_LINK_TOUR]: return True return False def is_heating_device(json_payload): """Return true if this json payload is a hot+cool device.""" if json_payload['ProductType'] in [DYSON_PURE_HOT_COOL_LINK_TOUR]: return True return False def printable_fields(fields): """Return printable fields. :param fields list of tuble with (label, vallue) """ for field in fields: yield field[0]+"="+field[1] def unpad(string): """Un pad string.""" return string[:-ord(string[len(string) - 1:])] def decrypt_password(encrypted_password): """Decrypt password. :param encrypted_password: Encrypted password """ key = b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f\x10' \ b'\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f ' init_vector = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' \ b'\x00\x00\x00\x00' cipher = AES.new(key, AES.MODE_CBC, init_vector) json_password = json.loads(unpad( cipher.decrypt(base64.b64decode(encrypted_password)).decode('utf-8'))) return json_password["apPasswordHash"] def is_360_eye_device(json_payload): """Return true if this json payload is a Dyson 360 Eye device.""" if json_payload['ProductType'] == DYSON_360_EYE: return True return False ================================================ FILE: libpurecoollink/zeroconf.py ================================================ from __future__ import ( absolute_import, division, print_function, unicode_literals) """ Multicast DNS Service Discovery for Python, v0.14-wmcbrine Copyright 2003 Paul Scott-Murphy, 2014 William McBrine This module provides a framework for the use of DNS Service Discovery using IP multicast. This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA """ import enum import errno import logging import re import select import socket import struct import sys import threading import time from functools import reduce import netifaces from six import binary_type, indexbytes, int2byte, iteritems, text_type from six.moves import xrange __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' __version__ = '0.18.0' __license__ = 'LGPL' __all__ = [ "__version__", "Zeroconf", "ServiceInfo", "ServiceBrowser", "Error", "InterfaceChoice", "ServiceStateChange", ] log = logging.getLogger(__name__) log.addHandler(logging.NullHandler()) if log.level == logging.NOTSET: log.setLevel(logging.WARN) # Some timing constants _UNREGISTER_TIME = 125 _CHECK_TIME = 175 _REGISTER_TIME = 225 _LISTENER_TIME = 200 _BROWSER_TIME = 500 # Some DNS constants _MDNS_ADDR = '224.0.0.251' _MDNS_PORT = 5353 _DNS_PORT = 53 _DNS_TTL = 60 * 60 # one hour default TTL _MAX_MSG_TYPICAL = 1460 # unused _MAX_MSG_ABSOLUTE = 8966 _FLAGS_QR_MASK = 0x8000 # query response mask _FLAGS_QR_QUERY = 0x0000 # query _FLAGS_QR_RESPONSE = 0x8000 # response _FLAGS_AA = 0x0400 # Authoritative answer _FLAGS_TC = 0x0200 # Truncated _FLAGS_RD = 0x0100 # Recursion desired _FLAGS_RA = 0x8000 # Recursion available _FLAGS_Z = 0x0040 # Zero _FLAGS_AD = 0x0020 # Authentic data _FLAGS_CD = 0x0010 # Checking disabled _CLASS_IN = 1 _CLASS_CS = 2 _CLASS_CH = 3 _CLASS_HS = 4 _CLASS_NONE = 254 _CLASS_ANY = 255 _CLASS_MASK = 0x7FFF _CLASS_UNIQUE = 0x8000 _TYPE_A = 1 _TYPE_NS = 2 _TYPE_MD = 3 _TYPE_MF = 4 _TYPE_CNAME = 5 _TYPE_SOA = 6 _TYPE_MB = 7 _TYPE_MG = 8 _TYPE_MR = 9 _TYPE_NULL = 10 _TYPE_WKS = 11 _TYPE_PTR = 12 _TYPE_HINFO = 13 _TYPE_MINFO = 14 _TYPE_MX = 15 _TYPE_TXT = 16 _TYPE_AAAA = 28 _TYPE_SRV = 33 _TYPE_ANY = 255 # Mapping constants to names _CLASSES = {_CLASS_IN: "in", _CLASS_CS: "cs", _CLASS_CH: "ch", _CLASS_HS: "hs", _CLASS_NONE: "none", _CLASS_ANY: "any"} _TYPES = {_TYPE_A: "a", _TYPE_NS: "ns", _TYPE_MD: "md", _TYPE_MF: "mf", _TYPE_CNAME: "cname", _TYPE_SOA: "soa", _TYPE_MB: "mb", _TYPE_MG: "mg", _TYPE_MR: "mr", _TYPE_NULL: "null", _TYPE_WKS: "wks", _TYPE_PTR: "ptr", _TYPE_HINFO: "hinfo", _TYPE_MINFO: "minfo", _TYPE_MX: "mx", _TYPE_TXT: "txt", _TYPE_AAAA: "quada", _TYPE_SRV: "srv", _TYPE_ANY: "any"} _HAS_A_TO_Z = re.compile(r'[A-Za-z]') _HAS_ONLY_A_TO_Z_NUM_HYPHEN = re.compile(r'^[A-Za-z0-9\-\_]+$') _HAS_ASCII_CONTROL_CHARS = re.compile(r'[\x00-\x1f\x7f]') @enum.unique class InterfaceChoice(enum.Enum): Default = 1 All = 2 @enum.unique class ServiceStateChange(enum.Enum): Added = 1 Removed = 2 HOST_ONLY_NETWORK_MASK = '255.255.255.255' # utility functions def current_time_millis(): """Current system time in milliseconds""" return time.time() * 1000 def service_type_name(type_): """ Validate a fully qualified service name, instance or subtype. [rfc6763] Returns fully qualified service name. Domain names used by mDNS-SD take the following forms: . <_tcp|_udp> . local. . . <_tcp|_udp> . local. ._sub . . <_tcp|_udp> . local. 1) must end with 'local.' This is true because we are implementing mDNS and since the 'm' means multi-cast, the 'local.' domain is mandatory. 2) local is preceded with either '_udp.' or '_tcp.' 3) service name precedes <_tcp|_udp> The rules for Service Names [RFC6335] state that they may be no more than fifteen characters long (not counting the mandatory underscore), consisting of only letters, digits, and hyphens, must begin and end with a letter or digit, must not contain consecutive hyphens, and must contain at least one letter. The instance name and sub type may be up to 63 bytes. The portion of the Service Instance Name is a user- friendly name consisting of arbitrary Net-Unicode text [RFC5198]. It MUST NOT contain ASCII control characters (byte values 0x00-0x1F and 0x7F) [RFC20] but otherwise is allowed to contain any characters, without restriction, including spaces, uppercase, lowercase, punctuation -- including dots -- accented characters, non-Roman text, and anything else that may be represented using Net-Unicode. :param type_: Type, SubType or service name to validate :return: fully qualified service name (eg: _http._tcp.local.) """ if not (type_.endswith('._tcp.local.') or type_.endswith('._udp.local.')): raise BadTypeInNameException( "Type '%s' must end with '._tcp.local.' or '._udp.local.'" % type_) remaining = type_[:-len('._tcp.local.')].split('.') name = remaining.pop() if not name: raise BadTypeInNameException("No Service name found") if len(remaining) == 1 and len(remaining[0]) == 0: raise BadTypeInNameException( "Type '%s' must not start with '.'" % type_) if name[0] != '_': raise BadTypeInNameException( "Service name (%s) must start with '_'" % name) # remove leading underscore name = name[1:] if len(name) > 15: raise BadTypeInNameException( "Service name (%s) must be <= 15 bytes" % name) if '--' in name: raise BadTypeInNameException( "Service name (%s) must not contain '--'" % name) if '-' in (name[0], name[-1]): raise BadTypeInNameException( "Service name (%s) may not start or end with '-'" % name) if not _HAS_A_TO_Z.search(name): raise BadTypeInNameException( "Service name (%s) must contain at least one letter (eg: 'A-Z')" % name) if not _HAS_ONLY_A_TO_Z_NUM_HYPHEN.search(name): raise BadTypeInNameException( "Service name (%s) must contain only these characters: " "A-Z, a-z, 0-9, hyphen ('-')" % name) if remaining and remaining[-1] == '_sub': remaining.pop() if len(remaining) == 0 or len(remaining[0]) == 0: raise BadTypeInNameException( "_sub requires a subtype name") if len(remaining) > 1: remaining = ['.'.join(remaining)] if remaining: length = len(remaining[0].encode('utf-8')) if length > 63: raise BadTypeInNameException("Too long: '%s'" % remaining[0]) if _HAS_ASCII_CONTROL_CHARS.search(remaining[0]): raise BadTypeInNameException( "Ascii control character 0x00-0x1F and 0x7F illegal in '%s'" % remaining[0]) return '_' + name + type_[-len('._tcp.local.'):] # Exceptions class Error(Exception): pass class IncomingDecodeError(Error): pass class NonUniqueNameException(Error): pass class NamePartTooLongException(Error): pass class AbstractMethodException(Error): pass class BadTypeInNameException(Error): pass # implementation classes class QuietLogger(object): _seen_logs = {} @classmethod def log_exception_warning(cls, logger_data=None): exc_info = sys.exc_info() exc_str = str(exc_info[1]) if exc_str not in cls._seen_logs: # log at warning level the first time this is seen cls._seen_logs[exc_str] = exc_info logger = log.warning else: logger = log.debug if logger_data is not None: logger(*logger_data) logger('Exception occurred:', exc_info=exc_info) @classmethod def log_warning_once(cls, *args): msg_str = args[0] if msg_str not in cls._seen_logs: cls._seen_logs[msg_str] = 0 logger = log.warning else: logger = log.debug cls._seen_logs[msg_str] += 1 logger(*args) class DNSEntry(object): """A DNS entry""" def __init__(self, name, type_, class_): self.key = name.lower() self.name = name self.type = type_ self.class_ = class_ & _CLASS_MASK self.unique = (class_ & _CLASS_UNIQUE) != 0 def __eq__(self, other): """Equality test on name, type, and class""" return (isinstance(other, DNSEntry) and self.name == other.name and self.type == other.type and self.class_ == other.class_) def __ne__(self, other): """Non-equality test""" return not self.__eq__(other) @staticmethod def get_class_(class_): """Class accessor""" return _CLASSES.get(class_, "?(%s)" % class_) @staticmethod def get_type(t): """Type accessor""" return _TYPES.get(t, "?(%s)" % t) def to_string(self, hdr, other): """String representation with additional information""" result = "%s[%s,%s" % (hdr, self.get_type(self.type), self.get_class_(self.class_)) if self.unique: result += "-unique," else: result += "," result += self.name if other is not None: result += ",%s]" % other else: result += "]" return result class DNSQuestion(DNSEntry): """A DNS question entry""" def __init__(self, name, type_, class_): DNSEntry.__init__(self, name, type_, class_) def answered_by(self, rec): """Returns true if the question is answered by the record""" return (self.class_ == rec.class_ and (self.type == rec.type or self.type == _TYPE_ANY) and self.name == rec.name) def __repr__(self): """String representation""" return DNSEntry.to_string(self, "question", None) class DNSRecord(DNSEntry): """A DNS record - like a DNS entry, but has a TTL""" def __init__(self, name, type_, class_, ttl): DNSEntry.__init__(self, name, type_, class_) self.ttl = ttl self.created = current_time_millis() def __eq__(self, other): """Abstract method""" raise AbstractMethodException def suppressed_by(self, msg): """Returns true if any answer in a message can suffice for the information held in this record.""" for record in msg.answers: if self.suppressed_by_answer(record): return True return False def suppressed_by_answer(self, other): """Returns true if another record has same name, type and class, and if its TTL is at least half of this record's.""" return self == other and other.ttl > (self.ttl / 2) def get_expiration_time(self, percent): """Returns the time at which this record will have expired by a certain percentage.""" return self.created + (percent * self.ttl * 10) def get_remaining_ttl(self, now): """Returns the remaining TTL in seconds.""" return max(0, (self.get_expiration_time(100) - now) / 1000.0) def is_expired(self, now): """Returns true if this record has expired.""" return self.get_expiration_time(100) <= now def is_stale(self, now): """Returns true if this record is at least half way expired.""" return self.get_expiration_time(50) <= now def reset_ttl(self, other): """Sets this record's TTL and created time to that of another record.""" self.created = other.created self.ttl = other.ttl def write(self, out): """Abstract method""" raise AbstractMethodException def to_string(self, other): """String representation with additional information""" arg = "%s/%s,%s" % ( self.ttl, self.get_remaining_ttl(current_time_millis()), other) return DNSEntry.to_string(self, "record", arg) class DNSAddress(DNSRecord): """A DNS address record""" def __init__(self, name, type_, class_, ttl, address): DNSRecord.__init__(self, name, type_, class_, ttl) self.address = address def write(self, out): """Used in constructing an outgoing packet""" out.write_string(self.address) def __eq__(self, other): """Tests equality on address""" return isinstance(other, DNSAddress) and self.address == other.address def __repr__(self): """String representation""" try: return str(socket.inet_ntoa(self.address)) except Exception: # TODO stop catching all Exceptions return str(self.address) class DNSHinfo(DNSRecord): """A DNS host information record""" def __init__(self, name, type_, class_, ttl, cpu, os): DNSRecord.__init__(self, name, type_, class_, ttl) try: self.cpu = cpu.decode('utf-8') except AttributeError: self.cpu = cpu try: self.os = os.decode('utf-8') except AttributeError: self.os = os def write(self, out): """Used in constructing an outgoing packet""" out.write_character_string(self.cpu.encode('utf-8')) out.write_character_string(self.os.encode('utf-8')) def __eq__(self, other): """Tests equality on cpu and os""" return (isinstance(other, DNSHinfo) and self.cpu == other.cpu and self.os == other.os) def __repr__(self): """String representation""" return self.cpu + " " + self.os class DNSPointer(DNSRecord): """A DNS pointer record""" def __init__(self, name, type_, class_, ttl, alias): DNSRecord.__init__(self, name, type_, class_, ttl) self.alias = alias def write(self, out): """Used in constructing an outgoing packet""" out.write_name(self.alias) def __eq__(self, other): """Tests equality on alias""" return isinstance(other, DNSPointer) and self.alias == other.alias def __repr__(self): """String representation""" return self.to_string(self.alias) class DNSText(DNSRecord): """A DNS text record""" def __init__(self, name, type_, class_, ttl, text): assert isinstance(text, (bytes, type(None))) DNSRecord.__init__(self, name, type_, class_, ttl) self.text = text def write(self, out): """Used in constructing an outgoing packet""" out.write_string(self.text) def __eq__(self, other): """Tests equality on text""" return isinstance(other, DNSText) and self.text == other.text def __repr__(self): """String representation""" if len(self.text) > 10: return self.to_string(self.text[:7]) + "..." else: return self.to_string(self.text) class DNSService(DNSRecord): """A DNS service record""" def __init__(self, name, type_, class_, ttl, priority, weight, port, server): DNSRecord.__init__(self, name, type_, class_, ttl) self.priority = priority self.weight = weight self.port = port self.server = server def write(self, out): """Used in constructing an outgoing packet""" out.write_short(self.priority) out.write_short(self.weight) out.write_short(self.port) out.write_name(self.server) def __eq__(self, other): """Tests equality on priority, weight, port and server""" return (isinstance(other, DNSService) and self.priority == other.priority and self.weight == other.weight and self.port == other.port and self.server == other.server) def __repr__(self): """String representation""" return self.to_string("%s:%s" % (self.server, self.port)) class DNSIncoming(QuietLogger): """Object representation of an incoming DNS packet""" def __init__(self, data): """Constructor from string holding bytes of packet""" self.offset = 0 self.data = data self.questions = [] self.answers = [] self.id = 0 self.flags = 0 self.num_questions = 0 self.num_answers = 0 self.num_authorities = 0 self.num_additionals = 0 self.valid = False try: self.read_header() self.read_questions() self.read_others() self.valid = True except (IndexError, struct.error, IncomingDecodeError): self.log_exception_warning(( 'Choked at offset %d while unpacking %r', self.offset, data)) def unpack(self, format_): length = struct.calcsize(format_) info = struct.unpack( format_, self.data[self.offset:self.offset + length]) self.offset += length return info def read_header(self): """Reads header portion of packet""" (self.id, self.flags, self.num_questions, self.num_answers, self.num_authorities, self.num_additionals) = self.unpack(b'!6H') def read_questions(self): """Reads questions section of packet""" for i in xrange(self.num_questions): name = self.read_name() type_, class_ = self.unpack(b'!HH') question = DNSQuestion(name, type_, class_) self.questions.append(question) # def read_int(self): # """Reads an integer from the packet""" # return self.unpack(b'!I')[0] def read_character_string(self): """Reads a character string from the packet""" length = indexbytes(self.data, self.offset) self.offset += 1 return self.read_string(length) def read_string(self, length): """Reads a string of a given length from the packet""" info = self.data[self.offset:self.offset + length] self.offset += length return info def read_unsigned_short(self): """Reads an unsigned short from the packet""" return self.unpack(b'!H')[0] def read_others(self): """Reads the answers, authorities and additionals section of the packet""" n = self.num_answers + self.num_authorities + self.num_additionals for i in xrange(n): domain = self.read_name() type_, class_, ttl, length = self.unpack(b'!HHiH') rec = None if type_ == _TYPE_A: rec = DNSAddress( domain, type_, class_, ttl, self.read_string(4)) elif type_ == _TYPE_CNAME or type_ == _TYPE_PTR: rec = DNSPointer( domain, type_, class_, ttl, self.read_name()) elif type_ == _TYPE_TXT: rec = DNSText( domain, type_, class_, ttl, self.read_string(length)) elif type_ == _TYPE_SRV: rec = DNSService( domain, type_, class_, ttl, self.read_unsigned_short(), self.read_unsigned_short(), self.read_unsigned_short(), self.read_name()) elif type_ == _TYPE_HINFO: rec = DNSHinfo( domain, type_, class_, ttl, self.read_character_string(), self.read_character_string()) elif type_ == _TYPE_AAAA: rec = DNSAddress( domain, type_, class_, ttl, self.read_string(16)) else: # Try to ignore types we don't know about # Skip the payload for the resource record so the next # records can be parsed correctly self.offset += length if rec is not None: self.answers.append(rec) def is_query(self): """Returns true if this is a query""" return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_QUERY def is_response(self): """Returns true if this is a response""" return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_RESPONSE def read_utf(self, offset, length): """Reads a UTF-8 string of a given length from the packet""" return text_type(self.data[offset:offset + length], 'utf-8', 'replace') def read_name(self): """Reads a domain name from the packet""" result = '' off = self.offset next_ = -1 first = off while True: length = indexbytes(self.data, off) off += 1 if length == 0: break t = length & 0xC0 if t == 0x00: result = ''.join((result, self.read_utf(off, length) + '.')) off += length elif t == 0xC0: if next_ < 0: next_ = off + 1 off = ((length & 0x3F) << 8) | indexbytes(self.data, off) if off >= first: raise IncomingDecodeError( "Bad domain name (circular) at %s" % (off,)) first = off else: raise IncomingDecodeError("Bad domain name at %s" % (off,)) if next_ >= 0: self.offset = next_ else: self.offset = off return result class DNSOutgoing(object): """Object representation of an outgoing packet""" def __init__(self, flags, multicast=True): self.finished = False self.id = 0 self.multicast = multicast self.flags = flags self.names = {} self.data = [] self.size = 12 self.state = self.State.init self.questions = [] self.answers = [] self.authorities = [] self.additionals = [] def __repr__(self): return '' % ', '.join([ 'multicast=%s' % self.multicast, 'flags=%s' % self.flags, 'questions=%s' % self.questions, 'answers=%s' % self.answers, 'authorities=%s' % self.authorities, 'additionals=%s' % self.additionals, ]) class State(enum.Enum): init = 0 finished = 1 def add_question(self, record): """Adds a question""" self.questions.append(record) def add_answer(self, inp, record): """Adds an answer""" if not record.suppressed_by(inp): self.add_answer_at_time(record, 0) def add_answer_at_time(self, record, now): """Adds an answer if it does not expire by a certain time""" if record is not None: if now == 0 or not record.is_expired(now): self.answers.append((record, now)) def add_authorative_answer(self, record): """Adds an authoritative answer""" self.authorities.append(record) def add_additional_answer(self, record): """ Adds an additional answer From: RFC 6763, DNS-Based Service Discovery, February 2013 12. DNS Additional Record Generation DNS has an efficiency feature whereby a DNS server may place additional records in the additional section of the DNS message. These additional records are records that the client did not explicitly request, but the server has reasonable grounds to expect that the client might request them shortly, so including them can save the client from having to issue additional queries. This section recommends which additional records SHOULD be generated to improve network efficiency, for both Unicast and Multicast DNS-SD responses. 12.1. PTR Records When including a DNS-SD Service Instance Enumeration or Selective Instance Enumeration (subtype) PTR record in a response packet, the server/responder SHOULD include the following additional records: o The SRV record(s) named in the PTR rdata. o The TXT record(s) named in the PTR rdata. o All address records (type "A" and "AAAA") named in the SRV rdata. 12.2. SRV Records When including an SRV record in a response packet, the server/responder SHOULD include the following additional records: o All address records (type "A" and "AAAA") named in the SRV rdata. """ self.additionals.append(record) def pack(self, format_, value): self.data.append(struct.pack(format_, value)) self.size += struct.calcsize(format_) def write_byte(self, value): """Writes a single byte to the packet""" self.pack(b'!c', int2byte(value)) def insert_short(self, index, value): """Inserts an unsigned short in a certain position in the packet""" self.data.insert(index, struct.pack(b'!H', value)) self.size += 2 def write_short(self, value): """Writes an unsigned short to the packet""" self.pack(b'!H', value) def write_int(self, value): """Writes an unsigned integer to the packet""" self.pack(b'!I', int(value)) def write_string(self, value): """Writes a string to the packet""" assert isinstance(value, bytes) self.data.append(value) self.size += len(value) def write_utf(self, s): """Writes a UTF-8 string of a given length to the packet""" utfstr = s.encode('utf-8') length = len(utfstr) if length > 64: raise NamePartTooLongException self.write_byte(length) self.write_string(utfstr) def write_character_string(self, value): assert isinstance(value, bytes) length = len(value) if length > 256: raise NamePartTooLongException self.write_byte(length) self.write_string(value) def write_name(self, name): """ Write names to packet 18.14. Name Compression When generating Multicast DNS messages, implementations SHOULD use name compression wherever possible to compress the names of resource records, by replacing some or all of the resource record name with a compact two-byte reference to an appearance of that data somewhere earlier in the message [RFC1035]. """ # split name into each label parts = name.split('.') if not parts[-1]: parts.pop() # construct each suffix name_suffices = ['.'.join(parts[i:]) for i in range(len(parts))] # look for an existing name or suffix for count, sub_name in enumerate(name_suffices): if sub_name in self.names: break else: count += 1 # note the new names we are saving into the packet for suffix in name_suffices[:count]: self.names[suffix] = self.size + len(name) - len(suffix) - 1 # write the new names out. for part in parts[:count]: self.write_utf(part) # if we wrote part of the name, create a pointer to the rest if count != len(name_suffices): # Found substring in packet, create pointer index = self.names[name_suffices[count]] self.write_byte((index >> 8) | 0xC0) self.write_byte(index & 0xFF) else: # this is the end of a name self.write_byte(0) def write_question(self, question): """Writes a question to the packet""" self.write_name(question.name) self.write_short(question.type) self.write_short(question.class_) def write_record(self, record, now): """Writes a record (answer, authoritative answer, additional) to the packet""" if self.state == self.State.finished: return 1 start_data_length, start_size = len(self.data), self.size self.write_name(record.name) self.write_short(record.type) if record.unique and self.multicast: self.write_short(record.class_ | _CLASS_UNIQUE) else: self.write_short(record.class_) if now == 0: self.write_int(record.ttl) else: self.write_int(record.get_remaining_ttl(now)) index = len(self.data) # Adjust size for the short we will write before this record self.size += 2 record.write(self) self.size -= 2 length = sum((len(d) for d in self.data[index:])) # Here is the short we adjusted for self.insert_short(index, length) # if we go over, then rollback and quit if self.size > _MAX_MSG_ABSOLUTE: while len(self.data) > start_data_length: self.data.pop() self.size = start_size self.state = self.State.finished return 1 return 0 def packet(self): """Returns a string containing the packet's bytes No further parts should be added to the packet once this is done.""" overrun_answers, overrun_authorities, overrun_additionals = 0, 0, 0 if self.state != self.State.finished: for question in self.questions: self.write_question(question) for answer, time_ in self.answers: overrun_answers += self.write_record(answer, time_) for authority in self.authorities: overrun_authorities += self.write_record(authority, 0) for additional in self.additionals: overrun_additionals += self.write_record(additional, 0) self.state = self.State.finished self.insert_short(0, len(self.additionals) - overrun_additionals) self.insert_short(0, len(self.authorities) - overrun_authorities) self.insert_short(0, len(self.answers) - overrun_answers) self.insert_short(0, len(self.questions)) self.insert_short(0, self.flags) if self.multicast: self.insert_short(0, 0) else: self.insert_short(0, self.id) return b''.join(self.data) class DNSCache(object): """A cache of DNS entries""" def __init__(self): self.cache = {} def add(self, entry): """Adds an entry""" self.cache.setdefault(entry.key, []).append(entry) def remove(self, entry): """Removes an entry""" try: list_ = self.cache[entry.key] list_.remove(entry) except (KeyError, ValueError): pass def get(self, entry): """Gets an entry by key. Will return None if there is no matching entry.""" try: list_ = self.cache[entry.key] for cached_entry in list_: if entry.__eq__(cached_entry): return cached_entry except (KeyError, ValueError): return None def get_by_details(self, name, type_, class_): """Gets an entry by details. Will return None if there is no matching entry.""" entry = DNSEntry(name, type_, class_) return self.get(entry) def entries_with_name(self, name): """Returns a list of entries whose key matches the name.""" try: return self.cache[name.lower()] except KeyError: return [] def current_entry_with_name_and_alias(self, name, alias): now = current_time_millis() for record in self.entries_with_name(name): if (record.type == _TYPE_PTR and not record.is_expired(now) and record.alias == alias): return record def entries(self): """Returns a list of all entries""" if not self.cache: return [] else: # avoid size change during iteration by copying the cache values = list(self.cache.values()) return reduce(lambda a, b: a + b, values) class Engine(threading.Thread): """An engine wraps read access to sockets, allowing objects that need to receive data from sockets to be called back when the sockets are ready. A reader needs a handle_read() method, which is called when the socket it is interested in is ready for reading. Writers are not implemented here, because we only send short packets. """ def __init__(self, zc): threading.Thread.__init__(self, name='zeroconf-Engine') self.daemon = True self.zc = zc self.readers = {} # maps socket to reader self.timeout = 5 self.condition = threading.Condition() self.start() def run(self): while not self.zc.done: with self.condition: rs = self.readers.keys() if len(rs) == 0: # No sockets to manage, but we wait for the timeout # or addition of a socket self.condition.wait(self.timeout) if len(rs) != 0: try: rr, wr, er = select.select(rs, [], [], self.timeout) if not self.zc.done: for socket_ in rr: reader = self.readers.get(socket_) if reader: reader.handle_read(socket_) except (select.error, socket.error) as e: # If the socket was closed by another thread, during # shutdown, ignore it and exit if e.args[0] != socket.EBADF or not self.zc.done: raise def add_reader(self, reader, socket_): with self.condition: self.readers[socket_] = reader self.condition.notify() def del_reader(self, socket_): with self.condition: del self.readers[socket_] self.condition.notify() class Listener(QuietLogger): """A Listener is used by this module to listen on the multicast group to which DNS messages are sent, allowing the implementation to cache information as it arrives. It requires registration with an Engine object in order to have the read() method called when a socket is available for reading.""" def __init__(self, zc): self.zc = zc self.data = None def handle_read(self, socket_): try: data, (addr, port) = socket_.recvfrom(_MAX_MSG_ABSOLUTE) except Exception: self.log_exception_warning() return log.debug('Received from %r:%r: %r ', addr, port, data) self.data = data msg = DNSIncoming(data) if not msg.valid: pass elif msg.is_query(): # Always multicast responses if port == _MDNS_PORT: self.zc.handle_query(msg, _MDNS_ADDR, _MDNS_PORT) # If it's not a multicast query, reply via unicast # and multicast elif port == _DNS_PORT: self.zc.handle_query(msg, addr, port) self.zc.handle_query(msg, _MDNS_ADDR, _MDNS_PORT) else: self.zc.handle_response(msg) class Reaper(threading.Thread): """A Reaper is used by this module to remove cache entries that have expired.""" def __init__(self, zc): threading.Thread.__init__(self, name='zeroconf-Reaper') self.daemon = True self.zc = zc self.start() def run(self): while True: self.zc.wait(10 * 1000) if self.zc.done: return now = current_time_millis() for record in self.zc.cache.entries(): if record.is_expired(now): self.zc.update_record(now, record) self.zc.cache.remove(record) class Signal(object): def __init__(self): self._handlers = [] def fire(self, **kwargs): for h in list(self._handlers): h(**kwargs) @property def registration_interface(self): return SignalRegistrationInterface(self._handlers) class SignalRegistrationInterface(object): def __init__(self, handlers): self._handlers = handlers def register_handler(self, handler): self._handlers.append(handler) return self def unregister_handler(self, handler): self._handlers.remove(handler) return self class ServiceBrowser(threading.Thread): """Used to browse for a service of a specific type. The listener object will have its add_service() and remove_service() methods called when this browser discovers changes in the services availability.""" def __init__(self, zc, type_, handlers=None, listener=None): """Creates a browser for a specific type""" assert handlers or listener, 'You need to specify at least one handler' if not type_.endswith(service_type_name(type_)): raise BadTypeInNameException threading.Thread.__init__( self, name='zeroconf-ServiceBrowser_' + type_) self.daemon = True self.zc = zc self.type = type_ self.services = {} self.next_time = current_time_millis() self.delay = _BROWSER_TIME self._handlers_to_call = [] self._service_state_changed = Signal() self.done = False if hasattr(handlers, 'add_service'): listener = handlers handlers = None handlers = handlers or [] if listener: def on_change(zeroconf, service_type, name, state_change): args = (zeroconf, service_type, name) if state_change is ServiceStateChange.Added: listener.add_service(*args) elif state_change is ServiceStateChange.Removed: listener.remove_service(*args) else: raise NotImplementedError(state_change) handlers.append(on_change) for h in handlers: self.service_state_changed.register_handler(h) self.start() @property def service_state_changed(self): return self._service_state_changed.registration_interface def update_record(self, zc, now, record): """Callback invoked by Zeroconf when new information arrives. Updates information required by browser in the Zeroconf cache.""" def enqueue_callback(state_change, name): self._handlers_to_call.append( lambda zeroconf: self._service_state_changed.fire( zeroconf=zeroconf, service_type=self.type, name=name, state_change=state_change, )) if record.type == _TYPE_PTR and record.name == self.type: expired = record.is_expired(now) service_key = record.alias.lower() try: old_record = self.services[service_key] except KeyError: if not expired: self.services[service_key] = record enqueue_callback(ServiceStateChange.Added, record.alias) else: if not expired: old_record.reset_ttl(record) else: del self.services[service_key] enqueue_callback(ServiceStateChange.Removed, record.alias) return expires = record.get_expiration_time(75) if expires < self.next_time: self.next_time = expires def cancel(self): self.done = True self.zc.remove_listener(self) self.join() def run(self): self.zc.add_listener(self, DNSQuestion(self.type, _TYPE_PTR, _CLASS_IN)) while True: now = current_time_millis() if len(self._handlers_to_call) == 0 and self.next_time > now: self.zc.wait(self.next_time - now) if self.zc.done or self.done: return now = current_time_millis() if self.next_time <= now: out = DNSOutgoing(_FLAGS_QR_QUERY) out.add_question(DNSQuestion(self.type, _TYPE_PTR, _CLASS_IN)) for record in self.services.values(): if not record.is_expired(now): out.add_answer_at_time(record, now) self.zc.send(out) self.next_time = now + self.delay self.delay = min(20 * 1000, self.delay * 2) if len(self._handlers_to_call) > 0 and not self.zc.done: handler = self._handlers_to_call.pop(0) handler(self.zc) class ServiceInfo(object): """Service information""" def __init__(self, type_, name, address=None, port=None, weight=0, priority=0, properties=None, server=None): """Create a service description. type_: fully qualified service type name name: fully qualified service name address: IP address as unsigned short, network byte order port: port that the service runs on weight: weight of the service priority: priority of the service properties: dictionary of properties (or a string holding the bytes for the text field) server: fully qualified name for service host (defaults to name)""" if not type_.endswith(service_type_name(name)): raise BadTypeInNameException self.type = type_ self.name = name self.address = address self.port = port self.weight = weight self.priority = priority if server: self.server = server else: self.server = name self._properties = {} self._set_properties(properties) @property def properties(self): return self._properties def _set_properties(self, properties): """Sets properties and text of this info from a dictionary""" if isinstance(properties, dict): self._properties = properties list_ = [] result = b'' for key, value in iteritems(properties): if isinstance(key, text_type): key = key.encode('utf-8') if value is None: suffix = b'' elif isinstance(value, text_type): suffix = value.encode('utf-8') elif isinstance(value, binary_type): suffix = value elif isinstance(value, int): if value: suffix = b'true' else: suffix = b'false' else: suffix = b'' list_.append(b'='.join((key, suffix))) for item in list_: result = b''.join((result, int2byte(len(item)), item)) self.text = result else: self.text = properties def _set_text(self, text): """Sets properties and text given a text field""" self.text = text result = {} end = len(text) index = 0 strs = [] while index < end: length = indexbytes(text, index) index += 1 strs.append(text[index:index + length]) index += length for s in strs: parts = s.split(b'=', 1) try: key, value = parts except ValueError: # No equals sign at all key = s value = False else: if value == b'true': value = True elif value == b'false' or not value: value = False # Only update non-existent properties if key and result.get(key) is None: result[key] = value self._properties = result def get_name(self): """Name accessor""" if self.type is not None and self.name.endswith("." + self.type): return self.name[:len(self.name) - len(self.type) - 1] return self.name def update_record(self, zc, now, record): """Updates service information from a DNS record""" if record is not None and not record.is_expired(now): if record.type == _TYPE_A: # if record.name == self.name: if record.name == self.server: self.address = record.address elif record.type == _TYPE_SRV: if record.name == self.name: self.server = record.server self.port = record.port self.weight = record.weight self.priority = record.priority # self.address = None self.update_record( zc, now, zc.cache.get_by_details( self.server, _TYPE_A, _CLASS_IN)) elif record.type == _TYPE_TXT: if record.name == self.name: self._set_text(record.text) def request(self, zc, timeout): """Returns true if the service could be discovered on the network, and updates this object with details discovered. """ now = current_time_millis() delay = _LISTENER_TIME next_ = now + delay last = now + timeout record_types_for_check_cache = [ (_TYPE_SRV, _CLASS_IN), (_TYPE_TXT, _CLASS_IN), ] if self.server is not None: record_types_for_check_cache.append((_TYPE_A, _CLASS_IN)) for record_type in record_types_for_check_cache: cached = zc.cache.get_by_details(self.name, *record_type) if cached: self.update_record(zc, now, cached) if None not in (self.server, self.address, self.text): return True try: zc.add_listener(self, DNSQuestion(self.name, _TYPE_ANY, _CLASS_IN)) while None in (self.server, self.address, self.text): if last <= now: return False if next_ <= now: out = DNSOutgoing(_FLAGS_QR_QUERY) out.add_question( DNSQuestion(self.name, _TYPE_SRV, _CLASS_IN)) out.add_answer_at_time( zc.cache.get_by_details( self.name, _TYPE_SRV, _CLASS_IN), now) out.add_question( DNSQuestion(self.name, _TYPE_TXT, _CLASS_IN)) out.add_answer_at_time( zc.cache.get_by_details( self.name, _TYPE_TXT, _CLASS_IN), now) if self.server is not None: out.add_question( DNSQuestion(self.server, _TYPE_A, _CLASS_IN)) out.add_answer_at_time( zc.cache.get_by_details( self.server, _TYPE_A, _CLASS_IN), now) zc.send(out) next_ = now + delay delay *= 2 zc.wait(min(next_, last) - now) now = current_time_millis() finally: zc.remove_listener(self) return True def __eq__(self, other): """Tests equality of service name""" return isinstance(other, ServiceInfo) and other.name == self.name def __ne__(self, other): """Non-equality test""" return not self.__eq__(other) def __repr__(self): """String representation""" return '%s(%s)' % ( type(self).__name__, ', '.join( '%s=%r' % (name, getattr(self, name)) for name in ( 'type', 'name', 'address', 'port', 'weight', 'priority', 'server', 'properties', ) ) ) class ZeroconfServiceTypes(object): """ Return all of the advertised services on any local networks """ def __init__(self): self.found_services = set() def add_service(self, zc, type_, name): self.found_services.add(name) def remove_service(self, zc, type_, name): pass @classmethod def find(cls, zc=None, timeout=5, interfaces=InterfaceChoice.All): """ Return all of the advertised services on any local networks. :param zc: Zeroconf() instance. Pass in if already have an instance running or if non-default interfaces are needed :param timeout: seconds to wait for any responses :return: tuple of service type strings """ local_zc = zc or Zeroconf(interfaces=interfaces) listener = cls() browser = ServiceBrowser( local_zc, '_services._dns-sd._udp.local.', listener=listener) # wait for responses time.sleep(timeout) # close down anything we opened if zc is None: local_zc.close() else: browser.cancel() return tuple(sorted(listener.found_services)) def get_all_addresses(address_family): return list(set( addr['addr'] for iface in netifaces.interfaces() for addr in netifaces.ifaddresses(iface).get(address_family, []) if addr.get('netmask') != HOST_ONLY_NETWORK_MASK )) def normalize_interface_choice(choice, address_family): if choice is InterfaceChoice.Default: choice = ['0.0.0.0'] elif choice is InterfaceChoice.All: choice = get_all_addresses(address_family) return choice def new_socket(): s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # SO_REUSEADDR should be equivalent to SO_REUSEPORT for # multicast UDP sockets (p 731, "TCP/IP Illustrated, # Volume 2"), but some BSD-derived systems require # SO_REUSEPORT to be specified explicity. Also, not all # versions of Python have SO_REUSEPORT available. # Catch OSError and socket.error for kernel versions <3.9 because lacking # SO_REUSEPORT support. try: reuseport = socket.SO_REUSEPORT except AttributeError: pass else: try: s.setsockopt(socket.SOL_SOCKET, reuseport, 1) except (OSError, socket.error) as err: # OSError on python 3, socket.error on python 2 if not err.errno == errno.ENOPROTOOPT: raise # OpenBSD needs the ttl and loop values for the IP_MULTICAST_TTL and # IP_MULTICAST_LOOP socket options as an unsigned char. ttl = struct.pack(b'B', 255) s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) loop = struct.pack(b'B', 1) s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop) s.bind(('', _MDNS_PORT)) return s def get_errno(e): assert isinstance(e, socket.error) return e.args[0] class Zeroconf(QuietLogger): """Implementation of Zeroconf Multicast DNS Service Discovery Supports registration, unregistration, queries and browsing. """ def __init__( self, interfaces=InterfaceChoice.All, ): """Creates an instance of the Zeroconf class, establishing multicast communications, listening and reaping threads. :type interfaces: :class:`InterfaceChoice` or sequence of ip addresses """ # hook for threads self._GLOBAL_DONE = False self._listen_socket = new_socket() interfaces = normalize_interface_choice(interfaces, socket.AF_INET) self._respond_sockets = [] for i in interfaces: log.debug('Adding %r to multicast group', i) try: self._listen_socket.setsockopt( socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, socket.inet_aton(_MDNS_ADDR) + socket.inet_aton(i)) except socket.error as e: if get_errno(e) == errno.EADDRINUSE: log.info( 'Address in use when adding %s to multicast group, ' 'it is expected to happen on some systems', i, ) elif get_errno(e) == errno.EADDRNOTAVAIL: log.info( 'Address not available when adding %s to multicast ' 'group, it is expected to happen on some systems', i, ) continue else: raise respond_socket = new_socket() respond_socket.setsockopt( socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(i)) self._respond_sockets.append(respond_socket) self.listeners = [] self.browsers = {} self.services = {} self.servicetypes = {} self.cache = DNSCache() self.condition = threading.Condition() self.engine = Engine(self) self.listener = Listener(self) self.engine.add_reader(self.listener, self._listen_socket) self.reaper = Reaper(self) self.debug = None @property def done(self): return self._GLOBAL_DONE def wait(self, timeout): """Calling thread waits for a given number of milliseconds or until notified.""" with self.condition: self.condition.wait(timeout / 1000.0) def notify_all(self): """Notifies all waiting threads""" with self.condition: self.condition.notify_all() def get_service_info(self, type_, name, timeout=3000): """Returns network's service information for a particular name and type, or None if no service matches by the timeout, which defaults to 3 seconds.""" info = ServiceInfo(type_, name) if info.request(self, timeout): return info def add_service_listener(self, type_, listener): """Adds a listener for a particular service type. This object will then have its update_record method called when information arrives for that type.""" self.remove_service_listener(listener) self.browsers[listener] = ServiceBrowser(self, type_, listener) def remove_service_listener(self, listener): """Removes a listener from the set that is currently listening.""" if listener in self.browsers: self.browsers[listener].cancel() del self.browsers[listener] def remove_all_service_listeners(self): """Removes a listener from the set that is currently listening.""" for listener in [k for k in self.browsers]: self.remove_service_listener(listener) def register_service(self, info, ttl=_DNS_TTL, allow_name_change=False): """Registers service information to the network with a default TTL of 60 seconds. Zeroconf will then respond to requests for information for that service. The name of the service may be changed if needed to make it unique on the network.""" self.check_service(info, allow_name_change) self.services[info.name.lower()] = info if info.type in self.servicetypes: self.servicetypes[info.type] += 1 else: self.servicetypes[info.type] = 1 now = current_time_millis() next_time = now i = 0 while i < 3: if now < next_time: self.wait(next_time - now) now = current_time_millis() continue out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) out.add_answer_at_time( DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, ttl, info.name), 0) out.add_answer_at_time( DNSService(info.name, _TYPE_SRV, _CLASS_IN, ttl, info.priority, info.weight, info.port, info.server), 0) out.add_answer_at_time( DNSText(info.name, _TYPE_TXT, _CLASS_IN, ttl, info.text), 0) if info.address: out.add_answer_at_time( DNSAddress(info.server, _TYPE_A, _CLASS_IN, ttl, info.address), 0) self.send(out) i += 1 next_time += _REGISTER_TIME def unregister_service(self, info): """Unregister a service.""" try: del self.services[info.name.lower()] if self.servicetypes[info.type] > 1: self.servicetypes[info.type] -= 1 else: del self.servicetypes[info.type] except Exception as e: # TODO stop catching all Exceptions log.exception('Unknown error, possibly benign: %r', e) now = current_time_millis() next_time = now i = 0 while i < 3: if now < next_time: self.wait(next_time - now) now = current_time_millis() continue out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) out.add_answer_at_time( DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, 0, info.name), 0) out.add_answer_at_time( DNSService(info.name, _TYPE_SRV, _CLASS_IN, 0, info.priority, info.weight, info.port, info.name), 0) out.add_answer_at_time( DNSText(info.name, _TYPE_TXT, _CLASS_IN, 0, info.text), 0) if info.address: out.add_answer_at_time( DNSAddress(info.server, _TYPE_A, _CLASS_IN, 0, info.address), 0) self.send(out) i += 1 next_time += _UNREGISTER_TIME def unregister_all_services(self): """Unregister all registered services.""" if len(self.services) > 0: now = current_time_millis() next_time = now i = 0 while i < 3: if now < next_time: self.wait(next_time - now) now = current_time_millis() continue out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) for info in self.services.values(): out.add_answer_at_time(DNSPointer( info.type, _TYPE_PTR, _CLASS_IN, 0, info.name), 0) out.add_answer_at_time(DNSService( info.name, _TYPE_SRV, _CLASS_IN, 0, info.priority, info.weight, info.port, info.server), 0) out.add_answer_at_time(DNSText( info.name, _TYPE_TXT, _CLASS_IN, 0, info.text), 0) if info.address: out.add_answer_at_time(DNSAddress( info.server, _TYPE_A, _CLASS_IN, 0, info.address), 0) self.send(out) i += 1 next_time += _UNREGISTER_TIME def check_service(self, info, allow_name_change): """Checks the network for a unique service name, modifying the ServiceInfo passed in if it is not unique.""" # This is kind of funky because of the subtype based tests # need to make subtypes a first class citizen service_name = service_type_name(info.name) if not info.type.endswith(service_name): raise BadTypeInNameException instance_name = info.name[:-len(service_name) - 1] next_instance_number = 2 now = current_time_millis() next_time = now i = 0 while i < 3: # check for a name conflict while self.cache.current_entry_with_name_and_alias( info.type, info.name): if not allow_name_change: raise NonUniqueNameException # change the name and look for a conflict info.name = '%s-%s.%s' % ( instance_name, next_instance_number, info.type) next_instance_number += 1 service_type_name(info.name) next_time = now i = 0 if now < next_time: self.wait(next_time - now) now = current_time_millis() continue out = DNSOutgoing(_FLAGS_QR_QUERY | _FLAGS_AA) self.debug = out out.add_question(DNSQuestion(info.type, _TYPE_PTR, _CLASS_IN)) out.add_authorative_answer(DNSPointer( info.type, _TYPE_PTR, _CLASS_IN, _DNS_TTL, info.name)) self.send(out) i += 1 next_time += _CHECK_TIME def add_listener(self, listener, question): """Adds a listener for a given question. The listener will have its update_record method called when information is available to answer the question.""" now = current_time_millis() self.listeners.append(listener) if question is not None: for record in self.cache.entries_with_name(question.name): if question.answered_by(record) and not record.is_expired(now): listener.update_record(self, now, record) self.notify_all() def remove_listener(self, listener): """Removes a listener.""" try: self.listeners.remove(listener) self.notify_all() except Exception as e: # TODO stop catching all Exceptions log.exception('Unknown error, possibly benign: %r', e) def update_record(self, now, rec): """Used to notify listeners of new information that has updated a record.""" for listener in self.listeners: listener.update_record(self, now, rec) self.notify_all() def handle_response(self, msg): """Deal with incoming response packets. All answers are held in the cache, and listeners are notified.""" now = current_time_millis() for record in msg.answers: expired = record.is_expired(now) if record in self.cache.entries(): if expired: self.cache.remove(record) else: entry = self.cache.get(record) if entry is not None: entry.reset_ttl(record) else: self.cache.add(record) for record in msg.answers: self.update_record(now, record) def handle_query(self, msg, addr, port): """Deal with incoming query packets. Provides a response if possible.""" out = None # Support unicast client responses # if port != _MDNS_PORT: out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=False) for question in msg.questions: out.add_question(question) for question in msg.questions: if question.type == _TYPE_PTR: if question.name == "_services._dns-sd._udp.local.": for stype in self.servicetypes.keys(): if out is None: out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) out.add_answer(msg, DNSPointer( "_services._dns-sd._udp.local.", _TYPE_PTR, _CLASS_IN, _DNS_TTL, stype)) for service in self.services.values(): if question.name == service.type: if out is None: out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) out.add_answer(msg, DNSPointer( service.type, _TYPE_PTR, _CLASS_IN, _DNS_TTL, service.name)) else: try: if out is None: out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) # Answer A record queries for any service addresses we know if question.type in (_TYPE_A, _TYPE_ANY): for service in self.services.values(): if service.server == question.name.lower(): out.add_answer(msg, DNSAddress( question.name, _TYPE_A, _CLASS_IN | _CLASS_UNIQUE, _DNS_TTL, service.address)) service = self.services.get(question.name.lower(), None) if not service: continue if question.type in (_TYPE_SRV, _TYPE_ANY): out.add_answer(msg, DNSService( question.name, _TYPE_SRV, _CLASS_IN | _CLASS_UNIQUE, _DNS_TTL, service.priority, service.weight, service.port, service.server)) if question.type in (_TYPE_TXT, _TYPE_ANY): out.add_answer(msg, DNSText( question.name, _TYPE_TXT, _CLASS_IN | _CLASS_UNIQUE, _DNS_TTL, service.text)) if question.type == _TYPE_SRV: out.add_additional_answer(DNSAddress( service.server, _TYPE_A, _CLASS_IN | _CLASS_UNIQUE, _DNS_TTL, service.address)) except Exception: # TODO stop catching all Exceptions self.log_exception_warning() if out is not None and out.answers: out.id = msg.id self.send(out, addr, port) def send(self, out, addr=_MDNS_ADDR, port=_MDNS_PORT): """Sends an outgoing packet.""" packet = out.packet() if len(packet) > _MAX_MSG_ABSOLUTE: self.log_warning_once("Dropping %r over-sized packet (%d bytes) %r", out, len(packet), packet) return log.debug('Sending %r (%d bytes) as %r...', out, len(packet), packet) for s in self._respond_sockets: if self._GLOBAL_DONE: return try: bytes_sent = s.sendto(packet, 0, (addr, port)) except Exception: # TODO stop catching all Exceptions # on send errors, log the exception and keep going self.log_exception_warning() else: if bytes_sent != len(packet): self.log_warning_once( '!!! sent %d out of %d bytes to %r' % ( bytes_sent, len(packet)), s) def close(self): """Ends the background threads, and prevent this instance from servicing further queries.""" if not self._GLOBAL_DONE: self._GLOBAL_DONE = True # remove service listeners self.remove_all_service_listeners() self.unregister_all_services() # shutdown recv socket and thread self.engine.del_reader(self._listen_socket) self._listen_socket.close() self.engine.join() # shutdown the rest self.notify_all() self.reaper.join() for s in self._respond_sockets: s.close() ================================================ FILE: requirements.txt ================================================ netifaces six requests paho_mqtt pycryptodome ================================================ FILE: requirements_test.txt ================================================ flake8>=3.0.4 pylint>=1.5.6 coveralls>=1.1 pydocstyle>=2.0.0 pytest>=2.9.2 pytest-cov>=2.3.1 mypy-lang>=0.4 ================================================ FILE: setup.cfg ================================================ [metadata] description-file = README.md ================================================ FILE: setup.py ================================================ #!/usr/bin/env python3 from setuptools import setup, find_packages PACKAGES = find_packages(exclude=['tests', 'tests.*']) REQUIRES = [ 'requests>=2,<3', 'netifaces', 'six', 'paho_mqtt', 'pycryptodome' ] PROJECT_CLASSIFIERS = [ 'Intended Audience :: Developers', 'License :: OSI Approved :: Apache Software License', 'Operating System :: OS Independent', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.4', 'Topic :: Software Development :: Libraries' ] setup( name="libpurecoollink", version="0.4.2", license="Apache License 2.0", url="http://libpurecoollink.readthedocs.io", download_url="https://github.com/CharlesBlonde/libpurecoollink", author="Charles Blonde", author_email="charles.blonde@gmail.com", description="Dyson Pure Cool/Hot+Cool Link and 360 eye robot " "vacuum devices Python library", packages=PACKAGES, include_package_data=True, zip_safe=True, platforms='any', install_requires=REQUIRES, test_suite='tests', keywords=['dyson', 'purecoollink', 'eye360', 'purehotcoollink'], classifiers=PROJECT_CLASSIFIERS, ) ================================================ FILE: tests/data/sensor.json ================================================ { "msg": "ENVIRONMENTAL-CURRENT-SENSOR-DATA", "time": "2017-06-17T23:05:49.001Z", "data": { "tact": "2967", "hact": "0054", "pact": "0004", "vact": "0005", "sltm": "0028" } } ================================================ FILE: tests/data/sensor_sltm_off.json ================================================ { "msg": "ENVIRONMENTAL-CURRENT-SENSOR-DATA", "time": "2017-06-17T23:05:49.001Z", "data": { "tact": "2967", "hact": "0054", "pact": "0004", "vact": "0005", "sltm": "OFF" } } ================================================ FILE: tests/data/state.json ================================================ { "msg": "CURRENT-STATE", "time": "2017-02-26T16:25:35.000Z", "mode-reason": "LAPP", "state-reason": "ENV", "dial": "OFF", "rssi": "-55", "product-state": { "fmod": "AUTO", "fnst": "FAN", "fnsp": "AUTO", "qtar": "0004", "oson": "OFF", "rhtm": "ON", "filf": "2087", "ercd": "02C0", "nmod": "ON", "wacd": "NONE" }, "scheduler": { "srsc": "cbd0", "dstv": "0001", "tzid": "0001" } } ================================================ FILE: tests/data/state_hot.json ================================================ { "msg": "CURRENT-STATE", "time": "2017-02-26T16:25:35.000Z", "mode-reason": "LAPP", "state-reason": "ENV", "dial": "OFF", "rssi": "-55", "product-state": { "fmod": "AUTO", "fnst": "FAN", "fnsp": "AUTO", "qtar": "0004", "oson": "OFF", "rhtm": "ON", "filf": "2087", "ercd": "02C0", "nmod": "ON", "wacd": "NONE", "tilt": "OK", "ffoc": "ON", "hmax": "2950", "hmod": "HEAT", "hsta": "HEAT" }, "scheduler": { "srsc": "cbd0", "dstv": "0001", "tzid": "0001" } } ================================================ FILE: tests/data/vacuum/goodbye.json ================================================ { "msg" : "GOODBYE", "reason" : "UNKNOWN", "time" : "2017-07-30T16:00:13Z" } ================================================ FILE: tests/data/vacuum/map-data.json ================================================ { "msg": "MAP-DATA", "gridID": "1", "cleanId": "0e000000-4a47-3845-5548-454131323334", "data": { "content-type": "application/json", "content-encoding": "gzip", "content": "xxx" }, "time": "2017-07-16T07:34:00Z" } ================================================ FILE: tests/data/vacuum/map-global.json ================================================ { "msg" : "MAP-GLOBAL", "gridID" : "1", "x" : 0, "y" : 0, "angle" : -180, "cleanId" : "0e000000-4a47-3845-5548-454131323334", "time" : "2017-07-16T07:31:35Z" } ================================================ FILE: tests/data/vacuum/map-grid.json ================================================ { "msg" : "MAP-GRID", "gridID" : "1", "resolution" : 43, "width" : 144, "height" : 144, "cleanId" : "0e000000-4a47-3845-5548-454131323334", "anchor" : [ 16, 72 ], "time" : "2017-07-16T07:34:31Z" } ================================================ FILE: tests/data/vacuum/state-change.json ================================================ { "msg" : "STATE-CHANGE", "oldstate" : "INACTIVE_CHARGED", "newstate" : "FULL_CLEAN_INITIATED", "fullCleanType" : "immediate", "cleanId" : "0e000000-4a47-3845-5548-454131323334", "currentVacuumPowerMode" : "halfPower", "defaultVacuumPowerMode" : "halfPower", "globalPosition" : [ 6, 37 ], "batteryChargeLevel" : 95, "time" : "2017-07-16T07:31:29Z" } ================================================ FILE: tests/data/vacuum/state-unknown-values.json ================================================ { "msg" : "CURRENT-STATE", "state" : "UNKNOWN", "fullCleanType" : "", "cleanId" : "0d000000-4a47-3845-5548-454131323334", "currentVacuumPowerMode" : "unknown", "defaultVacuumPowerMode" : "unknown", "globalPosition" : [ 6, 37 ], "batteryChargeLevel" : 100, "time" : "2017-07-16T07:31:28Z" } ================================================ FILE: tests/data/vacuum/state.json ================================================ { "msg" : "CURRENT-STATE", "state" : "INACTIVE_CHARGED", "fullCleanType" : "", "cleanId" : "0d000000-4a47-3845-5548-454131323334", "currentVacuumPowerMode" : "halfPower", "defaultVacuumPowerMode" : "halfPower", "globalPosition" : [ 6, 37 ], "batteryChargeLevel" : 100, "time" : "2017-07-16T07:31:28Z" } ================================================ FILE: tests/data/vacuum/telemetry-data.json ================================================ { "msg": "TELEMETRY-DATA", "id": "40010000", "field1": "1.0.0", "field2": "2.000000", "field3": "", "field4": "0e000000-4a47-3845-5548-454131323334", "time": "2017-07-16T07:34:34Z" } ================================================ FILE: tests/test_360_eye.py ================================================ import unittest from unittest import mock from unittest.mock import Mock import json from libpurecoollink.dyson_360_eye import Dyson360Eye, NetworkDevice, \ Dyson360EyeState, Dyson360EyeMapGlobal, Dyson360EyeMapData, \ Dyson360EyeMapGrid, Dyson360EyeTelemetryData, Dyson360Goodbye from libpurecoollink.const import PowerMode, Dyson360EyeMode def _mocked_request_state(*args, **kwargs): assert args[0] == 'N223/device-id-1/command' msg = json.loads(args[1]) assert msg['msg'] in ['REQUEST-CURRENT-STATE'] assert msg['time'] def _mocked_send_start_command(*args, **kwargs): assert args[0] == 'N223/device-id-1/command' msg = json.loads(args[1]) assert msg['msg'] in ['START'] assert msg['time'] class TestDysonEye360Device(unittest.TestCase): def setUp(self): pass def tearDown(self): pass @staticmethod def _device_sample(): return Dyson360Eye({ "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "11.3.5.10", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/70ZGysII1K" "e1i0ZHakFH84DZuxsSQ4KTT2vbCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": "N223" }) def test_status_topic(self): device = self._device_sample() self.assertEqual(device.status_topic, "N223/device-id-1/status") @mock.patch('paho.mqtt.client.Client.publish', side_effect=_mocked_request_state) @mock.patch('paho.mqtt.client.Client.connect') def test_request_state(self, mocked_connect, mocked_publish): device = self._device_sample() network_device = NetworkDevice('device-1', 'host', 1883) device.connection_callback(True) device._add_network_device(network_device) device.state_data_available() connected = device.connect('192.168.1.1') self.assertTrue(connected) self.assertEqual(mocked_connect.call_count, 1) mocked_connect.assert_called_with('192.168.1.1', 1883) self.assertEqual(mocked_publish.call_count, 1) device.request_current_state() self.assertEqual(mocked_publish.call_count, 2) self.assertEqual(device.__repr__(), "Dyson360Eye(serial=device-id-1," "active=True,name=device-1," "version=11.3.5.10," "auto_update=True," "new_version_available=False," "product_type=N223," "network_device=NetworkDevice(" "name=device-1," "address=192.168.1.1,port=1883))") def test_start_not_connected(self): self._called = False def publish(topic, data, qos): self._called = True device = self._device_sample() device._connected = False device._mqtt = Mock() device._mqtt.publish = publish device.start() self.assertFalse(self._called) def test_start(self): self._parameters = None def publish(topic, data, qos): self._parameters = (topic, data, qos) device = self._device_sample() device._connected = True device._mqtt = Mock() device._mqtt.publish = publish device.start() self.assertEqual(self._parameters[0], "N223/device-id-1/command") self.assertEqual(json.loads(self._parameters[1])["msg"], "START") self.assertEqual(json.loads(self._parameters[1])["fullCleanType"], "immediate") self.assertEqual(self._parameters[2], 1) def test_pause(self): self._parameters = None def publish(topic, data, qos): self._parameters = (topic, data, qos) device = self._device_sample() device._connected = True device._mqtt = Mock() device._mqtt.publish = publish device.pause() self.assertEqual(self._parameters[0], "N223/device-id-1/command") self.assertEqual(json.loads(self._parameters[1])["msg"], "PAUSE") self.assertTrue("fullCleanType" not in json.loads(self._parameters[1])) self.assertEqual(self._parameters[2], 1) def test_resume(self): self._parameters = None def publish(topic, data, qos): self._parameters = (topic, data, qos) device = self._device_sample() device._connected = True device._mqtt = Mock() device._mqtt.publish = publish device.resume() self.assertEqual(self._parameters[0], "N223/device-id-1/command") self.assertEqual(json.loads(self._parameters[1])["msg"], "RESUME") self.assertTrue("fullCleanType" not in json.loads(self._parameters[1])) self.assertEqual(self._parameters[2], 1) def test_abort(self): self._parameters = None def publish(topic, data, qos): self._parameters = (topic, data, qos) device = self._device_sample() device._connected = True device._mqtt = Mock() device._mqtt.publish = publish device.abort() self.assertEqual(self._parameters[0], "N223/device-id-1/command") self.assertEqual(json.loads(self._parameters[1])["msg"], "ABORT") self.assertTrue("fullCleanType" not in json.loads(self._parameters[1])) self.assertEqual(self._parameters[2], 1) def test_set_power_mode(self): self._parameters = None def publish(topic, data, qos): self._parameters = (topic, data, qos) device = self._device_sample() device._connected = True device._mqtt = Mock() device._mqtt.publish = publish device.set_power_mode(PowerMode.MAX) self.assertEqual(self._parameters[0], "N223/device-id-1/command") self.assertEqual(json.loads(self._parameters[1])["msg"], "STATE-SET") self.assertEqual( json.loads(self._parameters[1])["data"]["defaultVacuumPowerMode"], "fullPower") self.assertTrue("fullCleanType" not in json.loads(self._parameters[1])) self.assertEqual(self._parameters[2], 1) device.set_power_mode(PowerMode.QUIET) self.assertEqual(self._parameters[0], "N223/device-id-1/command") self.assertEqual(json.loads(self._parameters[1])["msg"], "STATE-SET") self.assertEqual( json.loads(self._parameters[1])["data"]["defaultVacuumPowerMode"], "halfPower") self.assertTrue("fullCleanType" not in json.loads(self._parameters[1])) self.assertEqual(self._parameters[2], 1) def test_on_unknown_message(self): def callback_function(msg): assert False device = self._device_sample() device._connected = True message = Mock() message.payload = Mock() message.payload.decode.return_value = '{"msg":"nothing"}' device.add_message_listener(callback_function) Dyson360Eye.on_message(None, device, message) def test_on_state_message(self): self.message = None def callback_function(msg): self.message = msg state_message = open("tests/data/vacuum/state.json", "r").read() device = self._device_sample() device._connected = True message = Mock() message.payload = Mock() message.payload.decode.return_value = state_message device.add_message_listener(callback_function) Dyson360Eye.on_message(None, device, message) self.assertTrue(isinstance(self.message, Dyson360EyeState)) self.assertEqual(self.message.clean_id, "0d000000-4a47-3845-5548-454131323334") self.assertEqual(self.message.state, Dyson360EyeMode.INACTIVE_CHARGED) self.assertEqual(self.message.full_clean_type, "") self.assertEqual(self.message.position, (6, 37)) self.assertEqual(self.message.power_mode, PowerMode.QUIET) self.assertEqual(self.message.battery_level, 100) self.assertEqual(self.message.__repr__(), "Dyson360EyeState(" "state=Dyson360EyeMode.INACTIVE_CHARGED," "clean_id=0d000000-4a47-3845-5548-454131323334," "full_clean_type=,power_mode=PowerMode.QUIET," "battery_level=100,position=(6, 37))") def test_on_state_unknown_values_message(self): self.message = None def callback_function(msg): self.message = msg state_message = open("tests/data/vacuum/state-unknown-values.json", "r").read() device = self._device_sample() device._connected = True message = Mock() message.payload = Mock() message.payload.decode.return_value = state_message device.add_message_listener(callback_function) Dyson360Eye.on_message(None, device, message) self.assertTrue(isinstance(self.message, Dyson360EyeState)) self.assertEqual(self.message.clean_id, "0d000000-4a47-3845-5548-454131323334") self.assertEqual(self.message.state, "UNKNOWN") self.assertEqual(self.message.full_clean_type, "") self.assertEqual(self.message.position, (6, 37)) self.assertEqual(self.message.power_mode, "unknown") self.assertEqual(self.message.battery_level, 100) self.assertEqual(self.message.__repr__(), "Dyson360EyeState(state=UNKNOWN," "clean_id=0d000000-4a47-3845-5548-454131323334," "full_clean_type=,power_mode=unknown," "battery_level=100,position=(6, 37))") def test_on_state_change_message(self): self.message = None def callback_function(msg): self.message = msg state_message = open("tests/data/vacuum/state-change.json", "r").read() device = self._device_sample() device._connected = True message = Mock() message.payload = Mock() message.payload.decode.return_value = state_message device.add_message_listener(callback_function) Dyson360Eye.on_message(None, device, message) self.assertTrue(isinstance(self.message, Dyson360EyeState)) self.assertEqual(self.message.clean_id, "0e000000-4a47-3845-5548-454131323334") self.assertEqual(self.message.state, Dyson360EyeMode.FULL_CLEAN_INITIATED) self.assertEqual(self.message.full_clean_type, "immediate") self.assertEqual(self.message.position, (6, 37)) self.assertEqual(self.message.power_mode, PowerMode.QUIET) self.assertEqual(self.message.battery_level, 95) self.assertEqual(self.message.__repr__(), "Dyson360EyeState(" "state=Dyson360EyeMode.FULL_CLEAN_INITIATED," "clean_id=0e000000-4a47-3845-5548-454131323334," "full_clean_type=immediate," "power_mode=PowerMode.QUIET," "battery_level=95,position=(6, 37))") def test_on_map_global_message(self): self.message = None def callback_function(msg): self.message = msg state_message = open("tests/data/vacuum/map-global.json", "r").read() device = self._device_sample() device._connected = True message = Mock() message.payload = Mock() message.payload.decode.return_value = state_message device.add_message_listener(callback_function) Dyson360Eye.on_message(None, device, message) self.assertTrue(isinstance(self.message, Dyson360EyeMapGlobal)) self.assertEqual(self.message.clean_id, "0e000000-4a47-3845-5548-454131323334") self.assertEqual(self.message.grid_id, "1") self.assertEqual(self.message.position_x, 0) self.assertEqual(self.message.position_y, 0) self.assertEqual(self.message.angle, -180) self.assertEqual(self.message.time.strftime("%Y-%m-%dT%H:%M:%SZ"), "2017-07-16T07:31:35Z") self.assertEqual(self.message.__repr__(), "Dyson360EyeMapGlobal(grid_id=1," "clean_id=0e000000-4a47-3845-5548-454131323334," "x=0,y=0,angle=-180,time=2017-07-16 07:31:35)") def test_on_map_grid_message(self): self.message = None def callback_function(msg): self.message = msg state_message = open("tests/data/vacuum/map-grid.json", "r").read() device = self._device_sample() device._connected = True message = Mock() message.payload = Mock() message.payload.decode.return_value = state_message device.add_message_listener(callback_function) Dyson360Eye.on_message(None, device, message) self.assertTrue(isinstance(self.message, Dyson360EyeMapGrid)) self.assertEqual(self.message.clean_id, "0e000000-4a47-3845-5548-454131323334") self.assertEqual(self.message.grid_id, "1") self.assertEqual(self.message.resolution, 43) self.assertEqual(self.message.width, 144) self.assertEqual(self.message.height, 144) self.assertEqual(self.message.anchor, (16, 72)) self.assertEqual(self.message.time.strftime("%Y-%m-%dT%H:%M:%SZ"), "2017-07-16T07:34:31Z") self.assertEqual(self.message.__repr__(), "Dyson360EyeMapGrid(grid_id=1," "clean_id=0e000000-4a47-3845-5548-454131323334," "resolution=43,width=144,height=144," "anchor=(16, 72),time=2017-07-16 07:34:31)") def test_on_map_data_message(self): self.message = None def callback_function(msg): self.message = msg state_message = open("tests/data/vacuum/map-data.json", "r").read() device = self._device_sample() device._connected = True message = Mock() message.payload = Mock() message.payload.decode.return_value = state_message device.add_message_listener(callback_function) Dyson360Eye.on_message(None, device, message) self.assertTrue(isinstance(self.message, Dyson360EyeMapData)) self.assertEqual(self.message.clean_id, "0e000000-4a47-3845-5548-454131323334") self.assertEqual(self.message.grid_id, "1") self.assertEqual(self.message.content_type, "application/json") self.assertEqual(self.message.content_encoding, "gzip") self.assertEqual(self.message.content, "xxx") self.assertEqual(self.message.time.strftime("%Y-%m-%dT%H:%M:%SZ"), "2017-07-16T07:34:00Z") self.assertEqual(self.message.__repr__(), "Dyson360EyeMapData(grid_id=1," "clean_id=0e000000-4a47-3845-5548-454131323334," "content_type=application/json,content_encoding=gzip," "content=xxx,time=2017-07-16 07:34:00)") def test_on_telemetry_data_message(self): self.message = None def callback_function(msg): self.message = msg state_message = open("tests/data/vacuum/telemetry-data.json", "r").read() device = self._device_sample() device._connected = True message = Mock() message.payload = Mock() message.payload.decode.return_value = state_message device.add_message_listener(callback_function) Dyson360Eye.on_message(None, device, message) self.assertTrue(isinstance(self.message, Dyson360EyeTelemetryData)) self.assertEqual(self.message.field1, "1.0.0") self.assertEqual(self.message.field2, "2.000000") self.assertEqual(self.message.field3, "") self.assertEqual(self.message.field4, "0e000000-4a47-3845-5548-454131323334") self.assertEqual(self.message.telemetry_data_id, "40010000") self.assertEqual(self.message.time.strftime("%Y-%m-%dT%H:%M:%SZ"), "2017-07-16T07:34:34Z") self.assertEqual(self.message.__repr__(), "Dyson360EyeTelemetryData(" "telemetry_data_id=40010000,field1=1.0.0," "field2=2.000000,field3=," "field4=0e000000-4a47-3845-5548-454131323334," "time=2017-07-16 07:34:34)") def test_on_goodbye_message(self): self.message = None def callback_function(msg): self.message = msg state_message = open("tests/data/vacuum/goodbye.json", "r").read() device = self._device_sample() device._connected = True message = Mock() message.payload = Mock() message.payload.decode.return_value = state_message device.add_message_listener(callback_function) Dyson360Eye.on_message(None, device, message) self.assertTrue(isinstance(self.message, Dyson360Goodbye)) self.assertEqual(self.message.reason, "UNKNOWN") self.assertEqual(self.message.time.strftime("%Y-%m-%dT%H:%M:%SZ"), "2017-07-30T16:00:13Z") self.assertEqual(self.message.__repr__(), "Dyson360EyeGoodbye(reason=UNKNOWN," "time=2017-07-30 16:00:13)") ================================================ FILE: tests/test_dyson_account.py ================================================ import unittest from unittest import mock from libpurecoollink.dyson import DysonAccount, DysonPureCoolLink, \ DysonPureHotCoolLink, Dyson360Eye, DysonNotLoggedException class MockResponse: def __init__(self, json, status_code=200): self._json = json self.status_code = status_code def json(self, **kwargs): return self._json def _mocked_login_post_failed(*args, **kwargs): url = 'https://{0}{1}?{2}={3}'.format('api.cp.dyson.com', '/v1/userregistration/authenticate', 'country', 'language') payload = {'Password': 'password', 'Email': 'email'} if args[0] == url and args[1] == payload: return MockResponse({ 'Account': 'account', 'Password': 'password' }, 401) else: raise Exception("Unknown call") def _mocked_login_post(*args, **kwargs): url = 'https://{0}{1}?{2}={3}'.format('api.cp.dyson.com', '/v1/userregistration/authenticate', 'country', 'language') payload = {'Password': 'password', 'Email': 'email'} if args[0] == url and args[1] == payload: return MockResponse({ 'Account': 'account', 'Password': 'password' }) else: raise Exception("Unknown call") def _mocked_list_devices(*args, **kwargs): url = 'https://{0}{1}'.format('api.cp.dyson.com', '/v1/provisioningservice/manifest') if args[0] == url: return MockResponse( [ { "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/" "70ZGysII1Ke1i0ZHakFH84DZuxsSQ4KTT2v" "bCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": "475" }, { "Active": False, "Serial": "device-id-2", "Name": "device-2", "ScaleUnit": "SU02", "Version": "21.02.04", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuebkH6aWl2H5Q1vCq" "CQSjJfENzMefozxWaDoW1yDluPsi09SGT5nW" "MxqxtrfkxnUtRQ==", "AutoUpdate": False, "NewVersionAvailable": True, "ProductType": "455" }, { "Active": True, "Serial": "device-id-3", "Name": "device-3", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/" "70ZGysII1Ke1i0ZHakFH84DZuxsSQ4KTT2v" "bCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": "N223" } ] ) class TestDysonAccount(unittest.TestCase): def setUp(self): pass def tearDown(self): pass @mock.patch('requests.post', side_effect=_mocked_login_post) def test_connect_account(self, mocked_login): dyson_account = DysonAccount("email", "password", "language") logged = dyson_account.login() self.assertEqual(mocked_login.call_count, 1) self.assertTrue(logged) @mock.patch('requests.post', side_effect=_mocked_login_post_failed) def test_connect_account_failed(self, mocked_login): dyson_account = DysonAccount("email", "password", "language") logged = dyson_account.login() self.assertEqual(mocked_login.call_count, 1) self.assertFalse(logged) def test_not_logged(self): dyson_account = DysonAccount("email", "password", "language") self.assertRaises(DysonNotLoggedException, dyson_account.devices) @mock.patch('requests.get', side_effect=_mocked_list_devices) @mock.patch('requests.post', side_effect=_mocked_login_post) def test_list_devices(self, mocked_login, mocked_list_devices): dyson_account = DysonAccount("email", "password", "language") dyson_account.login() self.assertEqual(mocked_login.call_count, 1) self.assertTrue(dyson_account.logged) devices = dyson_account.devices() self.assertEqual(mocked_list_devices.call_count, 1) self.assertEqual(len(devices), 3) self.assertTrue(isinstance(devices[0], DysonPureCoolLink)) self.assertTrue(isinstance(devices[1], DysonPureHotCoolLink)) self.assertTrue(isinstance(devices[2], Dyson360Eye)) self.assertTrue(devices[0].active) self.assertTrue(devices[0].auto_update) self.assertFalse(devices[0].new_version_available) self.assertEqual(devices[0].serial, 'device-id-1') self.assertEqual(devices[0].name, 'device-1') self.assertEqual(devices[0].version, '21.03.08') self.assertEqual(devices[0].product_type, '475') self.assertEqual(devices[0].credentials, 'password1') ================================================ FILE: tests/test_libpurecoollink.py ================================================ import unittest from unittest import mock from unittest.mock import Mock import json from libpurecoollink.dyson_device import NetworkDevice from libpurecoollink.dyson_pure_cool_link import DysonPureCoolState, \ DysonEnvironmentalSensorState, DysonPureCoolLink from libpurecoollink.dyson_pure_hotcool_link import DysonPureHotCoolLink from libpurecoollink.dyson_pure_state import DysonPureHotCoolState from libpurecoollink.const import FanMode, NightMode, FanSpeed, Oscillation, \ FanState, QualityTarget, StandbyMonitoring as SM, \ DYSON_PURE_COOL_LINK_DESK as Desk, DYSON_PURE_HOT_COOL_LINK_TOUR as Hot, \ HeatMode, HeatState, HeatTarget, FocusMode, TiltState, ResetFilter from libpurecoollink.exceptions import DysonInvalidTargetTemperatureException def _mocked_request_state(*args, **kwargs): assert args[0] == '475/device-id-1/command' msg = json.loads(args[1]) assert msg['msg'] in ['REQUEST-CURRENT-STATE', 'REQUEST-PRODUCT-ENVIRONMENT-CURRENT-SENSOR-DATA'] assert msg['time'] def _mocked_send_command(*args, **kwargs): assert args[0] == '{0}/device-id-1/command'.format(Desk) payload = json.loads(args[1]) if payload['msg'] == "STATE-SET": assert payload['time'] assert payload['data']['fmod'] == "FAN" assert payload['data']['nmod'] == "OFF" assert payload['data']['oson'] == "ON" assert payload['data']['rstf'] == "STET" assert payload['data']['qtar'] == "0004" assert payload['data']['fnsp'] == "0003" assert payload['data']['sltm'] == "STET" assert payload['data']['rhtm'] == "ON" assert payload['mode-reason'] == "LAPP" assert payload['msg'] == "STATE-SET" assert args[2] == 1 def _mocked_send_command_hot(*args, **kwargs): assert args[0] == '{0}/device-id-1/command'.format(Hot) payload = json.loads(args[1]) if payload['msg'] == "STATE-SET": assert payload['time'] assert payload['data']['fmod'] == "FAN" assert payload['data']['nmod'] == "OFF" assert payload['data']['oson'] == "ON" assert payload['data']['rstf'] == "STET" assert payload['data']['qtar'] == "0004" assert payload['data']['fnsp'] == "0003" assert payload['data']['sltm'] == "STET" assert payload['data']['rhtm'] == "ON" assert payload['data']['hmod'] == "HEAT" assert payload['data']['hmax'] == "2980" assert payload['data']['ffoc'] == "ON" assert payload['mode-reason'] == "LAPP" assert payload['msg'] == "STATE-SET" assert args[2] == 1 def _mocked_send_command_rst_filter(*args, **kwargs): assert args[0] == '475/device-id-1/command' payload = json.loads(args[1]) if payload['msg'] == "STATE-SET": assert payload['time'] assert payload['data']['fmod'] == "FAN" assert payload['data']['nmod'] == "OFF" assert payload['data']['oson'] == "ON" assert payload['data']['rstf'] == "RSTF" assert payload['data']['qtar'] == "0004" assert payload['data']['fnsp'] == "0003" assert payload['data']['sltm'] == "STET" assert payload['data']['rhtm'] == "ON" assert payload['mode-reason'] == "LAPP" assert payload['msg'] == "STATE-SET" assert args[2] == 1 def _mocked_send_command_timer(*args, **kwargs): assert args[0] == '475/device-id-1/command' payload = json.loads(args[1]) if payload['msg'] == "STATE-SET": assert payload['time'] assert payload['data']['sltm'] == 10 assert args[2] == 1 def _mocked_send_command_timer_off(*args, **kwargs): assert args[0] == '475/device-id-1/command' payload = json.loads(args[1]) if payload['msg'] == "STATE-SET": assert payload['time'] assert payload['data']['sltm'] == 0 assert args[2] == 1 def on_add_device(network_device): pass class TestLibPureCoolLink(unittest.TestCase): def setUp(self): pass def tearDown(self): pass @mock.patch('paho.mqtt.client.Client.loop_start') @mock.patch('paho.mqtt.client.Client.connect') def test_connect_device(self, mocked_connect, mocked_loop): device = DysonPureCoolLink({ "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/" "70ZGysII1Ke1i0ZHakFH84DZuxsSQ4KTT2v" "bCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": "475" }) network_device = NetworkDevice('device-1', 'host', 1111) device.state_data_available() device.sensor_data_available() device.connection_callback(True) device._add_network_device(network_device) connected = device.auto_connect() self.assertTrue(connected) self.assertIsNone(device.state) self.assertEqual(device.network_device, network_device) self.assertEqual(mocked_connect.call_count, 1) self.assertEqual(mocked_loop.call_count, 1) device.disconnect() @mock.patch('paho.mqtt.client.Client.loop_start') @mock.patch('paho.mqtt.client.Client.connect') def test_connect_device_with_config(self, mocked_connect, mocked_loop): device = DysonPureCoolLink({ "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/" "70ZGysII1Ke1i0ZHakFH84DZuxsSQ4KTT2v" "bCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": "475" }) device.connection_callback(True) device.state_data_available() device.sensor_data_available() connected = device.connect("192.168.0.2") self.assertTrue(connected) self.assertIsNone(device.state) self.assertEqual(device.network_device.name, "device-1") self.assertEqual(device.network_device.address, "192.168.0.2") self.assertEqual(device.network_device.port, 1883) self.assertEqual(mocked_connect.call_count, 1) self.assertEqual(mocked_loop.call_count, 1) device.disconnect() @mock.patch('paho.mqtt.client.Client.loop_stop') @mock.patch('paho.mqtt.client.Client.loop_start') @mock.patch('paho.mqtt.client.Client.connect') def test_connect_device_with_config_failed(self, mocked_connect, mocked_loop_start, mocked_loop_stop): device = DysonPureCoolLink({ "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/" "70ZGysII1Ke1i0ZHakFH84DZuxsSQ4KTT2v" "bCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": "475" }) device.connection_callback(False) connected = device.connect("192.168.0.2") self.assertFalse(connected) self.assertIsNone(device.state) self.assertEqual(device.network_device.name, "device-1") self.assertEqual(device.network_device.address, "192.168.0.2") self.assertEqual(device.network_device.port, 1883) self.assertEqual(mocked_connect.call_count, 1) self.assertEqual(mocked_loop_start.call_count, 1) self.assertEqual(mocked_loop_stop.call_count, 1) @mock.patch('libpurecoollink.zeroconf.Zeroconf.close') def test_connect_device_fail(self, mocked_close_zeroconf): device = DysonPureCoolLink({ "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/" "70ZGysII1Ke1i0ZHakFH84DZuxsSQ4KTT2v" "bCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": "475" }) connected = device.auto_connect(retry=1, timeout=1) self.assertFalse(connected) self.assertEqual(mocked_close_zeroconf.call_count, 1) def test_status_topic(self): device = DysonPureCoolLink({ "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/" "70ZGysII1Ke1i0ZHakFH84DZuxsSQ4KTT2v" "bCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": "475" }) self.assertEqual(device.status_topic, "475/device-id-1/status/current") @mock.patch('socket.inet_ntoa', ) def test_device_dyson_listener(self, mocked_ntoa): listener = DysonPureCoolLink.DysonDeviceListener('serial-1', on_add_device) zeroconf = Mock() listener.remove_service(zeroconf, "ptype", "serial-1") info = Mock() info.address = "192.168.0.1" zeroconf.get_service_info = Mock() zeroconf.get_service_info.return_value = info listener.add_service(zeroconf, '_dyson_mqtt._tcp.local.', 'ptype_serial-1._dyson_mqtt._tcp.local.') def test_on_connect(self): client = Mock() client.subscribe = Mock() userdata = Mock() userdata.status_topic = "ptype/serial/status/current" DysonPureCoolLink.on_connect(client, userdata, None, 0) userdata.connection_callback.assert_called_with(True) self.assertEqual(userdata.connection_callback.call_count, 1) client.subscribe.assert_called_with("ptype/serial/status/current") def test_on_connect_failed(self): userdata = Mock() userdata.product_type = 'ptype' userdata.serial = 'serial' DysonPureCoolLink.on_connect(None, userdata, None, 1) userdata.connection_callback.assert_called_with(False) self.assertEqual(userdata.connection_callback.call_count, 1) def test_add_message_listener(self): def on_message(): pass def on_message_2(): pass device = DysonPureCoolLink({ "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/70ZGysII1K" "e1i0ZHakFH84DZuxsSQ4KTT2vbCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": "475" }) device.add_message_listener(on_message) assert len(device.callback_message) == 1 device.remove_message_listener(on_message) assert len(device.callback_message) == 0 device.add_message_listener(on_message_2) device.add_message_listener(on_message) assert len(device.callback_message) == 2 device.clear_message_listener() assert len(device.callback_message) == 0 def test_on_message(self): def on_message(msg): assert isinstance(msg, DysonPureCoolState) device = DysonPureCoolLink({ "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/" "70ZGysII1Ke1i0ZHakFH84DZuxsSQ4KTT2v" "bCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": "475" }) device.add_message_listener(on_message) msg = Mock() payload = open("tests/data/state.json", "r").read() msg.payload = Mock() msg.payload.decode.return_value = payload DysonPureCoolLink.on_message(None, device, msg) def test_on_message_hot(self): def on_message(msg): assert isinstance(msg, DysonPureHotCoolState) device = DysonPureCoolLink({ "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/" "70ZGysII1Ke1i0ZHakFH84DZuxsSQ4KTT2v" "bCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": "455" }) device.add_message_listener(on_message) msg = Mock() payload = open("tests/data/state_hot.json", "r").read() msg.payload = Mock() msg.payload.decode.return_value = payload DysonPureCoolLink.on_message(None, device, msg) def test_on_message_sensor(self): def on_message(msg): assert isinstance(msg, DysonEnvironmentalSensorState) userdata = Mock() userdata.callback_message = [on_message] msg = Mock() payload = b'{"msg": "ENVIRONMENTAL-CURRENT-SENSOR-DATA","time":' \ b'"2017-06-17T23:05:49.001Z","data": '\ b'{"tact": "2967","hact": "0054","pact": "0004",' \ b'"vact": "0005","sltm": "0028"}}' msg.payload = payload DysonPureCoolLink.on_message(None, userdata, msg) def test_on_message_with_unknown_message(self): def on_message(msg): # Should not be called assert msg == 0 userdata = Mock() userdata.callback_message = [on_message] msg = Mock() payload = b'{"msg": "ENVIRONMENTAL-CURRENT-SENSOR-DATAS","time":' \ b'"2017-06-17T23:05:49.001Z","data": ' \ b'{"tact": "2967","hact": "0054","pact": "0004",' \ b'"vact": "0005","sltm": "0028"}}' msg.payload = payload DysonPureCoolLink.on_message(None, userdata, msg) def test_on_message_without_callback(self): userdata = Mock() userdata.callback_message = [] msg = Mock() payload = b'{"msg":"CURRENT-STATE","time":' \ b'"2017-02-19T15:00:18.000Z","mode-reason":"LAPP",' \ b'"state-reason":"MODE","dial":"OFF","rssi":"-58",' \ b'"product-state":{"fmod":"AUTO","fnst":"FAN",' \ b'"fnsp":"AUTO","qtar":"0004","oson":"OFF","rhtm":"ON",' \ b'"filf":"2159","ercd":"02C0","nmod":"ON","wacd":"NONE"},' \ b'"scheduler":{"srsc":"cbd0","dstv":"0001","tzid":"0001"}}' msg.payload = payload DysonPureCoolLink.on_message(None, userdata, msg) @mock.patch('paho.mqtt.client.Client.publish', side_effect=_mocked_request_state) @mock.patch('paho.mqtt.client.Client.connect') def test_request_state(self, mocked_connect, mocked_publish): device = DysonPureCoolLink({ "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/70ZGysII1K" "e1i0ZHakFH84DZuxsSQ4KTT2vbCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": "475" }) network_device = NetworkDevice('device-1', 'host', 1111) device.connection_callback(True) device._add_network_device(network_device) device.state_data_available() device.sensor_data_available() connected = device.connect(None) self.assertTrue(connected) self.assertEqual(mocked_connect.call_count, 1) self.assertEqual(mocked_publish.call_count, 2) device.request_current_state() self.assertEqual(mocked_publish.call_count, 3) device.request_environmental_state() self.assertEqual(mocked_publish.call_count, 4) device.disconnect() @mock.patch('paho.mqtt.client.Client.publish', side_effect=_mocked_request_state) @mock.patch('paho.mqtt.client.Client.connect') def test_dont_request_state_if_not_connected(self, mocked_connect, mocked_publish): device = DysonPureCoolLink({ "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/70ZGysII1K" "e1i0ZHakFH84DZuxsSQ4KTT2vbCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": "475" }) network_device = NetworkDevice('device-1', 'host', 1111) device.connection_callback(False) device._add_network_device(network_device) connected = device.connect(None, "192.168.0.2") self.assertFalse(connected) self.assertEqual(mocked_connect.call_count, 1) device.request_current_state() self.assertEqual(mocked_publish.call_count, 0) device.request_environmental_state() self.assertEqual(mocked_publish.call_count, 0) @mock.patch('paho.mqtt.client.Client.publish', side_effect=_mocked_send_command) @mock.patch('paho.mqtt.client.Client.connect') def test_set_configuration(self, mocked_connect, mocked_publish): device = DysonPureCoolLink({ "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/70ZGysII1K" "e1i0ZHakFH84DZuxsSQ4KTT2vbCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": Desk }) network_device = NetworkDevice('device-1', 'host', 1111) device._add_network_device(network_device) device._current_state = DysonPureCoolState( open("tests/data/state.json", "r").read()) device.connection_callback(True) device.state_data_available() device.sensor_data_available() connected = device.auto_connect() self.assertTrue(connected) self.assertEqual(mocked_connect.call_count, 1) device.set_configuration(fan_mode=FanMode.FAN, oscillation=Oscillation.OSCILLATION_ON, fan_speed=FanSpeed.FAN_SPEED_3, night_mode=NightMode.NIGHT_MODE_OFF, quality_target=QualityTarget.QUALITY_NORMAL, standby_monitoring=SM.STANDBY_MONITORING_ON ) self.assertEqual(mocked_publish.call_count, 3) self.assertEqual(device.__repr__(), "DysonPureCoolLink(serial=device-id-1,active=True," "name=device-1,version=21.03.08,auto_update=True," "new_version_available=False,product_type=469," "network_device=NetworkDevice(name=device-1," "address=host,port=1111))") device.disconnect() @mock.patch('paho.mqtt.client.Client.publish', side_effect=_mocked_send_command_hot) @mock.patch('paho.mqtt.client.Client.connect') def test_set_configuration_hot(self, mocked_connect, mocked_publish): device = DysonPureHotCoolLink({ "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/70ZGysII1K" "e1i0ZHakFH84DZuxsSQ4KTT2vbCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": Hot }) network_device = NetworkDevice('device-1', 'host', 1111) device._add_network_device(network_device) device._current_state = DysonPureCoolState( open("tests/data/state_hot.json", "r").read()) device.connection_callback(True) device.state_data_available() device.sensor_data_available() connected = device.auto_connect() self.assertTrue(connected) self.assertEqual(mocked_connect.call_count, 1) device.set_configuration(fan_mode=FanMode.FAN, oscillation=Oscillation.OSCILLATION_ON, fan_speed=FanSpeed.FAN_SPEED_3, night_mode=NightMode.NIGHT_MODE_OFF, quality_target=QualityTarget.QUALITY_NORMAL, standby_monitoring=SM.STANDBY_MONITORING_ON, heat_mode=HeatMode.HEAT_ON, focus_mode=FocusMode.FOCUS_ON, heat_target=HeatTarget.celsius(25) ) self.assertEqual(mocked_publish.call_count, 3) self.assertEqual(device.__repr__(), "DysonPureHotCoolLink(serial=device-id-1,active=True," "name=device-1,version=21.03.08,auto_update=True," "new_version_available=False,product_type=455," "network_device=NetworkDevice(name=device-1," "address=host,port=1111))") device.disconnect() @mock.patch('paho.mqtt.client.Client.publish', side_effect=_mocked_send_command_rst_filter) @mock.patch('paho.mqtt.client.Client.connect') def test_set_configuration_rst_filter(self, mocked_connect, mocked_publish): device = DysonPureCoolLink({ "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/70ZGysII1K" "e1i0ZHakFH84DZuxsSQ4KTT2vbCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": "475" }) network_device = NetworkDevice('device-1', 'host', 1111) device._add_network_device(network_device) device._current_state = DysonPureCoolState( open("tests/data/state.json", "r").read()) device.connection_callback(True) device.state_data_available() device.sensor_data_available() connected = device.auto_connect() self.assertTrue(connected) self.assertEqual(mocked_connect.call_count, 1) device.set_configuration(fan_mode=FanMode.FAN, oscillation=Oscillation.OSCILLATION_ON, fan_speed=FanSpeed.FAN_SPEED_3, night_mode=NightMode.NIGHT_MODE_OFF, quality_target=QualityTarget.QUALITY_NORMAL, standby_monitoring=SM.STANDBY_MONITORING_ON, reset_filter=ResetFilter.RESET_FILTER ) self.assertEqual(mocked_publish.call_count, 3) self.assertEqual(device.__repr__(), "DysonPureCoolLink(serial=device-id-1,active=True," "name=device-1,version=21.03.08,auto_update=True," "new_version_available=False,product_type=475," "network_device=NetworkDevice(name=device-1," "address=host,port=1111))") device.disconnect() @mock.patch('paho.mqtt.client.Client.publish', side_effect=_mocked_send_command_timer) @mock.patch('paho.mqtt.client.Client.connect') def test_set_configuration_timer(self, mocked_connect, mocked_publish): device = DysonPureCoolLink({ "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/70ZGysII1K" "e1i0ZHakFH84DZuxsSQ4KTT2vbCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": "475" }) network_device = NetworkDevice('device-1', 'host', 1111) device._add_network_device(network_device) device._current_state = DysonPureCoolState( open("tests/data/state.json", "r").read()) device.connection_callback(True) device.state_data_available() device.sensor_data_available() connected = device.auto_connect() self.assertTrue(connected) self.assertEqual(mocked_connect.call_count, 1) device.set_configuration(sleep_timer=10) self.assertEqual(mocked_publish.call_count, 3) self.assertEqual(device.__repr__(), "DysonPureCoolLink(serial=device-id-1,active=True," "name=device-1,version=21.03.08,auto_update=True," "new_version_available=False,product_type=475," "network_device=NetworkDevice(name=device-1," "address=host,port=1111))") device.disconnect() @mock.patch('paho.mqtt.client.Client.publish', side_effect=_mocked_send_command_timer_off) @mock.patch('paho.mqtt.client.Client.connect') def test_set_configuration_timer_off(self, mocked_connect, mocked_publish): device = DysonPureCoolLink({ "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/70ZGysII1K" "e1i0ZHakFH84DZuxsSQ4KTT2vbCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": "475" }) network_device = NetworkDevice('device-1', 'host', 1111) device._add_network_device(network_device) device._current_state = DysonPureCoolState( open("tests/data/state.json", "r").read()) device.connection_callback(True) device.state_data_available() device.sensor_data_available() connected = device.auto_connect() self.assertTrue(connected) self.assertEqual(mocked_connect.call_count, 1) device.set_configuration(sleep_timer=0) self.assertEqual(mocked_publish.call_count, 3) self.assertEqual(device.__repr__(), "DysonPureCoolLink(serial=device-id-1,active=True," "name=device-1,version=21.03.08,auto_update=True," "new_version_available=False,product_type=475," "network_device=NetworkDevice(name=device-1," "address=host,port=1111))") device.disconnect() @mock.patch('paho.mqtt.client.Client.publish', side_effect=_mocked_send_command) @mock.patch('paho.mqtt.client.Client.connect') def test_dont_set_configuration_if_not_connected(self, mocked_connect, mocked_publish): device = DysonPureCoolLink({ "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/70ZGysII1K" "e1i0ZHakFH84DZuxsSQ4KTT2vbCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": "475" }) network_device = NetworkDevice('device-1', 'host', 1111) device._add_network_device(network_device) device._current_state = DysonPureCoolState( open("tests/data/state.json", "r").read()) device.connection_callback(False) connected = device.auto_connect() self.assertFalse(connected) self.assertEqual(mocked_connect.call_count, 1) device.set_configuration(fan_mode=FanMode.FAN, oscillation=Oscillation.OSCILLATION_ON, fan_speed=FanSpeed.FAN_SPEED_3, night_mode=NightMode.NIGHT_MODE_OFF) self.assertEqual(mocked_publish.call_count, 0) self.assertEqual(device.__repr__(), "DysonPureCoolLink(serial=device-id-1,active=True," "name=device-1,version=21.03.08,auto_update=True," "new_version_available=False,product_type=475," "network_device=NetworkDevice(name=device-1," "address=host,port=1111))") def test_network_device(self): device = NetworkDevice("device", "192.168.1.1", "8090") self.assertEqual(device.name, "device") self.assertEqual(device.address, "192.168.1.1") self.assertEqual(device.port, "8090") self.assertEqual(device.__repr__(), "NetworkDevice(name=device,address=192.168.1.1," "port=8090)") def test_dyson_state(self): dyson_state = DysonPureCoolState( open("tests/data/state.json", "r").read()) self.assertEqual(dyson_state.fan_mode, FanMode.AUTO.value) self.assertEqual(dyson_state.fan_state, FanState.FAN_ON.value) self.assertEqual(dyson_state.night_mode, NightMode.NIGHT_MODE_ON.value) self.assertEqual(dyson_state.speed, FanSpeed.FAN_SPEED_AUTO.value) self.assertEqual(dyson_state.oscillation, Oscillation.OSCILLATION_OFF.value) self.assertEqual(dyson_state.filter_life, '2087') self.assertEqual(dyson_state.__repr__(), "DysonPureCoolState(fan_mode=AUTO,fan_state=FAN," "night_mode=ON,speed=AUTO,oscillation=OFF," "filter_life=2087,quality_target=0004," "standby_monitoring=ON)") self.assertEqual(dyson_state.quality_target, QualityTarget.QUALITY_NORMAL.value) self.assertEqual(dyson_state.standby_monitoring, SM.STANDBY_MONITORING_ON.value) def test_dyson_state_hot(self): dyson_state = DysonPureHotCoolState( open("tests/data/state_hot.json", "r").read()) self.assertEqual(dyson_state.fan_mode, FanMode.AUTO.value) self.assertEqual(dyson_state.fan_state, FanState.FAN_ON.value) self.assertEqual(dyson_state.night_mode, NightMode.NIGHT_MODE_ON.value) self.assertEqual(dyson_state.speed, FanSpeed.FAN_SPEED_AUTO.value) self.assertEqual(dyson_state.oscillation, Oscillation.OSCILLATION_OFF.value) self.assertEqual(dyson_state.filter_life, '2087') self.assertEqual(dyson_state.heat_mode, HeatMode.HEAT_ON.value) self.assertEqual(dyson_state.heat_state, HeatState.HEAT_STATE_ON.value) self.assertEqual(dyson_state.tilt, TiltState.TILT_FALSE.value) self.assertEqual(dyson_state.focus_mode, FocusMode.FOCUS_ON.value) self.assertEqual(dyson_state.heat_target, '2950') self.assertEqual(dyson_state.__repr__(), "DysonHotCoolState(fan_mode=AUTO,fan_state=FAN," "night_mode=ON,speed=AUTO,oscillation=OFF," "filter_life=2087,quality_target=0004," "standby_monitoring=ON,tilt=OK,focus_mode=ON," "heat_mode=HEAT,heat_target=2950,heat_state=HEAT)") self.assertEqual(dyson_state.quality_target, QualityTarget.QUALITY_NORMAL.value) self.assertEqual(dyson_state.standby_monitoring, SM.STANDBY_MONITORING_ON.value) def test_sensor_state(self): sensor_state = DysonEnvironmentalSensorState( open("tests/data/sensor.json", "r").read()) self.assertEqual(sensor_state.sleep_timer, 28) self.assertEqual(sensor_state.dust, 4) self.assertEqual(sensor_state.humidity, 54) self.assertEqual(sensor_state.temperature, 296.7) self.assertEqual(sensor_state.volatil_organic_compounds, 5) self.assertEqual(sensor_state.__repr__(), "DysonEnvironmentalSensorState(humidity=54," "air quality=5,temperature=296.7," "dust=4,sleep_timer=28)") def test_sensor_state_sleep_timer_off(self): sensor_state = DysonEnvironmentalSensorState( open("tests/data/sensor_sltm_off.json", "r").read()) self.assertEqual(sensor_state.sleep_timer, 0) self.assertEqual(sensor_state.dust, 4) self.assertEqual(sensor_state.humidity, 54) self.assertEqual(sensor_state.temperature, 296.7) self.assertEqual(sensor_state.volatil_organic_compounds, 5) def test_heat_target_celsius(self): self.assertEqual(HeatTarget.celsius(25), "2980") with self.assertRaises(DysonInvalidTargetTemperatureException) as ex: HeatTarget.celsius(38) invalid_target_exception = ex.exception self.assertEqual(invalid_target_exception.temperature_unit, DysonInvalidTargetTemperatureException.CELSIUS) self.assertEqual(invalid_target_exception.current_value, 38) self.assertEqual(invalid_target_exception.__repr__(), "38 is not a valid temperature target. " "It must be between 1 to 37 inclusive.") def test_heat_target_fahrenheit(self): self.assertEqual(HeatTarget.fahrenheit(77), "2980") with self.assertRaises(DysonInvalidTargetTemperatureException) as ex: HeatTarget.fahrenheit(99) invalid_target_exception = ex.exception self.assertEqual(invalid_target_exception.temperature_unit, DysonInvalidTargetTemperatureException.FAHRENHEIT) self.assertEqual(invalid_target_exception.current_value, 99) self.assertEqual(invalid_target_exception.__repr__(), "99 is not a valid temperature target. " "It must be between 34 to 98 inclusive.") def test_device_connected(self): device = DysonPureCoolLink({ "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/70ZGysII1K" "e1i0ZHakFH84DZuxsSQ4KTT2vbCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": "475" }) device.connected = True self.assertTrue(device.connected) device.connected = False self.assertFalse(device.connected) def test_environment_state(self): device = DysonPureCoolLink({ "Active": True, "Serial": "device-id-1", "Name": "device-1", "ScaleUnit": "SU01", "Version": "21.03.08", "LocalCredentials": "1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/70ZGysII1K" "e1i0ZHakFH84DZuxsSQ4KTT2vbCm7uYeTORULKLKQ==", "AutoUpdate": True, "NewVersionAvailable": False, "ProductType": "475" }) sensor_state = DysonEnvironmentalSensorState( open("tests/data/sensor.json", "r").read()) device.environmental_state = sensor_state self.assertEqual(device.environmental_state.dust, 4) ================================================ FILE: tests/test_utils.py ================================================ import unittest from libpurecoollink.utils import support_heating, is_heating_device, \ is_360_eye_device, printable_fields, decrypt_password class TestUtils(unittest.TestCase): def setUp(self): pass def tearDown(self): pass def test_support_heating(self): self.assertTrue(support_heating("455")) self.assertFalse(support_heating("469")) def test_is_heating_device(self): self.assertTrue(is_heating_device({"ProductType": "455"})) self.assertFalse(is_heating_device({"ProductType": "469"})) def test_is_360_eye_device(self): self.assertTrue(is_360_eye_device({"ProductType": "N223"})) self.assertFalse(is_360_eye_device({"ProductType": "455"})) def test_printable_fields(self): idx = 0 fields = ["field1=value1", "field2=value2"] for field in printable_fields( [("field1", "value1"), ("field2", "value2")]): self.assertEqual(field, fields[idx]) idx += 1 def test_decrypt_password(self): password = decrypt_password("1/aJ5t52WvAfn+z+fjDuef86kQDQPefbQ6/70" "ZGysII1Ke1i0ZHakFH84DZuxsSQ4KTT2vbCm7" "uYeTORULKLKQ==") self.assertEqual(password, "password1") ================================================ FILE: tox.ini ================================================ [tox] envlist = py34, py35, py36, lint skip_missing_interpreters = True [testenv:py34] setenv = LANG=en_US.UTF-8 PYTHONPATH = {toxinidir}:{toxinidir}/libpurecoollink commands = py.test -v --duration=10 --cov --cov-report= {posargs} deps = -r{toxinidir}/requirements.txt -r{toxinidir}/requirements_test.txt [testenv:py35] setenv = LANG=en_US.UTF-8 PYTHONPATH = {toxinidir}:{toxinidir}/libpurecoollink commands = py.test -v --duration=10 --cov --cov-report= {posargs} deps = -r{toxinidir}/requirements.txt -r{toxinidir}/requirements_test.txt [testenv:py36] setenv = LANG=en_US.UTF-8 PYTHONPATH = {toxinidir}:{toxinidir}/libpurecoollink commands = py.test -v --duration=10 --cov --cov-report= {posargs} deps = -r{toxinidir}/requirements.txt -r{toxinidir}/requirements_test.txt [testenv:lint] basepython = python3 ignore_errors = True commands = flake8 libpurecoollink tests --exclude zeroconf.py pylint libpurecoollink --ignore zeroconf.py pydocstyle libpurecoollink --match='(?!test_)(?!zeroconf).*\.py' deps = -r{toxinidir}/requirements.txt -r{toxinidir}/requirements_test.txt