Showing preview only (243K chars total). Download the full file or copy to clipboard to get everything.
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 <https://github.com/ThomasHoussin> (add parameters)
- Soraxas <https://github.com/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
<http://www.apache.org/licenses/LICENSE-2.0>
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
[](https://travis-ci.org/CharlesBlonde/libpurecoollink) [](https://coveralls.io/github/CharlesBlonde/libpurecoollink?branch=master)[](https://pypi.python.org/pypi/libpurecoollink) [](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 <target>' where <target> 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
================================================
<p>
<iframe src="https://ghbtns.com/github-btn.html?user=CharlesBlonde&repo=libpurecoollink&type=watch&count=true&size=large"
allowtransparency="true" frameborder="0" scrolling="0" width="200px" height="35px"></iframe>
</p>
<h3>📰 Useful Links</h3>
<ul>
<li><a href="http://github.com/CharlesBlonde/libpurecoollink">Libpurecoollink @ GitHub</a></li>
<li><a href="http://pypi.python.org/pypi/libpurecoollink">Libpurecoollink @ PyPI</a></li>
<li><a href="http://github.com/CharlesBlonde/libpurecoollink/issues">Issue Tracker</a></li>
</ul>
================================================
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
# "<project> v<release> 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 <link> 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 <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>`_.
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("<dyson_account_email>","<dyson_account_password>","<language>")
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("<dyson_account_email>","<dyson_account_password>","<language>")
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("<dyson_account_email>","<dyson_account_password>","<language>")
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("<dyson_account_email>","<dyson_account_password>","<language>")
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 <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 ...)
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 <jakub@stasiak.at>'
__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:
<sn> . <_tcp|_udp> . local.
<Instance> . <sn> . <_tcp|_udp> . local.
<sub>._sub . <sn> . <_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 <sn> 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 <Instance> and sub type <sub> 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 '<DNSOutgoing:{%s}>' % ', '.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):
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
SYMBOL INDEX (453 symbols across 14 files)
FILE: libpurecoollink/const.py
class FanMode (line 12) | class FanMode(Enum):
class Oscillation (line 20) | class Oscillation(Enum):
class NightMode (line 27) | class NightMode(Enum):
class FanSpeed (line 34) | class FanSpeed(Enum):
class FanState (line 50) | class FanState(Enum):
class QualityTarget (line 57) | class QualityTarget(Enum):
class StandbyMonitoring (line 65) | class StandbyMonitoring(Enum):
class FocusMode (line 72) | class FocusMode(Enum):
class TiltState (line 79) | class TiltState(Enum):
class HeatMode (line 86) | class HeatMode(Enum):
class HeatState (line 93) | class HeatState(Enum):
class HeatTarget (line 100) | class HeatTarget:
method celsius (line 104) | def celsius(temperature):
method fahrenheit (line 114) | def fahrenheit(temperature):
class ResetFilter (line 125) | class ResetFilter(Enum):
class PowerMode (line 132) | class PowerMode(Enum):
class Dyson360EyeMode (line 139) | class Dyson360EyeMode(Enum):
class Dyson360EyeCommand (line 154) | class Dyson360EyeCommand(Enum):
FILE: libpurecoollink/dyson.py
class DysonAccount (line 20) | class DysonAccount:
method __init__ (line 23) | def __init__(self, email, password, country):
method login (line 36) | def login(self):
method devices (line 55) | def devices(self):
method logged (line 77) | def logged(self):
FILE: libpurecoollink/dyson_360_eye.py
class Dyson360Eye (line 17) | class Dyson360Eye(DysonDevice):
method connect (line 20) | def connect(self, device_ip, device_port=DEFAULT_PORT):
method status_topic (line 51) | def status_topic(self):
method _send_command (line 55) | def _send_command(self, command, data=None):
method set_power_mode (line 77) | def set_power_mode(self, power_mode):
method start (line 85) | def start(self):
method pause (line 90) | def pause(self):
method resume (line 94) | def resume(self):
method abort (line 98) | def abort(self):
method call_callback_functions (line 103) | def call_callback_functions(functions, message):
method on_message (line 109) | def on_message(client, userdata, msg):
method __repr__ (line 136) | def __repr__(self):
class Dyson360EyeState (line 142) | class Dyson360EyeState:
method is_state_message (line 146) | def is_state_message(payload):
method __init__ (line 150) | def __init__(self, json_body):
method state (line 177) | def state(self):
method full_clean_type (line 182) | def full_clean_type(self):
method position (line 187) | def position(self):
method power_mode (line 192) | def power_mode(self):
method battery_level (line 197) | def battery_level(self):
method clean_id (line 202) | def clean_id(self):
method __repr__ (line 206) | def __repr__(self):
class Dyson360EyeTelemetryData (line 217) | class Dyson360EyeTelemetryData:
method is_telemetry_data (line 221) | def is_telemetry_data(payload):
method __init__ (line 226) | def __init__(self, json_body):
method telemetry_data_id (line 238) | def telemetry_data_id(self):
method field1 (line 243) | def field1(self):
method field2 (line 248) | def field2(self):
method field3 (line 253) | def field3(self):
method field4 (line 258) | def field4(self):
method time (line 263) | def time(self):
method __repr__ (line 267) | def __repr__(self):
class Dyson360EyeMapData (line 279) | class Dyson360EyeMapData:
method is_map_data (line 283) | def is_map_data(payload):
method __init__ (line 288) | def __init__(self, json_body):
method grid_id (line 300) | def grid_id(self):
method clean_id (line 305) | def clean_id(self):
method content_type (line 310) | def content_type(self):
method content_encoding (line 315) | def content_encoding(self):
method content (line 320) | def content(self):
method time (line 325) | def time(self):
method __repr__ (line 329) | def __repr__(self):
class Dyson360EyeMapGrid (line 340) | class Dyson360EyeMapGrid:
method is_map_grid (line 344) | def is_map_grid(payload):
method __init__ (line 349) | def __init__(self, json_body):
method grid_id (line 363) | def grid_id(self):
method clean_id (line 368) | def clean_id(self):
method resolution (line 373) | def resolution(self):
method width (line 378) | def width(self):
method height (line 383) | def height(self):
method anchor (line 388) | def anchor(self):
method time (line 393) | def time(self):
method __repr__ (line 397) | def __repr__(self):
class Dyson360EyeMapGlobal (line 409) | class Dyson360EyeMapGlobal:
method is_map_global (line 413) | def is_map_global(payload):
method __init__ (line 418) | def __init__(self, json_body):
method grid_id (line 430) | def grid_id(self):
method clean_id (line 435) | def clean_id(self):
method position_x (line 440) | def position_x(self):
method position_y (line 445) | def position_y(self):
method angle (line 450) | def angle(self):
method time (line 455) | def time(self):
method __repr__ (line 459) | def __repr__(self):
class Dyson360Goodbye (line 471) | class Dyson360Goodbye:
method is_goodbye_message (line 475) | def is_goodbye_message(payload):
method __init__ (line 480) | def __init__(self, json_body):
method reason (line 488) | def reason(self):
method time (line 493) | def time(self):
method __repr__ (line 497) | def __repr__(self):
FILE: libpurecoollink/dyson_device.py
class NetworkDevice (line 28) | class NetworkDevice:
method __init__ (line 31) | def __init__(self, name, address, port):
method name (line 43) | def name(self):
method address (line 48) | def address(self):
method port (line 53) | def port(self):
method __repr__ (line 57) | def __repr__(self):
class DysonDevice (line 64) | class DysonDevice:
method on_connect (line 68) | def on_connect(client, userdata, flags, return_code):
method __init__ (line 81) | def __init__(self, json_body):
method connection_callback (line 105) | def connection_callback(self, connected):
method connect (line 110) | def connect(self, device_ip, device_port=DEFAULT_PORT):
method status_topic (line 121) | def status_topic(self):
method command_topic (line 126) | def command_topic(self):
method request_current_state (line 130) | def request_current_state(self):
method state (line 144) | def state(self):
method state (line 149) | def state(self, value):
method active (line 154) | def active(self):
method serial (line 159) | def serial(self):
method name (line 164) | def name(self):
method version (line 169) | def version(self):
method credentials (line 174) | def credentials(self):
method auto_update (line 179) | def auto_update(self):
method new_version_available (line 184) | def new_version_available(self):
method product_type (line 189) | def product_type(self):
method network_device (line 194) | def network_device(self):
method _add_network_device (line 198) | def _add_network_device(self, network_device):
method callback_message (line 206) | def callback_message(self):
method add_message_listener (line 210) | def add_message_listener(self, callback_message):
method remove_message_listener (line 214) | def remove_message_listener(self, callback_message):
method clear_message_listener (line 219) | def clear_message_listener(self):
method device_available (line 224) | def device_available(self):
method state_data_available (line 228) | def state_data_available(self):
method _fields (line 233) | def _fields(self):
FILE: libpurecoollink/dyson_pure_cool_link.py
class DysonPureCoolLink (line 24) | class DysonPureCoolLink(DysonDevice):
class DysonDeviceListener (line 27) | class DysonDeviceListener(object):
method __init__ (line 30) | def __init__(self, serial, add_device_function):
method remove_service (line 39) | def remove_service(self, zeroconf, device_type, name):
method add_service (line 44) | def add_service(self, zeroconf, device_type, name):
method __init__ (line 61) | def __init__(self, json_body):
method status_topic (line 73) | def status_topic(self):
method on_message (line 79) | def on_message(client, userdata, msg):
method auto_connect (line 104) | def auto_connect(self, timeout=5, retry=15):
method connect (line 131) | def connect(self, device_ip, device_port=DEFAULT_PORT):
method _mqtt_connect (line 143) | def _mqtt_connect(self):
method sensor_data_available (line 168) | def sensor_data_available(self):
method disconnect (line 173) | def disconnect(self):
method request_environmental_state (line 178) | def request_environmental_state(self):
method set_fan_configuration (line 193) | def set_fan_configuration(self, data):
method _parse_command_args (line 211) | def _parse_command_args(self, **kwargs):
method set_configuration (line 255) | def set_configuration(self, **kwargs):
method environmental_state (line 264) | def environmental_state(self):
method environmental_state (line 269) | def environmental_state(self, value):
method connected (line 274) | def connected(self):
method connected (line 279) | def connected(self, value):
method __repr__ (line 283) | def __repr__(self):
class EnvironmentalSensorThread (line 289) | class EnvironmentalSensorThread(Thread):
method __init__ (line 295) | def __init__(self, request_data_method, interval=30):
method stop (line 302) | def stop(self):
method run (line 306) | def run(self):
FILE: libpurecoollink/dyson_pure_hotcool_link.py
class DysonPureHotCoolLink (line 11) | class DysonPureHotCoolLink(DysonPureCoolLink):
method _parse_command_args (line 14) | def _parse_command_args(self, **kwargs):
method set_configuration (line 35) | def set_configuration(self, **kwargs):
method __repr__ (line 43) | def __repr__(self):
FILE: libpurecoollink/dyson_pure_state.py
class DysonPureCoolState (line 9) | class DysonPureCoolState:
method is_state_message (line 13) | def is_state_message(payload):
method _get_field_value (line 18) | def _get_field_value(state, field):
method __init__ (line 23) | def __init__(self, payload):
method fan_mode (line 41) | def fan_mode(self):
method fan_state (line 46) | def fan_state(self):
method night_mode (line 51) | def night_mode(self):
method speed (line 56) | def speed(self):
method oscillation (line 61) | def oscillation(self):
method filter_life (line 66) | def filter_life(self):
method quality_target (line 71) | def quality_target(self):
method standby_monitoring (line 76) | def standby_monitoring(self):
method __repr__ (line 80) | def __repr__(self):
class DysonEnvironmentalSensorState (line 92) | class DysonEnvironmentalSensorState:
method is_environmental_state_message (line 96) | def is_environmental_state_message(payload):
method __get_field_value (line 102) | def __get_field_value(state, field):
method __init__ (line 107) | def __init__(self, payload):
method humidity (line 127) | def humidity(self):
method volatil_organic_compounds (line 132) | def volatil_organic_compounds(self):
method temperature (line 137) | def temperature(self):
method dust (line 142) | def dust(self):
method sleep_timer (line 147) | def sleep_timer(self):
method __repr__ (line 151) | def __repr__(self):
class DysonPureHotCoolState (line 162) | class DysonPureHotCoolState(DysonPureCoolState):
method __init__ (line 165) | def __init__(self, payload):
method tilt (line 184) | def tilt(self):
method focus_mode (line 189) | def focus_mode(self):
method heat_target (line 194) | def heat_target(self):
method heat_mode (line 199) | def heat_mode(self):
method heat_state (line 204) | def heat_state(self):
method __repr__ (line 208) | def __repr__(self):
FILE: libpurecoollink/exceptions.py
class DysonInvalidTargetTemperatureException (line 6) | class DysonInvalidTargetTemperatureException(Exception):
method __init__ (line 12) | def __init__(self, temperature_unit, current_value):
method temperature_unit (line 23) | def temperature_unit(self):
method current_value (line 28) | def current_value(self):
method __repr__ (line 32) | def __repr__(self):
class DysonNotLoggedException (line 42) | class DysonNotLoggedException(Exception):
method __init__ (line 45) | def __init__(self):
FILE: libpurecoollink/utils.py
function support_heating (line 8) | def support_heating(product_type):
function is_heating_device (line 18) | def is_heating_device(json_payload):
function printable_fields (line 25) | def printable_fields(fields):
function unpad (line 34) | def unpad(string):
function decrypt_password (line 39) | def decrypt_password(encrypted_password):
function is_360_eye_device (line 54) | def is_360_eye_device(json_payload):
FILE: libpurecoollink/zeroconf.py
class InterfaceChoice (line 156) | class InterfaceChoice(enum.Enum):
class ServiceStateChange (line 162) | class ServiceStateChange(enum.Enum):
function current_time_millis (line 173) | def current_time_millis():
function service_type_name (line 178) | def service_type_name(type_):
class Error (line 286) | class Error(Exception):
class IncomingDecodeError (line 290) | class IncomingDecodeError(Error):
class NonUniqueNameException (line 294) | class NonUniqueNameException(Error):
class NamePartTooLongException (line 298) | class NamePartTooLongException(Error):
class AbstractMethodException (line 302) | class AbstractMethodException(Error):
class BadTypeInNameException (line 306) | class BadTypeInNameException(Error):
class QuietLogger (line 312) | class QuietLogger(object):
method log_exception_warning (line 316) | def log_exception_warning(cls, logger_data=None):
method log_warning_once (line 330) | def log_warning_once(cls, *args):
class DNSEntry (line 341) | class DNSEntry(object):
method __init__ (line 345) | def __init__(self, name, type_, class_):
method __eq__ (line 352) | def __eq__(self, other):
method __ne__ (line 359) | def __ne__(self, other):
method get_class_ (line 364) | def get_class_(class_):
method get_type (line 369) | def get_type(t):
method to_string (line 373) | def to_string(self, hdr, other):
class DNSQuestion (line 389) | class DNSQuestion(DNSEntry):
method __init__ (line 393) | def __init__(self, name, type_, class_):
method answered_by (line 396) | def answered_by(self, rec):
method __repr__ (line 402) | def __repr__(self):
class DNSRecord (line 407) | class DNSRecord(DNSEntry):
method __init__ (line 411) | def __init__(self, name, type_, class_, ttl):
method __eq__ (line 416) | def __eq__(self, other):
method suppressed_by (line 420) | def suppressed_by(self, msg):
method suppressed_by_answer (line 428) | def suppressed_by_answer(self, other):
method get_expiration_time (line 433) | def get_expiration_time(self, percent):
method get_remaining_ttl (line 438) | def get_remaining_ttl(self, now):
method is_expired (line 442) | def is_expired(self, now):
method is_stale (line 446) | def is_stale(self, now):
method reset_ttl (line 450) | def reset_ttl(self, other):
method write (line 456) | def write(self, out):
method to_string (line 460) | def to_string(self, other):
class DNSAddress (line 467) | class DNSAddress(DNSRecord):
method __init__ (line 471) | def __init__(self, name, type_, class_, ttl, address):
method write (line 475) | def write(self, out):
method __eq__ (line 479) | def __eq__(self, other):
method __repr__ (line 483) | def __repr__(self):
class DNSHinfo (line 491) | class DNSHinfo(DNSRecord):
method __init__ (line 495) | def __init__(self, name, type_, class_, ttl, cpu, os):
method write (line 506) | def write(self, out):
method __eq__ (line 511) | def __eq__(self, other):
method __repr__ (line 516) | def __repr__(self):
class DNSPointer (line 521) | class DNSPointer(DNSRecord):
method __init__ (line 525) | def __init__(self, name, type_, class_, ttl, alias):
method write (line 529) | def write(self, out):
method __eq__ (line 533) | def __eq__(self, other):
method __repr__ (line 537) | def __repr__(self):
class DNSText (line 542) | class DNSText(DNSRecord):
method __init__ (line 546) | def __init__(self, name, type_, class_, ttl, text):
method write (line 551) | def write(self, out):
method __eq__ (line 555) | def __eq__(self, other):
method __repr__ (line 559) | def __repr__(self):
class DNSService (line 567) | class DNSService(DNSRecord):
method __init__ (line 571) | def __init__(self, name, type_, class_, ttl,
method write (line 579) | def write(self, out):
method __eq__ (line 586) | def __eq__(self, other):
method __repr__ (line 594) | def __repr__(self):
class DNSIncoming (line 599) | class DNSIncoming(QuietLogger):
method __init__ (line 603) | def __init__(self, data):
method unpack (line 627) | def unpack(self, format_):
method read_header (line 634) | def read_header(self):
method read_questions (line 639) | def read_questions(self):
method read_character_string (line 652) | def read_character_string(self):
method read_string (line 658) | def read_string(self, length):
method read_unsigned_short (line 664) | def read_unsigned_short(self):
method read_others (line 668) | def read_others(self):
method is_query (line 707) | def is_query(self):
method is_response (line 711) | def is_response(self):
method read_utf (line 715) | def read_utf(self, offset, length):
method read_name (line 719) | def read_name(self):
class DNSOutgoing (line 754) | class DNSOutgoing(object):
method __init__ (line 758) | def __init__(self, flags, multicast=True):
method __repr__ (line 773) | def __repr__(self):
class State (line 783) | class State(enum.Enum):
method add_question (line 787) | def add_question(self, record):
method add_answer (line 791) | def add_answer(self, inp, record):
method add_answer_at_time (line 796) | def add_answer_at_time(self, record, now):
method add_authorative_answer (line 802) | def add_authorative_answer(self, record):
method add_additional_answer (line 806) | def add_additional_answer(self, record):
method pack (line 844) | def pack(self, format_, value):
method write_byte (line 848) | def write_byte(self, value):
method insert_short (line 852) | def insert_short(self, index, value):
method write_short (line 857) | def write_short(self, value):
method write_int (line 861) | def write_int(self, value):
method write_string (line 865) | def write_string(self, value):
method write_utf (line 871) | def write_utf(self, s):
method write_character_string (line 880) | def write_character_string(self, value):
method write_name (line 888) | def write_name(self, name):
method write_question (line 934) | def write_question(self, question):
method write_record (line 940) | def write_record(self, record, now):
method packet (line 977) | def packet(self):
class DNSCache (line 1008) | class DNSCache(object):
method __init__ (line 1012) | def __init__(self):
method add (line 1015) | def add(self, entry):
method remove (line 1019) | def remove(self, entry):
method get (line 1027) | def get(self, entry):
method get_by_details (line 1038) | def get_by_details(self, name, type_, class_):
method entries_with_name (line 1044) | def entries_with_name(self, name):
method current_entry_with_name_and_alias (line 1051) | def current_entry_with_name_and_alias(self, name, alias):
method entries (line 1059) | def entries(self):
class Engine (line 1069) | class Engine(threading.Thread):
method __init__ (line 1082) | def __init__(self, zc):
method run (line 1091) | def run(self):
method add_reader (line 1115) | def add_reader(self, reader, socket_):
method del_reader (line 1120) | def del_reader(self, socket_):
class Listener (line 1126) | class Listener(QuietLogger):
method __init__ (line 1135) | def __init__(self, zc):
method handle_read (line 1139) | def handle_read(self, socket_):
class Reaper (line 1168) | class Reaper(threading.Thread):
method __init__ (line 1173) | def __init__(self, zc):
method run (line 1179) | def run(self):
class Signal (line 1191) | class Signal(object):
method __init__ (line 1192) | def __init__(self):
method fire (line 1195) | def fire(self, **kwargs):
method registration_interface (line 1200) | def registration_interface(self):
class SignalRegistrationInterface (line 1204) | class SignalRegistrationInterface(object):
method __init__ (line 1206) | def __init__(self, handlers):
method register_handler (line 1209) | def register_handler(self, handler):
method unregister_handler (line 1213) | def unregister_handler(self, handler):
class ServiceBrowser (line 1218) | class ServiceBrowser(threading.Thread):
method __init__ (line 1226) | def __init__(self, zc, type_, handlers=None, listener=None):
method service_state_changed (line 1268) | def service_state_changed(self):
method update_record (line 1271) | def update_record(self, zc, now, record):
method cancel (line 1306) | def cancel(self):
method run (line 1311) | def run(self):
class ServiceInfo (line 1337) | class ServiceInfo(object):
method __init__ (line 1341) | def __init__(self, type_, name, address=None, port=None, weight=0,
method properties (line 1371) | def properties(self):
method _set_properties (line 1374) | def _set_properties(self, properties):
method _set_text (line 1404) | def _set_text(self, text):
method get_name (line 1437) | def get_name(self):
method update_record (line 1443) | def update_record(self, zc, now, record):
method request (line 1464) | def request(self, zc, timeout):
method __eq__ (line 1523) | def __eq__(self, other):
method __ne__ (line 1527) | def __ne__(self, other):
method __repr__ (line 1531) | def __repr__(self):
class ZeroconfServiceTypes (line 1545) | class ZeroconfServiceTypes(object):
method __init__ (line 1549) | def __init__(self):
method add_service (line 1552) | def add_service(self, zc, type_, name):
method remove_service (line 1555) | def remove_service(self, zc, type_, name):
method find (line 1559) | def find(cls, zc=None, timeout=5, interfaces=InterfaceChoice.All):
function get_all_addresses (line 1585) | def get_all_addresses(address_family):
function normalize_interface_choice (line 1594) | def normalize_interface_choice(choice, address_family):
function new_socket (line 1602) | def new_socket():
function get_errno (line 1636) | def get_errno(e):
class Zeroconf (line 1641) | class Zeroconf(QuietLogger):
method __init__ (line 1648) | def __init__(
method done (line 1709) | def done(self):
method wait (line 1712) | def wait(self, timeout):
method notify_all (line 1718) | def notify_all(self):
method get_service_info (line 1723) | def get_service_info(self, type_, name, timeout=3000):
method add_service_listener (line 1731) | def add_service_listener(self, type_, listener):
method remove_service_listener (line 1738) | def remove_service_listener(self, listener):
method remove_all_service_listeners (line 1744) | def remove_all_service_listeners(self):
method register_service (line 1749) | def register_service(self, info, ttl=_DNS_TTL, allow_name_change=False):
method unregister_service (line 1786) | def unregister_service(self, info):
method unregister_all_services (line 1821) | def unregister_all_services(self):
method check_service (line 1849) | def check_service(self, info, allow_name_change):
method add_listener (line 1894) | def add_listener(self, listener, question):
method remove_listener (line 1906) | def remove_listener(self, listener):
method update_record (line 1914) | def update_record(self, now, rec):
method handle_response (line 1921) | def handle_response(self, msg):
method handle_query (line 1940) | def handle_query(self, msg, addr, port):
method send (line 2006) | def send(self, out, addr=_MDNS_ADDR, port=_MDNS_PORT):
method close (line 2028) | def close(self):
FILE: tests/test_360_eye.py
function _mocked_request_state (line 13) | def _mocked_request_state(*args, **kwargs):
function _mocked_send_start_command (line 20) | def _mocked_send_start_command(*args, **kwargs):
class TestDysonEye360Device (line 27) | class TestDysonEye360Device(unittest.TestCase):
method setUp (line 28) | def setUp(self):
method tearDown (line 31) | def tearDown(self):
method _device_sample (line 35) | def _device_sample():
method test_status_topic (line 49) | def test_status_topic(self):
method test_request_state (line 56) | def test_request_state(self, mocked_connect, mocked_publish):
method test_start_not_connected (line 79) | def test_start_not_connected(self):
method test_start (line 92) | def test_start(self):
method test_pause (line 109) | def test_pause(self):
method test_resume (line 125) | def test_resume(self):
method test_abort (line 141) | def test_abort(self):
method test_set_power_mode (line 157) | def test_set_power_mode(self):
method test_on_unknown_message (line 185) | def test_on_unknown_message(self):
method test_on_state_message (line 197) | def test_on_state_message(self):
method test_on_state_unknown_values_message (line 226) | def test_on_state_unknown_values_message(self):
method test_on_state_change_message (line 255) | def test_on_state_change_message(self):
method test_on_map_global_message (line 286) | def test_on_map_global_message(self):
method test_on_map_grid_message (line 314) | def test_on_map_grid_message(self):
method test_on_map_data_message (line 344) | def test_on_map_data_message(self):
method test_on_telemetry_data_message (line 373) | def test_on_telemetry_data_message(self):
method test_on_goodbye_message (line 404) | def test_on_goodbye_message(self):
FILE: tests/test_dyson_account.py
class MockResponse (line 9) | class MockResponse:
method __init__ (line 10) | def __init__(self, json, status_code=200):
method json (line 14) | def json(self, **kwargs):
function _mocked_login_post_failed (line 18) | def _mocked_login_post_failed(*args, **kwargs):
function _mocked_login_post (line 33) | def _mocked_login_post(*args, **kwargs):
function _mocked_list_devices (line 48) | def _mocked_list_devices(*args, **kwargs):
class TestDysonAccount (line 97) | class TestDysonAccount(unittest.TestCase):
method setUp (line 98) | def setUp(self):
method tearDown (line 101) | def tearDown(self):
method test_connect_account (line 105) | def test_connect_account(self, mocked_login):
method test_connect_account_failed (line 112) | def test_connect_account_failed(self, mocked_login):
method test_not_logged (line 118) | def test_not_logged(self):
method test_list_devices (line 124) | def test_list_devices(self, mocked_login, mocked_list_devices):
FILE: tests/test_libpurecoollink.py
function _mocked_request_state (line 19) | def _mocked_request_state(*args, **kwargs):
function _mocked_send_command (line 27) | def _mocked_send_command(*args, **kwargs):
function _mocked_send_command_hot (line 45) | def _mocked_send_command_hot(*args, **kwargs):
function _mocked_send_command_rst_filter (line 66) | def _mocked_send_command_rst_filter(*args, **kwargs):
function _mocked_send_command_timer (line 84) | def _mocked_send_command_timer(*args, **kwargs):
function _mocked_send_command_timer_off (line 93) | def _mocked_send_command_timer_off(*args, **kwargs):
function on_add_device (line 102) | def on_add_device(network_device):
class TestLibPureCoolLink (line 106) | class TestLibPureCoolLink(unittest.TestCase):
method setUp (line 107) | def setUp(self):
method tearDown (line 110) | def tearDown(self):
method test_connect_device (line 115) | def test_connect_device(self, mocked_connect, mocked_loop):
method test_connect_device_with_config (line 144) | def test_connect_device_with_config(self, mocked_connect, mocked_loop):
method test_connect_device_with_config_failed (line 174) | def test_connect_device_with_config_failed(self,
method test_connect_device_fail (line 203) | def test_connect_device_fail(self, mocked_close_zeroconf):
method test_status_topic (line 221) | def test_status_topic(self):
method test_device_dyson_listener (line 238) | def test_device_dyson_listener(self, mocked_ntoa):
method test_on_connect (line 250) | def test_on_connect(self):
method test_on_connect_failed (line 260) | def test_on_connect_failed(self):
method test_add_message_listener (line 268) | def test_add_message_listener(self):
method test_on_message (line 297) | def test_on_message(self):
method test_on_message_hot (line 320) | def test_on_message_hot(self):
method test_on_message_sensor (line 343) | def test_on_message_sensor(self):
method test_on_message_with_unknown_message (line 357) | def test_on_message_with_unknown_message(self):
method test_on_message_without_callback (line 372) | def test_on_message_without_callback(self):
method test_request_state (line 389) | def test_request_state(self, mocked_connect, mocked_publish):
method test_dont_request_state_if_not_connected (line 420) | def test_dont_request_state_if_not_connected(self, mocked_connect,
method test_set_configuration (line 448) | def test_set_configuration(self, mocked_connect, mocked_publish):
method test_set_configuration_hot (line 490) | def test_set_configuration_hot(self, mocked_connect, mocked_publish):
method test_set_configuration_rst_filter (line 535) | def test_set_configuration_rst_filter(self, mocked_connect,
method test_set_configuration_timer (line 579) | def test_set_configuration_timer(self, mocked_connect, mocked_publish):
method test_set_configuration_timer_off (line 615) | def test_set_configuration_timer_off(self, mocked_connect, mocked_publ...
method test_dont_set_configuration_if_not_connected (line 651) | def test_dont_set_configuration_if_not_connected(self, mocked_connect,
method test_network_device (line 685) | def test_network_device(self):
method test_dyson_state (line 694) | def test_dyson_state(self):
method test_dyson_state_hot (line 714) | def test_dyson_state_hot(self):
method test_sensor_state (line 740) | def test_sensor_state(self):
method test_sensor_state_sleep_timer_off (line 753) | def test_sensor_state_sleep_timer_off(self):
method test_heat_target_celsius (line 762) | def test_heat_target_celsius(self):
method test_heat_target_fahrenheit (line 775) | def test_heat_target_fahrenheit(self):
method test_device_connected (line 788) | def test_device_connected(self):
method test_environment_state (line 806) | def test_environment_state(self):
FILE: tests/test_utils.py
class TestUtils (line 7) | class TestUtils(unittest.TestCase):
method setUp (line 8) | def setUp(self):
method tearDown (line 11) | def tearDown(self):
method test_support_heating (line 14) | def test_support_heating(self):
method test_is_heating_device (line 18) | def test_is_heating_device(self):
method test_is_360_eye_device (line 22) | def test_is_360_eye_device(self):
method test_printable_fields (line 26) | def test_printable_fields(self):
method test_decrypt_password (line 34) | def test_decrypt_password(self):
Condensed preview — 46 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (250K chars).
[
{
"path": ".coveragerc",
"chars": 69,
"preview": "[run]\nsource = libpurecoollink\nomit =\n libpurecoollink/zeroconf.py"
},
{
"path": ".gitignore",
"chars": 185,
"preview": "*.iml\nruntime/*\n.coverage\n.tox/\n.cache/\nlibpurecoollink.egg-info/\nlibpurecoollink/__pycache__/\ntests/__pycache__/\ndist/*"
},
{
"path": ".travis.yml",
"chars": 288,
"preview": "language: python\nmatrix:\n include:\n - python: \"3.4.2\"\n env: TOXENV=lint\n - python: \"3.4.2\"\n env: TOXENV"
},
{
"path": "AUTHORS.rst",
"chars": 214,
"preview": "Thanks to all the wonderful folks who have contributed to Libpurecoollink:\n\n- ThomasHoussin <https://github.com/ThomasH"
},
{
"path": "LICENSE.md",
"chars": 10360,
"preview": "Copyright 2017 Charles Blonde.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this fil"
},
{
"path": "MANIFEST.in",
"chars": 38,
"preview": "include README.rst\ninclude LICENSE.md\n"
},
{
"path": "README.md",
"chars": 3063,
"preview": "# Dyson Pure Cool Link Python library\n\n[ states.\"\"\"\n\n# pylint: disable=too-many-public-methods,too-many-instance-a"
},
{
"path": "libpurecoollink/exceptions.py",
"chars": 1556,
"preview": "\"\"\"Dyson exceptions.\"\"\"\n\n# pylint: disable=useless-super-delegation\n\n\nclass DysonInvalidTargetTemperatureException(Excep"
},
{
"path": "libpurecoollink/utils.py",
"chars": 1720,
"preview": "\"\"\"Utilities for Dyson Pure Hot+Cool link devices.\"\"\"\nimport json\nimport base64\nfrom Crypto.Cipher import AES\nfrom .cons"
},
{
"path": "libpurecoollink/zeroconf.py",
"chars": 68878,
"preview": "from __future__ import (\n absolute_import, division, print_function, unicode_literals)\n\n\"\"\" Multicast DNS Service Dis"
},
{
"path": "requirements.txt",
"chars": 45,
"preview": "netifaces\nsix\nrequests\npaho_mqtt\npycryptodome"
},
{
"path": "requirements_test.txt",
"chars": 107,
"preview": "flake8>=3.0.4\npylint>=1.5.6\ncoveralls>=1.1\npydocstyle>=2.0.0\npytest>=2.9.2\npytest-cov>=2.3.1\nmypy-lang>=0.4"
},
{
"path": "setup.cfg",
"chars": 39,
"preview": "[metadata]\ndescription-file = README.md"
},
{
"path": "setup.py",
"chars": 1231,
"preview": "#!/usr/bin/env python3\nfrom setuptools import setup, find_packages\n\nPACKAGES = find_packages(exclude=['tests', 'tests.*'"
},
{
"path": "tests/data/sensor.json",
"chars": 202,
"preview": "{\n \"msg\": \"ENVIRONMENTAL-CURRENT-SENSOR-DATA\",\n \"time\": \"2017-06-17T23:05:49.001Z\",\n \"data\": {\n \"tact\": \"2967\",\n "
},
{
"path": "tests/data/sensor_sltm_off.json",
"chars": 201,
"preview": "{\n \"msg\": \"ENVIRONMENTAL-CURRENT-SENSOR-DATA\",\n \"time\": \"2017-06-17T23:05:49.001Z\",\n \"data\": {\n \"tact\": \"2967\",\n "
},
{
"path": "tests/data/state.json",
"chars": 450,
"preview": "{\n \"msg\": \"CURRENT-STATE\",\n \"time\": \"2017-02-26T16:25:35.000Z\",\n \"mode-reason\": \"LAPP\",\n \"state-reason\": \"ENV\",\n \"d"
},
{
"path": "tests/data/state_hot.json",
"chars": 546,
"preview": "{\n \"msg\": \"CURRENT-STATE\",\n \"time\": \"2017-02-26T16:25:35.000Z\",\n \"mode-reason\": \"LAPP\",\n \"state-reason\": \"ENV\",\n \"d"
},
{
"path": "tests/data/vacuum/goodbye.json",
"chars": 82,
"preview": "{\n \"msg\" : \"GOODBYE\",\n \"reason\" : \"UNKNOWN\",\n \"time\" : \"2017-07-30T16:00:13Z\"\n}"
},
{
"path": "tests/data/vacuum/map-data.json",
"chars": 237,
"preview": "{\n \"msg\": \"MAP-DATA\",\n \"gridID\": \"1\",\n \"cleanId\": \"0e000000-4a47-3845-5548-454131323334\",\n \"data\": {\n \"content-ty"
},
{
"path": "tests/data/vacuum/map-global.json",
"chars": 173,
"preview": "{\n \"msg\" : \"MAP-GLOBAL\",\n \"gridID\" : \"1\",\n \"x\" : 0,\n \"y\" : 0,\n \"angle\" : -180,\n \"cleanId\" : \"0e000000-4a47-3845-55"
},
{
"path": "tests/data/vacuum/map-grid.json",
"chars": 222,
"preview": "{\n \"msg\" : \"MAP-GRID\",\n \"gridID\" : \"1\",\n \"resolution\" : 43,\n \"width\" : 144,\n \"height\" : 144,\n \"cleanId\" : \"0e00000"
},
{
"path": "tests/data/vacuum/state-change.json",
"chars": 379,
"preview": "{\n \"msg\" : \"STATE-CHANGE\",\n \"oldstate\" : \"INACTIVE_CHARGED\",\n \"newstate\" : \"FULL_CLEAN_INITIATED\",\n \"fullCleanType\" "
},
{
"path": "tests/data/vacuum/state-unknown-values.json",
"chars": 317,
"preview": "{\n \"msg\" : \"CURRENT-STATE\",\n \"state\" : \"UNKNOWN\",\n \"fullCleanType\" : \"\",\n \"cleanId\" : \"0d000000-4a47-3845-5548-45413"
},
{
"path": "tests/data/vacuum/state.json",
"chars": 330,
"preview": "{\n \"msg\" : \"CURRENT-STATE\",\n \"state\" : \"INACTIVE_CHARGED\",\n \"fullCleanType\" : \"\",\n \"cleanId\" : \"0d000000-4a47-3845-5"
},
{
"path": "tests/data/vacuum/telemetry-data.json",
"chars": 196,
"preview": "{\n \"msg\": \"TELEMETRY-DATA\",\n \"id\": \"40010000\",\n \"field1\": \"1.0.0\",\n \"field2\": \"2.000000\",\n \"field3\": \"\",\n \"field4\""
},
{
"path": "tests/test_360_eye.py",
"chars": 17984,
"preview": "import unittest\n\nfrom unittest import mock\nfrom unittest.mock import Mock\nimport json\n\nfrom libpurecoollink.dyson_360_ey"
},
{
"path": "tests/test_dyson_account.py",
"chars": 5628,
"preview": "import unittest\n\nfrom unittest import mock\n\nfrom libpurecoollink.dyson import DysonAccount, DysonPureCoolLink, \\\n Dys"
},
{
"path": "tests/test_libpurecoollink.py",
"chars": 37132,
"preview": "import unittest\n\nfrom unittest import mock\nfrom unittest.mock import Mock\nimport json\n\nfrom libpurecoollink.dyson_device"
},
{
"path": "tests/test_utils.py",
"chars": 1310,
"preview": "import unittest\n\nfrom libpurecoollink.utils import support_heating, is_heating_device, \\\n is_360_eye_device, printabl"
},
{
"path": "tox.ini",
"chars": 1174,
"preview": "[tox]\nenvlist = py34, py35, py36, lint\nskip_missing_interpreters = True\n\n[testenv:py34]\nsetenv =\n LANG=en_US.UTF-8\n "
}
]
About this extraction
This page contains the full source code of the CharlesBlonde/libpurecoollink GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 46 files (229.8 KB), approximately 56.1k tokens, and a symbol index with 453 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.