From 338acd8f99376d360dcb8b91fdb24a69ce2e36c4 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sun, 10 Mar 2019 22:03:09 +0100 Subject: [PATCH 01/45] Add structure using pyscaffold --- .coveragerc | 28 +++ .gitignore | 49 ++++ AUTHORS.rst | 5 + CHANGELOG.rst | 10 + LICENSE.txt | 21 ++ README.rst | 19 ++ docs/Makefile | 193 +++++++++++++++ docs/_static/.gitignore | 1 + docs/authors.rst | 2 + docs/changelog.rst | 2 + docs/conf.py | 272 ++++++++++++++++++++++ docs/index.rst | 59 +++++ docs/license.rst | 7 + requirements.txt | 17 ++ setup.cfg | 111 +++++++++ setup.py | 24 ++ src/openwebnet/__init__.py | 15 ++ OpenWebNet.py => src/openwebnet/client.py | 26 +-- src/openwebnet/skeleton.py | 116 +++++++++ tests/conftest.py | 11 + tests/test_openwebnet.py | 16 ++ 21 files changed, 991 insertions(+), 13 deletions(-) create mode 100644 .coveragerc create mode 100644 .gitignore create mode 100644 AUTHORS.rst create mode 100644 CHANGELOG.rst create mode 100644 LICENSE.txt create mode 100644 README.rst create mode 100644 docs/Makefile create mode 100644 docs/_static/.gitignore create mode 100644 docs/authors.rst create mode 100644 docs/changelog.rst create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/license.rst create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 src/openwebnet/__init__.py rename OpenWebNet.py => src/openwebnet/client.py (95%) create mode 100644 src/openwebnet/skeleton.py create mode 100644 tests/conftest.py create mode 100644 tests/test_openwebnet.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..e568bab --- /dev/null +++ b/.coveragerc @@ -0,0 +1,28 @@ +# .coveragerc to control coverage.py +[run] +branch = True +source = openwebnet +# omit = bad_file.py + +[paths] +source = + src/ + */site-packages/ + +[report] +# Regexes for lines to exclude from consideration +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + + # Don't complain about missing debug-only code: + def __repr__ + if self\.debug + + # Don't complain if tests don't hit defensive assertion code: + raise AssertionError + raise NotImplementedError + + # Don't complain if non-runnable code isn't run: + if 0: + if __name__ == .__main__.: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2873e01 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Temporary and binary files +*~ +*.py[cod] +*.so +*.cfg +!.isort.cfg +!setup.cfg +*.orig +*.log +*.pot +__pycache__/* +.cache/* +.*.swp +*/.ipynb_checkpoints/* + +# Project files +.ropeproject +.project +.pydevproject +.settings +.idea +tags + +# Package files +*.egg +*.eggs/ +.installed.cfg +*.egg-info + +# Unittest and coverage +htmlcov/* +.coverage +.tox +junit.xml +coverage.xml +.pytest_cache/ + +# Build and docs folder/files +build/* +dist/* +sdist/* +docs/api/* +docs/_rst/* +docs/_build/* +cover/* +MANIFEST + +# Per-project virtualenvs +.venv*/ diff --git a/AUTHORS.rst b/AUTHORS.rst new file mode 100644 index 0000000..fb97b28 --- /dev/null +++ b/AUTHORS.rst @@ -0,0 +1,5 @@ +============ +Contributors +============ + +* Karel Vervaeke diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..226e6f5 --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,10 @@ +========= +Changelog +========= + +Version 0.1 +=========== + +- Feature A added +- FIX: nasty bug #1729 fixed +- add your changes here! diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..f4a677e --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2019 Karel Vervaeke + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..c599b92 --- /dev/null +++ b/README.rst @@ -0,0 +1,19 @@ +========== +openwebnet +========== + + +Add a short description here! + + +Description +=========== + +A longer description of your project goes here... + + +Note +==== + +This project has been set up using PyScaffold 3.1. For details and usage +information on PyScaffold see https://pyscaffold.org/. diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..7a6e41f --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,193 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build +AUTODOCDIR = api +AUTODOCBUILD = sphinx-apidoc +PROJECT = openwebnet +MODULEDIR = ../src/openwebnet + +# 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 doc-requirements + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* $(AUTODOCDIR) + +$(AUTODOCDIR): $(MODULEDIR) + mkdir -p $@ + $(AUTODOCBUILD) -f -o $@ $^ + +doc-requirements: $(AUTODOCDIR) + +html: doc-requirements + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: doc-requirements + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: doc-requirements + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: doc-requirements + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: doc-requirements + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: doc-requirements + $(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: doc-requirements + $(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/$(PROJECT).qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/$(PROJECT).qhc" + +devhelp: doc-requirements + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $HOME/.local/share/devhelp/$(PROJECT)" + @echo "# ln -s $(BUILDDIR)/devhelp $HOME/.local/share/devhelp/$(PROJEC)" + @echo "# devhelp" + +epub: doc-requirements + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +patch-latex: + find _build/latex -iname "*.tex" | xargs -- \ + sed -i'' 's~includegraphics{~includegraphics\[keepaspectratio,max size={\\textwidth}{\\textheight}\]{~g' + +latex: doc-requirements + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + $(MAKE) patch-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: doc-requirements + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + $(MAKE) patch-latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: doc-requirements + $(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: doc-requirements + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: doc-requirements + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: doc-requirements + $(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: doc-requirements + $(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: doc-requirements + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: doc-requirements + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: doc-requirements + $(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: doc-requirements + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +xml: doc-requirements + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: doc-requirements + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/_static/.gitignore b/docs/_static/.gitignore new file mode 100644 index 0000000..3c96363 --- /dev/null +++ b/docs/_static/.gitignore @@ -0,0 +1 @@ +# Empty directory diff --git a/docs/authors.rst b/docs/authors.rst new file mode 100644 index 0000000..cd8e091 --- /dev/null +++ b/docs/authors.rst @@ -0,0 +1,2 @@ +.. _authors: +.. include:: ../AUTHORS.rst diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 0000000..871950d --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1,2 @@ +.. _changes: +.. include:: ../CHANGELOG.rst diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..e5051d4 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,272 @@ +# -*- coding: utf-8 -*- +# +# 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 os +import sys +import inspect +import shutil + +__location__ = os.path.join(os.getcwd(), os.path.dirname( + inspect.getfile(inspect.currentframe()))) + +# 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.join(__location__, '../src')) + +# -- Run sphinx-apidoc ------------------------------------------------------ +# This hack is necessary since RTD does not issue `sphinx-apidoc` before running +# `sphinx-build -b html . _build/html`. See Issue: +# https://github.com/rtfd/readthedocs.org/issues/1139 +# DON'T FORGET: Check the box "Install your project inside a virtualenv using +# setup.py install" in the RTD Advanced Settings. +# Additionally it helps us to avoid running apidoc manually + +try: # for Sphinx >= 1.7 + from sphinx.ext import apidoc +except ImportError: + from sphinx import apidoc + +output_dir = os.path.join(__location__, "api") +module_dir = os.path.join(__location__, "../src/openwebnet") +try: + shutil.rmtree(output_dir) +except FileNotFoundError: + pass + +try: + import sphinx + from pkg_resources import parse_version + + cmd_line_template = "sphinx-apidoc -f -o {outputdir} {moduledir}" + cmd_line = cmd_line_template.format(outputdir=output_dir, moduledir=module_dir) + + args = cmd_line.split(" ") + if parse_version(sphinx.__version__) >= parse_version('1.7'): + args = args[1:] + + apidoc.main(args) +except Exception as e: + print("Running `sphinx-apidoc` failed!\n{}".format(e)) + +# -- 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.intersphinx', 'sphinx.ext.todo', + 'sphinx.ext.autosummary', 'sphinx.ext.viewcode', 'sphinx.ext.coverage', + 'sphinx.ext.doctest', 'sphinx.ext.ifconfig', 'sphinx.ext.mathjax', + 'sphinx.ext.napoleon'] + +# 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 = u'openwebnet' +copyright = u'2019, Karel Vervaeke' + +# 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 = '' # Is set by calling `setup.py docs` +# The full version, including alpha/beta/rc tags. +release = '' # Is set by calling `setup.py docs` + +# 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 = { + 'sidebar_width': '300px', + 'page_width': '1200px' +} + +# Add any paths that contain custom themes here, relative to this directory. +# html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +try: + from openwebnet import __version__ as version +except ImportError: + pass +else: + release = version + +# A shorter title for the navigation bar. Default is the same as html_title. +# html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +# html_logo = "" + +# 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'] + +# 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 = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# html_additional_pages = {} + +# If false, no module index is generated. +# html_domain_indices = True + +# If false, no index is generated. +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# html_split_index = False + +# If true, links to the reST sources are added to the pages. +# html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +# html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +# html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +# html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'openwebnet-doc' + + +# -- 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]). +latex_documents = [ + ('index', 'user_guide.tex', u'openwebnet Documentation', + u'Karel Vervaeke', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +# latex_logo = "" + +# 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 + +# -- External mapping ------------------------------------------------------------ +python_version = '.'.join(map(str, sys.version_info[0:2])) +intersphinx_mapping = { + 'sphinx': ('http://www.sphinx-doc.org/en/stable', None), + 'python': ('https://docs.python.org/' + python_version, None), + 'matplotlib': ('https://matplotlib.org', None), + 'numpy': ('https://docs.scipy.org/doc/numpy', None), + 'sklearn': ('http://scikit-learn.org/stable', None), + 'pandas': ('http://pandas.pydata.org/pandas-docs/stable', None), + 'scipy': ('https://docs.scipy.org/doc/scipy/reference', None), +} diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..22b05c5 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,59 @@ +========== +openwebnet +========== + +This is the documentation of **openwebnet**. + +.. note:: + + This is the main page of your project's `Sphinx`_ documentation. + It is formatted in `reStructuredText`_. Add additional pages + by creating rst-files in ``docs`` and adding them to the `toctree`_ below. + Use then `references`_ in order to link them from this page, e.g. + :ref:`authors` and :ref:`changes`. + + It is also possible to refer to the documentation of other Python packages + with the `Python domain syntax`_. By default you can reference the + documentation of `Sphinx`_, `Python`_, `NumPy`_, `SciPy`_, `matplotlib`_, + `Pandas`_, `Scikit-Learn`_. You can add more by extending the + ``intersphinx_mapping`` in your Sphinx's ``conf.py``. + + The pretty useful extension `autodoc`_ is activated by default and lets + you include documentation from docstrings. Docstrings can be written in + `Google style`_ (recommended!), `NumPy style`_ and `classical style`_. + + +Contents +======== + +.. toctree:: + :maxdepth: 2 + + License + Authors + Changelog + Module Reference + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + +.. _toctree: http://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html +.. _reStructuredText: http://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html +.. _references: http://www.sphinx-doc.org/en/stable/markup/inline.html +.. _Python domain syntax: http://sphinx-doc.org/domains.html#the-python-domain +.. _Sphinx: http://www.sphinx-doc.org/ +.. _Python: http://docs.python.org/ +.. _Numpy: http://docs.scipy.org/doc/numpy +.. _SciPy: http://docs.scipy.org/doc/scipy/reference/ +.. _matplotlib: https://matplotlib.org/contents.html# +.. _Pandas: http://pandas.pydata.org/pandas-docs/stable +.. _Scikit-Learn: http://scikit-learn.org/stable +.. _autodoc: http://www.sphinx-doc.org/en/stable/ext/autodoc.html +.. _Google style: https://github.com/google/styleguide/blob/gh-pages/pyguide.md#38-comments-and-docstrings +.. _NumPy style: https://numpydoc.readthedocs.io/en/latest/format.html +.. _classical style: http://www.sphinx-doc.org/en/stable/domains.html#info-field-lists diff --git a/docs/license.rst b/docs/license.rst new file mode 100644 index 0000000..3989c51 --- /dev/null +++ b/docs/license.rst @@ -0,0 +1,7 @@ +.. _license: + +======= +License +======= + +.. include:: ../LICENSE.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..434a5f2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,17 @@ +# ============================================================================= +# DEPRECATION WARNING: +# +# The file `requirements.txt` does not influence the package dependencies and +# will not be automatically created in the next version of PyScaffold (v4.x). +# +# Please have look at the docs for better alternatives +# (`Dependency Management` section). +# ============================================================================= +# +# Add your pinned requirements so that they can be easily installed with: +# pip install -r requirements.txt +# Remember to also add them in setup.cfg but unpinned. +# Example: +# numpy==1.13.3 +# scipy==1.0 + diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..1a5a8f7 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,111 @@ +# This file is used to configure your project. +# Read more about the various options under: +# http://setuptools.readthedocs.io/en/latest/setuptools.html#configuring-setup-using-setup-cfg-files + +[metadata] +name = openwebnet +description = Add a short description here! +author = Karel Vervaeke +author-email = karel@vervaeke.info +license = mit +url = https://pyscaffold.org/ +long-description = file: README.rst +# Change if running only on Windows, Mac or Linux (comma-separated) +platforms = any +# Add here all kinds of additional classifiers as defined under +# https://pypi.python.org/pypi?%3Aaction=list_classifiers +classifiers = + Development Status :: 4 - Beta + Programming Language :: Python + +[options] +zip_safe = False +packages = find: +include_package_data = True +package_dir = + =src +# DON'T CHANGE THE FOLLOWING LINE! IT WILL BE UPDATED BY PYSCAFFOLD! +setup_requires = pyscaffold>=3.1a0,<3.2a0 +# Add here dependencies of your project (semicolon/line-separated), e.g. +# install_requires = numpy; scipy +# The usage of test_requires is discouraged, see `Dependency Management` docs +# tests_require = pytest; pytest-cov +# Require a specific Python version, e.g. Python 2.7 or >= 3.4 +# python_requires = >=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.* + +[options.packages.find] +where = src +exclude = + tests + +[options.extras_require] +# Add here additional requirements for extra features, to install with: +# `pip install openwebnet[PDF]` like: +# PDF = ReportLab; RXP +# Add here test requirements (semicolon/line-separated) +testing = + pytest + pytest-cov + +[options.entry_points] +# Add here console scripts like: +# console_scripts = +# script_name = openwebnet.module:function +# For example: +# console_scripts = +# fibonacci = openwebnet.skeleton:run +# And any other entry points, for example: +# pyscaffold.cli = +# awesome = pyscaffoldext.awesome.extension:AwesomeExtension + +[test] +# py.test options when running `python setup.py test` +# addopts = --verbose +extras = True + +[tool:pytest] +# Options for py.test: +# Specify command line options as you would do when invoking py.test directly. +# e.g. --cov-report html (or xml) for html/xml output or --junitxml junit.xml +# in order to write a coverage file that can be read by Jenkins. +addopts = + --cov openwebnet --cov-report term-missing + --verbose +norecursedirs = + dist + build + .tox +testpaths = tests + +[aliases] +build = bdist_wheel +release = build upload + +[bdist_wheel] +# Use this option if your package is pure-python +universal = 1 + +[build_sphinx] +source_dir = docs +build_dir = docs/_build + +[devpi:upload] +# Options for the devpi: PyPI server and packaging tool +# VCS export must be deactivated since we are using setuptools-scm +no-vcs = 1 +formats = bdist_wheel + +[flake8] +# Some sane defaults for the code style checker flake8 +exclude = + .tox + build + dist + .eggs + docs/conf.py + +[pyscaffold] +# PyScaffold's parameters when the project was created. +# This will be used when updating. Do not change! +version = 3.1 +package = openwebnet diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..23537a9 --- /dev/null +++ b/setup.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" + Setup file for openwebnet. + Use setup.cfg to configure your project. + + This file was generated with PyScaffold 3.1. + PyScaffold helps you to put up the scaffold of your new Python project. + Learn more under: https://pyscaffold.org/ +""" +import sys + +from pkg_resources import require, VersionConflict +from setuptools import setup + +try: + require('setuptools>=38.3') +except VersionConflict: + print("Error: version of setuptools is too old (<38.3)!") + sys.exit(1) + + +if __name__ == "__main__": + setup(use_pyscaffold=True) diff --git a/src/openwebnet/__init__.py b/src/openwebnet/__init__.py new file mode 100644 index 0000000..753c88d --- /dev/null +++ b/src/openwebnet/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from pkg_resources import get_distribution, DistributionNotFound + +from openwebnet.client import OpenWebNet + +__all__=[OpenWebNet] + +try: + # Change here if project is renamed and does not equal the package name + dist_name = __name__ + __version__ = get_distribution(dist_name).version +except DistributionNotFound: + __version__ = 'unknown' +finally: + del get_distribution, DistributionNotFound diff --git a/OpenWebNet.py b/src/openwebnet/client.py similarity index 95% rename from OpenWebNet.py rename to src/openwebnet/client.py index fbde638..8d78d8e 100644 --- a/OpenWebNet.py +++ b/src/openwebnet/client.py @@ -21,7 +21,7 @@ class OpenWebNet(object): def __init__(self,host,port,password): self._host = host self._port = int(port) - self._psw = password + self._password = password self._session = False self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -40,7 +40,7 @@ def read_data(self): return str(self._socket.recv(1024).decode()) #Calculate the password to start operation - def calculated_psw (self, nonce): + def calculated_password (self, nonce): m_1 = 0xFFFFFFFF m_8 = 0xFFFFFFF8 m_16 = 0xFFFFFFF0 @@ -49,7 +49,7 @@ def calculated_psw (self, nonce): flag = True num1 = 0 num2 = 0 - self._psw = int(self._psw) + self._password = int(self._password) for c in nonce: num1 = num1 & m_1 @@ -57,7 +57,7 @@ def calculated_psw (self, nonce): if c == '1': length = not flag if not length: - num2 = self._psw + num2 = self._password num1 = num2 & m_128 num1 = num1 >> 7 num2 = num2 << 25 @@ -66,7 +66,7 @@ def calculated_psw (self, nonce): elif c == '2': length = not flag if not length: - num2 = self._psw + num2 = self._password num1 = num2 & m_16 num1 = num1 >> 4 num2 = num2 << 28 @@ -75,7 +75,7 @@ def calculated_psw (self, nonce): elif c == '3': length = not flag if not length: - num2 = self._psw + num2 = self._password num1 = num2 & m_8 num1 = num1 >> 3 num2 = num2 << 29 @@ -85,7 +85,7 @@ def calculated_psw (self, nonce): length = not flag if not length: - num2 = self._psw + num2 = self._password num1 = num2 << 1 num2 = num2 >> 31 num1 = num1 + num2 @@ -93,7 +93,7 @@ def calculated_psw (self, nonce): elif c == '5': length = not flag if not length: - num2 = self._psw + num2 = self._password num1 = num2 << 5 num2 = num2 >> 27 num1 = num1 + num2 @@ -101,7 +101,7 @@ def calculated_psw (self, nonce): elif c == '6': length = not flag if not length: - num2 = self._psw + num2 = self._password num1 = num2 << 12 num2 = num2 >> 20 num1 = num1 + num2 @@ -109,7 +109,7 @@ def calculated_psw (self, nonce): elif c == '7': length = not flag if not length: - num2 = self._psw + num2 = self._password num1 = num2 & 0xFF00 num1 = num1 + (( num2 & 0xFF ) << 24 ) num1 = num1 + (( num2 & 0xFF0000 ) >> 16 ) @@ -119,7 +119,7 @@ def calculated_psw (self, nonce): elif c == '8': length = not flag if not length: - num2 = self._psw + num2 = self._password num1 = num2 & 0xFFFF num1 = num1 << 16 num1 = num1 + ( num2 >> 24 ) @@ -130,7 +130,7 @@ def calculated_psw (self, nonce): elif c == '9': length = not flag if not length: - num2 = self._psw + num2 = self._password num1 = ~num2 flag = False else: @@ -157,7 +157,7 @@ def cmd_session(self): return False #calculate the psw - psw_open = '*#' + str(self.calculated_psw(answer)) + '##' + psw_open = '*#' + str(self.calculated_password(answer)) + '##' #send the password self.send_data(psw_open) diff --git a/src/openwebnet/skeleton.py b/src/openwebnet/skeleton.py new file mode 100644 index 0000000..1a70adb --- /dev/null +++ b/src/openwebnet/skeleton.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This is a skeleton file that can serve as a starting point for a Python +console script. To run this script uncomment the following lines in the +[options.entry_points] section in setup.cfg: + + console_scripts = + fibonacci = openwebnet.skeleton:run + +Then run `python setup.py install` which will install the command `fibonacci` +inside your current environment. +Besides console scripts, the header (i.e. until _logger...) of this file can +also be used as template for Python modules. + +Note: This skeleton file can be safely removed if not needed! +""" + +import argparse +import sys +import logging + +from openwebnet import __version__ + +__author__ = "Karel Vervaeke" +__copyright__ = "Karel Vervaeke" +__license__ = "mit" + +_logger = logging.getLogger(__name__) + + +def fib(n): + """Fibonacci example function + + Args: + n (int): integer + + Returns: + int: n-th Fibonacci number + """ + assert n > 0 + a, b = 1, 1 + for i in range(n-1): + a, b = b, a+b + return a + + +def parse_args(args): + """Parse command line parameters + + Args: + args ([str]): command line parameters as list of strings + + Returns: + :obj:`argparse.Namespace`: command line parameters namespace + """ + parser = argparse.ArgumentParser( + description="Just a Fibonnaci demonstration") + parser.add_argument( + '--version', + action='version', + version='openwebnet {ver}'.format(ver=__version__)) + parser.add_argument( + dest="n", + help="n-th Fibonacci number", + type=int, + metavar="INT") + parser.add_argument( + '-v', + '--verbose', + dest="loglevel", + help="set loglevel to INFO", + action='store_const', + const=logging.INFO) + parser.add_argument( + '-vv', + '--very-verbose', + dest="loglevel", + help="set loglevel to DEBUG", + action='store_const', + const=logging.DEBUG) + return parser.parse_args(args) + + +def setup_logging(loglevel): + """Setup basic logging + + Args: + loglevel (int): minimum loglevel for emitting messages + """ + logformat = "[%(asctime)s] %(levelname)s:%(name)s:%(message)s" + logging.basicConfig(level=loglevel, stream=sys.stdout, + format=logformat, datefmt="%Y-%m-%d %H:%M:%S") + + +def main(args): + """Main entry point allowing external calls + + Args: + args ([str]): command line parameter list + """ + args = parse_args(args) + setup_logging(args.loglevel) + _logger.debug("Starting crazy calculations...") + print("The {}-th Fibonacci number is {}".format(args.n, fib(args.n))) + _logger.info("Script ends here") + + +def run(): + """Entry point for console_scripts + """ + main(sys.argv[1:]) + + +if __name__ == "__main__": + run() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d8cdee3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" + Dummy conftest.py for openwebnet. + + If you don't know what this is for, just leave it empty. + Read more about conftest.py under: + https://pytest.org/latest/plugins.html +""" + +# import pytest diff --git a/tests/test_openwebnet.py b/tests/test_openwebnet.py new file mode 100644 index 0000000..6d8b2e1 --- /dev/null +++ b/tests/test_openwebnet.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import pytest +from openwebnet import OpenWebNet + +__author__ = "Karel Vervaeke" +__copyright__ = "Karel Vervaeke" +__license__ = "mit" + + +def test_OpenWebNet_constructor(): + client = OpenWebNet('192.168.1.10', 20000, '123456') + assert client._host == '192.168.1.10' + assert client._port == 20000 + assert client._password == '123456' From eb98de656bfa2a1eda11122abdb0a88eabb525e7 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sun, 10 Mar 2019 22:04:51 +0100 Subject: [PATCH 02/45] updated readme --- README.md | 3 ++- README.rst | 19 ------------------- 2 files changed, 2 insertions(+), 20 deletions(-) delete mode 100644 README.rst diff --git a/README.md b/README.md index 770e0bd..b2c3326 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # OpenWebNet -TEST Python Class for interaction with OpenWebNet bus + +Python client for interaction with OpenWebNet bus diff --git a/README.rst b/README.rst deleted file mode 100644 index c599b92..0000000 --- a/README.rst +++ /dev/null @@ -1,19 +0,0 @@ -========== -openwebnet -========== - - -Add a short description here! - - -Description -=========== - -A longer description of your project goes here... - - -Note -==== - -This project has been set up using PyScaffold 3.1. For details and usage -information on PyScaffold see https://pyscaffold.org/. From 0d109661a4f2ab32931de455b6b2b501009c30c4 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sun, 10 Mar 2019 22:06:21 +0100 Subject: [PATCH 03/45] Updated authors --- AUTHORS.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index fb97b28..1fdbd17 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -1,5 +1,4 @@ -============ -Contributors -============ +# Contributors +* Pippocla (GitHub https://github.com/pippocla) * Karel Vervaeke From b988c5be805eb3141b509f6f96a25bb92bd93b9c Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Wed, 13 Mar 2019 20:24:29 +0100 Subject: [PATCH 04/45] work in progress -- gotta switch laptops, forgot charger :s --- src/openwebnet/__init__.py | 6 +- src/openwebnet/client.py | 328 ++++++++++++------------------------- src/openwebnet/messages.py | 8 + src/openwebnet/password.py | 97 +++++++++++ src/openwebnet/skeleton.py | 116 ------------- 5 files changed, 217 insertions(+), 338 deletions(-) create mode 100644 src/openwebnet/messages.py create mode 100644 src/openwebnet/password.py delete mode 100644 src/openwebnet/skeleton.py diff --git a/src/openwebnet/__init__.py b/src/openwebnet/__init__.py index 753c88d..ac4530d 100644 --- a/src/openwebnet/__init__.py +++ b/src/openwebnet/__init__.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -from pkg_resources import get_distribution, DistributionNotFound - +from openwebnet import messages from openwebnet.client import OpenWebNet +from pkg_resources import get_distribution, DistributionNotFound -__all__=[OpenWebNet] +__all__=[OpenWebNet, messages] try: # Change here if project is renamed and does not equal the package name diff --git a/src/openwebnet/client.py b/src/openwebnet/client.py index 8d78d8e..6f894da 100644 --- a/src/openwebnet/client.py +++ b/src/openwebnet/client.py @@ -1,357 +1,247 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- import socket +from logging import getLogger + +from openwebnet import messages + +_LOGGER = getLogger(__name__) """ -Read Write class for OpenWebNet bus +OpenWebNet client """ -class OpenWebNet(object): - - #OK message from bus - ACK = '*#*1##' - #Non OK message from bus - NACK = '*#*0##' - #OpenWeb string for open a command session - CMD_SESSION = '*99*0##' - #OpenWeb string for open an event session - EVENT_SESSION = '*99*1##' +class OpenWebNet: - #Init metod - def __init__(self,host,port,password): + # Init metod + def __init__(self, host, port, password): self._host = host self._port = int(port) self._password = password self._session = False self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - - #Connection with host + # Connection with host def connection(self): - self._socket.connect((self._host,self._port)) + self._socket.connect((self._host, self._port)) print('connection') - #Send data to host - def send_data(self,data): + # Send data to host + def send_data(self, data): self._socket.send(data.encode()) - #Read data from host + # Read data from host def read_data(self): return str(self._socket.recv(1024).decode()) -#Calculate the password to start operation - def calculated_password (self, nonce): - m_1 = 0xFFFFFFFF - m_8 = 0xFFFFFFF8 - m_16 = 0xFFFFFFF0 - m_128 = 0xFFFFFF80 - m_16777216 = 0XFF000000 - flag = True - num1 = 0 - num2 = 0 - self._password = int(self._password) - - for c in nonce: - num1 = num1 & m_1 - num2 = num2 & m_1 - if c == '1': - length = not flag - if not length: - num2 = self._password - num1 = num2 & m_128 - num1 = num1 >> 7 - num2 = num2 << 25 - num1 = num1 + num2 - flag = False - elif c == '2': - length = not flag - if not length: - num2 = self._password - num1 = num2 & m_16 - num1 = num1 >> 4 - num2 = num2 << 28 - num1 = num1 + num2 - flag = False - elif c == '3': - length = not flag - if not length: - num2 = self._password - num1 = num2 & m_8 - num1 = num1 >> 3 - num2 = num2 << 29 - num1 = num1 + num2 - flag = False - elif c == '4': - length = not flag - - if not length: - num2 = self._password - num1 = num2 << 1 - num2 = num2 >> 31 - num1 = num1 + num2 - flag = False - elif c == '5': - length = not flag - if not length: - num2 = self._password - num1 = num2 << 5 - num2 = num2 >> 27 - num1 = num1 + num2 - flag = False - elif c == '6': - length = not flag - if not length: - num2 = self._password - num1 = num2 << 12 - num2 = num2 >> 20 - num1 = num1 + num2 - flag = False - elif c == '7': - length = not flag - if not length: - num2 = self._password - num1 = num2 & 0xFF00 - num1 = num1 + (( num2 & 0xFF ) << 24 ) - num1 = num1 + (( num2 & 0xFF0000 ) >> 16 ) - num2 = ( num2 & m_16777216 ) >> 8 - num1 = num1 + num2 - flag = False - elif c == '8': - length = not flag - if not length: - num2 = self._password - num1 = num2 & 0xFFFF - num1 = num1 << 16 - num1 = num1 + ( num2 >> 24 ) - num2 = num2 & 0xFF0000 - num2 = num2 >> 8 - num1 = num1 + num2 - flag = False - elif c == '9': - length = not flag - if not length: - num2 = self._password - num1 = ~num2 - flag = False - else: - num1 = num2 - num2 = num1 - return num1 & m_1 - - #Open command session + # Open command session def cmd_session(self): - #create the connection + # create the connection self.connection() - #if the bus answer with a NACK report the error - if self.read_data() == OpenWebNet.NACK : + # if the bus answer with a NACK report the error + if self.read_data() == messages.NACK: _LOGGER.exception("Non posso inizializzare la comunicazione con il gateway") - #open commanc session - self.send_data(OpenWebNet.CMD_SESSION) + # open commanc session + self.send_data(messages.CMD_SESSION) answer = self.read_data() - #if the bus answer with a NACK report the error - if answer == OpenWebNet.NACK: + # if the bus answer with a NACK report the error + if answer == messages.NACK: _LOGGER.exception("Il gateway rifiuta la sessione comandi") return False - #calculate the psw + # calculate the psw psw_open = '*#' + str(self.calculated_password(answer)) + '##' - #send the password + # send the password self.send_data(psw_open) - #if the bus answer with a NACK report the error - if self.read_data() == OpenWebNet.NACK: - _LOGGER.exception("Password errata") + # if the bus answer with a NACK report the error + if self.read_data() == messages.NACK: + _LOGGER.exception("Password errata") - #othefwise set the variable to True + # othefwise set the variable to True else: self._session = True print('cmd_session') - - #Extractor for the answer from the bus - def extractor(self,answer): + # Extractor for the answer from the bus + def extractor(self, answer): value_list = [] - print('estrattore riceve',answer) - #scan on all the caracters on the answer + print('estrattore riceve', answer) + # scan on all the caracters on the answer index = 0 while index <= len(answer) - 1: - print('index',index) + print('index', index) if answer[index] != '*' and answer[index] != '#': lenght = 0 val = '' while lenght <= len(answer) - 1 - index: if answer[index + lenght] != '*' and answer[index + lenght] != '#': - lenght = lenght +1 - print('lenght',lenght) + lenght = lenght + 1 + print('lenght', lenght) else: break - print('aggiungo a val',answer[index:index + lenght]) + print('aggiungo a val', answer[index:index + lenght]) val = val + answer[index:index + lenght] - print('val',val) + print('val', val) value_list.append(val) - print('value_list',value_list) + print('value_list', value_list) index = index + lenght lenght = 0 index = index + 1 print(value_list) return value_list - - #Check that bus send al the data - def check_answer (self,message): - #if final part of the message is not and ACK or NACK + # Check that bus send al the data + def check_answer(self, message): + # if final part of the message is not and ACK or NACK end_message = '' print('message ricevuto da check answer', message) - print('OpenWebNet.ACK',OpenWebNet.ACK) - if message[len(message)- 6:] != OpenWebNet.ACK and message[len(message)- 6:] != OpenWebNet.NACK: - #the answer is not completed, read again from bus - print('message -len',message[len(message)-6:]) + print('messages.ACK', messages.ACK) + if message[len(message) - 6:] != messages.ACK and message[len(message) - 6:] != messages.NACK: + # the answer is not completed, read again from bus + print('message -len', message[len(message) - 6:]) end_message = self.read_data() - #add it + # add it - print('message +end message',message + end_message) + print('message +end message', message + end_message) return message + end_message - #check if I get a NACK - if message[len(message)- 6:] == OpenWebNet.NACK: + # check if I get a NACK + if message[len(message) - 6:] == messages.NACK: _LOGGER.exception("Errore Comando non effettuato") - return message + # Normal request to BUS + def normal_request(self, who, where, what): - #Normal request to BUS - def normal_request(self,who,where,what): - - #if the command session is not active + # if the command session is not active if not self._session: self.cmd_session() - #prepare the request + # prepare the request normal_request = '*' + who + '*' + what + '*' + where + '##' - #and send + # and send self.send_data(normal_request) - #read the answer + # read the answer message = self.read_data() - #check if I get a NACK - if message == OpenWebNet.NACK: + # check if I get a NACK + if message == messages.NACK: _LOGGER.exception("Errore Comando non effettuato") - - #Request of state of a components on the bus - def stato_request(self,who,where): + # Request of state of a components on the bus + def stato_request(self, who, where): print('stato request)') - #if the command session is not active + # if the command session is not active if not self._session: self.cmd_session() - #preparo la richiesta + # preparo la richiesta stato_request = '*#' + who + '*' + where + '##' - print('richiesta',stato_request) - #e la Invio + print('richiesta', stato_request) + # e la Invio self.send_data(stato_request) - #e leggo la risposta + # e leggo la risposta message = self.read_data() - print('messagge',message) - #verifico se il bus ha trasmesso tutti i dati + print('messagge', message) + # verifico se il bus ha trasmesso tutti i dati check_message = self.check_answer(message) - #verifico se ho ricevuto un NACK - if message[len(message)- 6:] == OpenWebNet.NACK: + # verifico se ho ricevuto un NACK + if message[len(message) - 6:] == messages.NACK: _LOGGER.exception("Errore Comando non effettuato") - #o un ACK + # o un ACK else: - #nel qual caso estraggo i dati della risposta e li restituisco sotto forma di lista + # nel qual caso estraggo i dati della risposta e li restituisco sotto forma di lista return self.extractor(check_message[:len(check_message) - 6]) - #Richiesta grandezza - def grandezza_request(self,who,where, grandezza): - #Se non è attiva apro sessione comandi + # Richiesta grandezza + def grandezza_request(self, who, where, grandezza): + # Se non è attiva apro sessione comandi if not self._session: self.cmd_session() - #preparo la richiesta + # preparo la richiesta grandezza_request = '*#' + who + '*' + where + '*' + grandezza + '##' - #e la Invio + # e la Invio self.send_data(grandezza_request) - #e leggo la risposta + # e leggo la risposta message = self.read_data() - #verifico se il bus ha trasmesso tutti i dati + # verifico se il bus ha trasmesso tutti i dati check_message = self.check_answer(message) - #verifico se ho ricevuto un NACK - if message[len(message)- 6:] == OpenWebNet.NACK: + # verifico se ho ricevuto un NACK + if message[len(message) - 6:] == messages.NACK: _LOGGER.exception("Errore Comando non effettuato") - #o un ACK + # o un ACK else: - #nel qual caso estraggo i dati della risposta e li restituisco sotto forma di lista + # nel qual caso estraggo i dati della risposta e li restituisco sotto forma di lista return self.extractor(check_message[:len(check_message) - 6]) - #Scrittura di una grandezza - def grandezza_write(self,who,where,grandezza,valori): - #Se non è attiva apro sessione comandi + # Scrittura di una grandezza + def grandezza_write(self, who, where, grandezza, valori): + # Se non è attiva apro sessione comandi if not self._session: self.cmd_session() - #preparo la richiesta - val ='' + # preparo la richiesta + val = '' for item in valori: val = '*' + val[item] grandezza_write = '*#' + who + '*' + where + '*#' + grandezza + val + '##' - #e la Invio + # e la Invio self.send_data(stato_request) - #e leggo la risposta + # e leggo la risposta return self.read_data() - #metodo che invia il comando di accensione della luce where sul bus - def luce_on (self,where): - self.normal_request('1',where,'1') + # metodo che invia il comando di accensione della luce where sul bus + def luce_on(self, where): + self.normal_request('1', where, '1') - #metodo che invia il comandi di spegnimento della luce where sul bus - def luce_off(self,where): - self.normal_request('1',where,'0') + # metodo che invia il comandi di spegnimento della luce where sul bus + def luce_off(self, where): + self.normal_request('1', where, '0') - #metodo per la richiesta dello stato della luce where sul bus - def stato_luce(self,where): + # metodo per la richiesta dello stato della luce where sul bus + def stato_luce(self, where): print('stato_luce') - stato = self.stato_request('1',where) + stato = self.stato_request('1', where) if stato[1] == '1': return True else: return False - #Metodo per la lettura della temperatura - def read_temperature(self,where): + # Metodo per la lettura della temperatura + def read_temperature(self, where): print('lettura temperatura') - temperatura = self.grandezza_request('4',where,'0') - return float(temperatura[3])/10.0 + temperatura = self.grandezza_request('4', where, '0') + return float(temperatura[3]) / 10.0 - #Metodo per la lettura della temperatura settata nella sonda - def read_setTemperature(self,where): + # Metodo per la lettura della temperatura settata nella sonda + def read_setTemperature(self, where): print('lettura set temperature') - setTemperatura = self.grandezza_request('4',where,'14') - return float(setTemperatura[3])/10.0 + setTemperatura = self.grandezza_request('4', where, '14') + return float(setTemperatura[3]) / 10.0 - #Metodo per la lettura dello stato della elettrovalvola - def read_sondaStatus(self,where): + # Metodo per la lettura dello stato della elettrovalvola + def read_sondaStatus(self, where): print('lettura stato sonda temperature') - stato_sonda = self.grandezza_request('4',where,'19') - print('stato sonda',stato_sonda[4]) + stato_sonda = self.grandezza_request('4', where, '19') + print('stato sonda', stato_sonda[4]) if stato_sonda[4] == '0': return 'OFF' else: diff --git a/src/openwebnet/messages.py b/src/openwebnet/messages.py new file mode 100644 index 0000000..5d229be --- /dev/null +++ b/src/openwebnet/messages.py @@ -0,0 +1,8 @@ +# OK message from bus +ACK = '*#*1##' +# Non OK message from bus +NACK = '*#*0##' +# OpenWeb string for open a command session +CMD_SESSION = '*99*0##' +# OpenWeb string for open an event session +EVENT_SESSION = '*99*1##' diff --git a/src/openwebnet/password.py b/src/openwebnet/password.py new file mode 100644 index 0000000..466e9d5 --- /dev/null +++ b/src/openwebnet/password.py @@ -0,0 +1,97 @@ +def calculate_password(self, nonce): + m_1 = 0xFFFFFFFF + m_8 = 0xFFFFFFF8 + m_16 = 0xFFFFFFF0 + m_128 = 0xFFFFFF80 + m_16777216 = 0XFF000000 + flag = True + num1 = 0 + num2 = 0 + self._password = int(self._password) + + for c in nonce: + num1 = num1 & m_1 + num2 = num2 & m_1 + if c == '1': + length = not flag + if not length: + num2 = self._password + num1 = num2 & m_128 + num1 = num1 >> 7 + num2 = num2 << 25 + num1 = num1 + num2 + flag = False + elif c == '2': + length = not flag + if not length: + num2 = self._password + num1 = num2 & m_16 + num1 = num1 >> 4 + num2 = num2 << 28 + num1 = num1 + num2 + flag = False + elif c == '3': + length = not flag + if not length: + num2 = self._password + num1 = num2 & m_8 + num1 = num1 >> 3 + num2 = num2 << 29 + num1 = num1 + num2 + flag = False + elif c == '4': + length = not flag + + if not length: + num2 = self._password + num1 = num2 << 1 + num2 = num2 >> 31 + num1 = num1 + num2 + flag = False + elif c == '5': + length = not flag + if not length: + num2 = self._password + num1 = num2 << 5 + num2 = num2 >> 27 + num1 = num1 + num2 + flag = False + elif c == '6': + length = not flag + if not length: + num2 = self._password + num1 = num2 << 12 + num2 = num2 >> 20 + num1 = num1 + num2 + flag = False + elif c == '7': + length = not flag + if not length: + num2 = self._password + num1 = num2 & 0xFF00 + num1 = num1 + ((num2 & 0xFF) << 24) + num1 = num1 + ((num2 & 0xFF0000) >> 16) + num2 = (num2 & m_16777216) >> 8 + num1 = num1 + num2 + flag = False + elif c == '8': + length = not flag + if not length: + num2 = self._password + num1 = num2 & 0xFFFF + num1 = num1 << 16 + num1 = num1 + (num2 >> 24) + num2 = num2 & 0xFF0000 + num2 = num2 >> 8 + num1 = num1 + num2 + flag = False + elif c == '9': + length = not flag + if not length: + num2 = self._password + num1 = ~num2 + flag = False + else: + num1 = num2 + num2 = num1 + return num1 & m_1 diff --git a/src/openwebnet/skeleton.py b/src/openwebnet/skeleton.py deleted file mode 100644 index 1a70adb..0000000 --- a/src/openwebnet/skeleton.py +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -This is a skeleton file that can serve as a starting point for a Python -console script. To run this script uncomment the following lines in the -[options.entry_points] section in setup.cfg: - - console_scripts = - fibonacci = openwebnet.skeleton:run - -Then run `python setup.py install` which will install the command `fibonacci` -inside your current environment. -Besides console scripts, the header (i.e. until _logger...) of this file can -also be used as template for Python modules. - -Note: This skeleton file can be safely removed if not needed! -""" - -import argparse -import sys -import logging - -from openwebnet import __version__ - -__author__ = "Karel Vervaeke" -__copyright__ = "Karel Vervaeke" -__license__ = "mit" - -_logger = logging.getLogger(__name__) - - -def fib(n): - """Fibonacci example function - - Args: - n (int): integer - - Returns: - int: n-th Fibonacci number - """ - assert n > 0 - a, b = 1, 1 - for i in range(n-1): - a, b = b, a+b - return a - - -def parse_args(args): - """Parse command line parameters - - Args: - args ([str]): command line parameters as list of strings - - Returns: - :obj:`argparse.Namespace`: command line parameters namespace - """ - parser = argparse.ArgumentParser( - description="Just a Fibonnaci demonstration") - parser.add_argument( - '--version', - action='version', - version='openwebnet {ver}'.format(ver=__version__)) - parser.add_argument( - dest="n", - help="n-th Fibonacci number", - type=int, - metavar="INT") - parser.add_argument( - '-v', - '--verbose', - dest="loglevel", - help="set loglevel to INFO", - action='store_const', - const=logging.INFO) - parser.add_argument( - '-vv', - '--very-verbose', - dest="loglevel", - help="set loglevel to DEBUG", - action='store_const', - const=logging.DEBUG) - return parser.parse_args(args) - - -def setup_logging(loglevel): - """Setup basic logging - - Args: - loglevel (int): minimum loglevel for emitting messages - """ - logformat = "[%(asctime)s] %(levelname)s:%(name)s:%(message)s" - logging.basicConfig(level=loglevel, stream=sys.stdout, - format=logformat, datefmt="%Y-%m-%d %H:%M:%S") - - -def main(args): - """Main entry point allowing external calls - - Args: - args ([str]): command line parameter list - """ - args = parse_args(args) - setup_logging(args.loglevel) - _logger.debug("Starting crazy calculations...") - print("The {}-th Fibonacci number is {}".format(args.n, fib(args.n))) - _logger.info("Script ends here") - - -def run(): - """Entry point for console_scripts - """ - main(sys.argv[1:]) - - -if __name__ == "__main__": - run() From 6eff94ed355ee2451f54853f7f98d638d1869104 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Wed, 13 Mar 2019 21:40:21 +0100 Subject: [PATCH 05/45] Clean up, translate methods, messages and comments to English, refactor --- example.py | 5 + src/openwebnet/client.py | 192 ++++++++++--------------------------- src/openwebnet/password.py | 22 ++--- 3 files changed, 66 insertions(+), 153 deletions(-) create mode 100644 example.py diff --git a/example.py b/example.py new file mode 100644 index 0000000..a80585f --- /dev/null +++ b/example.py @@ -0,0 +1,5 @@ +from openwebnet import OpenWebNet + +client = OpenWebNet('192.168.1.10', 20000, '951753') + +client.luce_on('11') diff --git a/src/openwebnet/client.py b/src/openwebnet/client.py index 6f894da..a2468af 100644 --- a/src/openwebnet/client.py +++ b/src/openwebnet/client.py @@ -3,16 +3,12 @@ from logging import getLogger from openwebnet import messages +from openwebnet.password import calculate_password _LOGGER = getLogger(__name__) -""" -OpenWebNet client -""" class OpenWebNet: - - # Init metod def __init__(self, host, port, password): self._host = host self._port = int(port) @@ -20,229 +16,141 @@ def __init__(self, host, port, password): self._session = False self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - # Connection with host - def connection(self): + def connect(self): self._socket.connect((self._host, self._port)) - print('connection') - # Send data to host def send_data(self, data): self._socket.send(data.encode()) - # Read data from host def read_data(self): return str(self._socket.recv(1024).decode()) - # Open command session def cmd_session(self): - # create the connection - self.connection() + self.connect() - # if the bus answer with a NACK report the error if self.read_data() == messages.NACK: - _LOGGER.exception("Non posso inizializzare la comunicazione con il gateway") + _LOGGER.exception("Could not initialize connection with the gateway") - # open commanc session self.send_data(messages.CMD_SESSION) answer = self.read_data() - # if the bus answer with a NACK report the error if answer == messages.NACK: - _LOGGER.exception("Il gateway rifiuta la sessione comandi") + _LOGGER.exception("The gateway refused the session command") return False - # calculate the psw - psw_open = '*#' + str(self.calculated_password(answer)) + '##' - - # send the password - self.send_data(psw_open) + self.send_password(answer) - # if the bus answer with a NACK report the error if self.read_data() == messages.NACK: - _LOGGER.exception("Password errata") - - # othefwise set the variable to True + _LOGGER.exception("Password refused") else: self._session = True - print('cmd_session') - # Extractor for the answer from the bus - def extractor(self, answer): + def send_password(self, nonce): + psw_open = '*#' + str(calculate_password(self._password, answer)) + '##' + self.send_data(psw_open) + + def extract_values(self, answer): value_list = [] - print('estrattore riceve', answer) - # scan on all the caracters on the answer index = 0 while index <= len(answer) - 1: - print('index', index) if answer[index] != '*' and answer[index] != '#': - lenght = 0 + length = 0 val = '' - while lenght <= len(answer) - 1 - index: - if answer[index + lenght] != '*' and answer[index + lenght] != '#': - lenght = lenght + 1 - print('lenght', lenght) + while length <= len(answer) - 1 - index: + if answer[index + length] != '*' and answer[index + length] != '#': + length = length + 1 else: break - print('aggiungo a val', answer[index:index + lenght]) - val = val + answer[index:index + lenght] - print('val', val) + val = val + answer[index:index + length] value_list.append(val) - print('value_list', value_list) - index = index + lenght - lenght = 0 + index = index + length index = index + 1 - print(value_list) return value_list - # Check that bus send al the data + # Check that bus sent all the data def check_answer(self, message): - # if final part of the message is not and ACK or NACK - end_message = '' - print('message ricevuto da check answer', message) - print('messages.ACK', messages.ACK) if message[len(message) - 6:] != messages.ACK and message[len(message) - 6:] != messages.NACK: # the answer is not completed, read again from bus - print('message -len', message[len(message) - 6:]) end_message = self.read_data() - # add it - - print('message +end message', message + end_message) return message + end_message - # check if I get a NACK if message[len(message) - 6:] == messages.NACK: - _LOGGER.exception("Errore Comando non effettuato") + _LOGGER.exception("Error: command execuction failed") return message - # Normal request to BUS def normal_request(self, who, where, what): - - # if the command session is not active if not self._session: self.cmd_session() - # prepare the request normal_request = '*' + who + '*' + what + '*' + where + '##' - - # and send self.send_data(normal_request) - # read the answer message = self.read_data() - - # check if I get a NACK if message == messages.NACK: - _LOGGER.exception("Errore Comando non effettuato") + _LOGGER.exception("Error: command execution failed") - # Request of state of a components on the bus - def stato_request(self, who, where): - print('stato request)') - # if the command session is not active + def request_state(self, who, where): if not self._session: self.cmd_session() - # preparo la richiesta stato_request = '*#' + who + '*' + where + '##' - print('richiesta', stato_request) - # e la Invio self.send_data(stato_request) - # e leggo la risposta + return self.read_response_values() + + def read_response_values(self): message = self.read_data() - print('messagge', message) - # verifico se il bus ha trasmesso tutti i dati check_message = self.check_answer(message) - - # verifico se ho ricevuto un NACK if message[len(message) - 6:] == messages.NACK: - _LOGGER.exception("Errore Comando non effettuato") - # o un ACK + return None else: - # nel qual caso estraggo i dati della risposta e li restituisco sotto forma di lista - return self.extractor(check_message[:len(check_message) - 6]) + return self.extract_values(check_message[:len(check_message) - 6]) - # Richiesta grandezza - def grandezza_request(self, who, where, grandezza): - # Se non è attiva apro sessione comandi + def dimension_read_request(self, who, where, dimension): if not self._session: self.cmd_session() - # preparo la richiesta - grandezza_request = '*#' + who + '*' + where + '*' + grandezza + '##' + dimension_request = '*#' + who + '*' + where + '*' + dimension + '##' + self.send_data(dimension_request) - # e la Invio - self.send_data(grandezza_request) + return self.read_response_values() - # e leggo la risposta - message = self.read_data() - - # verifico se il bus ha trasmesso tutti i dati - check_message = self.check_answer(message) - - # verifico se ho ricevuto un NACK - if message[len(message) - 6:] == messages.NACK: - _LOGGER.exception("Errore Comando non effettuato") - # o un ACK - else: - # nel qual caso estraggo i dati della risposta e li restituisco sotto forma di lista - return self.extractor(check_message[:len(check_message) - 6]) - - # Scrittura di una grandezza - def grandezza_write(self, who, where, grandezza, valori): - # Se non è attiva apro sessione comandi + def dimension_write_request(self, who, where, dimension, values): if not self._session: self.cmd_session() - # preparo la richiesta - val = '' - for item in valori: - val = '*' + val[item] - - grandezza_write = '*#' + who + '*' + where + '*#' + grandezza + val + '##' - - # e la Invio - self.send_data(stato_request) + write_values = ''.join(['*%s'%item for item in values]) + write_request = '*#' + who + '*' + where + '*#' + dimension + write_values + '##' + self.send_data(write_request) - # e leggo la risposta return self.read_data() - # metodo che invia il comando di accensione della luce where sul bus - def luce_on(self, where): + def light_on(self, where): self.normal_request('1', where, '1') - # metodo che invia il comandi di spegnimento della luce where sul bus - def luce_off(self, where): + def light_off(self, where): self.normal_request('1', where, '0') - # metodo per la richiesta dello stato della luce where sul bus - def stato_luce(self, where): - print('stato_luce') - stato = self.stato_request('1', where) + def light_status(self, where): + state = self.request_state('1', where) - if stato[1] == '1': + if state[1] == '1': return True else: return False - # Metodo per la lettura della temperatura def read_temperature(self, where): - print('lettura temperatura') - temperatura = self.grandezza_request('4', where, '0') - return float(temperatura[3]) / 10.0 - - # Metodo per la lettura della temperatura settata nella sonda - def read_setTemperature(self, where): - print('lettura set temperature') - setTemperatura = self.grandezza_request('4', where, '14') - return float(setTemperatura[3]) / 10.0 - - # Metodo per la lettura dello stato della elettrovalvola - def read_sondaStatus(self, where): - print('lettura stato sonda temperature') - stato_sonda = self.grandezza_request('4', where, '19') - print('stato sonda', stato_sonda[4]) - if stato_sonda[4] == '0': + temperature = self.dimension_read_request('4', where, '0') + return float(temperature[3]) / 10.0 + + def read_set_temperature(self, where): + temperature = self.dimension_read_request('4', where, '14') + return float(temperature[3]) / 10.0 + + def read_valve_status(self, where): + valve_status = self.dimension_read_request('4', where, '19') + if valve_status[4] == '0': return 'OFF' else: return 'ON' diff --git a/src/openwebnet/password.py b/src/openwebnet/password.py index 466e9d5..94b9a30 100644 --- a/src/openwebnet/password.py +++ b/src/openwebnet/password.py @@ -1,4 +1,4 @@ -def calculate_password(self, nonce): +def calculate_password(password, nonce): m_1 = 0xFFFFFFFF m_8 = 0xFFFFFFF8 m_16 = 0xFFFFFFF0 @@ -7,7 +7,7 @@ def calculate_password(self, nonce): flag = True num1 = 0 num2 = 0 - self._password = int(self._password) + password = int(password) for c in nonce: num1 = num1 & m_1 @@ -15,7 +15,7 @@ def calculate_password(self, nonce): if c == '1': length = not flag if not length: - num2 = self._password + num2 = password num1 = num2 & m_128 num1 = num1 >> 7 num2 = num2 << 25 @@ -24,7 +24,7 @@ def calculate_password(self, nonce): elif c == '2': length = not flag if not length: - num2 = self._password + num2 = password num1 = num2 & m_16 num1 = num1 >> 4 num2 = num2 << 28 @@ -33,7 +33,7 @@ def calculate_password(self, nonce): elif c == '3': length = not flag if not length: - num2 = self._password + num2 = password num1 = num2 & m_8 num1 = num1 >> 3 num2 = num2 << 29 @@ -43,7 +43,7 @@ def calculate_password(self, nonce): length = not flag if not length: - num2 = self._password + num2 = password num1 = num2 << 1 num2 = num2 >> 31 num1 = num1 + num2 @@ -51,7 +51,7 @@ def calculate_password(self, nonce): elif c == '5': length = not flag if not length: - num2 = self._password + num2 = password num1 = num2 << 5 num2 = num2 >> 27 num1 = num1 + num2 @@ -59,7 +59,7 @@ def calculate_password(self, nonce): elif c == '6': length = not flag if not length: - num2 = self._password + num2 = password num1 = num2 << 12 num2 = num2 >> 20 num1 = num1 + num2 @@ -67,7 +67,7 @@ def calculate_password(self, nonce): elif c == '7': length = not flag if not length: - num2 = self._password + num2 = password num1 = num2 & 0xFF00 num1 = num1 + ((num2 & 0xFF) << 24) num1 = num1 + ((num2 & 0xFF0000) >> 16) @@ -77,7 +77,7 @@ def calculate_password(self, nonce): elif c == '8': length = not flag if not length: - num2 = self._password + num2 = password num1 = num2 & 0xFFFF num1 = num1 << 16 num1 = num1 + (num2 >> 24) @@ -88,7 +88,7 @@ def calculate_password(self, nonce): elif c == '9': length = not flag if not length: - num2 = self._password + num2 = password num1 = ~num2 flag = False else: From 8d442f8a1a124d309086af762c4d6dd22d2c5488 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Thu, 14 Mar 2019 22:18:58 +0100 Subject: [PATCH 06/45] Updated example + fix send_password --- example.py | 3 ++- src/openwebnet/client.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/example.py b/example.py index a80585f..5caab0d 100644 --- a/example.py +++ b/example.py @@ -2,4 +2,5 @@ client = OpenWebNet('192.168.1.10', 20000, '951753') -client.luce_on('11') +client.light_on('11') + diff --git a/src/openwebnet/client.py b/src/openwebnet/client.py index a2468af..b06f26c 100644 --- a/src/openwebnet/client.py +++ b/src/openwebnet/client.py @@ -46,7 +46,7 @@ def cmd_session(self): self._session = True def send_password(self, nonce): - psw_open = '*#' + str(calculate_password(self._password, answer)) + '##' + psw_open = '*#' + str(calculate_password(self._password, nonce)) + '##' self.send_data(psw_open) def extract_values(self, answer): From 8eff4d99da892d04cbe8e01be9996703e214110c Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Thu, 14 Mar 2019 22:20:33 +0100 Subject: [PATCH 07/45] Moved example to readme --- README.md | 10 ++++++++++ example.py | 6 ------ 2 files changed, 10 insertions(+), 6 deletions(-) delete mode 100644 example.py diff --git a/README.md b/README.md index b2c3326..e7b3a8a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,13 @@ # OpenWebNet Python client for interaction with OpenWebNet bus + +## Example usage: + +``` +from openwebnet import OpenWebNet + +client = OpenWebNet('192.168.1.10', 20000, '951753') + +client.light_on('11') +``` diff --git a/example.py b/example.py deleted file mode 100644 index 5caab0d..0000000 --- a/example.py +++ /dev/null @@ -1,6 +0,0 @@ -from openwebnet import OpenWebNet - -client = OpenWebNet('192.168.1.10', 20000, '951753') - -client.light_on('11') - From de7649830565ecb303ee6609aed9c184d3897fd3 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Thu, 14 Mar 2019 22:23:04 +0100 Subject: [PATCH 08/45] Updated author to pippocla --- LICENSE.txt | 2 +- docs/conf.py | 2 +- setup.cfg | 4 ++-- tests/test_openwebnet.py | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/LICENSE.txt b/LICENSE.txt index f4a677e..1642a4a 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019 Karel Vervaeke +Copyright (c) 2019 pippocla Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/docs/conf.py b/docs/conf.py index e5051d4..07bd8c4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -82,7 +82,7 @@ # General information about the project. project = u'openwebnet' -copyright = u'2019, Karel Vervaeke' +copyright = u'2019, Pippocla' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/setup.cfg b/setup.cfg index 1a5a8f7..67b865f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,8 +5,8 @@ [metadata] name = openwebnet description = Add a short description here! -author = Karel Vervaeke -author-email = karel@vervaeke.info +author = Pippocla +author-email = unknown@example.com license = mit url = https://pyscaffold.org/ long-description = file: README.rst diff --git a/tests/test_openwebnet.py b/tests/test_openwebnet.py index 6d8b2e1..bec9d53 100644 --- a/tests/test_openwebnet.py +++ b/tests/test_openwebnet.py @@ -4,8 +4,8 @@ import pytest from openwebnet import OpenWebNet -__author__ = "Karel Vervaeke" -__copyright__ = "Karel Vervaeke" +__author__ = "Pippocla" +__copyright__ = "Pippocla" __license__ = "mit" From fb4192d7c7d8da401906037822cbdf760a016d42 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Fri, 15 Mar 2019 23:21:08 +0100 Subject: [PATCH 09/45] Implemented symple async event client --- eventclient_example.py | 12 +++ src/openwebnet/__init__.py | 3 +- src/openwebnet/client.py | 23 ++++- src/openwebnet/eventclient.py | 97 ++++++++++++++++++++ src/openwebnet/messages.py | 17 ++++ tests/{test_openwebnet.py => test_client.py} | 0 tests/test_messages.py | 21 +++++ 7 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 eventclient_example.py create mode 100644 src/openwebnet/eventclient.py rename tests/{test_openwebnet.py => test_client.py} (100%) create mode 100644 tests/test_messages.py diff --git a/eventclient_example.py b/eventclient_example.py new file mode 100644 index 0000000..f6118bc --- /dev/null +++ b/eventclient_example.py @@ -0,0 +1,12 @@ +from openwebnet import EventClient + + +def handle_connect(): + print("Connected") + +def handle_event(event): + print("I got an event", event) + + +client = EventClient('192.168.1.10', 20000, '951753', handle_connect, handle_event) +client.start() diff --git a/src/openwebnet/__init__.py b/src/openwebnet/__init__.py index ac4530d..5805e42 100644 --- a/src/openwebnet/__init__.py +++ b/src/openwebnet/__init__.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- from openwebnet import messages from openwebnet.client import OpenWebNet +from openwebnet.eventclient import EventClient from pkg_resources import get_distribution, DistributionNotFound -__all__=[OpenWebNet, messages] +__all__=[OpenWebNet, EventClient, messages] try: # Change here if project is renamed and does not equal the package name diff --git a/src/openwebnet/client.py b/src/openwebnet/client.py index b06f26c..153d480 100644 --- a/src/openwebnet/client.py +++ b/src/openwebnet/client.py @@ -17,16 +17,28 @@ def __init__(self, host, port, password): self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) def connect(self): - self._socket.connect((self._host, self._port)) + print("connecting with",self._host, self._port) + try: + self._socket.connect((self._host, self._port)) + return True + except IOError: + _LOGGER.exception("Could not connect") + self._socket.close() + return False def send_data(self, data): self._socket.send(data.encode()) + print("--->", data) def read_data(self): - return str(self._socket.recv(1024).decode()) + data = str(self._socket.recv(1024).decode()) + print("<---", data) + return data def cmd_session(self): - self.connect() + connected = self.connect() + if not connected: + return if self.read_data() == messages.NACK: _LOGGER.exception("Could not initialize connection with the gateway") @@ -105,7 +117,9 @@ def read_response_values(self): if message[len(message) - 6:] == messages.NACK: return None else: - return self.extract_values(check_message[:len(check_message) - 6]) + extracted = self.extract_values(check_message[:len(check_message) - 6]) + print("extracted", extracted) + return extracted def dimension_read_request(self, who, where, dimension): if not self._session: @@ -154,3 +168,4 @@ def read_valve_status(self, where): return 'OFF' else: return 'ON' + diff --git a/src/openwebnet/eventclient.py b/src/openwebnet/eventclient.py new file mode 100644 index 0000000..42de65a --- /dev/null +++ b/src/openwebnet/eventclient.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +import asyncore +import socket +from logging import getLogger + +from openwebnet import messages +from openwebnet.password import calculate_password + +_LOGGER = getLogger(__name__) + + +class OpenWebNetEventDispatcher(asyncore.dispatcher): + def __init__(self, host, port, event_listener): + asyncore.dispatcher.__init__(self) + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + self.connect((host, port)) + self.buffer = "" + self.read_buffer = "" + self.event_listener = event_listener + + def handle_connect(self): + _LOGGER.debug("connected", self) + + def handle_close(self): + _LOGGER.debug("closed", self) + + def handle_read(self): + read_result = self.recv(8196).decode() + _LOGGER.debug("<---- %s", read_result) + self.read_buffer += read_result + if self.read_buffer[-2:] == "##": + self.emit_messages() + + def emit_messages(self): + message_list = messages.extract_messages(self.read_buffer) + self.event_listener.handle_messages(message_list) + self.read_buffer = "" + + def writable(self): + return len(self.buffer) > 0 + + def handle_write(self): + sent = self.send(self.buffer.encode()) + _LOGGER.debug("----> %s", self.buffer[sent:]) + self.buffer = self.buffer[sent:] + + def write(self, data): + self.buffer += data + + +class EventClient: + def __init__(self, host, port, password, connect_listener, event_listener): + self.dispatcher = OpenWebNetEventDispatcher(host, int(port), self) + self._password = password + self.event_listener = event_listener + self.connect_listener = connect_listener + + self.state = 'INITIAL' + + def start(self): + asyncore.loop() + + def handle_messages(self, msgs): + _LOGGER.debug("%s - %s", self.state, msgs) + + if self.state == 'ERROR': + _LOGGER.error("got messages in error state:", msgs) + + elif self.state == 'INITIAL': + if msgs[-1] == messages.ACK: + self.send_data(messages.EVENT_SESSION) + self.state = 'EVENT_SESSION_REQUESTED' + else: + self.state = 'ERROR' + raise asyncore.ExitNow('Server did not send ACK on connect') + + elif self.state == 'EVENT_SESSION_REQUESTED': + if msgs[-1] == messages.NACK: + self.state = 'ERROR' + nonce = messages.extract_single(msgs[0]) + password = calculate_password(self._password, nonce) + self.dispatcher.write(messages.generate_single(password)) + self.state = 'PASSWORD_SENT' + + elif self.state == 'PASSWORD_SENT': + if msgs[-1] == messages.ACK: + self.connect_listener() + self.state = 'EVENT_SESSION_ACTIVE' + else: + raise asyncore.ExitNow('Server did not reply with ACK after sending password') + self.state = 'ERROR' + + elif self.state == 'EVENT_SESSION_ACTIVE': + self.event_listener(msgs) + + def send_data(self, data): + self.dispatcher.write(data) diff --git a/src/openwebnet/messages.py b/src/openwebnet/messages.py index 5d229be..2ccd3c7 100644 --- a/src/openwebnet/messages.py +++ b/src/openwebnet/messages.py @@ -6,3 +6,20 @@ CMD_SESSION = '*99*0##' # OpenWeb string for open an event session EVENT_SESSION = '*99*1##' + + +def extract_messages(data): + if not data.startswith("*"): + raise Exception("data does not start with *") + if not data.endswith("##"): + raise Exception('data does not end with ##') + parts = [part + "##" for part in data.split("##")[:-1]] + return parts + + +def extract_single(message): + return message[2:-2] + + +def generate_single(message): + return "*#%s##" % message diff --git a/tests/test_openwebnet.py b/tests/test_client.py similarity index 100% rename from tests/test_openwebnet.py rename to tests/test_client.py diff --git a/tests/test_messages.py b/tests/test_messages.py new file mode 100644 index 0000000..4c7ade2 --- /dev/null +++ b/tests/test_messages.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- + +from openwebnet.messages import extract_messages +from openwebnet import messages + + +def test_extract_messages_nack(): + assert extract_messages(messages.NACK) == [messages.NACK] + + +def test_extract_messages_nack_nack(): + assert extract_messages(messages.NACK + messages.NACK) == [messages.NACK, messages.NACK] + + +def test_extract_messages_ack_ack(): + assert extract_messages(messages.ACK + messages.ACK) == [messages.ACK, messages.ACK] + + +def test_extract_value_and_ack(): + value_message = "*1#0#13##" + assert extract_messages(value_message + messages.ACK) == [value_message, messages.ACK] \ No newline at end of file From bef4e6e1947b668788f4a6af2849646eee95372d Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Fri, 15 Mar 2019 23:22:20 +0100 Subject: [PATCH 10/45] updated readme --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e7b3a8a..241d945 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # OpenWebNet -Python client for interaction with OpenWebNet bus +Python client for interaction with OpenWebNet bus. +There's a synchronous client for command sessions and an asynchronous client for event sessions. ## Example usage: From 6c06cb7d9e3f1f8b214abb49980a4348b11e32b0 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sat, 6 Apr 2019 22:25:04 +0200 Subject: [PATCH 11/45] Cleaned up examples. Implemented retry mechanism for status querying (since sometimes we get light status responses for a different light) --- .gitignore | 3 ++ README.md | 14 +++--- eventclient_example.py | 12 ----- examples/client.py | 25 +++++++++++ examples/commandclient.py | 39 +++++++++++++++++ examples/eventclient.py | 20 +++++++++ examples/openwebnet_config.yml.sample | 4 ++ src/openwebnet/__init__.py | 4 +- src/openwebnet/client.py | 63 +++++++++++++++++---------- src/openwebnet/dispatcher.py | 47 ++++++++++++++++++++ src/openwebnet/eventclient.py | 46 +++---------------- tests/test_client.py | 6 +-- 12 files changed, 196 insertions(+), 87 deletions(-) delete mode 100644 eventclient_example.py create mode 100644 examples/client.py create mode 100644 examples/commandclient.py create mode 100644 examples/eventclient.py create mode 100644 examples/openwebnet_config.yml.sample create mode 100644 src/openwebnet/dispatcher.py diff --git a/.gitignore b/.gitignore index 2873e01..d7f1918 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Avoid accidental commit of real credentials +openwebnet_config.yml + # Temporary and binary files *~ *.py[cod] diff --git a/README.md b/README.md index 241d945..d64a71b 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,14 @@ There's a synchronous client for command sessions and an asynchronous client for ## Example usage: -``` -from openwebnet import OpenWebNet +### CommandClient -client = OpenWebNet('192.168.1.10', 20000, '951753') +The CommandClient connects to the gateway and establishes a comand session. +The CommandClient works synchronously. -client.light_on('11') -``` +See examples/commandclient.py + +### EventClient + +The EventClient connects to the gateway and establishes an event session. +The EventClient works asynchronously diff --git a/eventclient_example.py b/eventclient_example.py deleted file mode 100644 index f6118bc..0000000 --- a/eventclient_example.py +++ /dev/null @@ -1,12 +0,0 @@ -from openwebnet import EventClient - - -def handle_connect(): - print("Connected") - -def handle_event(event): - print("I got an event", event) - - -client = EventClient('192.168.1.10', 20000, '951753', handle_connect, handle_event) -client.start() diff --git a/examples/client.py b/examples/client.py new file mode 100644 index 0000000..9150bc4 --- /dev/null +++ b/examples/client.py @@ -0,0 +1,25 @@ + +import os +import yaml +from openwebnet import CommandClient, EventClient + +def get_command_client(): + config = read_config() + client = CommandClient(config['host'], config['port'], config['password']) + + return client + +def get_event_client(connect_callback, message_callback): + config = read_config() + client = EventClient(config['host'], config['port'], config['password'], connect_callback, message_callback) + + return client + +def read_config(): + if not os.path.exists('openwebnet_config.yml'): + print("Please create a file named openwebnet_config.yml") + print("See the sample file") + + config = yaml.load(open('openwebnet_config.yml')) + return config + diff --git a/examples/commandclient.py b/examples/commandclient.py new file mode 100644 index 0000000..a330a24 --- /dev/null +++ b/examples/commandclient.py @@ -0,0 +1,39 @@ + +import os +import yaml +from time import sleep +from client import get_command_client + +KITCHEN_LIGHT = '13' + +def main(): + client = get_command_client() + + print_light_status(client, KITCHEN_LIGHT) + sleep(3) + + turn_on(client, KITCHEN_LIGHT) + print_light_status(client, KITCHEN_LIGHT) + sleep(3) + + turn_off(client, KITCHEN_LIGHT) + print_light_status(client, KITCHEN_LIGHT) + +def turn_off(client, where): + print("Sending light off command") + client.light_off(where) + +def turn_on(client, where): + print("Sending light on command") + client.light_on(where) + +def print_light_status(client, where): + status = client.light_status(where) + + if status: + print("Light is currently on") + else: + print("Light is currently off") + +if __name__ == '__main__': + main() diff --git a/examples/eventclient.py b/examples/eventclient.py new file mode 100644 index 0000000..7fa7ef3 --- /dev/null +++ b/examples/eventclient.py @@ -0,0 +1,20 @@ + +import os +import yaml +from time import sleep +from client import get_event_client + +def main(): + client = get_event_client(handle_connect, handle_message) + client.start() + print("I will listen for events for 5 seconds. Try switching a few lights on and off") + sleep(5) + +def handle_connect(): + print("Connected with gateway") + +def handle_message(msg): + print(msg) + +if __name__=='__main__': + main() diff --git a/examples/openwebnet_config.yml.sample b/examples/openwebnet_config.yml.sample new file mode 100644 index 0000000..57fd242 --- /dev/null +++ b/examples/openwebnet_config.yml.sample @@ -0,0 +1,4 @@ + +host: 192.168.1.10 +port: 20000 +password: '951753' diff --git a/src/openwebnet/__init__.py b/src/openwebnet/__init__.py index 5805e42..f0a6dc8 100644 --- a/src/openwebnet/__init__.py +++ b/src/openwebnet/__init__.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- from openwebnet import messages -from openwebnet.client import OpenWebNet +from openwebnet.client import CommandClient from openwebnet.eventclient import EventClient from pkg_resources import get_distribution, DistributionNotFound -__all__=[OpenWebNet, EventClient, messages] +__all__=[CommandClient, EventClient, messages] try: # Change here if project is renamed and does not equal the package name diff --git a/src/openwebnet/client.py b/src/openwebnet/client.py index 153d480..215a224 100644 --- a/src/openwebnet/client.py +++ b/src/openwebnet/client.py @@ -1,23 +1,24 @@ # -*- coding: utf-8 -*- import socket from logging import getLogger +import threading from openwebnet import messages from openwebnet.password import calculate_password _LOGGER = getLogger(__name__) - -class OpenWebNet: +class CommandClient: def __init__(self, host, port, password): self._host = host self._port = int(port) self._password = password self._session = False self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._lock = threading.Lock() def connect(self): - print("connecting with",self._host, self._port) + _LOGGER.info("connecting with %s:%s",self._host, self._port) try: self._socket.connect((self._host, self._port)) return True @@ -27,12 +28,12 @@ def connect(self): return False def send_data(self, data): + _LOGGER.debug("---> %s", data) self._socket.send(data.encode()) - print("--->", data) def read_data(self): data = str(self._socket.recv(1024).decode()) - print("<---", data) + _LOGGER.debug("<--- %s", data) return data def cmd_session(self): @@ -92,24 +93,37 @@ def check_answer(self, message): return message def normal_request(self, who, where, what): - if not self._session: - self.cmd_session() + with self._lock: + if not self._session: + self.cmd_session() - normal_request = '*' + who + '*' + what + '*' + where + '##' - self.send_data(normal_request) + normal_request = '*' + who + '*' + what + '*' + where + '##' + self.send_data(normal_request) - message = self.read_data() - if message == messages.NACK: - _LOGGER.exception("Error: command execution failed") + message = self.read_data() + if message == messages.NACK: + _LOGGER.exception("Error: command execution failed") def request_state(self, who, where): - if not self._session: - self.cmd_session() - - stato_request = '*#' + who + '*' + where + '##' - self.send_data(stato_request) - - return self.read_response_values() + with self._lock: + if not self._session: + self.cmd_session() + + request = '*#' + who + '*' + where + '##' + + self.send_data(request) + response = self.read_response_values() + attempts_left=3 + while response[2] != where and attempts_left > 0: + logger.debug("asked for status of %s, but got status of %s, trying again"%(where, response[2])) + self.send_data(request) + response = self.read_response_values() + attempts_left -= 1 + + if response[2] != where: + logger.warn("Oh-oh, did not get desired response after 3 tries:", where, response) + return False + return response[1] == '1' def read_response_values(self): message = self.read_data() @@ -118,7 +132,6 @@ def read_response_values(self): return None else: extracted = self.extract_values(check_message[:len(check_message) - 6]) - print("extracted", extracted) return extracted def dimension_read_request(self, who, where, dimension): @@ -149,10 +162,12 @@ def light_off(self, where): def light_status(self, where): state = self.request_state('1', where) - if state[1] == '1': - return True - else: - return False + #if state[1] == '1': + # return True + #else: + # return False + + return state def read_temperature(self, where): temperature = self.dimension_read_request('4', where, '0') diff --git a/src/openwebnet/dispatcher.py b/src/openwebnet/dispatcher.py new file mode 100644 index 0000000..b4ca209 --- /dev/null +++ b/src/openwebnet/dispatcher.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +import asyncore +import socket +from logging import getLogger + +_LOGGER = getLogger(__name__) + +from openwebnet import messages + +class OpenWebNetEventDispatcher(asyncore.dispatcher): + def __init__(self, host, port, event_listener): + asyncore.dispatcher.__init__(self) + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + self.connect((host, port)) + self.buffer = "" + self.read_buffer = "" + self.event_listener = event_listener + + def handle_connect(self): + _LOGGER.debug("connected", self) + + def handle_close(self): + _LOGGER.debug("closed", self) + + def handle_read(self): + read_result = self.recv(8196).decode() + _LOGGER.debug("<---- %s", read_result) + self.read_buffer += read_result + if self.read_buffer[-2:] == "##": + self.emit_messages() + + def emit_messages(self): + message_list = messages.extract_messages(self.read_buffer) + self.event_listener.handle_messages(message_list) + self.read_buffer = "" + + def writable(self): + return len(self.buffer) > 0 + + def handle_write(self): + sent = self.send(self.buffer.encode()) + _LOGGER.debug("----> %s", self.buffer[sent:]) + self.buffer = self.buffer[sent:] + + def write(self, data): + self.buffer += data + diff --git a/src/openwebnet/eventclient.py b/src/openwebnet/eventclient.py index 42de65a..8dd063f 100644 --- a/src/openwebnet/eventclient.py +++ b/src/openwebnet/eventclient.py @@ -3,62 +3,26 @@ import socket from logging import getLogger +import threading + from openwebnet import messages from openwebnet.password import calculate_password +from openwebnet.dispatcher import OpenWebNetEventDispatcher _LOGGER = getLogger(__name__) -class OpenWebNetEventDispatcher(asyncore.dispatcher): - def __init__(self, host, port, event_listener): - asyncore.dispatcher.__init__(self) - self.create_socket(socket.AF_INET, socket.SOCK_STREAM) - self.connect((host, port)) - self.buffer = "" - self.read_buffer = "" - self.event_listener = event_listener - - def handle_connect(self): - _LOGGER.debug("connected", self) - - def handle_close(self): - _LOGGER.debug("closed", self) - - def handle_read(self): - read_result = self.recv(8196).decode() - _LOGGER.debug("<---- %s", read_result) - self.read_buffer += read_result - if self.read_buffer[-2:] == "##": - self.emit_messages() - - def emit_messages(self): - message_list = messages.extract_messages(self.read_buffer) - self.event_listener.handle_messages(message_list) - self.read_buffer = "" - - def writable(self): - return len(self.buffer) > 0 - - def handle_write(self): - sent = self.send(self.buffer.encode()) - _LOGGER.debug("----> %s", self.buffer[sent:]) - self.buffer = self.buffer[sent:] - - def write(self, data): - self.buffer += data - - class EventClient: def __init__(self, host, port, password, connect_listener, event_listener): self.dispatcher = OpenWebNetEventDispatcher(host, int(port), self) self._password = password self.event_listener = event_listener self.connect_listener = connect_listener - + self._thread = threading.Thread(target=asyncore.loop, daemon=True) self.state = 'INITIAL' def start(self): - asyncore.loop() + self._thread.start() def handle_messages(self, msgs): _LOGGER.debug("%s - %s", self.state, msgs) diff --git a/tests/test_client.py b/tests/test_client.py index bec9d53..afff104 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,15 +2,15 @@ # -*- coding: utf-8 -*- import pytest -from openwebnet import OpenWebNet +from openwebnet import CommandClient __author__ = "Pippocla" __copyright__ = "Pippocla" __license__ = "mit" -def test_OpenWebNet_constructor(): - client = OpenWebNet('192.168.1.10', 20000, '123456') +def test_CommandClient(): + client = CommandClient('192.168.1.10', 20000, '123456') assert client._host == '192.168.1.10' assert client._port == 20000 assert client._password == '123456' From eaa0a584790e30506ab9605ff315dc0e72855492 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sat, 6 Apr 2019 22:39:52 +0200 Subject: [PATCH 12/45] Fix logger reference --- src/openwebnet/client.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/openwebnet/client.py b/src/openwebnet/client.py index 215a224..d43b1e6 100644 --- a/src/openwebnet/client.py +++ b/src/openwebnet/client.py @@ -115,13 +115,13 @@ def request_state(self, who, where): response = self.read_response_values() attempts_left=3 while response[2] != where and attempts_left > 0: - logger.debug("asked for status of %s, but got status of %s, trying again"%(where, response[2])) + _LOGGER.debug("asked for status of %s, but got status of %s, trying again"%(where, response[2])) self.send_data(request) response = self.read_response_values() attempts_left -= 1 if response[2] != where: - logger.warn("Oh-oh, did not get desired response after 3 tries:", where, response) + _LOGGER.warn("Oh-oh, did not get desired response after 3 tries:", where, response) return False return response[1] == '1' @@ -161,12 +161,6 @@ def light_off(self, where): def light_status(self, where): state = self.request_state('1', where) - - #if state[1] == '1': - # return True - #else: - # return False - return state def read_temperature(self, where): From 3fe0db03f0d7125840a59e8fa7b05e23c10d0526 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sun, 7 Apr 2019 15:22:02 +0200 Subject: [PATCH 13/45] Allow sending arbitrary lighting commands (e.g. to enable blinking, dimmer values, ...) --- src/openwebnet/client.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/openwebnet/client.py b/src/openwebnet/client.py index d43b1e6..49faa06 100644 --- a/src/openwebnet/client.py +++ b/src/openwebnet/client.py @@ -122,8 +122,8 @@ def request_state(self, who, where): if response[2] != where: _LOGGER.warn("Oh-oh, did not get desired response after 3 tries:", where, response) - return False - return response[1] == '1' + return None + return response[1] def read_response_values(self): message = self.read_data() @@ -153,15 +153,17 @@ def dimension_write_request(self, who, where, dimension, values): return self.read_data() + def light_command(self, where, what): + self.normal_request('1', str(where), str(what)) + def light_on(self, where): - self.normal_request('1', where, '1') + self.light_command(where, 1) def light_off(self, where): - self.normal_request('1', where, '0') + self.light_command(where, 0) def light_status(self, where): - state = self.request_state('1', where) - return state + return self.request_state('1', where) def read_temperature(self, where): temperature = self.dimension_read_request('4', where, '0') From 391c2482745e842e1dbd7b2a204f50d9bf8e5c30 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sun, 14 Apr 2019 18:05:44 +0200 Subject: [PATCH 14/45] Changed project name to reopenwebnet to indicate forking --- .gitignore | 2 +- README.md | 4 ++-- docs/Makefile | 4 ++-- docs/conf.py | 10 +++++----- docs/index.rst | 6 ++---- examples/client.py | 8 ++++---- ...fig.yml.sample => reopenwebnet_config.yml.sample} | 0 setup.cfg | 12 ++++++------ setup.py | 2 +- src/{openwebnet => reopenwebnet}/__init__.py | 6 +++--- src/{openwebnet => reopenwebnet}/client.py | 4 ++-- src/{openwebnet => reopenwebnet}/dispatcher.py | 2 +- src/{openwebnet => reopenwebnet}/eventclient.py | 6 +++--- src/{openwebnet => reopenwebnet}/messages.py | 0 src/{openwebnet => reopenwebnet}/password.py | 0 tests/conftest.py | 2 +- tests/test_client.py | 2 +- tests/test_messages.py | 6 +++--- 18 files changed, 37 insertions(+), 39 deletions(-) rename examples/{openwebnet_config.yml.sample => reopenwebnet_config.yml.sample} (100%) rename src/{openwebnet => reopenwebnet}/__init__.py (76%) rename src/{openwebnet => reopenwebnet}/client.py (98%) rename src/{openwebnet => reopenwebnet}/dispatcher.py (97%) rename src/{openwebnet => reopenwebnet}/eventclient.py (92%) rename src/{openwebnet => reopenwebnet}/messages.py (100%) rename src/{openwebnet => reopenwebnet}/password.py (100%) diff --git a/.gitignore b/.gitignore index d7f1918..4f0059b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Avoid accidental commit of real credentials -openwebnet_config.yml +reopenwebnet_config.yml # Temporary and binary files *~ diff --git a/README.md b/README.md index d64a71b..ed4941d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# OpenWebNet +# ReOpenWebNet -Python client for interaction with OpenWebNet bus. +Python client for interaction with ReOpenWebNet bus. There's a synchronous client for command sessions and an asynchronous client for event sessions. ## Example usage: diff --git a/docs/Makefile b/docs/Makefile index 7a6e41f..eef2496 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -8,8 +8,8 @@ PAPER = BUILDDIR = _build AUTODOCDIR = api AUTODOCBUILD = sphinx-apidoc -PROJECT = openwebnet -MODULEDIR = ../src/openwebnet +PROJECT = reopenwebnet +MODULEDIR = ../src/reopenwebnet # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $?), 1) diff --git a/docs/conf.py b/docs/conf.py index 07bd8c4..f8e689c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,7 +35,7 @@ from sphinx import apidoc output_dir = os.path.join(__location__, "api") -module_dir = os.path.join(__location__, "../src/openwebnet") +module_dir = os.path.join(__location__, "../src/reopenwebnet") try: shutil.rmtree(output_dir) except FileNotFoundError: @@ -81,7 +81,7 @@ master_doc = 'index' # General information about the project. -project = u'openwebnet' +project = u'reopenwebnet' copyright = u'2019, Pippocla' # The version info for the project you're documenting, acts as replacement for @@ -151,7 +151,7 @@ # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". try: - from openwebnet import __version__ as version + from reopenwebnet import __version__ as version except ImportError: pass else: @@ -216,7 +216,7 @@ # html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'openwebnet-doc' +htmlhelp_basename = 'reopenwebnet-doc' # -- Options for LaTeX output -------------------------------------------------- @@ -235,7 +235,7 @@ # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'user_guide.tex', u'openwebnet Documentation', + ('index', 'user_guide.tex', u'Reopenwebnet Documentation', u'Karel Vervaeke', 'manual'), ] diff --git a/docs/index.rst b/docs/index.rst index 22b05c5..4ebba24 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,8 +1,6 @@ -========== -openwebnet -========== +# reopenwebnet -This is the documentation of **openwebnet**. +This is the documentation of **reopenwebnet**. .. note:: diff --git a/examples/client.py b/examples/client.py index 9150bc4..db292e5 100644 --- a/examples/client.py +++ b/examples/client.py @@ -1,7 +1,7 @@ import os import yaml -from openwebnet import CommandClient, EventClient +from reopenwebnet import CommandClient, EventClient def get_command_client(): config = read_config() @@ -16,10 +16,10 @@ def get_event_client(connect_callback, message_callback): return client def read_config(): - if not os.path.exists('openwebnet_config.yml'): - print("Please create a file named openwebnet_config.yml") + if not os.path.exists('reopenwebnet_config.yml'): + print("Please create a file named reopenwebnet_config.yml") print("See the sample file") - config = yaml.load(open('openwebnet_config.yml')) + config = yaml.load(open('reopenwebnet_config.yml')) return config diff --git a/examples/openwebnet_config.yml.sample b/examples/reopenwebnet_config.yml.sample similarity index 100% rename from examples/openwebnet_config.yml.sample rename to examples/reopenwebnet_config.yml.sample diff --git a/setup.cfg b/setup.cfg index 67b865f..f1f0c47 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,7 +3,7 @@ # http://setuptools.readthedocs.io/en/latest/setuptools.html#configuring-setup-using-setup-cfg-files [metadata] -name = openwebnet +name = reopenwebnet description = Add a short description here! author = Pippocla author-email = unknown@example.com @@ -40,7 +40,7 @@ exclude = [options.extras_require] # Add here additional requirements for extra features, to install with: -# `pip install openwebnet[PDF]` like: +# `pip install reopenwebnet[PDF]` like: # PDF = ReportLab; RXP # Add here test requirements (semicolon/line-separated) testing = @@ -50,10 +50,10 @@ testing = [options.entry_points] # Add here console scripts like: # console_scripts = -# script_name = openwebnet.module:function +# script_name = reopenwebnet.module:function # For example: # console_scripts = -# fibonacci = openwebnet.skeleton:run +# fibonacci = reopenwebnet.skeleton:run # And any other entry points, for example: # pyscaffold.cli = # awesome = pyscaffoldext.awesome.extension:AwesomeExtension @@ -69,7 +69,7 @@ extras = True # e.g. --cov-report html (or xml) for html/xml output or --junitxml junit.xml # in order to write a coverage file that can be read by Jenkins. addopts = - --cov openwebnet --cov-report term-missing + --cov reopenwebnet --cov-report term-missing --verbose norecursedirs = dist @@ -108,4 +108,4 @@ exclude = # PyScaffold's parameters when the project was created. # This will be used when updating. Do not change! version = 3.1 -package = openwebnet +package = reopenwebnet diff --git a/setup.py b/setup.py index 23537a9..13308bb 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ - Setup file for openwebnet. + Setup file for Reopenwebnet. Use setup.cfg to configure your project. This file was generated with PyScaffold 3.1. diff --git a/src/openwebnet/__init__.py b/src/reopenwebnet/__init__.py similarity index 76% rename from src/openwebnet/__init__.py rename to src/reopenwebnet/__init__.py index f0a6dc8..dce4300 100644 --- a/src/openwebnet/__init__.py +++ b/src/reopenwebnet/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -from openwebnet import messages -from openwebnet.client import CommandClient -from openwebnet.eventclient import EventClient +from reopenwebnet import messages +from reopenwebnet.client import CommandClient +from reopenwebnet.eventclient import EventClient from pkg_resources import get_distribution, DistributionNotFound __all__=[CommandClient, EventClient, messages] diff --git a/src/openwebnet/client.py b/src/reopenwebnet/client.py similarity index 98% rename from src/openwebnet/client.py rename to src/reopenwebnet/client.py index 49faa06..644e2d0 100644 --- a/src/openwebnet/client.py +++ b/src/reopenwebnet/client.py @@ -3,8 +3,8 @@ from logging import getLogger import threading -from openwebnet import messages -from openwebnet.password import calculate_password +from reopenwebnet import messages +from reopenwebnet.password import calculate_password _LOGGER = getLogger(__name__) diff --git a/src/openwebnet/dispatcher.py b/src/reopenwebnet/dispatcher.py similarity index 97% rename from src/openwebnet/dispatcher.py rename to src/reopenwebnet/dispatcher.py index b4ca209..1cd028e 100644 --- a/src/openwebnet/dispatcher.py +++ b/src/reopenwebnet/dispatcher.py @@ -5,7 +5,7 @@ _LOGGER = getLogger(__name__) -from openwebnet import messages +from reopenwebnet import messages class OpenWebNetEventDispatcher(asyncore.dispatcher): def __init__(self, host, port, event_listener): diff --git a/src/openwebnet/eventclient.py b/src/reopenwebnet/eventclient.py similarity index 92% rename from src/openwebnet/eventclient.py rename to src/reopenwebnet/eventclient.py index 8dd063f..42284ba 100644 --- a/src/openwebnet/eventclient.py +++ b/src/reopenwebnet/eventclient.py @@ -5,9 +5,9 @@ import threading -from openwebnet import messages -from openwebnet.password import calculate_password -from openwebnet.dispatcher import OpenWebNetEventDispatcher +from reopenwebnet import messages +from reopenwebnet.password import calculate_password +from reopenwebnet.dispatcher import OpenWebNetEventDispatcher _LOGGER = getLogger(__name__) diff --git a/src/openwebnet/messages.py b/src/reopenwebnet/messages.py similarity index 100% rename from src/openwebnet/messages.py rename to src/reopenwebnet/messages.py diff --git a/src/openwebnet/password.py b/src/reopenwebnet/password.py similarity index 100% rename from src/openwebnet/password.py rename to src/reopenwebnet/password.py diff --git a/tests/conftest.py b/tests/conftest.py index d8cdee3..eab8aa2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ - Dummy conftest.py for openwebnet. + Dummy conftest.py for reopenwebnet. If you don't know what this is for, just leave it empty. Read more about conftest.py under: diff --git a/tests/test_client.py b/tests/test_client.py index afff104..ef21165 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- import pytest -from openwebnet import CommandClient +from reopenwebnet import CommandClient __author__ = "Pippocla" __copyright__ = "Pippocla" diff --git a/tests/test_messages.py b/tests/test_messages.py index 4c7ade2..56fe0fd 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -from openwebnet.messages import extract_messages -from openwebnet import messages +from reopenwebnet.messages import extract_messages +from reopenwebnet import messages def test_extract_messages_nack(): @@ -18,4 +18,4 @@ def test_extract_messages_ack_ack(): def test_extract_value_and_ack(): value_message = "*1#0#13##" - assert extract_messages(value_message + messages.ACK) == [value_message, messages.ACK] \ No newline at end of file + assert extract_messages(value_message + messages.ACK) == [value_message, messages.ACK] From 76f508d5bdbd185deaba73448b48a18a64562f1a Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sun, 14 Apr 2019 18:07:55 +0200 Subject: [PATCH 15/45] Added fork notice --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ed4941d..5425604 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ Python client for interaction with ReOpenWebNet bus. There's a synchronous client for command sessions and an asynchronous client for event sessions. +This is a fork from https://github.com/pippocla/openwebnet (I felt there were some shortcomings in the original project & I could not easily reach the owner to share my changes) + ## Example usage: ### CommandClient From 29f6dd7775db577b663704dd9f8ffb09a5c06efb Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sun, 14 Apr 2019 18:19:41 +0200 Subject: [PATCH 16/45] Updated description & maintainer/owner metadata --- LICENSE.txt | 2 +- docs/conf.py | 2 +- setup.cfg | 6 +++--- tests/test_client.py | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/LICENSE.txt b/LICENSE.txt index 1642a4a..2d0e8da 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019 pippocla +Copyright (c) 2019 karel1980 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/docs/conf.py b/docs/conf.py index f8e689c..7cdc4ec 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -82,7 +82,7 @@ # General information about the project. project = u'reopenwebnet' -copyright = u'2019, Pippocla' +copyright = u'2019, karel1980' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/setup.cfg b/setup.cfg index f1f0c47..4b946f4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,9 +4,9 @@ [metadata] name = reopenwebnet -description = Add a short description here! -author = Pippocla -author-email = unknown@example.com +description = An OpenWebNet client +author = karel1980 +author-email = karel@vervaeke.info license = mit url = https://pyscaffold.org/ long-description = file: README.rst diff --git a/tests/test_client.py b/tests/test_client.py index ef21165..a75922c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,8 +4,8 @@ import pytest from reopenwebnet import CommandClient -__author__ = "Pippocla" -__copyright__ = "Pippocla" +__author__ = "karel1980" +__copyright__ = "karel1980" __license__ = "mit" From 9dd0c7f37a6ebfc91f6791c552bbe2171d5a4526 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Fri, 26 Apr 2019 21:21:44 +0200 Subject: [PATCH 17/45] always convert request arguments to string + set socket timeout to 3 seconds since we're intended for lan usage & we want to avoid blocking --- src/reopenwebnet/client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/reopenwebnet/client.py b/src/reopenwebnet/client.py index 644e2d0..94ab063 100644 --- a/src/reopenwebnet/client.py +++ b/src/reopenwebnet/client.py @@ -15,10 +15,11 @@ def __init__(self, host, port, password): self._password = password self._session = False self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._socket.settimeout(3.0) self._lock = threading.Lock() def connect(self): - _LOGGER.info("connecting with %s:%s",self._host, self._port) + _LOGGER.debug("connecting with %s:%s",self._host, self._port) try: self._socket.connect((self._host, self._port)) return True @@ -93,6 +94,7 @@ def check_answer(self, message): return message def normal_request(self, who, where, what): + who,where,what = str(who),str(where),str(what) with self._lock: if not self._session: self.cmd_session() @@ -105,6 +107,7 @@ def normal_request(self, who, where, what): _LOGGER.exception("Error: command execution failed") def request_state(self, who, where): + who,where = str(who),str(where) with self._lock: if not self._session: self.cmd_session() From 7c3d0d5304e74efe1ca9a59f45d751409e324c30 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Fri, 26 Apr 2019 21:25:57 +0200 Subject: [PATCH 18/45] Update release steps because I will forget them --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 5425604..d6e02e3 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,9 @@ See examples/commandclient.py The EventClient connects to the gateway and establishes an event session. The EventClient works asynchronously + +## Releasing + +git tag x.y.z +python setup.py build +twine upload dist/reopenwebnet-x.y.z-py2.py3-none-any.whl From 1a4363bb97fb01b1fdd2849f67bfa2e02b46602e Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sun, 28 Apr 2019 15:39:03 +0200 Subject: [PATCH 19/45] Refactored commandclient a bit --- examples/commandclient.py | 13 +++- examples/eventclient.py | 4 +- src/reopenwebnet/client.py | 148 ++++++++++++++++++++----------------- 3 files changed, 95 insertions(+), 70 deletions(-) diff --git a/examples/commandclient.py b/examples/commandclient.py index a330a24..93df6e4 100644 --- a/examples/commandclient.py +++ b/examples/commandclient.py @@ -9,6 +9,8 @@ def main(): client = get_command_client() + print_status_all_lights(client) + print_light_status(client, KITCHEN_LIGHT) sleep(3) @@ -19,6 +21,11 @@ def main(): turn_off(client, KITCHEN_LIGHT) print_light_status(client, KITCHEN_LIGHT) + +def print_status_all_lights(client): + print("Fetching status of all lights with 1 command") + print(client.request_state_multi('1', '0')) + def turn_off(client, where): print("Sending light off command") client.light_off(where) @@ -30,10 +37,12 @@ def turn_on(client, where): def print_light_status(client, where): status = client.light_status(where) - if status: + if status == '1': print("Light is currently on") - else: + elif status == '0': print("Light is currently off") + else: + print("Light status = ", status) if __name__ == '__main__': main() diff --git a/examples/eventclient.py b/examples/eventclient.py index 7fa7ef3..ae0033f 100644 --- a/examples/eventclient.py +++ b/examples/eventclient.py @@ -7,8 +7,8 @@ def main(): client = get_event_client(handle_connect, handle_message) client.start() - print("I will listen for events for 5 seconds. Try switching a few lights on and off") - sleep(5) + print("I will listen for events for 20 seconds. Try switching a few lights on and off") + sleep(20) def handle_connect(): print("Connected with gateway") diff --git a/src/reopenwebnet/client.py b/src/reopenwebnet/client.py index 94ab063..e0c7858 100644 --- a/src/reopenwebnet/client.py +++ b/src/reopenwebnet/client.py @@ -8,6 +8,13 @@ _LOGGER = getLogger(__name__) +""" Terminology: + request = string sent to gateway + response = string sent from gateway + frame = a string representing a single frame as defined in the openwebnet documentation + decoded frame = all the values in a frame converted to tuple. e.g. *#4*#0*#0*0250*1## becomes ('#4', '#0', '#0', '0250', '1') +""" + class CommandClient: def __init__(self, host, port, password): self._host = host @@ -15,7 +22,7 @@ def __init__(self, host, port, password): self._password = password self._session = False self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self._socket.settimeout(3.0) + self._socket.settimeout(20.0) self._lock = threading.Lock() def connect(self): @@ -28,14 +35,56 @@ def connect(self): self._socket.close() return False - def send_data(self, data): - _LOGGER.debug("---> %s", data) - self._socket.send(data.encode()) + def normal_request(self, who, where, what): + """ Handles a normal request. Throws an exception when response does ends with NACK """ + who,where,what = str(who),str(where),str(what) + request = '*' + who + '*' + what + '*' + where + '##' + frames = self._execute_request(request) - def read_data(self): - data = str(self._socket.recv(1024).decode()) - _LOGGER.debug("<--- %s", data) - return data + if frames[-1] == messages.NACK: + _LOGGER.exception("Error: command execution failed") + + def request_state(self, who, where): + who,where = str(who),str(where) + frames = self.request_state_multi(who, where) + + if len(frames) != 1: + _LOGGER.error('single state request yielded multiple messages') + return None + + frame = frames[0] + + if (frame[0] != who or frame[2] != where): + _LOGGER.error("requested status for %s/%s but got response for %s/%s"%(who, where, frame[0], frame[2])) + return None + + return frame[1] + + def request_state_multi(self, who, where): + who,where=str(who),str(where) + request = '*#' + who + '*' + where + '##' + frames = self._execute_request(request) + + if frames[-1] != messages.ACK: + _LOGGER.error('response does not end with ACK') + raise Exception('response does not end with ACK') + + return self.convert_frames_to_tuples(frames[:-1]) + + def _execute_request(self, request): + """ sends a request and returns a list of frames sent back by the gateway""" + response = None + with self._lock: + if not self._session: + self.cmd_session() + self.send_data(request) + response = self._read_complete_response() + + frames = [ x + "##" for x in response.split("##")[:-1]] + if frames[-1] != messages.ACK: + _LOGGER.error("response did not end with ACK frame: " + frames) + + return frames def cmd_session(self): connected = self.connect() @@ -63,6 +112,15 @@ def send_password(self, nonce): psw_open = '*#' + str(calculate_password(self._password, nonce)) + '##' self.send_data(psw_open) + def send_data(self, data): + _LOGGER.debug("---> %s", data) + self._socket.send(data.encode()) + + def read_data(self): + data = str(self._socket.recv(1024).decode()) + _LOGGER.debug("<--- %s", data) + return data + def extract_values(self, answer): value_list = [] index = 0 @@ -81,70 +139,21 @@ def extract_values(self, answer): index = index + 1 return value_list - # Check that bus sent all the data - def check_answer(self, message): - if message[len(message) - 6:] != messages.ACK and message[len(message) - 6:] != messages.NACK: - # the answer is not completed, read again from bus - end_message = self.read_data() - return message + end_message - - if message[len(message) - 6:] == messages.NACK: - _LOGGER.exception("Error: command execuction failed") - - return message - - def normal_request(self, who, where, what): - who,where,what = str(who),str(where),str(what) - with self._lock: - if not self._session: - self.cmd_session() - - normal_request = '*' + who + '*' + what + '*' + where + '##' - self.send_data(normal_request) - - message = self.read_data() - if message == messages.NACK: - _LOGGER.exception("Error: command execution failed") - - def request_state(self, who, where): - who,where = str(who),str(where) - with self._lock: - if not self._session: - self.cmd_session() - - request = '*#' + who + '*' + where + '##' + def _read_complete_response(self): + response = self.read_data() + while response[-6:] != messages.ACK and response[-6:] != messages.NACK: + response += self.read_data() - self.send_data(request) - response = self.read_response_values() - attempts_left=3 - while response[2] != where and attempts_left > 0: - _LOGGER.debug("asked for status of %s, but got status of %s, trying again"%(where, response[2])) - self.send_data(request) - response = self.read_response_values() - attempts_left -= 1 - - if response[2] != where: - _LOGGER.warn("Oh-oh, did not get desired response after 3 tries:", where, response) - return None - return response[1] - - def read_response_values(self): - message = self.read_data() - check_message = self.check_answer(message) - if message[len(message) - 6:] == messages.NACK: - return None - else: - extracted = self.extract_values(check_message[:len(check_message) - 6]) - return extracted + return response def dimension_read_request(self, who, where, dimension): if not self._session: self.cmd_session() dimension_request = '*#' + who + '*' + where + '*' + dimension + '##' - self.send_data(dimension_request) + frames = self._execute_request(dimension_request) - return self.read_response_values() + return frames def dimension_write_request(self, who, where, dimension, values): if not self._session: @@ -152,9 +161,16 @@ def dimension_write_request(self, who, where, dimension, values): write_values = ''.join(['*%s'%item for item in values]) write_request = '*#' + who + '*' + where + '*#' + dimension + write_values + '##' - self.send_data(write_request) + frames = self._execute_request(write_request) + + # TODO: check / parse frames + return frames + + def convert_frames_to_tuples(self, frames): + return [self.convert_frame_to_tuple(frame) for frame in frames] - return self.read_data() + def convert_frame_to_tuple(self, frame): + return frame[:-2].split("*")[1:] def light_command(self, where, what): self.normal_request('1', str(where), str(what)) From 01514715bbf6b43002603379b93c90673a22fdb2 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sun, 28 Apr 2019 15:40:37 +0200 Subject: [PATCH 20/45] Updated readme --- README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d6e02e3..7bef543 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,14 @@ See examples/commandclient.py The EventClient connects to the gateway and establishes an event session. The EventClient works asynchronously +## Testing + +To run the test suite: + + python setup.py test + ## Releasing -git tag x.y.z -python setup.py build -twine upload dist/reopenwebnet-x.y.z-py2.py3-none-any.whl + git tag x.y.z + python setup.py build + twine upload dist/reopenwebnet-x.y.z-py2.py3-none-any.whl From 01ee9201d6bd372c01e6976387e65014056d7d1f Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Mon, 29 Apr 2019 17:53:14 +0200 Subject: [PATCH 21/45] When not connected, return 'None' responses. Allow specifying socket timeout for command client --- examples/perftest.py | 22 ++++++++++++++++ examples/request_multi.py | 17 ++++++++++++ examples/test_reconnect.py | 21 +++++++++++++++ src/reopenwebnet/client.py | 53 ++++++++++++++++++++++++++------------ 4 files changed, 96 insertions(+), 17 deletions(-) create mode 100644 examples/perftest.py create mode 100644 examples/request_multi.py create mode 100644 examples/test_reconnect.py diff --git a/examples/perftest.py b/examples/perftest.py new file mode 100644 index 0000000..1cb187b --- /dev/null +++ b/examples/perftest.py @@ -0,0 +1,22 @@ + +import os +import yaml +import logging +from time import sleep +import timeit + +logging.basicConfig(level=logging.DEBUG) + +perf= timeit.timeit(stmt = 'request_light_state()', number = 100, setup=""" + +from client import get_command_client + +TABLE_LIGHT = '10' +light = TABLE_LIGHT + +client = get_command_client() +def request_light_state(): + client.request_state('1', light) +""") + +print("time taken: ", perf) diff --git a/examples/request_multi.py b/examples/request_multi.py new file mode 100644 index 0000000..ead7530 --- /dev/null +++ b/examples/request_multi.py @@ -0,0 +1,17 @@ + +import os +import yaml +import logging +from time import sleep +from client import get_command_client + +logging.basicConfig(level=logging.DEBUG) + +def main(): + client = get_command_client() + + print("Requesting all light states:") + print(client.request_state_multi('1', '0')) + +if __name__ == '__main__': + main() diff --git a/examples/test_reconnect.py b/examples/test_reconnect.py new file mode 100644 index 0000000..dbe22ee --- /dev/null +++ b/examples/test_reconnect.py @@ -0,0 +1,21 @@ + +import os +import yaml +from time import sleep +from client import get_command_client +import timeit + +LIGHT = '17' + +def main(): + client = get_command_client() + + print("Will indefinitely print light status with 1 second intervals") + print("Try switching between networks & it should print 'None' while not connected to the right network, and resume normal operation when connected to the right network") + + while True: + print(client.request_state('1', LIGHT)) + time.sleep(1) + +if __name__ == '__main__': + main() diff --git a/src/reopenwebnet/client.py b/src/reopenwebnet/client.py index e0c7858..c07fbe0 100644 --- a/src/reopenwebnet/client.py +++ b/src/reopenwebnet/client.py @@ -16,38 +16,36 @@ """ class CommandClient: - def __init__(self, host, port, password): + def __init__(self, host, port, password, timeout=3.0): self._host = host self._port = int(port) self._password = password self._session = False self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self._socket.settimeout(20.0) + self._socket.settimeout(timeout) self._lock = threading.Lock() - def connect(self): - _LOGGER.debug("connecting with %s:%s",self._host, self._port) - try: - self._socket.connect((self._host, self._port)) - return True - except IOError: - _LOGGER.exception("Could not connect") - self._socket.close() - return False - def normal_request(self, who, where, what): """ Handles a normal request. Throws an exception when response does ends with NACK """ who,where,what = str(who),str(where),str(what) request = '*' + who + '*' + what + '*' + where + '##' frames = self._execute_request(request) + if frames is None: + return None + if frames[-1] == messages.NACK: _LOGGER.exception("Error: command execution failed") + return False + return True def request_state(self, who, where): who,where = str(who),str(where) frames = self.request_state_multi(who, where) + if frames is None: + return None + if len(frames) != 1: _LOGGER.error('single state request yielded multiple messages') return None @@ -65,20 +63,31 @@ def request_state_multi(self, who, where): request = '*#' + who + '*' + where + '##' frames = self._execute_request(request) + if frames is None: + return None + if frames[-1] != messages.ACK: _LOGGER.error('response does not end with ACK') - raise Exception('response does not end with ACK') + return None return self.convert_frames_to_tuples(frames[:-1]) def _execute_request(self, request): """ sends a request and returns a list of frames sent back by the gateway""" response = None + with self._lock: - if not self._session: - self.cmd_session() - self.send_data(request) - response = self._read_complete_response() + try: + if not self._session: + self.cmd_session() + self.send_data(request) + response = self._read_complete_response() + except IOError: + self._session = False + self._socket.close() + + if response is None: + return response frames = [ x + "##" for x in response.split("##")[:-1]] if frames[-1] != messages.ACK: @@ -108,6 +117,16 @@ def cmd_session(self): else: self._session = True + def connect(self): + _LOGGER.debug("connecting with %s:%s",self._host, self._port) + try: + self._socket.connect((self._host, self._port)) + return True + except IOError: + _LOGGER.exception("Could not connect") + self._socket.close() + return False + def send_password(self, nonce): psw_open = '*#' + str(calculate_password(self._password, nonce)) + '##' self.send_data(psw_open) From 49ec15f503dbee5e74ab4b59728ad0aeb9e7a082 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Mon, 29 Apr 2019 23:03:23 +0200 Subject: [PATCH 22/45] Fixed autoreconnect logic --- examples/test_reconnect.py | 9 +++++---- src/reopenwebnet/client.py | 19 +++++++++++++------ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/examples/test_reconnect.py b/examples/test_reconnect.py index dbe22ee..98ff615 100644 --- a/examples/test_reconnect.py +++ b/examples/test_reconnect.py @@ -1,9 +1,10 @@ -import os -import yaml from time import sleep from client import get_command_client -import timeit +import logging + +logging.basicConfig(level=logging.DEBUG) + LIGHT = '17' @@ -15,7 +16,7 @@ def main(): while True: print(client.request_state('1', LIGHT)) - time.sleep(1) + sleep(1) if __name__ == '__main__': main() diff --git a/src/reopenwebnet/client.py b/src/reopenwebnet/client.py index c07fbe0..90b6a4f 100644 --- a/src/reopenwebnet/client.py +++ b/src/reopenwebnet/client.py @@ -21,8 +21,7 @@ def __init__(self, host, port, password, timeout=3.0): self._port = int(port) self._password = password self._session = False - self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self._socket.settimeout(timeout) + self._timeout = timeout self._lock = threading.Lock() def normal_request(self, who, where, what): @@ -80,9 +79,13 @@ def _execute_request(self, request): try: if not self._session: self.cmd_session() + + if not self._session: + return None + _LOGGER.debug('about to send request') self.send_data(request) response = self._read_complete_response() - except IOError: + except (IOError, OSError, socket.timeout) as e: self._session = False self._socket.close() @@ -98,8 +101,10 @@ def _execute_request(self, request): def cmd_session(self): connected = self.connect() if not connected: + _LOGGER.info("socket connection failed") return + _LOGGER.debug("reading for cmd session setup") if self.read_data() == messages.NACK: _LOGGER.exception("Could not initialize connection with the gateway") @@ -108,7 +113,7 @@ def cmd_session(self): answer = self.read_data() if answer == messages.NACK: _LOGGER.exception("The gateway refused the session command") - return False + return self.send_password(answer) @@ -119,13 +124,15 @@ def cmd_session(self): def connect(self): _LOGGER.debug("connecting with %s:%s",self._host, self._port) + self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._socket.settimeout(self._timeout) try: self._socket.connect((self._host, self._port)) return True - except IOError: + except IOError as e: _LOGGER.exception("Could not connect") self._socket.close() - return False + return False def send_password(self, nonce): psw_open = '*#' + str(calculate_password(self._password, nonce)) + '##' From 2797f539ee994693302f269b405d4374f856c0ea Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Tue, 30 Apr 2019 21:49:14 +0200 Subject: [PATCH 23/45] Updated readme & pypi documentation + reimplement retry strategy --- README.md | 28 +++++++++++++++++++++++----- setup.py | 8 +++++++- src/reopenwebnet/client.py | 26 +++++++++++++++----------- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 7bef543..51d762d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,13 @@ # ReOpenWebNet -Python client for interaction with ReOpenWebNet bus. -There's a synchronous client for command sessions and an asynchronous client for event sessions. +ReOpenWebNet is a client library for communicating with an OpenWebNet server over tcp. -This is a fork from https://github.com/pippocla/openwebnet (I felt there were some shortcomings in the original project & I could not easily reach the owner to share my changes) +OpenWebNet is a communication protocol developed by Bticino, to enable communication between devices of its home automation product suite 'MyHome' +For more information about OpenWebNet, see https://www.myopen-legrandgroup.com/developers/ + +ReOpenWebNet provides a synchronous client for so-called command sessions and an asynchronous client for event sessions. + +This is a fork from https://github.com/pippocla/openwebnet ## Example usage: @@ -12,12 +16,26 @@ This is a fork from https://github.com/pippocla/openwebnet (I felt there were so The CommandClient connects to the gateway and establishes a comand session. The CommandClient works synchronously. -See examples/commandclient.py +``` +from reopenwebnet.client import CommandClient + +host, port, password = 'localhost', 20000, '123456' + +client = CommandClient(host, port, password) + +# Turns on the light with id '10' +client.normal_request('1', '10', '1') + +# Prints the status of the light with id '11' +print(client.request_state('1', '11')) +``` ### EventClient The EventClient connects to the gateway and establishes an event session. -The EventClient works asynchronously +The EventClient works asynchronously. + +See `examples/eventclient.py` for an example ## Testing diff --git a/setup.py b/setup.py index 13308bb..62f2082 100644 --- a/setup.py +++ b/setup.py @@ -12,6 +12,7 @@ from pkg_resources import require, VersionConflict from setuptools import setup +from os import path try: require('setuptools>=38.3') @@ -19,6 +20,11 @@ print("Error: version of setuptools is too old (<38.3)!") sys.exit(1) +this_directory = path.abspath(path.dirname(__file__)) +with open(path.join(this_directory, 'README.md'), encoding='utf-8') as f: + long_description = f.read() if __name__ == "__main__": - setup(use_pyscaffold=True) + setup(use_pyscaffold=True + long_description=long_description, + long_description_content_type='text/markdown') diff --git a/src/reopenwebnet/client.py b/src/reopenwebnet/client.py index 90b6a4f..c667579 100644 --- a/src/reopenwebnet/client.py +++ b/src/reopenwebnet/client.py @@ -40,22 +40,26 @@ def normal_request(self, who, where, what): def request_state(self, who, where): who,where = str(who),str(where) - frames = self.request_state_multi(who, where) - if frames is None: - return None + result = None + tries_remain = 5 + while result is None and tries_remain > 0: + frames = self.request_state_multi(who, where) + if frames is None: + continue - if len(frames) != 1: - _LOGGER.error('single state request yielded multiple messages') - return None + if len(frames) != 1: + #_LOGGER.error('single state request yielded multiple messages') + continue - frame = frames[0] + frame = frames[0] - if (frame[0] != who or frame[2] != where): - _LOGGER.error("requested status for %s/%s but got response for %s/%s"%(who, where, frame[0], frame[2])) - return None + if (frame[0] != who or frame[2] != where): + #_LOGGER.error("requested status for %s/%s but got response for %s/%s"%(who, where, frame[0], frame[2])) + continue - return frame[1] + return frame[1] + return None def request_state_multi(self, who, where): who,where=str(who),str(where) From cbeb2a4e850bebd4861ece42f7a21ed363209ca3 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Tue, 30 Apr 2019 21:51:30 +0200 Subject: [PATCH 24/45] Fix setup.py --- setup.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 62f2082..9443f76 100644 --- a/setup.py +++ b/setup.py @@ -25,6 +25,4 @@ long_description = f.read() if __name__ == "__main__": - setup(use_pyscaffold=True - long_description=long_description, - long_description_content_type='text/markdown') + setup(use_pyscaffold=True, long_description=long_description, long_description_content_type='text/markdown') From 6e291f7fb6176812e97ba4a4cf957377d74bd59f Mon Sep 17 00:00:00 2001 From: rgd Date: Wed, 5 Jun 2019 22:13:23 +0200 Subject: [PATCH 25/45] ADD shutter action in CommandClient class Fonctionnality: - Shutter on - Shutter off - Shutter stop --- examples/commandclient.py | 34 +++++++++++++++++++++++++- src/reopenwebnet/client.py | 50 +++++++++++++++++++++++++++----------- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/examples/commandclient.py b/examples/commandclient.py index 93df6e4..7328e20 100644 --- a/examples/commandclient.py +++ b/examples/commandclient.py @@ -1,11 +1,23 @@ +# @Author: michael +# @Date: 05-Jun-2019 +# @Filename: commandclient.py +# @Last modified by: michael +# @Last modified time: 05-Jun-2019 +# @License: GNU GPL v3 + import os -import yaml from time import sleep + +import yaml + from client import get_command_client KITCHEN_LIGHT = '13' +KITCHEN_SHUTTER = '52' + + def main(): client = get_command_client() @@ -20,20 +32,29 @@ def main(): turn_off(client, KITCHEN_LIGHT) print_light_status(client, KITCHEN_LIGHT) + sleep(3) + + turn_off_shutter(client, KITCHEN_SHUTTER) + sleep(3) + + turn_on_shutter(client, KITCHEN_SHUTTER) def print_status_all_lights(client): print("Fetching status of all lights with 1 command") print(client.request_state_multi('1', '0')) + def turn_off(client, where): print("Sending light off command") client.light_off(where) + def turn_on(client, where): print("Sending light on command") client.light_on(where) + def print_light_status(client, where): status = client.light_status(where) @@ -44,5 +65,16 @@ def print_light_status(client, where): else: print("Light status = ", status) + +def turn_on_shutter(client, where): + print("Sending shutter on command") + client.shutter_on(where) + + +def turn_off_shutter(client, where): + print("Sending shutter off command") + client.shutter_off(where) + + if __name__ == '__main__': main() diff --git a/src/reopenwebnet/client.py b/src/reopenwebnet/client.py index c667579..a36a900 100644 --- a/src/reopenwebnet/client.py +++ b/src/reopenwebnet/client.py @@ -1,7 +1,15 @@ +# @Author: michael +# @Date: 05-Jun-2019 +# @Filename: client.py +# @Last modified by: michael +# @Last modified time: 05-Jun-2019 +# @License: GNU GPL v3 + + # -*- coding: utf-8 -*- import socket -from logging import getLogger import threading +from logging import getLogger from reopenwebnet import messages from reopenwebnet.password import calculate_password @@ -15,6 +23,7 @@ decoded frame = all the values in a frame converted to tuple. e.g. *#4*#0*#0*0250*1## becomes ('#4', '#0', '#0', '0250', '1') """ + class CommandClient: def __init__(self, host, port, password, timeout=3.0): self._host = host @@ -26,7 +35,7 @@ def __init__(self, host, port, password, timeout=3.0): def normal_request(self, who, where, what): """ Handles a normal request. Throws an exception when response does ends with NACK """ - who,where,what = str(who),str(where),str(what) + who, where, what = str(who), str(where), str(what) request = '*' + who + '*' + what + '*' + where + '##' frames = self._execute_request(request) @@ -39,11 +48,11 @@ def normal_request(self, who, where, what): return True def request_state(self, who, where): - who,where = str(who),str(where) + who, where = str(who), str(where) result = None tries_remain = 5 - while result is None and tries_remain > 0: + while result is None and tries_remain > 0: frames = self.request_state_multi(who, where) if frames is None: continue @@ -60,9 +69,9 @@ def request_state(self, who, where): return frame[1] return None - + def request_state_multi(self, who, where): - who,where=str(who),str(where) + who, where = str(who), str(where) request = '*#' + who + '*' + where + '##' frames = self._execute_request(request) @@ -82,21 +91,21 @@ def _execute_request(self, request): with self._lock: try: if not self._session: - self.cmd_session() + self.cmd_session() if not self._session: return None _LOGGER.debug('about to send request') self.send_data(request) - response = self._read_complete_response() + response = self._read_complete_response() except (IOError, OSError, socket.timeout) as e: - self._session = False + self._session = False self._socket.close() if response is None: return response - frames = [ x + "##" for x in response.split("##")[:-1]] + frames = [x + "##" for x in response.split("##")[:-1]] if frames[-1] != messages.ACK: _LOGGER.error("response did not end with ACK frame: " + frames) @@ -110,7 +119,8 @@ def cmd_session(self): _LOGGER.debug("reading for cmd session setup") if self.read_data() == messages.NACK: - _LOGGER.exception("Could not initialize connection with the gateway") + _LOGGER.exception( + "Could not initialize connection with the gateway") self.send_data(messages.CMD_SESSION) @@ -127,7 +137,7 @@ def cmd_session(self): self._session = True def connect(self): - _LOGGER.debug("connecting with %s:%s",self._host, self._port) + _LOGGER.debug("connecting with %s:%s", self._host, self._port) self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._socket.settimeout(self._timeout) try: @@ -189,8 +199,9 @@ def dimension_write_request(self, who, where, dimension, values): if not self._session: self.cmd_session() - write_values = ''.join(['*%s'%item for item in values]) - write_request = '*#' + who + '*' + where + '*#' + dimension + write_values + '##' + write_values = ''.join(['*%s' % item for item in values]) + write_request = '*#' + who + '*' + where + \ + '*#' + dimension + write_values + '##' frames = self._execute_request(write_request) # TODO: check / parse frames @@ -229,3 +240,14 @@ def read_valve_status(self, where): else: return 'ON' + def shutter_command(self, where, what): + self.normal_request('2', str(where), str(what)) + + def shutter_off(self, where): + self.shutter_command(where, 2) + + def shutter_on(self, where): + self.shutter_command(where, 1) + + def shutter_stop(self, where): + self.shutter_command(where, 0) From c313e9421d4cfa8a0c803b9b98898211ac0def3b Mon Sep 17 00:00:00 2001 From: rgd Date: Sun, 16 Jun 2019 15:45:57 +0200 Subject: [PATCH 26/45] Remove headers and Modify README --- README.md | 23 +++++++++++++++++++++++ examples/commandclient.py | 8 -------- src/reopenwebnet/client.py | 10 +++------- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 51d762d..c5fcd12 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,34 @@ client = CommandClient(host, port, password) # Turns on the light with id '10' client.normal_request('1', '10', '1') +# Or +client.light_on('10') # Prints the status of the light with id '11' print(client.request_state('1', '11')) +# Or +print(client.light_status('11')) + +# Fit the electric shutters with id '20' +client.shutter_on('20') ``` +### Resume: + +Command | Description +------- | ---------- +normal_request(who, what, where) | Send a request +request_state(who, where) | Send a state request +light_on(where) | Turn on the light +light_off(where) | Turn off the light +light_status(where) | Get the status of the light +read_temperature(where) | Get the temperature +read_set_temperature(where) | Get the temperature set +read_valve_status(where) | Get the status of temperature valve +shutter_off(where) | Lower the electric shutters +shutter_on(where) | Fit the electric shutters +shutter_stop(where) | Stop the electric shutters + ### EventClient The EventClient connects to the gateway and establishes an event session. diff --git a/examples/commandclient.py b/examples/commandclient.py index 7328e20..8d306e3 100644 --- a/examples/commandclient.py +++ b/examples/commandclient.py @@ -1,11 +1,3 @@ -# @Author: michael -# @Date: 05-Jun-2019 -# @Filename: commandclient.py -# @Last modified by: michael -# @Last modified time: 05-Jun-2019 -# @License: GNU GPL v3 - - import os from time import sleep diff --git a/src/reopenwebnet/client.py b/src/reopenwebnet/client.py index a36a900..c3f227a 100644 --- a/src/reopenwebnet/client.py +++ b/src/reopenwebnet/client.py @@ -1,10 +1,3 @@ -# @Author: michael -# @Date: 05-Jun-2019 -# @Filename: client.py -# @Last modified by: michael -# @Last modified time: 05-Jun-2019 -# @License: GNU GPL v3 - # -*- coding: utf-8 -*- import socket @@ -244,10 +237,13 @@ def shutter_command(self, where, what): self.normal_request('2', str(where), str(what)) def shutter_off(self, where): + """Lower the electric shutters.""" self.shutter_command(where, 2) def shutter_on(self, where): + """Fit the electric shutters.""" self.shutter_command(where, 1) def shutter_stop(self, where): + """Stop the electric shutters.""" self.shutter_command(where, 0) From 8b8aca1cb87279316482e8383feaaf183ee8fbd7 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Wed, 12 Feb 2020 10:07:19 +0100 Subject: [PATCH 27/45] convert command client to asyncio + implement 'reconnect' feature + added gateway proxy gateway proxy combines command client and event client. only light support for now. --- README.md | 64 ++--- examples/client.py | 25 -- examples/commandclient.py | 79 +++--- examples/eventclient.py | 34 +-- examples/gatewayproxy.py | 32 +++ examples/perftest.py | 22 -- examples/request_multi.py | 17 -- examples/test_reconnect.py | 22 -- ...l.sample => reopenwebnet_config.yml.sample | 0 requirements.txt | 2 + setup.cfg | 4 +- setup.py | 2 +- src/reopenwebnet/__init__.py | 12 +- src/reopenwebnet/client.py | 249 ------------------ src/reopenwebnet/client_factory.py | 39 +++ src/reopenwebnet/commandclient.py | 95 +++++++ src/reopenwebnet/dispatcher.py | 47 ---- src/reopenwebnet/eventclient.py | 106 ++++---- src/reopenwebnet/gatewayproxy.py | 78 ++++++ src/reopenwebnet/messages.py | 146 ++++++++-- src/reopenwebnet/protocol.py | 88 +++++++ tests/test_client.py | 16 -- tests/test_messages.py | 59 ++++- 23 files changed, 648 insertions(+), 590 deletions(-) delete mode 100644 examples/client.py create mode 100644 examples/gatewayproxy.py delete mode 100644 examples/perftest.py delete mode 100644 examples/request_multi.py delete mode 100644 examples/test_reconnect.py rename examples/reopenwebnet_config.yml.sample => reopenwebnet_config.yml.sample (100%) delete mode 100644 src/reopenwebnet/client.py create mode 100644 src/reopenwebnet/client_factory.py create mode 100644 src/reopenwebnet/commandclient.py delete mode 100644 src/reopenwebnet/dispatcher.py create mode 100644 src/reopenwebnet/gatewayproxy.py create mode 100644 src/reopenwebnet/protocol.py delete mode 100644 tests/test_client.py diff --git a/README.md b/README.md index c5fcd12..84ca996 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,30 @@ # ReOpenWebNet -ReOpenWebNet is a client library for communicating with an OpenWebNet server over tcp. +ReOpenWebNet is a client library for communicating with an OpenWebNet gateway. -OpenWebNet is a communication protocol developed by Bticino, to enable communication between devices of its home automation product suite 'MyHome' -For more information about OpenWebNet, see https://www.myopen-legrandgroup.com/developers/ +OpenWebNet is a communication protocol developed by Bticino, to enable communication between devices of its home automation product suite 'MyHome'. For more information about OpenWebNet, see https://www.myopen-legrandgroup.com/developers/ -ReOpenWebNet provides a synchronous client for so-called command sessions and an asynchronous client for event sessions. +This project started as a fork from https://github.com/pippocla/openwebnet -This is a fork from https://github.com/pippocla/openwebnet +## Features -## Example usage: +Asynchronous components for interacting with the gateway. -### CommandClient +The author's OpenWebNet gateway doesn't respond well to sending commands -The CommandClient connects to the gateway and establishes a comand session. -The CommandClient works synchronously. +Low level components: + - CommandClient: establishes command sessions and lets you send commands and read the gateway's responses. The CommandClient does simple rate limiting because the OpenWebNet gateway seems to misbehave if you send too many commands in a short time. + - EventClient: establishes event sessions. -``` -from reopenwebnet.client import CommandClient +Both components automatically reconnect in case of connection loss. -host, port, password = 'localhost', 20000, '123456' +Higher level components: + - ClientFactory: reads configuration from file and can create CommandClient and EventClient instances + - GatewayProxy: Creates a CommandClient and an EventClient and keeps state in sync. -client = CommandClient(host, port, password) +## Examples -# Turns on the light with id '10' -client.normal_request('1', '10', '1') -# Or -client.light_on('10') - -# Prints the status of the light with id '11' -print(client.request_state('1', '11')) -# Or -print(client.light_status('11')) - -# Fit the electric shutters with id '20' -client.shutter_on('20') -``` - -### Resume: - -Command | Description -------- | ---------- -normal_request(who, what, where) | Send a request -request_state(who, where) | Send a state request -light_on(where) | Turn on the light -light_off(where) | Turn off the light -light_status(where) | Get the status of the light -read_temperature(where) | Get the temperature -read_set_temperature(where) | Get the temperature set -read_valve_status(where) | Get the status of temperature valve -shutter_off(where) | Lower the electric shutters -shutter_on(where) | Fit the electric shutters -shutter_stop(where) | Stop the electric shutters - -### EventClient - -The EventClient connects to the gateway and establishes an event session. -The EventClient works asynchronously. - -See `examples/eventclient.py` for an example +See examples/ for examples on the various components ## Testing diff --git a/examples/client.py b/examples/client.py deleted file mode 100644 index db292e5..0000000 --- a/examples/client.py +++ /dev/null @@ -1,25 +0,0 @@ - -import os -import yaml -from reopenwebnet import CommandClient, EventClient - -def get_command_client(): - config = read_config() - client = CommandClient(config['host'], config['port'], config['password']) - - return client - -def get_event_client(connect_callback, message_callback): - config = read_config() - client = EventClient(config['host'], config['port'], config['password'], connect_callback, message_callback) - - return client - -def read_config(): - if not os.path.exists('reopenwebnet_config.yml'): - print("Please create a file named reopenwebnet_config.yml") - print("See the sample file") - - config = yaml.load(open('reopenwebnet_config.yml')) - return config - diff --git a/examples/commandclient.py b/examples/commandclient.py index 8d306e3..2ae5e39 100644 --- a/examples/commandclient.py +++ b/examples/commandclient.py @@ -1,61 +1,50 @@ -import os +import asyncio from time import sleep -import yaml +from reopenwebnet.client_factory import ClientFactory +from reopenwebnet import messages -from client import get_command_client +import logging KITCHEN_LIGHT = '13' -KITCHEN_SHUTTER = '52' +logging.basicConfig(level=logging.INFO) -def main(): - client = get_command_client() +async def commandclient_demo(): + client = ClientFactory().get_command_client() + await client.start() + await example_single_light_status(client) + await example_all_lights_status_request(client) + await example_light_commands(client) - print_status_all_lights(client) - print_light_status(client, KITCHEN_LIGHT) - sleep(3) +async def example_light_commands(client): + print("2 x turn light on & turn it off again") + for i in range(2): + print("on") + print(await client.send_command(messages.NormalMessage('1', '1', KITCHEN_LIGHT))) + sleep(1) + print("off") + print(await client.send_command(messages.NormalMessage('1', '0', KITCHEN_LIGHT))) + sleep(1) - turn_on(client, KITCHEN_LIGHT) - print_light_status(client, KITCHEN_LIGHT) - sleep(3) + print("Requesting light status") + print(await client.send_command(messages.StatusRequestMessage('1', KITCHEN_LIGHT))) + sleep(1) - turn_off(client, KITCHEN_LIGHT) - print_light_status(client, KITCHEN_LIGHT) - sleep(3) - turn_off_shutter(client, KITCHEN_SHUTTER) - sleep(3) +async def example_all_lights_status_request(client): + print("Request all lights status") + print(await client.send_command(messages.StatusRequestMessage('1', '0'))) + sleep(1) - turn_on_shutter(client, KITCHEN_SHUTTER) - -def print_status_all_lights(client): - print("Fetching status of all lights with 1 command") - print(client.request_state_multi('1', '0')) - - -def turn_off(client, where): - print("Sending light off command") - client.light_off(where) - - -def turn_on(client, where): - print("Sending light on command") - client.light_on(where) - - -def print_light_status(client, where): - status = client.light_status(where) - - if status == '1': - print("Light is currently on") - elif status == '0': - print("Light is currently off") - else: - print("Light status = ", status) +async def example_single_light_status(client): + print("3 x Request light status with 3 second intervals") + for i in range(3): + print(await client.send_command(messages.StatusRequestMessage('1', KITCHEN_LIGHT))) + await asyncio.sleep(3) def turn_on_shutter(client, where): @@ -69,4 +58,6 @@ def turn_off_shutter(client, where): if __name__ == '__main__': - main() + logging.basicConfig(level=logging.DEBUG) + + asyncio.run(commandclient_demo()) diff --git a/examples/eventclient.py b/examples/eventclient.py index ae0033f..8caa679 100644 --- a/examples/eventclient.py +++ b/examples/eventclient.py @@ -1,20 +1,24 @@ +import asyncio +import logging -import os -import yaml -from time import sleep -from client import get_event_client +from reopenwebnet.client_factory import ClientFactory -def main(): - client = get_event_client(handle_connect, handle_message) - client.start() - print("I will listen for events for 20 seconds. Try switching a few lights on and off") - sleep(20) -def handle_connect(): - print("Connected with gateway") +async def eventclient_demo(): + def handle_connect(): + print("connected to openwebnet service") -def handle_message(msg): - print(msg) + def handle_messages(msgs): + print("received messages: ", msgs) -if __name__=='__main__': - main() + client = ClientFactory().get_event_client(handle_connect, handle_messages) + await client.start() + + print("Will listen for events for 1 hour. Hit ctrl-c to stop") + await asyncio.sleep(3600) + + +if __name__ == '__main__': + logging.basicConfig(level=logging.DEBUG) + + asyncio.run(eventclient_demo()) diff --git a/examples/gatewayproxy.py b/examples/gatewayproxy.py new file mode 100644 index 0000000..2160669 --- /dev/null +++ b/examples/gatewayproxy.py @@ -0,0 +1,32 @@ +import asyncio +from time import sleep + +from reopenwebnet.gatewayproxy import GatewayProxy +from reopenwebnet import messages + +import logging + +KITCHEN_LIGHT = '13' + + +async def gatewayproxy_demo(): + gw = GatewayProxy() + await gw.start() + await example_print_status(gw) + + +async def example_print_status(gateway): + print("Printing all light statuses with 3 second intervals") + print( + "Try toggling lights and causing network interruptions. Gateway should return to stable state after network " + "is restored") + + while True: + gateway.print_states() + await asyncio.sleep(2) + + +if __name__ == '__main__': + logging.basicConfig(level=logging.DEBUG) + + asyncio.run(gatewayproxy_demo()) diff --git a/examples/perftest.py b/examples/perftest.py deleted file mode 100644 index 1cb187b..0000000 --- a/examples/perftest.py +++ /dev/null @@ -1,22 +0,0 @@ - -import os -import yaml -import logging -from time import sleep -import timeit - -logging.basicConfig(level=logging.DEBUG) - -perf= timeit.timeit(stmt = 'request_light_state()', number = 100, setup=""" - -from client import get_command_client - -TABLE_LIGHT = '10' -light = TABLE_LIGHT - -client = get_command_client() -def request_light_state(): - client.request_state('1', light) -""") - -print("time taken: ", perf) diff --git a/examples/request_multi.py b/examples/request_multi.py deleted file mode 100644 index ead7530..0000000 --- a/examples/request_multi.py +++ /dev/null @@ -1,17 +0,0 @@ - -import os -import yaml -import logging -from time import sleep -from client import get_command_client - -logging.basicConfig(level=logging.DEBUG) - -def main(): - client = get_command_client() - - print("Requesting all light states:") - print(client.request_state_multi('1', '0')) - -if __name__ == '__main__': - main() diff --git a/examples/test_reconnect.py b/examples/test_reconnect.py deleted file mode 100644 index 98ff615..0000000 --- a/examples/test_reconnect.py +++ /dev/null @@ -1,22 +0,0 @@ - -from time import sleep -from client import get_command_client -import logging - -logging.basicConfig(level=logging.DEBUG) - - -LIGHT = '17' - -def main(): - client = get_command_client() - - print("Will indefinitely print light status with 1 second intervals") - print("Try switching between networks & it should print 'None' while not connected to the right network, and resume normal operation when connected to the right network") - - while True: - print(client.request_state('1', LIGHT)) - sleep(1) - -if __name__ == '__main__': - main() diff --git a/examples/reopenwebnet_config.yml.sample b/reopenwebnet_config.yml.sample similarity index 100% rename from examples/reopenwebnet_config.yml.sample rename to reopenwebnet_config.yml.sample diff --git a/requirements.txt b/requirements.txt index 434a5f2..7aaf82b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +nose # ============================================================================= # DEPRECATION WARNING: # @@ -15,3 +16,4 @@ # numpy==1.13.3 # scipy==1.0 +paho-mqtt==1.5.0 diff --git a/setup.cfg b/setup.cfg index 4b946f4..3d593b4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,9 +27,9 @@ package_dir = # DON'T CHANGE THE FOLLOWING LINE! IT WILL BE UPDATED BY PYSCAFFOLD! setup_requires = pyscaffold>=3.1a0,<3.2a0 # Add here dependencies of your project (semicolon/line-separated), e.g. -# install_requires = numpy; scipy +install_requires = paho-mqtt>=1.5 # The usage of test_requires is discouraged, see `Dependency Management` docs -# tests_require = pytest; pytest-cov +tests_require = nose; pytest-cov # Require a specific Python version, e.g. Python 2.7 or >= 3.4 # python_requires = >=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.* diff --git a/setup.py b/setup.py index 9443f76..aff97f6 100644 --- a/setup.py +++ b/setup.py @@ -25,4 +25,4 @@ long_description = f.read() if __name__ == "__main__": - setup(use_pyscaffold=True, long_description=long_description, long_description_content_type='text/markdown') + setup(use_pyscaffold=True, long_description=long_description) diff --git a/src/reopenwebnet/__init__.py b/src/reopenwebnet/__init__.py index dce4300..fb9f0fb 100644 --- a/src/reopenwebnet/__init__.py +++ b/src/reopenwebnet/__init__.py @@ -1,10 +1,14 @@ # -*- coding: utf-8 -*- -from reopenwebnet import messages -from reopenwebnet.client import CommandClient -from reopenwebnet.eventclient import EventClient from pkg_resources import get_distribution, DistributionNotFound -__all__=[CommandClient, EventClient, messages] +from reopenwebnet import messages +from reopenwebnet import client_factory +from reopenwebnet import commandclient +from reopenwebnet import eventclient +from reopenwebnet import gatewayproxy + +__all__=[messages, client_factory, commandclient, eventclient, gatewayproxy] + try: # Change here if project is renamed and does not equal the package name diff --git a/src/reopenwebnet/client.py b/src/reopenwebnet/client.py deleted file mode 100644 index c3f227a..0000000 --- a/src/reopenwebnet/client.py +++ /dev/null @@ -1,249 +0,0 @@ - -# -*- coding: utf-8 -*- -import socket -import threading -from logging import getLogger - -from reopenwebnet import messages -from reopenwebnet.password import calculate_password - -_LOGGER = getLogger(__name__) - -""" Terminology: - request = string sent to gateway - response = string sent from gateway - frame = a string representing a single frame as defined in the openwebnet documentation - decoded frame = all the values in a frame converted to tuple. e.g. *#4*#0*#0*0250*1## becomes ('#4', '#0', '#0', '0250', '1') -""" - - -class CommandClient: - def __init__(self, host, port, password, timeout=3.0): - self._host = host - self._port = int(port) - self._password = password - self._session = False - self._timeout = timeout - self._lock = threading.Lock() - - def normal_request(self, who, where, what): - """ Handles a normal request. Throws an exception when response does ends with NACK """ - who, where, what = str(who), str(where), str(what) - request = '*' + who + '*' + what + '*' + where + '##' - frames = self._execute_request(request) - - if frames is None: - return None - - if frames[-1] == messages.NACK: - _LOGGER.exception("Error: command execution failed") - return False - return True - - def request_state(self, who, where): - who, where = str(who), str(where) - - result = None - tries_remain = 5 - while result is None and tries_remain > 0: - frames = self.request_state_multi(who, where) - if frames is None: - continue - - if len(frames) != 1: - #_LOGGER.error('single state request yielded multiple messages') - continue - - frame = frames[0] - - if (frame[0] != who or frame[2] != where): - #_LOGGER.error("requested status for %s/%s but got response for %s/%s"%(who, where, frame[0], frame[2])) - continue - - return frame[1] - return None - - def request_state_multi(self, who, where): - who, where = str(who), str(where) - request = '*#' + who + '*' + where + '##' - frames = self._execute_request(request) - - if frames is None: - return None - - if frames[-1] != messages.ACK: - _LOGGER.error('response does not end with ACK') - return None - - return self.convert_frames_to_tuples(frames[:-1]) - - def _execute_request(self, request): - """ sends a request and returns a list of frames sent back by the gateway""" - response = None - - with self._lock: - try: - if not self._session: - self.cmd_session() - - if not self._session: - return None - _LOGGER.debug('about to send request') - self.send_data(request) - response = self._read_complete_response() - except (IOError, OSError, socket.timeout) as e: - self._session = False - self._socket.close() - - if response is None: - return response - - frames = [x + "##" for x in response.split("##")[:-1]] - if frames[-1] != messages.ACK: - _LOGGER.error("response did not end with ACK frame: " + frames) - - return frames - - def cmd_session(self): - connected = self.connect() - if not connected: - _LOGGER.info("socket connection failed") - return - - _LOGGER.debug("reading for cmd session setup") - if self.read_data() == messages.NACK: - _LOGGER.exception( - "Could not initialize connection with the gateway") - - self.send_data(messages.CMD_SESSION) - - answer = self.read_data() - if answer == messages.NACK: - _LOGGER.exception("The gateway refused the session command") - return - - self.send_password(answer) - - if self.read_data() == messages.NACK: - _LOGGER.exception("Password refused") - else: - self._session = True - - def connect(self): - _LOGGER.debug("connecting with %s:%s", self._host, self._port) - self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self._socket.settimeout(self._timeout) - try: - self._socket.connect((self._host, self._port)) - return True - except IOError as e: - _LOGGER.exception("Could not connect") - self._socket.close() - return False - - def send_password(self, nonce): - psw_open = '*#' + str(calculate_password(self._password, nonce)) + '##' - self.send_data(psw_open) - - def send_data(self, data): - _LOGGER.debug("---> %s", data) - self._socket.send(data.encode()) - - def read_data(self): - data = str(self._socket.recv(1024).decode()) - _LOGGER.debug("<--- %s", data) - return data - - def extract_values(self, answer): - value_list = [] - index = 0 - while index <= len(answer) - 1: - if answer[index] != '*' and answer[index] != '#': - length = 0 - val = '' - while length <= len(answer) - 1 - index: - if answer[index + length] != '*' and answer[index + length] != '#': - length = length + 1 - else: - break - val = val + answer[index:index + length] - value_list.append(val) - index = index + length - index = index + 1 - return value_list - - def _read_complete_response(self): - response = self.read_data() - while response[-6:] != messages.ACK and response[-6:] != messages.NACK: - response += self.read_data() - - return response - - def dimension_read_request(self, who, where, dimension): - if not self._session: - self.cmd_session() - - dimension_request = '*#' + who + '*' + where + '*' + dimension + '##' - frames = self._execute_request(dimension_request) - - return frames - - def dimension_write_request(self, who, where, dimension, values): - if not self._session: - self.cmd_session() - - write_values = ''.join(['*%s' % item for item in values]) - write_request = '*#' + who + '*' + where + \ - '*#' + dimension + write_values + '##' - frames = self._execute_request(write_request) - - # TODO: check / parse frames - return frames - - def convert_frames_to_tuples(self, frames): - return [self.convert_frame_to_tuple(frame) for frame in frames] - - def convert_frame_to_tuple(self, frame): - return frame[:-2].split("*")[1:] - - def light_command(self, where, what): - self.normal_request('1', str(where), str(what)) - - def light_on(self, where): - self.light_command(where, 1) - - def light_off(self, where): - self.light_command(where, 0) - - def light_status(self, where): - return self.request_state('1', where) - - def read_temperature(self, where): - temperature = self.dimension_read_request('4', where, '0') - return float(temperature[3]) / 10.0 - - def read_set_temperature(self, where): - temperature = self.dimension_read_request('4', where, '14') - return float(temperature[3]) / 10.0 - - def read_valve_status(self, where): - valve_status = self.dimension_read_request('4', where, '19') - if valve_status[4] == '0': - return 'OFF' - else: - return 'ON' - - def shutter_command(self, where, what): - self.normal_request('2', str(where), str(what)) - - def shutter_off(self, where): - """Lower the electric shutters.""" - self.shutter_command(where, 2) - - def shutter_on(self, where): - """Fit the electric shutters.""" - self.shutter_command(where, 1) - - def shutter_stop(self, where): - """Stop the electric shutters.""" - self.shutter_command(where, 0) diff --git a/src/reopenwebnet/client_factory.py b/src/reopenwebnet/client_factory.py new file mode 100644 index 0000000..3674e32 --- /dev/null +++ b/src/reopenwebnet/client_factory.py @@ -0,0 +1,39 @@ +import os + +import yaml +from yaml import SafeLoader + +from reopenwebnet.commandclient import CommandClient +from reopenwebnet.eventclient import EventClient + + +class ClientFactory: + def __init__(self): + self.config_file = self.get_config_file() + self.config = self.read_config() + + @staticmethod + def get_config_file(): + env_path = os.environ.get('REOPENWEBNET_CONFIG', None) + if env_path is not None: + return env_path + + return os.path.expanduser('~/.reopenwebnet/config.yaml') + + def read_config(self): + if not os.path.exists(self.config_file): + print(f"Could not find config file at {self.config_file}") + + config = yaml.load(open(self.config_file), Loader=SafeLoader) + return config + + def get_command_client(self): + client = CommandClient(self.config['host'], self.config['port'], self.config['password']) + + return client + + def get_event_client(self, connect_callback, message_callback): + client = EventClient(self.config['host'], self.config['port'], self.config['password'], connect_callback, + message_callback) + + return client diff --git a/src/reopenwebnet/commandclient.py b/src/reopenwebnet/commandclient.py new file mode 100644 index 0000000..17e4397 --- /dev/null +++ b/src/reopenwebnet/commandclient.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +import asyncio +from logging import getLogger + +from reopenwebnet import messages +from reopenwebnet.protocol import OpenWebNetProtocol + +_LOGGER = getLogger(__name__) + + +class CommandClient: + + def __init__(self, host, port, passwd): + self.host = host + self.port = port + self.password = passwd + + self.shutdown = False + self.transport = None + self.buffer = "" + self.message_buffer = [] + self.connect_timeout = 5 + self.reconnect_interval = 3600 * 24 + self.queue = None + + async def start(self): + asyncio.ensure_future(self.connect_loop()) + + async def connect_loop(self): + while not self.shutdown: + try: + # TODO: instead of fixed reconnect_interval schedule the reconnect at fixed moment of the day + await asyncio.wait_for(self.connect(), self.reconnect_interval) + except asyncio.TimeoutError: + print("Was connected for %s seconds. Will reconnect" % self.reconnect_interval) + + async def connect(self): + try: + if self.transport is not None: + self.transport.close() + self.transport = None + + _LOGGER.debug("Trying to connect...") + loop = asyncio.get_running_loop() + on_con_lost = loop.create_future() + transport, protocol = await asyncio.wait_for(loop.create_connection( + lambda: OpenWebNetProtocol(messages.CMD_SESSION, self.password, self.on_connect, + self.on_event, on_con_lost), + self.host, + self.port), self.connect_timeout) + self.transport = transport + + await on_con_lost + _LOGGER.debug("connection lost. will try again in 3 seconds") + await asyncio.sleep(3) + except KeyboardInterrupt: + _LOGGER.debug("aborted by user") + self.stop() + except (asyncio.TimeoutError, OSError) as e: + _LOGGER.debug("Failed to create connection in time. Will try again in 3 seconds") + await asyncio.sleep(3) + + def on_connect(self): + pass + + def on_event(self, msgs): + asyncio.ensure_future(self.add_to_queue(msgs)) + + async def add_to_queue(self, msgs): + for msg in msgs: + _LOGGER.debug("put message in queue: %s", msg) + await self.queue.put(msg) + + async def send_command(self, message): + while self.transport is None: + _LOGGER.debug("not connected yet. waiting 1 second") + await asyncio.sleep(1) + + self.queue = asyncio.Queue() + _LOGGER.debug("sending message: %s", message) + self.transport.write(str(message).encode('utf-8')) + + response = [] + while messages.ACK not in response and messages.NACK not in response: + _LOGGER.debug("waiting for next message") + msg = await self.queue.get() + _LOGGER.debug("got message from queue: %s", msg) + response.append(msg) + + return response + + def stop(self): + self.shutdown = True + if self.transport is not None: + self.transport.close() diff --git a/src/reopenwebnet/dispatcher.py b/src/reopenwebnet/dispatcher.py deleted file mode 100644 index 1cd028e..0000000 --- a/src/reopenwebnet/dispatcher.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- -import asyncore -import socket -from logging import getLogger - -_LOGGER = getLogger(__name__) - -from reopenwebnet import messages - -class OpenWebNetEventDispatcher(asyncore.dispatcher): - def __init__(self, host, port, event_listener): - asyncore.dispatcher.__init__(self) - self.create_socket(socket.AF_INET, socket.SOCK_STREAM) - self.connect((host, port)) - self.buffer = "" - self.read_buffer = "" - self.event_listener = event_listener - - def handle_connect(self): - _LOGGER.debug("connected", self) - - def handle_close(self): - _LOGGER.debug("closed", self) - - def handle_read(self): - read_result = self.recv(8196).decode() - _LOGGER.debug("<---- %s", read_result) - self.read_buffer += read_result - if self.read_buffer[-2:] == "##": - self.emit_messages() - - def emit_messages(self): - message_list = messages.extract_messages(self.read_buffer) - self.event_listener.handle_messages(message_list) - self.read_buffer = "" - - def writable(self): - return len(self.buffer) > 0 - - def handle_write(self): - sent = self.send(self.buffer.encode()) - _LOGGER.debug("----> %s", self.buffer[sent:]) - self.buffer = self.buffer[sent:] - - def write(self, data): - self.buffer += data - diff --git a/src/reopenwebnet/eventclient.py b/src/reopenwebnet/eventclient.py index 42284ba..3bb7268 100644 --- a/src/reopenwebnet/eventclient.py +++ b/src/reopenwebnet/eventclient.py @@ -1,61 +1,67 @@ # -*- coding: utf-8 -*- -import asyncore +import asyncio import socket +import struct from logging import getLogger -import threading - from reopenwebnet import messages -from reopenwebnet.password import calculate_password -from reopenwebnet.dispatcher import OpenWebNetEventDispatcher +from reopenwebnet.protocol import OpenWebNetProtocol _LOGGER = getLogger(__name__) +def main(): + logging.basicConfig(level=logging.DEBUG) + + class EventClient: - def __init__(self, host, port, password, connect_listener, event_listener): - self.dispatcher = OpenWebNetEventDispatcher(host, int(port), self) - self._password = password - self.event_listener = event_listener + def __init__(self, host, port, password, connect_listener=None, event_listener=None): + self.host = host + self.port = port + self.password = password self.connect_listener = connect_listener - self._thread = threading.Thread(target=asyncore.loop, daemon=True) - self.state = 'INITIAL' - - def start(self): - self._thread.start() - - def handle_messages(self, msgs): - _LOGGER.debug("%s - %s", self.state, msgs) - - if self.state == 'ERROR': - _LOGGER.error("got messages in error state:", msgs) - - elif self.state == 'INITIAL': - if msgs[-1] == messages.ACK: - self.send_data(messages.EVENT_SESSION) - self.state = 'EVENT_SESSION_REQUESTED' - else: - self.state = 'ERROR' - raise asyncore.ExitNow('Server did not send ACK on connect') - - elif self.state == 'EVENT_SESSION_REQUESTED': - if msgs[-1] == messages.NACK: - self.state = 'ERROR' - nonce = messages.extract_single(msgs[0]) - password = calculate_password(self._password, nonce) - self.dispatcher.write(messages.generate_single(password)) - self.state = 'PASSWORD_SENT' - - elif self.state == 'PASSWORD_SENT': - if msgs[-1] == messages.ACK: - self.connect_listener() - self.state = 'EVENT_SESSION_ACTIVE' - else: - raise asyncore.ExitNow('Server did not reply with ACK after sending password') - self.state = 'ERROR' - - elif self.state == 'EVENT_SESSION_ACTIVE': - self.event_listener(msgs) - - def send_data(self, data): - self.dispatcher.write(data) + self.event_listener = event_listener + + self.shutdown = False + self.transport = None + self.connect_timeout = 5 + self.reconnect_interval = 3600 * 24 + + async def start(self): + while not self.shutdown: + try: + # TODO: instead of fixed reconnect_interval schedule the reconnect at fixed moment of the day + await asyncio.wait_for(self.connect(), self.reconnect_interval) + except asyncio.TimeoutError: + print("Was connected for %s seconds. Will reconnect" % self.reconnect_interval) + + async def connect(self): + try: + if self.transport is not None: + self.transport.close() + self.transport = None + + print("Trying to connect...") + loop = asyncio.get_running_loop() + on_con_lost = loop.create_future() + transport, protocol = await asyncio.wait_for(loop.create_connection( + lambda: OpenWebNetProtocol(messages.EVENT_SESSION, self.password, self.connect_listener, + self.event_listener, on_con_lost), + self.host, + self.port), self.connect_timeout) + self.transport = transport + await on_con_lost + print("connection lost. will try again in 3 seconds") + await asyncio.sleep(3) + except KeyboardInterrupt: + print("aborted by user") + self.stop() + except (asyncio.TimeoutError, OSError) as e: + print("Failed to create connection in time. Will try again in 3 seconds") + await asyncio.sleep(3) + + def stop(self): + self.shutdown = True + if self.transport is not None: + self.transport.close() + self.transport = None diff --git a/src/reopenwebnet/gatewayproxy.py b/src/reopenwebnet/gatewayproxy.py new file mode 100644 index 0000000..2b5e0f8 --- /dev/null +++ b/src/reopenwebnet/gatewayproxy.py @@ -0,0 +1,78 @@ +import asyncio +import logging + +from reopenwebnet import messages +from reopenwebnet.client_factory import ClientFactory +from reopenwebnet.messages import TYPE_NORMAL + +LOGGER = logging.getLogger() + + +class GatewayProxy: + def __init__(self, event_listener=None): + self.event_listener = event_listener + + self.states = {} + self.command_client = None + self.event_client = None + + async def start(self): + def on_event_client_connect(): + asyncio.ensure_future(self.on_event_client_connect()) + + def on_event(message): + self._process_messages(message) + + factory = ClientFactory() + self.command_client = factory.get_command_client() + self.event_client = factory.get_event_client(on_event_client_connect, on_event) + + LOGGER.debug("starting command client") + await self.command_client.start() + + LOGGER.debug("starting event client (async, so there is no 'start complete' log message)") + await self.command_client.start() + + await self.fetch_all_light_states() + + async def on_event_client_connect(self): + await self.fetch_all_light_states() + + async def fetch_all_light_states(self): + LOGGER.debug("fetching initial light states") + initial_light_states = await self.send_cmd(messages.StatusRequestMessage('1', '0')) + + self._process_messages(initial_light_states) + + async def send_cmd(self, message): + result = await self.command_client.send_command(message) + return result + + def print_states(self): + for (who, state) in self.states.items(): + print("Who: ", who) + for (k,v) in state.items(): + print(k, " --> ", v) + + print() + + def _process_messages(self, msgs): + if self.event_listener is not None: + self.event_listener(msgs) + for msg in msgs: + if msg.type == TYPE_NORMAL: + self.states.setdefault(msg.who, {})[msg.where] = msg.what + + +async def gatewayproxy_demo(): + gw = GatewayProxy() + + await gw.start() + print("Will print states every 3 seconds. Use Ctrl-C to stop") + while True: + await asyncio.sleep(3) + gw.print_states() + + +if __name__ == "__main__": + asyncio.run(gatewayproxy_demo()) diff --git a/src/reopenwebnet/messages.py b/src/reopenwebnet/messages.py index 2ccd3c7..306cf67 100644 --- a/src/reopenwebnet/messages.py +++ b/src/reopenwebnet/messages.py @@ -1,25 +1,137 @@ -# OK message from bus -ACK = '*#*1##' -# Non OK message from bus -NACK = '*#*0##' -# OpenWeb string for open a command session -CMD_SESSION = '*99*0##' -# OpenWeb string for open an event session -EVENT_SESSION = '*99*1##' +TYPE_OTHER = 'OTHER' +TYPE_ACK = 'ACK' +TYPE_NACK = 'NACK' +TYPE_NORMAL = 'NORMAL' +TYPE_STATUS_REQUEST = 'STATUS_REQUEST' +TYPE_DIMENSION_REQUEST = 'DIMENSION_REQUEST' +TYPE_DIMENSION_WRITING = 'DIMENSION_WRITING' +class FixedMessage: + def __init__(self, value, message_type): + self.value = value + self.type = message_type + + def __str__(self): + return self.value + + def __repr__(self): + return f"{self.type} : {self}" + + +class NormalMessage: + def __init__(self, who, what, where): + self.who = who + self.what = what + self.where = where + self.type = TYPE_NORMAL + + def __str__(self): + return f"*{self.who}*{self.what}*{self.where}##" + + def __repr__(self): + return f"{self.type} : {self}" + + +class StatusRequestMessage: + def __init__(self, who, where): + self.who = who + self.where = where + self.type = TYPE_STATUS_REQUEST + + def __str__(self): + return f"*#{self.who}*{self.where}##" + + def __repr__(self): + return f"{self.type} : {self}" + + +class DimensionRequestMessage: + def __init__(self, who, where, dimension): + self.who = who + self.where = where + self.dimension = dimension + self.type = TYPE_DIMENSION_REQUEST + + def __str__(self): + return f"*#{self.who}*{self.where}*{self.dimension}##" + + def __repr__(self): + return f"{self.type} : {self}" + + +class DimensionWritingMessage: + def __init__(self, who, where, dimension, values): + self.who = who + self.where = where + self.dimension = dimension + self.values = values + self.type = TYPE_DIMENSION_WRITING + + def __str__(self): + values = "*".join(self.values) + return f"*#{self.who}*{self.where}*#{self.dimension}*{values}##" + + def __repr__(self): + return f"{self.type} : {self}" + + +# OpenWeb string for opening a command session +CMD_SESSION = FixedMessage('*99*0##', TYPE_OTHER) +# OpenWeb string for opening an event session +EVENT_SESSION = FixedMessage('*99*1##', TYPE_OTHER) + +ACK = FixedMessage('*#*1##', TYPE_ACK) +NACK = FixedMessage('*#*0##', TYPE_NACK) + + +def bad_message(data): + raise Exception('Improperly formatted message:', data) + + +def parse_message(data): + if data == str(ACK): + return ACK + if data == str(NACK): + return NACK -def extract_messages(data): if not data.startswith("*"): - raise Exception("data does not start with *") + raise Exception(f"data does not start with *: {data}") if not data.endswith("##"): - raise Exception('data does not end with ##') - parts = [part + "##" for part in data.split("##")[:-1]] - return parts + raise Exception(f"data does not end with ##: {data}") + + parts = data[1:-2].split("*") + if not parts[0].startswith("#"): + if len(parts) != 3: + bad_message(data) + return NormalMessage(parts[0], parts[1], parts[2]) + + if len(parts) < 1: + return bad_message(data) + + if len(parts) == 1: + return FixedMessage(data, TYPE_OTHER) + + if len(parts) == 2: + return StatusRequestMessage(parts[0][1:], parts[1]) + + if len(parts) == 3: + return DimensionRequestMessage(parts[0][1:], parts[1], parts[2]) + + if not parts[2].startswith("#"): + bad_message(data) + + return DimensionWritingMessage(parts[0][1:], parts[1], parts[2][1:], parts[3:]) + + +def parse_messages(data): + if "##" not in data: + return [], data + parts = data.split("##") -def extract_single(message): - return message[2:-2] + messages = list(map(lambda part: parse_message(part + '##'), parts[:-1])) + if len(parts[-1]) == 0: + return messages, None -def generate_single(message): - return "*#%s##" % message + return messages, parts[-1] diff --git a/src/reopenwebnet/protocol.py b/src/reopenwebnet/protocol.py new file mode 100644 index 0000000..6b30e29 --- /dev/null +++ b/src/reopenwebnet/protocol.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +import asyncio +import socket +import struct +import time +from logging import getLogger + +from reopenwebnet import messages +from reopenwebnet.password import calculate_password + +_LOGGER = getLogger(__name__) + + +class OpenWebNetProtocol(asyncio.Protocol): + def __init__(self, session_type, password, connect_listener, event_listener, on_con_lost, write_delay=0.1): + self.session_type = session_type + self.on_con_lost = on_con_lost + self.connect_listener = connect_listener + self.event_listener = event_listener + self.password = password + self.write_delay = write_delay + + self.state = 'NOT_CONNECTED' + self.buffer = "" + self.transport = None + self.next_message = 0 + + def connection_made(self, transport): + self.state = 'CONNECTED' + self.transport = transport + sock = transport.get_extra_info('socket') + if sock is not None: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDTIMEO, struct.pack('LL', 0, 10000)) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVTIMEO, struct.pack('LL', 0, 10000)) + + def data_received(self, data): + data = data.decode('utf-8') + _LOGGER.debug("received data %s", data) + self.buffer += data + + msgs, remainder = messages.parse_messages(self.buffer + data) + self.buffer = "" if remainder is None else remainder + + if self.state == 'ERROR': + _LOGGER.error("got data in error state:", data) + + elif self.state == 'CONNECTED': + if msgs[0] == messages.ACK: + self.send_message(self.session_type) + self.state = 'SESSION_REQUESTED' + else: + _LOGGER.error('Did not get initial ack on connect') + self.state = 'ERROR' + + elif self.state == 'SESSION_REQUESTED': + if msgs[-1] == messages.NACK: + self.state = 'ERROR' + nonce = msgs[0].value[2:-2] + + password = calculate_password(self.password, nonce) + self.send_message(messages.FixedMessage(f"*#{password}##", messages.TYPE_OTHER)) + self.state = 'PASSWORD_SENT' + + elif self.state == 'PASSWORD_SENT': + if msgs[-1] == messages.ACK: + self.state = 'EVENT_SESSION_ACTIVE' + if self.connect_listener is not None: + self.connect_listener() + else: + _LOGGER.error('Failed to establish event session') + self.state = 'ERROR' + + elif self.state == 'EVENT_SESSION_ACTIVE': + _LOGGER.debug("sending messages to event listener %s", msgs) + self.event_listener(msgs) + + def send_message(self, message): + now = time.time() + if now < self.next_message: + time.sleep(self.next_message - now) + self.next_message = now + self.write_delay + self.transport.write(str(message).encode('utf-8')) + + def connection_lost(self, exc): + self.state = 'NOT_CONNECTED' + self.transport = None + if not self.on_con_lost.done(): + self.on_con_lost.set_result(True) diff --git a/tests/test_client.py b/tests/test_client.py deleted file mode 100644 index a75922c..0000000 --- a/tests/test_client.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import pytest -from reopenwebnet import CommandClient - -__author__ = "karel1980" -__copyright__ = "karel1980" -__license__ = "mit" - - -def test_CommandClient(): - client = CommandClient('192.168.1.10', 20000, '123456') - assert client._host == '192.168.1.10' - assert client._port == 20000 - assert client._password == '123456' diff --git a/tests/test_messages.py b/tests/test_messages.py index 56fe0fd..e6abf78 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -1,21 +1,60 @@ # -*- coding: utf-8 -*- -from reopenwebnet.messages import extract_messages +from nose.tools import assert_equal from reopenwebnet import messages +from reopenwebnet.messages import parse_message -def test_extract_messages_nack(): - assert extract_messages(messages.NACK) == [messages.NACK] +def test_parse_messages_ack(): + assert_equal( parse_message(str(messages.ACK)), messages.ACK) -def test_extract_messages_nack_nack(): - assert extract_messages(messages.NACK + messages.NACK) == [messages.NACK, messages.NACK] +def test_parse_messages_nack(): + assert_equal( parse_message(str(messages.NACK)), messages.NACK) -def test_extract_messages_ack_ack(): - assert extract_messages(messages.ACK + messages.ACK) == [messages.ACK, messages.ACK] +def test_parse_messages_normal(): + actual = parse_message("*1*2*3##") + assert_equal(actual.type, messages.TYPE_NORMAL) + assert_equal(str(actual), '*1*2*3##') + assert_equal(actual.who, '1') + assert_equal(actual.what, '2') + assert_equal(actual.where, '3') + + +def test_parse_messages_status_request(): + actual = parse_message("*#1*2##") + + assert_equal(actual.type, messages.TYPE_STATUS_REQUEST) + assert_equal(str(actual), '*#1*2##') + assert_equal(actual.who, '1') + assert_equal(actual.where, '2') + + +def test_parse_messages_dimension_request(): + actual = parse_message("*#1*2*3##") + + assert_equal(actual.type, messages.TYPE_DIMENSION_REQUEST) + assert_equal(str(actual), "*#1*2*3##") + assert_equal(actual.who, '1') + assert_equal(actual.where, '2') + assert_equal(actual.dimension, '3') + + +def test_parse_messages_dimension_writing(): + actual = parse_message("*#1*2*#3*4*5##") + + assert_equal(actual.type, messages.TYPE_DIMENSION_WRITING) + assert_equal(str(actual), "*#1*2*#3*4*5##") + assert_equal(actual.who, '1') + assert_equal(actual.where, '2') + assert_equal(actual.dimension, '3') + assert_equal(actual.values, ['4', '5']) + +def test_parse_nonce_message(): + actual = parse_message("*#123456789##") + + assert_equal(actual.type, messages.TYPE_OTHER) + assert_equal(actual.value, "*#123456789##") -def test_extract_value_and_ack(): - value_message = "*1#0#13##" - assert extract_messages(value_message + messages.ACK) == [value_message, messages.ACK] From 14a051992c78caf65aab61f5cafc8a40f71c1963 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Wed, 12 Feb 2020 10:13:01 +0100 Subject: [PATCH 28/45] Indicate python requirement >=3.7 --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index 3d593b4..31b4f6d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,6 +32,7 @@ install_requires = paho-mqtt>=1.5 tests_require = nose; pytest-cov # Require a specific Python version, e.g. Python 2.7 or >= 3.4 # python_requires = >=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.* +python_requires = >=3.7 [options.packages.find] where = src From 9c78a55bd73d0be56952e1e80786b4ab95ae61c1 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Wed, 12 Feb 2020 10:15:59 +0100 Subject: [PATCH 29/45] Updated url --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 31b4f6d..1669519 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,7 +8,7 @@ description = An OpenWebNet client author = karel1980 author-email = karel@vervaeke.info license = mit -url = https://pyscaffold.org/ +url = https://github.com/karel1980/reopenwebnet long-description = file: README.rst # Change if running only on Windows, Mac or Linux (comma-separated) platforms = any From ebe6e4507da036352d116c9847a727d890e79b64 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sat, 15 Feb 2020 11:55:02 +0100 Subject: [PATCH 30/45] Got rid of some duplication + redo configuration mechanism to allow others --- src/reopenwebnet/client_factory.py | 39 ------------------------------ 1 file changed, 39 deletions(-) delete mode 100644 src/reopenwebnet/client_factory.py diff --git a/src/reopenwebnet/client_factory.py b/src/reopenwebnet/client_factory.py deleted file mode 100644 index 3674e32..0000000 --- a/src/reopenwebnet/client_factory.py +++ /dev/null @@ -1,39 +0,0 @@ -import os - -import yaml -from yaml import SafeLoader - -from reopenwebnet.commandclient import CommandClient -from reopenwebnet.eventclient import EventClient - - -class ClientFactory: - def __init__(self): - self.config_file = self.get_config_file() - self.config = self.read_config() - - @staticmethod - def get_config_file(): - env_path = os.environ.get('REOPENWEBNET_CONFIG', None) - if env_path is not None: - return env_path - - return os.path.expanduser('~/.reopenwebnet/config.yaml') - - def read_config(self): - if not os.path.exists(self.config_file): - print(f"Could not find config file at {self.config_file}") - - config = yaml.load(open(self.config_file), Loader=SafeLoader) - return config - - def get_command_client(self): - client = CommandClient(self.config['host'], self.config['port'], self.config['password']) - - return client - - def get_event_client(self, connect_callback, message_callback): - client = EventClient(self.config['host'], self.config['port'], self.config['password'], connect_callback, - message_callback) - - return client From 8a94b4f9cdb04d31045a95b38847bb4278807936 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sat, 15 Feb 2020 11:56:37 +0100 Subject: [PATCH 31/45] Got rid of a bit of duplication + various improvements --- examples/commandclient.py | 12 +++- examples/eventclient.py | 13 ++-- examples/gatewayproxy.py | 15 ++--- setup.cfg | 2 +- src/reopenwebnet/__init__.py | 5 +- src/reopenwebnet/commandclient.py | 100 +++++++++--------------------- src/reopenwebnet/config.py | 19 ++++++ src/reopenwebnet/eventclient.py | 62 +++--------------- src/reopenwebnet/gatewayproxy.py | 91 ++++++++++++++------------- src/reopenwebnet/messages.py | 1 + src/reopenwebnet/protocol.py | 63 ++++++++++++++++++- 11 files changed, 192 insertions(+), 191 deletions(-) create mode 100644 src/reopenwebnet/config.py diff --git a/examples/commandclient.py b/examples/commandclient.py index 2ae5e39..946c0f9 100644 --- a/examples/commandclient.py +++ b/examples/commandclient.py @@ -1,19 +1,25 @@ import asyncio from time import sleep -from reopenwebnet.client_factory import ClientFactory from reopenwebnet import messages import logging +from reopenwebnet.commandclient import CommandClient +from reopenwebnet.config import read_environment_config + KITCHEN_LIGHT = '13' logging.basicConfig(level=logging.INFO) async def commandclient_demo(): - client = ClientFactory().get_command_client() - await client.start() + def on_connect(): + print("command session started") + client = CommandClient(read_environment_config(), on_connect) + + asyncio.ensure_future(client.start()) + await example_single_light_status(client) await example_all_lights_status_request(client) await example_light_commands(client) diff --git a/examples/eventclient.py b/examples/eventclient.py index 8caa679..7a7610e 100644 --- a/examples/eventclient.py +++ b/examples/eventclient.py @@ -1,18 +1,19 @@ import asyncio import logging -from reopenwebnet.client_factory import ClientFactory +from reopenwebnet.config import read_environment_config +from reopenwebnet.eventclient import EventClient async def eventclient_demo(): - def handle_connect(): - print("connected to openwebnet service") + def on_session_start(): + print("event session started") - def handle_messages(msgs): + def on_event(msgs): print("received messages: ", msgs) - client = ClientFactory().get_event_client(handle_connect, handle_messages) - await client.start() + client = EventClient(read_environment_config(), on_session_start, on_event) + asyncio.ensure_future(client.start()) print("Will listen for events for 1 hour. Hit ctrl-c to stop") await asyncio.sleep(3600) diff --git a/examples/gatewayproxy.py b/examples/gatewayproxy.py index 2160669..e79ed58 100644 --- a/examples/gatewayproxy.py +++ b/examples/gatewayproxy.py @@ -1,17 +1,18 @@ import asyncio -from time import sleep +import logging +from reopenwebnet.config import read_environment_config from reopenwebnet.gatewayproxy import GatewayProxy -from reopenwebnet import messages - -import logging KITCHEN_LIGHT = '13' async def gatewayproxy_demo(): - gw = GatewayProxy() - await gw.start() + def on_state_change(msg): + print("State change", msg) + + gw = GatewayProxy(read_environment_config(), on_state_change) + gw.start() await example_print_status(gw) @@ -22,7 +23,7 @@ async def example_print_status(gateway): "is restored") while True: - gateway.print_states() + print(gateway.states) await asyncio.sleep(2) diff --git a/setup.cfg b/setup.cfg index 1669519..e169954 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,7 +27,7 @@ package_dir = # DON'T CHANGE THE FOLLOWING LINE! IT WILL BE UPDATED BY PYSCAFFOLD! setup_requires = pyscaffold>=3.1a0,<3.2a0 # Add here dependencies of your project (semicolon/line-separated), e.g. -install_requires = paho-mqtt>=1.5 +install_requires = pyyaml>=5.3;paho-mqtt>=1.5 # The usage of test_requires is discouraged, see `Dependency Management` docs tests_require = nose; pytest-cov # Require a specific Python version, e.g. Python 2.7 or >= 3.4 diff --git a/src/reopenwebnet/__init__.py b/src/reopenwebnet/__init__.py index fb9f0fb..99f0c36 100644 --- a/src/reopenwebnet/__init__.py +++ b/src/reopenwebnet/__init__.py @@ -1,13 +1,12 @@ # -*- coding: utf-8 -*- from pkg_resources import get_distribution, DistributionNotFound -from reopenwebnet import messages -from reopenwebnet import client_factory from reopenwebnet import commandclient from reopenwebnet import eventclient from reopenwebnet import gatewayproxy +from reopenwebnet import messages -__all__=[messages, client_factory, commandclient, eventclient, gatewayproxy] +__all__=[messages, commandclient, eventclient, gatewayproxy] try: diff --git a/src/reopenwebnet/commandclient.py b/src/reopenwebnet/commandclient.py index 17e4397..c864bdb 100644 --- a/src/reopenwebnet/commandclient.py +++ b/src/reopenwebnet/commandclient.py @@ -3,93 +3,53 @@ from logging import getLogger from reopenwebnet import messages -from reopenwebnet.protocol import OpenWebNetProtocol +from reopenwebnet.protocol import OpenWebNetClient _LOGGER = getLogger(__name__) class CommandClient: + def __init__(self, config, on_connect): + self.on_connect = on_connect - def __init__(self, host, port, passwd): - self.host = host - self.port = port - self.password = passwd + def on_session_started(): + if self.on_connect is not None: + self.on_connect() - self.shutdown = False - self.transport = None - self.buffer = "" - self.message_buffer = [] - self.connect_timeout = 5 - self.reconnect_interval = 3600 * 24 - self.queue = None + self.client = OpenWebNetClient(config, messages.CMD_SESSION, on_session_started, + lambda msgs: self.on_messages_received(msgs)) + + self.read_queue = None + self.lock = asyncio.Lock() async def start(self): - asyncio.ensure_future(self.connect_loop()) - - async def connect_loop(self): - while not self.shutdown: - try: - # TODO: instead of fixed reconnect_interval schedule the reconnect at fixed moment of the day - await asyncio.wait_for(self.connect(), self.reconnect_interval) - except asyncio.TimeoutError: - print("Was connected for %s seconds. Will reconnect" % self.reconnect_interval) - - async def connect(self): - try: - if self.transport is not None: - self.transport.close() - self.transport = None - - _LOGGER.debug("Trying to connect...") - loop = asyncio.get_running_loop() - on_con_lost = loop.create_future() - transport, protocol = await asyncio.wait_for(loop.create_connection( - lambda: OpenWebNetProtocol(messages.CMD_SESSION, self.password, self.on_connect, - self.on_event, on_con_lost), - self.host, - self.port), self.connect_timeout) - self.transport = transport - - await on_con_lost - _LOGGER.debug("connection lost. will try again in 3 seconds") - await asyncio.sleep(3) - except KeyboardInterrupt: - _LOGGER.debug("aborted by user") - self.stop() - except (asyncio.TimeoutError, OSError) as e: - _LOGGER.debug("Failed to create connection in time. Will try again in 3 seconds") - await asyncio.sleep(3) - - def on_connect(self): - pass - - def on_event(self, msgs): + await self.client.start() + + def on_messages_received(self, msgs): asyncio.ensure_future(self.add_to_queue(msgs)) async def add_to_queue(self, msgs): for msg in msgs: - _LOGGER.debug("put message in queue: %s", msg) - await self.queue.put(msg) + await self.read_queue.put(msg) async def send_command(self, message): - while self.transport is None: - _LOGGER.debug("not connected yet. waiting 1 second") - await asyncio.sleep(1) + async with self.lock: + while self.client.transport is None: + _LOGGER.debug("not connected yet. waiting 1 second") + await asyncio.sleep(1) + + self.read_queue = asyncio.Queue() - self.queue = asyncio.Queue() - _LOGGER.debug("sending message: %s", message) - self.transport.write(str(message).encode('utf-8')) + self.client.transport.write(str(message).encode('utf-8')) - response = [] - while messages.ACK not in response and messages.NACK not in response: - _LOGGER.debug("waiting for next message") - msg = await self.queue.get() - _LOGGER.debug("got message from queue: %s", msg) - response.append(msg) + response = [] + while messages.ACK not in response and messages.NACK not in response: + _LOGGER.debug("waiting for next message") + msg = await self.read_queue.get() + _LOGGER.debug("got message from queue: %s", msg) + response.append(msg) - return response + return response def stop(self): - self.shutdown = True - if self.transport is not None: - self.transport.close() + self.client.stop() diff --git a/src/reopenwebnet/config.py b/src/reopenwebnet/config.py new file mode 100644 index 0000000..eb063f0 --- /dev/null +++ b/src/reopenwebnet/config.py @@ -0,0 +1,19 @@ +import os + +import yaml +from yaml import SafeLoader + + +class Config: + def __init__(self, host, port, password): + self.host = host + self.port = port + self.password = password + + +def read_environment_config(): + default_config_path = os.path.expanduser('~/.reopenwebnet/config.yaml') + config_path = os.environ.get('REOPENWEBNET_CONFIG', default_config_path) + + yml_config = yaml.load(open(config_path), Loader=SafeLoader) + return Config(yml_config['host'], yml_config['port'], yml_config.get('password', None)) diff --git a/src/reopenwebnet/eventclient.py b/src/reopenwebnet/eventclient.py index 3bb7268..79b155f 100644 --- a/src/reopenwebnet/eventclient.py +++ b/src/reopenwebnet/eventclient.py @@ -1,13 +1,10 @@ # -*- coding: utf-8 -*- -import asyncio -import socket -import struct -from logging import getLogger +import logging from reopenwebnet import messages -from reopenwebnet.protocol import OpenWebNetProtocol +from reopenwebnet.protocol import OpenWebNetClient -_LOGGER = getLogger(__name__) +_LOGGER = logging.getLogger(__name__) def main(): @@ -15,53 +12,14 @@ def main(): class EventClient: - def __init__(self, host, port, password, connect_listener=None, event_listener=None): - self.host = host - self.port = port - self.password = password - self.connect_listener = connect_listener - self.event_listener = event_listener - - self.shutdown = False - self.transport = None - self.connect_timeout = 5 - self.reconnect_interval = 3600 * 24 + def __init__(self, config, on_session_start=None, event_listener=None): + def on_event(msgs): + if event_listener is not None: + event_listener(msgs) + self.client = OpenWebNetClient(config, messages.EVENT_SESSION, on_session_start, on_event) async def start(self): - while not self.shutdown: - try: - # TODO: instead of fixed reconnect_interval schedule the reconnect at fixed moment of the day - await asyncio.wait_for(self.connect(), self.reconnect_interval) - except asyncio.TimeoutError: - print("Was connected for %s seconds. Will reconnect" % self.reconnect_interval) - - async def connect(self): - try: - if self.transport is not None: - self.transport.close() - self.transport = None - - print("Trying to connect...") - loop = asyncio.get_running_loop() - on_con_lost = loop.create_future() - transport, protocol = await asyncio.wait_for(loop.create_connection( - lambda: OpenWebNetProtocol(messages.EVENT_SESSION, self.password, self.connect_listener, - self.event_listener, on_con_lost), - self.host, - self.port), self.connect_timeout) - self.transport = transport - await on_con_lost - print("connection lost. will try again in 3 seconds") - await asyncio.sleep(3) - except KeyboardInterrupt: - print("aborted by user") - self.stop() - except (asyncio.TimeoutError, OSError) as e: - print("Failed to create connection in time. Will try again in 3 seconds") - await asyncio.sleep(3) + await self.client.start() def stop(self): - self.shutdown = True - if self.transport is not None: - self.transport.close() - self.transport = None + self.client.stop() \ No newline at end of file diff --git a/src/reopenwebnet/gatewayproxy.py b/src/reopenwebnet/gatewayproxy.py index 2b5e0f8..bc662f4 100644 --- a/src/reopenwebnet/gatewayproxy.py +++ b/src/reopenwebnet/gatewayproxy.py @@ -2,43 +2,58 @@ import logging from reopenwebnet import messages -from reopenwebnet.client_factory import ClientFactory +from reopenwebnet.commandclient import CommandClient +from reopenwebnet.eventclient import EventClient from reopenwebnet.messages import TYPE_NORMAL LOGGER = logging.getLogger() class GatewayProxy: - def __init__(self, event_listener=None): - self.event_listener = event_listener + def __init__(self, config, on_state_change=None): + self.on_state_change = on_state_change + + def on_command_session(): + self.on_command_session() + + def on_event_session(): + self.on_event_session() + + def on_event(msgs): + self.on_event(msgs) + + self.command_client = CommandClient(config, on_command_session) + self.event_client = EventClient(config, on_event_session, on_event) self.states = {} - self.command_client = None - self.event_client = None - async def start(self): - def on_event_client_connect(): - asyncio.ensure_future(self.on_event_client_connect()) + self.listeners = {} + + def start(self): + asyncio.ensure_future(self.command_client.start()) + asyncio.ensure_future(self.event_client.start()) - def on_event(message): - self._process_messages(message) + def register_listener(self, who, where, callback): + self.listeners.setdefault(who, {})[where] = callback + state = self.states.setdefault(who, {}).get(where, None) - factory = ClientFactory() - self.command_client = factory.get_command_client() - self.event_client = factory.get_event_client(on_event_client_connect, on_event) + # send initial state + if state is not None: + callback(state) - LOGGER.debug("starting command client") - await self.command_client.start() + async def cmd(self, msg): + return await self.command_client.send_command(msg) - LOGGER.debug("starting event client (async, so there is no 'start complete' log message)") - await self.command_client.start() + def on_command_session(self): + asyncio.ensure_future(self.fetch_full_state()) - await self.fetch_all_light_states() + def on_event_session(self): + asyncio.ensure_future(self.fetch_full_state()) - async def on_event_client_connect(self): - await self.fetch_all_light_states() + def on_event(self, msgs): + self._process_messages(msgs) - async def fetch_all_light_states(self): + async def fetch_full_state(self): LOGGER.debug("fetching initial light states") initial_light_states = await self.send_cmd(messages.StatusRequestMessage('1', '0')) @@ -48,31 +63,15 @@ async def send_cmd(self, message): result = await self.command_client.send_command(message) return result - def print_states(self): - for (who, state) in self.states.items(): - print("Who: ", who) - for (k,v) in state.items(): - print(k, " --> ", v) - - print() - def _process_messages(self, msgs): - if self.event_listener is not None: - self.event_listener(msgs) for msg in msgs: if msg.type == TYPE_NORMAL: - self.states.setdefault(msg.who, {})[msg.where] = msg.what - - -async def gatewayproxy_demo(): - gw = GatewayProxy() - - await gw.start() - print("Will print states every 3 seconds. Use Ctrl-C to stop") - while True: - await asyncio.sleep(3) - gw.print_states() - - -if __name__ == "__main__": - asyncio.run(gatewayproxy_demo()) + item = self.states.setdefault(msg.who, {}) + current_value = item.get(msg.where, None) + if current_value is None or current_value.what != msg.what: + if self.on_state_change is not None: + self.on_state_change(msg) + listener = self.listeners.get(msg.who, {}).get(msg.where, None) + if listener is not None: + listener(msg) + self.states.setdefault(msg.who, {})[msg.where] = msg diff --git a/src/reopenwebnet/messages.py b/src/reopenwebnet/messages.py index 306cf67..d345146 100644 --- a/src/reopenwebnet/messages.py +++ b/src/reopenwebnet/messages.py @@ -6,6 +6,7 @@ TYPE_DIMENSION_REQUEST = 'DIMENSION_REQUEST' TYPE_DIMENSION_WRITING = 'DIMENSION_WRITING' + class FixedMessage: def __init__(self, value, message_type): self.value = value diff --git a/src/reopenwebnet/protocol.py b/src/reopenwebnet/protocol.py index 6b30e29..77b7d5c 100644 --- a/src/reopenwebnet/protocol.py +++ b/src/reopenwebnet/protocol.py @@ -11,11 +11,68 @@ _LOGGER = getLogger(__name__) +class OpenWebNetClient: + def __init__(self, config, session_type, on_connect, on_event): + self.config = config + self.session_type = session_type + self.on_connect = on_connect + self.on_event = on_event + + self.reconnect_interval = 3600 * 24 + self.connect_timeout = 3 + + self.shutdown = False + self.transport = None + + async def start(self): + await self.connect_loop() + + async def connect_loop(self): + while not self.shutdown: + try: + # TODO: instead of fixed reconnect_interval schedule the reconnect at fixed moment of the day + await asyncio.wait_for(self.connect(), self.reconnect_interval) + except asyncio.TimeoutError: + print("Was connected for %s seconds. Will reconnect" % self.reconnect_interval) + + async def connect(self): + try: + if self.transport is not None: + self.transport.close() + self.transport = None + + _LOGGER.debug("Trying to connect...") + loop = asyncio.get_running_loop() + on_con_lost = loop.create_future() + transport, protocol = await asyncio.wait_for(loop.create_connection( + lambda: OpenWebNetProtocol(self.session_type, self.config.password, self.on_connect, + self.on_event, on_con_lost), + self.config.host, + self.config.port), self.connect_timeout) + self.transport = transport + + await on_con_lost + _LOGGER.debug("connection lost. will try again in 3 seconds") + await asyncio.sleep(3) + except KeyboardInterrupt: + _LOGGER.debug("aborted by user") + self.stop() + + except (asyncio.TimeoutError, OSError) as e: + _LOGGER.debug("Failed to create connection in time. Will try again in 3 seconds") + await asyncio.sleep(3) + + def stop(self): + self.shutdown = True + if self.transport is not None: + self.transport.close() + + class OpenWebNetProtocol(asyncio.Protocol): def __init__(self, session_type, password, connect_listener, event_listener, on_con_lost, write_delay=0.1): self.session_type = session_type self.on_con_lost = on_con_lost - self.connect_listener = connect_listener + self.on_session_started = connect_listener self.event_listener = event_listener self.password = password self.write_delay = write_delay @@ -64,8 +121,8 @@ def data_received(self, data): elif self.state == 'PASSWORD_SENT': if msgs[-1] == messages.ACK: self.state = 'EVENT_SESSION_ACTIVE' - if self.connect_listener is not None: - self.connect_listener() + if self.on_session_started is not None: + self.on_session_started() else: _LOGGER.error('Failed to establish event session') self.state = 'ERROR' From 5aa365bc2301e53f7c127f8b8672633a1df7bd14 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Mon, 5 Apr 2021 18:09:50 +0200 Subject: [PATCH 32/45] Added mqtt bridge; command session response does seem to send back an 'ACK' to every command, so removed response handling from command client --- .gitignore | 1 + README.md | 16 ++++--- bin/openwebnet-mqtt-bridge | 14 +++++++ src/reopenwebnet/__init__.py | 3 +- src/reopenwebnet/commandclient.py | 32 +++++++------- src/reopenwebnet/config.py | 21 +++++++--- src/reopenwebnet/eventclient.py | 3 +- src/reopenwebnet/gatewayproxy.py | 3 +- src/reopenwebnet/messages.py | 19 ++++++++- src/reopenwebnet/mqtt.py | 70 +++++++++++++++++++++++++++++++ tests/test_messages.py | 12 ++++++ 11 files changed, 164 insertions(+), 30 deletions(-) create mode 100755 bin/openwebnet-mqtt-bridge create mode 100644 src/reopenwebnet/mqtt.py diff --git a/.gitignore b/.gitignore index 4f0059b..53bfc8b 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ MANIFEST # Per-project virtualenvs .venv*/ +venv/ diff --git a/README.md b/README.md index 84ca996..1057d8f 100644 --- a/README.md +++ b/README.md @@ -10,21 +10,27 @@ This project started as a fork from https://github.com/pippocla/openwebnet Asynchronous components for interacting with the gateway. -The author's OpenWebNet gateway doesn't respond well to sending commands +### Python component -Low level components: +### MQTT Bridge + +See bin/openwebnet-mqtt-bridge +This bridge communicates with an openwebnet service over http and and mqtt service. +This should make it easier to interact with openwebnet in various tools (OpenHAB, Homeassistant, Node-Red) + +#### Low level components: - CommandClient: establishes command sessions and lets you send commands and read the gateway's responses. The CommandClient does simple rate limiting because the OpenWebNet gateway seems to misbehave if you send too many commands in a short time. - EventClient: establishes event sessions. -Both components automatically reconnect in case of connection loss. +Both components should automatically reconnect in case of connection loss. -Higher level components: +### Higher level components: - ClientFactory: reads configuration from file and can create CommandClient and EventClient instances - GatewayProxy: Creates a CommandClient and an EventClient and keeps state in sync. ## Examples -See examples/ for examples on the various components +See examples/ for programming examples for the various components ## Testing diff --git a/bin/openwebnet-mqtt-bridge b/bin/openwebnet-mqtt-bridge new file mode 100755 index 0000000..20888b9 --- /dev/null +++ b/bin/openwebnet-mqtt-bridge @@ -0,0 +1,14 @@ +#!/usr/bin/env python +import asyncio + +from reopenwebnet.config import read_environment_config +from reopenwebnet.mqtt import MqttBridge + + +async def main(): + bridge = MqttBridge(read_environment_config()) + print("starting bridge") + await bridge.start() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/reopenwebnet/__init__.py b/src/reopenwebnet/__init__.py index 99f0c36..7b84400 100644 --- a/src/reopenwebnet/__init__.py +++ b/src/reopenwebnet/__init__.py @@ -5,8 +5,9 @@ from reopenwebnet import eventclient from reopenwebnet import gatewayproxy from reopenwebnet import messages +from reopenwebnet import mqtt -__all__=[messages, commandclient, eventclient, gatewayproxy] +__all__=[messages, commandclient, eventclient, gatewayproxy, mqtt] try: diff --git a/src/reopenwebnet/commandclient.py b/src/reopenwebnet/commandclient.py index c864bdb..c3a8648 100644 --- a/src/reopenwebnet/commandclient.py +++ b/src/reopenwebnet/commandclient.py @@ -1,13 +1,13 @@ # -*- coding: utf-8 -*- import asyncio from logging import getLogger +import logging from reopenwebnet import messages from reopenwebnet.protocol import OpenWebNetClient _LOGGER = getLogger(__name__) - class CommandClient: def __init__(self, config, on_connect): self.on_connect = on_connect @@ -23,14 +23,17 @@ def on_session_started(): self.lock = asyncio.Lock() async def start(self): + logging.debug('command client starting') await self.client.start() def on_messages_received(self, msgs): - asyncio.ensure_future(self.add_to_queue(msgs)) + # asyncio.ensure_future(self.add_to_queue(msgs)) + pass - async def add_to_queue(self, msgs): - for msg in msgs: - await self.read_queue.put(msg) + # async def add_to_queue(self, msgs): + # for msg in msgs: + # _LOGGER.debug('Adding to read queue: %s', msg) + # await self.read_queue.put(msg) async def send_command(self, message): async with self.lock: @@ -38,18 +41,17 @@ async def send_command(self, message): _LOGGER.debug("not connected yet. waiting 1 second") await asyncio.sleep(1) - self.read_queue = asyncio.Queue() - self.client.transport.write(str(message).encode('utf-8')) - response = [] - while messages.ACK not in response and messages.NACK not in response: - _LOGGER.debug("waiting for next message") - msg = await self.read_queue.get() - _LOGGER.debug("got message from queue: %s", msg) - response.append(msg) - - return response + # self.read_queue = asyncio.Queue() + # response = [] + # while messages.ACK not in response and messages.NACK not in response: + # _LOGGER.debug("waiting for next message") + # msg = await self.read_queue.get() + # _LOGGER.debug("got message from queue: %s", msg) + # response.append(msg) + # print("RETURNING", response) + return None def stop(self): self.client.stop() diff --git a/src/reopenwebnet/config.py b/src/reopenwebnet/config.py index eb063f0..3572f59 100644 --- a/src/reopenwebnet/config.py +++ b/src/reopenwebnet/config.py @@ -5,10 +5,21 @@ class Config: - def __init__(self, host, port, password): - self.host = host - self.port = port - self.password = password + def __init__(self, config_dict): + self.host = config_dict['host'] + self.port = config_dict['port'] + self.password = config_dict['password'] + + self.mqtt = MqttConfig(config_dict['mqtt']) + + +class MqttConfig: + def __init__(self, config_dict): + self.host = config_dict['host'] + self.port = config_dict['port'] + self.user = config_dict.get('user') + self.password = config_dict.get('password') + self.client_id = config_dict.get('client_id') def read_environment_config(): @@ -16,4 +27,4 @@ def read_environment_config(): config_path = os.environ.get('REOPENWEBNET_CONFIG', default_config_path) yml_config = yaml.load(open(config_path), Loader=SafeLoader) - return Config(yml_config['host'], yml_config['port'], yml_config.get('password', None)) + return Config(yml_config) diff --git a/src/reopenwebnet/eventclient.py b/src/reopenwebnet/eventclient.py index 79b155f..f6bd4dd 100644 --- a/src/reopenwebnet/eventclient.py +++ b/src/reopenwebnet/eventclient.py @@ -19,7 +19,8 @@ def on_event(msgs): self.client = OpenWebNetClient(config, messages.EVENT_SESSION, on_session_start, on_event) async def start(self): + logging.debug('eventclient starting') await self.client.start() def stop(self): - self.client.stop() \ No newline at end of file + self.client.stop() diff --git a/src/reopenwebnet/gatewayproxy.py b/src/reopenwebnet/gatewayproxy.py index bc662f4..0d92f87 100644 --- a/src/reopenwebnet/gatewayproxy.py +++ b/src/reopenwebnet/gatewayproxy.py @@ -4,7 +4,6 @@ from reopenwebnet import messages from reopenwebnet.commandclient import CommandClient from reopenwebnet.eventclient import EventClient -from reopenwebnet.messages import TYPE_NORMAL LOGGER = logging.getLogger() @@ -65,7 +64,7 @@ async def send_cmd(self, message): def _process_messages(self, msgs): for msg in msgs: - if msg.type == TYPE_NORMAL: + if msg.__class__ == messages.CommandOrStatusMessage: item = self.states.setdefault(msg.who, {}) current_value = item.get(msg.where, None) if current_value is None or current_value.what != msg.what: diff --git a/src/reopenwebnet/messages.py b/src/reopenwebnet/messages.py index d345146..fc38b79 100644 --- a/src/reopenwebnet/messages.py +++ b/src/reopenwebnet/messages.py @@ -4,6 +4,7 @@ TYPE_NORMAL = 'NORMAL' TYPE_STATUS_REQUEST = 'STATUS_REQUEST' TYPE_DIMENSION_REQUEST = 'DIMENSION_REQUEST' +TYPE_DIMENSION_READING = 'DIMENSION_READING' TYPE_DIMENSION_WRITING = 'DIMENSION_WRITING' @@ -60,6 +61,22 @@ def __repr__(self): return f"{self.type} : {self}" +class DimensionReadingMessage: + def __init__(self, who, where, dimension, values): + self.who = who + self.where = where + self.dimension = dimension + self.values = values + self.type = TYPE_DIMENSION_READING + + def __str__(self): + values = "*".join(self.values) + return f"*#{self.who}*{self.where}*{self.dimension}*{values}##" + + def __repr__(self): + return f"{self.type} : {self}" + + class DimensionWritingMessage: def __init__(self, who, where, dimension, values): self.who = who @@ -119,7 +136,7 @@ def parse_message(data): return DimensionRequestMessage(parts[0][1:], parts[1], parts[2]) if not parts[2].startswith("#"): - bad_message(data) + return DimensionReadingMessage(parts[0][1:], parts[1], parts[2], parts[3:]) return DimensionWritingMessage(parts[0][1:], parts[1], parts[2][1:], parts[3:]) diff --git a/src/reopenwebnet/mqtt.py b/src/reopenwebnet/mqtt.py new file mode 100644 index 0000000..946eaec --- /dev/null +++ b/src/reopenwebnet/mqtt.py @@ -0,0 +1,70 @@ +import asyncio +import logging +import re + +import paho.mqtt.client as mqtt + +from reopenwebnet import messages +from reopenwebnet.commandclient import CommandClient +from reopenwebnet.eventclient import EventClient + +logging.basicConfig(level=logging.DEBUG) + +MQTT_LIGHT_COMMAND_PATTERN = re.compile('/openwebnet/1/(\\d+)/cmd') + + +class MqttBridge: + def __init__(self, config): + if config.mqtt is None: + raise Exception('mqtt configuration required') + + def on_command_session(): + logging.debug('openwebnet command session started') + + def on_event_session(): + logging.debug('openwebnet event session started') + + def on_event(msgs): + logging.debug('openwebnet messages received %s', msgs) + for msg in msgs: + # TODO: handle other 'who' types, allow registering transformations (to allow configuring different topic and payload) + if isinstance(msg, messages.NormalMessage): + if msg.who == '1': + self.mqtt.publish(f"/openwebnet/{msg.who}/{msg.where}/state", msg.what) + + self.command_client = CommandClient(config, on_command_session) + self.event_client = EventClient(config, on_event_session, on_event) + + self.queue = asyncio.Queue() + + def on_mqtt_message(client, dummy, message): + logging.debug('received mqtt message: %s / %s', message.topic, message.payload) + match = MQTT_LIGHT_COMMAND_PATTERN.match(message.topic) + if match is not None: + payload = message.payload.decode('ASCII') + + async def send(): + await self.command_client.send_command(messages.NormalMessage(1, payload, match.group(1))) + + asyncio.run(send()) + + self.mqtt = _create_mqtt_client(config.mqtt) + self.mqtt.on_message = on_mqtt_message + + async def start(self): + logging.debug('starting mqtt bridge') + self.mqtt.loop_start() + await asyncio.wait([self.command_client.start(), self.event_client.start()]) + +def _create_mqtt_client(mqtt_config): + client = mqtt.Client(mqtt_config.client_id) + if mqtt_config.user is not None: + client.username_pw_set(mqtt_config.user, mqtt_config.password) + + def on_connect(client, b, c, d): + logging.debug('mqtt connected %s/%s/%s/%s', client, b, c, d) + client.subscribe('/openwebnet/1/+/cmd') + + client.on_connect = on_connect + client.connect(mqtt_config.host, port=mqtt_config.port) + return client diff --git a/tests/test_messages.py b/tests/test_messages.py index e6abf78..56d2b3f 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -42,6 +42,17 @@ def test_parse_messages_dimension_request(): assert_equal(actual.dimension, '3') +def test_parse_messages_dimension_reading(): + actual = parse_message("*#1*02*4*100*2##") + + assert_equal(actual.type, messages.TYPE_DIMENSION_READING) + assert_equal(str(actual), "*#1*02*4*100*2##") + assert_equal(actual.who, '1') + assert_equal(actual.where, '02') + assert_equal(actual.dimension, '4') + assert_equal(actual.values, ['100', '2']) + + def test_parse_messages_dimension_writing(): actual = parse_message("*#1*2*#3*4*5##") @@ -58,3 +69,4 @@ def test_parse_nonce_message(): assert_equal(actual.type, messages.TYPE_OTHER) assert_equal(actual.value, "*#123456789##") + From 326c50789a4f6da7428c54dcb1a7bac673e97710 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sun, 19 Sep 2021 11:45:40 +0200 Subject: [PATCH 33/45] Things got too complated. Removing a ton of crap. event_session.py example works. Will gradually add back commands & mqtt support --- examples/commandclient.py | 69 --------------------------- examples/event_session.py | 53 +++++++++++++++++++++ examples/eventclient.py | 25 ---------- examples/gatewayproxy.py | 33 ------------- src/reopenwebnet/__init__.py | 9 +--- src/reopenwebnet/commandclient.py | 57 ---------------------- src/reopenwebnet/config.py | 4 +- src/reopenwebnet/eventclient.py | 26 ---------- src/reopenwebnet/gatewayproxy.py | 76 ----------------------------- src/reopenwebnet/protocol.py | 79 +++---------------------------- 10 files changed, 64 insertions(+), 367 deletions(-) delete mode 100644 examples/commandclient.py create mode 100644 examples/event_session.py delete mode 100644 examples/eventclient.py delete mode 100644 examples/gatewayproxy.py delete mode 100644 src/reopenwebnet/commandclient.py delete mode 100644 src/reopenwebnet/eventclient.py delete mode 100644 src/reopenwebnet/gatewayproxy.py diff --git a/examples/commandclient.py b/examples/commandclient.py deleted file mode 100644 index 946c0f9..0000000 --- a/examples/commandclient.py +++ /dev/null @@ -1,69 +0,0 @@ -import asyncio -from time import sleep - -from reopenwebnet import messages - -import logging - -from reopenwebnet.commandclient import CommandClient -from reopenwebnet.config import read_environment_config - -KITCHEN_LIGHT = '13' - -logging.basicConfig(level=logging.INFO) - - -async def commandclient_demo(): - def on_connect(): - print("command session started") - client = CommandClient(read_environment_config(), on_connect) - - asyncio.ensure_future(client.start()) - - await example_single_light_status(client) - await example_all_lights_status_request(client) - await example_light_commands(client) - - -async def example_light_commands(client): - print("2 x turn light on & turn it off again") - for i in range(2): - print("on") - print(await client.send_command(messages.NormalMessage('1', '1', KITCHEN_LIGHT))) - sleep(1) - print("off") - print(await client.send_command(messages.NormalMessage('1', '0', KITCHEN_LIGHT))) - sleep(1) - - print("Requesting light status") - print(await client.send_command(messages.StatusRequestMessage('1', KITCHEN_LIGHT))) - sleep(1) - - -async def example_all_lights_status_request(client): - print("Request all lights status") - print(await client.send_command(messages.StatusRequestMessage('1', '0'))) - sleep(1) - - -async def example_single_light_status(client): - print("3 x Request light status with 3 second intervals") - for i in range(3): - print(await client.send_command(messages.StatusRequestMessage('1', KITCHEN_LIGHT))) - await asyncio.sleep(3) - - -def turn_on_shutter(client, where): - print("Sending shutter on command") - client.shutter_on(where) - - -def turn_off_shutter(client, where): - print("Sending shutter off command") - client.shutter_off(where) - - -if __name__ == '__main__': - logging.basicConfig(level=logging.DEBUG) - - asyncio.run(commandclient_demo()) diff --git a/examples/event_session.py b/examples/event_session.py new file mode 100644 index 0000000..b0ef3e4 --- /dev/null +++ b/examples/event_session.py @@ -0,0 +1,53 @@ +import asyncio +import socket +from asyncio import FIRST_COMPLETED + +from reopenwebnet import messages +from reopenwebnet.protocol import OpenWebNetProtocol + +# logging.basicConfig(level=logging.DEBUG) + +async def schedule_stop(delay, on_stop): + await asyncio.sleep(delay) + on_stop.set_result(True) + + +async def main(): + loop = asyncio.get_running_loop() + + # Start openwebnet protocol + def on_event(*args): + print("got event", args) + + transport, protocol, on_con_lost = await start_openwebnet(loop, on_event) + + # Schedule stop + on_stop = await schedule_stop() + + # Wait until scheduled stop or connection loss + done, pending = await asyncio.wait([on_con_lost, on_stop], return_when=FIRST_COMPLETED) + if on_con_lost in done: + print("Connection lost") + if on_stop in done: + print("Scheduled stop") + + +async def schedule_stop(): + delay = 10 + print("Listening for openwebnet events for %d seconds. Try switching a light on and off" % delay) + on_stop = asyncio.ensure_future(asyncio.sleep(delay)) + return on_stop + + +async def start_openwebnet(loop, on_event): + mysock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + mysock.connect(('192.168.0.10', 20000)) + on_con_lost = loop.create_future() + + transport, protocol = await loop.create_connection( + lambda: OpenWebNetProtocol(messages.EVENT_SESSION, '951753', on_event, on_con_lost), + sock=mysock) + return transport, protocol, on_con_lost + + +asyncio.run(main()) diff --git a/examples/eventclient.py b/examples/eventclient.py deleted file mode 100644 index 7a7610e..0000000 --- a/examples/eventclient.py +++ /dev/null @@ -1,25 +0,0 @@ -import asyncio -import logging - -from reopenwebnet.config import read_environment_config -from reopenwebnet.eventclient import EventClient - - -async def eventclient_demo(): - def on_session_start(): - print("event session started") - - def on_event(msgs): - print("received messages: ", msgs) - - client = EventClient(read_environment_config(), on_session_start, on_event) - asyncio.ensure_future(client.start()) - - print("Will listen for events for 1 hour. Hit ctrl-c to stop") - await asyncio.sleep(3600) - - -if __name__ == '__main__': - logging.basicConfig(level=logging.DEBUG) - - asyncio.run(eventclient_demo()) diff --git a/examples/gatewayproxy.py b/examples/gatewayproxy.py deleted file mode 100644 index e79ed58..0000000 --- a/examples/gatewayproxy.py +++ /dev/null @@ -1,33 +0,0 @@ -import asyncio -import logging - -from reopenwebnet.config import read_environment_config -from reopenwebnet.gatewayproxy import GatewayProxy - -KITCHEN_LIGHT = '13' - - -async def gatewayproxy_demo(): - def on_state_change(msg): - print("State change", msg) - - gw = GatewayProxy(read_environment_config(), on_state_change) - gw.start() - await example_print_status(gw) - - -async def example_print_status(gateway): - print("Printing all light statuses with 3 second intervals") - print( - "Try toggling lights and causing network interruptions. Gateway should return to stable state after network " - "is restored") - - while True: - print(gateway.states) - await asyncio.sleep(2) - - -if __name__ == '__main__': - logging.basicConfig(level=logging.DEBUG) - - asyncio.run(gatewayproxy_demo()) diff --git a/src/reopenwebnet/__init__.py b/src/reopenwebnet/__init__.py index 7b84400..fc8efbb 100644 --- a/src/reopenwebnet/__init__.py +++ b/src/reopenwebnet/__init__.py @@ -1,14 +1,9 @@ # -*- coding: utf-8 -*- from pkg_resources import get_distribution, DistributionNotFound -from reopenwebnet import commandclient -from reopenwebnet import eventclient -from reopenwebnet import gatewayproxy -from reopenwebnet import messages -from reopenwebnet import mqtt - -__all__=[messages, commandclient, eventclient, gatewayproxy, mqtt] +from reopenwebnet import messages, protocol +__all__ = [messages, protocol] try: # Change here if project is renamed and does not equal the package name diff --git a/src/reopenwebnet/commandclient.py b/src/reopenwebnet/commandclient.py deleted file mode 100644 index c3a8648..0000000 --- a/src/reopenwebnet/commandclient.py +++ /dev/null @@ -1,57 +0,0 @@ -# -*- coding: utf-8 -*- -import asyncio -from logging import getLogger -import logging - -from reopenwebnet import messages -from reopenwebnet.protocol import OpenWebNetClient - -_LOGGER = getLogger(__name__) - -class CommandClient: - def __init__(self, config, on_connect): - self.on_connect = on_connect - - def on_session_started(): - if self.on_connect is not None: - self.on_connect() - - self.client = OpenWebNetClient(config, messages.CMD_SESSION, on_session_started, - lambda msgs: self.on_messages_received(msgs)) - - self.read_queue = None - self.lock = asyncio.Lock() - - async def start(self): - logging.debug('command client starting') - await self.client.start() - - def on_messages_received(self, msgs): - # asyncio.ensure_future(self.add_to_queue(msgs)) - pass - - # async def add_to_queue(self, msgs): - # for msg in msgs: - # _LOGGER.debug('Adding to read queue: %s', msg) - # await self.read_queue.put(msg) - - async def send_command(self, message): - async with self.lock: - while self.client.transport is None: - _LOGGER.debug("not connected yet. waiting 1 second") - await asyncio.sleep(1) - - self.client.transport.write(str(message).encode('utf-8')) - - # self.read_queue = asyncio.Queue() - # response = [] - # while messages.ACK not in response and messages.NACK not in response: - # _LOGGER.debug("waiting for next message") - # msg = await self.read_queue.get() - # _LOGGER.debug("got message from queue: %s", msg) - # response.append(msg) - # print("RETURNING", response) - return None - - def stop(self): - self.client.stop() diff --git a/src/reopenwebnet/config.py b/src/reopenwebnet/config.py index 3572f59..88678eb 100644 --- a/src/reopenwebnet/config.py +++ b/src/reopenwebnet/config.py @@ -7,7 +7,7 @@ class Config: def __init__(self, config_dict): self.host = config_dict['host'] - self.port = config_dict['port'] + self.port = config_dict.get('port', 20000) self.password = config_dict['password'] self.mqtt = MqttConfig(config_dict['mqtt']) @@ -16,7 +16,7 @@ def __init__(self, config_dict): class MqttConfig: def __init__(self, config_dict): self.host = config_dict['host'] - self.port = config_dict['port'] + self.port = config_dict.get('port', 1883) self.user = config_dict.get('user') self.password = config_dict.get('password') self.client_id = config_dict.get('client_id') diff --git a/src/reopenwebnet/eventclient.py b/src/reopenwebnet/eventclient.py deleted file mode 100644 index f6bd4dd..0000000 --- a/src/reopenwebnet/eventclient.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- coding: utf-8 -*- -import logging - -from reopenwebnet import messages -from reopenwebnet.protocol import OpenWebNetClient - -_LOGGER = logging.getLogger(__name__) - - -def main(): - logging.basicConfig(level=logging.DEBUG) - - -class EventClient: - def __init__(self, config, on_session_start=None, event_listener=None): - def on_event(msgs): - if event_listener is not None: - event_listener(msgs) - self.client = OpenWebNetClient(config, messages.EVENT_SESSION, on_session_start, on_event) - - async def start(self): - logging.debug('eventclient starting') - await self.client.start() - - def stop(self): - self.client.stop() diff --git a/src/reopenwebnet/gatewayproxy.py b/src/reopenwebnet/gatewayproxy.py deleted file mode 100644 index 0d92f87..0000000 --- a/src/reopenwebnet/gatewayproxy.py +++ /dev/null @@ -1,76 +0,0 @@ -import asyncio -import logging - -from reopenwebnet import messages -from reopenwebnet.commandclient import CommandClient -from reopenwebnet.eventclient import EventClient - -LOGGER = logging.getLogger() - - -class GatewayProxy: - def __init__(self, config, on_state_change=None): - self.on_state_change = on_state_change - - def on_command_session(): - self.on_command_session() - - def on_event_session(): - self.on_event_session() - - def on_event(msgs): - self.on_event(msgs) - - self.command_client = CommandClient(config, on_command_session) - self.event_client = EventClient(config, on_event_session, on_event) - - self.states = {} - - self.listeners = {} - - def start(self): - asyncio.ensure_future(self.command_client.start()) - asyncio.ensure_future(self.event_client.start()) - - def register_listener(self, who, where, callback): - self.listeners.setdefault(who, {})[where] = callback - state = self.states.setdefault(who, {}).get(where, None) - - # send initial state - if state is not None: - callback(state) - - async def cmd(self, msg): - return await self.command_client.send_command(msg) - - def on_command_session(self): - asyncio.ensure_future(self.fetch_full_state()) - - def on_event_session(self): - asyncio.ensure_future(self.fetch_full_state()) - - def on_event(self, msgs): - self._process_messages(msgs) - - async def fetch_full_state(self): - LOGGER.debug("fetching initial light states") - initial_light_states = await self.send_cmd(messages.StatusRequestMessage('1', '0')) - - self._process_messages(initial_light_states) - - async def send_cmd(self, message): - result = await self.command_client.send_command(message) - return result - - def _process_messages(self, msgs): - for msg in msgs: - if msg.__class__ == messages.CommandOrStatusMessage: - item = self.states.setdefault(msg.who, {}) - current_value = item.get(msg.where, None) - if current_value is None or current_value.what != msg.what: - if self.on_state_change is not None: - self.on_state_change(msg) - listener = self.listeners.get(msg.who, {}).get(msg.where, None) - if listener is not None: - listener(msg) - self.states.setdefault(msg.who, {})[msg.where] = msg diff --git a/src/reopenwebnet/protocol.py b/src/reopenwebnet/protocol.py index 77b7d5c..3592f2a 100644 --- a/src/reopenwebnet/protocol.py +++ b/src/reopenwebnet/protocol.py @@ -11,71 +11,13 @@ _LOGGER = getLogger(__name__) -class OpenWebNetClient: - def __init__(self, config, session_type, on_connect, on_event): - self.config = config - self.session_type = session_type - self.on_connect = on_connect - self.on_event = on_event - - self.reconnect_interval = 3600 * 24 - self.connect_timeout = 3 - - self.shutdown = False - self.transport = None - - async def start(self): - await self.connect_loop() - - async def connect_loop(self): - while not self.shutdown: - try: - # TODO: instead of fixed reconnect_interval schedule the reconnect at fixed moment of the day - await asyncio.wait_for(self.connect(), self.reconnect_interval) - except asyncio.TimeoutError: - print("Was connected for %s seconds. Will reconnect" % self.reconnect_interval) - - async def connect(self): - try: - if self.transport is not None: - self.transport.close() - self.transport = None - - _LOGGER.debug("Trying to connect...") - loop = asyncio.get_running_loop() - on_con_lost = loop.create_future() - transport, protocol = await asyncio.wait_for(loop.create_connection( - lambda: OpenWebNetProtocol(self.session_type, self.config.password, self.on_connect, - self.on_event, on_con_lost), - self.config.host, - self.config.port), self.connect_timeout) - self.transport = transport - - await on_con_lost - _LOGGER.debug("connection lost. will try again in 3 seconds") - await asyncio.sleep(3) - except KeyboardInterrupt: - _LOGGER.debug("aborted by user") - self.stop() - - except (asyncio.TimeoutError, OSError) as e: - _LOGGER.debug("Failed to create connection in time. Will try again in 3 seconds") - await asyncio.sleep(3) - - def stop(self): - self.shutdown = True - if self.transport is not None: - self.transport.close() - - class OpenWebNetProtocol(asyncio.Protocol): - def __init__(self, session_type, password, connect_listener, event_listener, on_con_lost, write_delay=0.1): + def __init__(self, session_type, password, event_listener, on_connection_lost): self.session_type = session_type - self.on_con_lost = on_con_lost - self.on_session_started = connect_listener - self.event_listener = event_listener self.password = password - self.write_delay = write_delay + self.write_delay=0.1 + self.event_listener = event_listener + self.on_connection_lost = on_connection_lost self.state = 'NOT_CONNECTED' self.buffer = "" @@ -85,17 +27,12 @@ def __init__(self, session_type, password, connect_listener, event_listener, on_ def connection_made(self, transport): self.state = 'CONNECTED' self.transport = transport - sock = transport.get_extra_info('socket') - if sock is not None: - sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDTIMEO, struct.pack('LL', 0, 10000)) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVTIMEO, struct.pack('LL', 0, 10000)) def data_received(self, data): data = data.decode('utf-8') - _LOGGER.debug("received data %s", data) self.buffer += data - msgs, remainder = messages.parse_messages(self.buffer + data) + msgs, remainder = messages.parse_messages(self.buffer) self.buffer = "" if remainder is None else remainder if self.state == 'ERROR': @@ -121,8 +58,6 @@ def data_received(self, data): elif self.state == 'PASSWORD_SENT': if msgs[-1] == messages.ACK: self.state = 'EVENT_SESSION_ACTIVE' - if self.on_session_started is not None: - self.on_session_started() else: _LOGGER.error('Failed to establish event session') self.state = 'ERROR' @@ -139,7 +74,7 @@ def send_message(self, message): self.transport.write(str(message).encode('utf-8')) def connection_lost(self, exc): + print("in protocol.connection_lost") self.state = 'NOT_CONNECTED' self.transport = None - if not self.on_con_lost.done(): - self.on_con_lost.set_result(True) + self.on_connection_lost.set_result(False) From 51cda863c142bd0899e903d0c67b89335a8d75e9 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sun, 19 Sep 2021 12:11:50 +0200 Subject: [PATCH 34/45] Cleaned up event session + added back command session example --- examples/command_session.py | 62 ++++++++++++++++++++++++++++++++++++ examples/event_session.py | 17 +++------- src/reopenwebnet/protocol.py | 15 ++++++--- 3 files changed, 78 insertions(+), 16 deletions(-) create mode 100644 examples/command_session.py diff --git a/examples/command_session.py b/examples/command_session.py new file mode 100644 index 0000000..a070a4b --- /dev/null +++ b/examples/command_session.py @@ -0,0 +1,62 @@ +import asyncio +import logging +import socket + +from reopenwebnet import messages +from reopenwebnet.protocol import OpenWebNetProtocol + +logging.basicConfig(level=logging.DEBUG) + + +async def schedule_stop(delay): + return asyncio.ensure_future(asyncio.sleep(delay)) + + +async def main(): + loop = asyncio.get_running_loop() + + # Start openwebnet protocol + def on_event(*args): + print("got event", args) + + transport, protocol, on_con_lost = await start_openwebnet(loop, on_event) + + # Wait a bit (protocol still initializing) + await asyncio.sleep(1) + + # Play with the lights + await light_on(protocol) + + await asyncio.sleep(1) + await light_off(protocol) + + await asyncio.sleep(1) + await light_on(protocol) + + await asyncio.sleep(1) + await light_off(protocol) + + +async def light_off(protocol): + print("Light off") + protocol.send_message(messages.NormalMessage(1, 0, 13)) + + +async def light_on(protocol): + print("Light on") + protocol.send_message(messages.NormalMessage(1, 1, 13)) + + +async def start_openwebnet(loop, on_event): + mysock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + mysock.connect(('192.168.0.10', 20000)) + on_con_lost = loop.create_future() + + transport, protocol = await loop.create_connection( + lambda: OpenWebNetProtocol(messages.CMD_SESSION, '951753', on_event, on_con_lost), + sock=mysock) + + return transport, protocol, on_con_lost + + +asyncio.run(main()) diff --git a/examples/event_session.py b/examples/event_session.py index b0ef3e4..bd66e4f 100644 --- a/examples/event_session.py +++ b/examples/event_session.py @@ -7,10 +7,8 @@ # logging.basicConfig(level=logging.DEBUG) -async def schedule_stop(delay, on_stop): - await asyncio.sleep(delay) - on_stop.set_result(True) - +async def schedule_stop(delay): + return asyncio.ensure_future(asyncio.sleep(delay)) async def main(): loop = asyncio.get_running_loop() @@ -22,7 +20,9 @@ def on_event(*args): transport, protocol, on_con_lost = await start_openwebnet(loop, on_event) # Schedule stop - on_stop = await schedule_stop() + delay = 10 + print("Listening for openwebnet events for %d seconds. Try switching a light on and off" % delay) + on_stop = await schedule_stop(delay) # Wait until scheduled stop or connection loss done, pending = await asyncio.wait([on_con_lost, on_stop], return_when=FIRST_COMPLETED) @@ -32,13 +32,6 @@ def on_event(*args): print("Scheduled stop") -async def schedule_stop(): - delay = 10 - print("Listening for openwebnet events for %d seconds. Try switching a light on and off" % delay) - on_stop = asyncio.ensure_future(asyncio.sleep(delay)) - return on_stop - - async def start_openwebnet(loop, on_event): mysock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) mysock.connect(('192.168.0.10', 20000)) diff --git a/src/reopenwebnet/protocol.py b/src/reopenwebnet/protocol.py index 3592f2a..b88f1cf 100644 --- a/src/reopenwebnet/protocol.py +++ b/src/reopenwebnet/protocol.py @@ -40,7 +40,7 @@ def data_received(self, data): elif self.state == 'CONNECTED': if msgs[0] == messages.ACK: - self.send_message(self.session_type) + self._send_message(self.session_type) self.state = 'SESSION_REQUESTED' else: _LOGGER.error('Did not get initial ack on connect') @@ -52,7 +52,7 @@ def data_received(self, data): nonce = msgs[0].value[2:-2] password = calculate_password(self.password, nonce) - self.send_message(messages.FixedMessage(f"*#{password}##", messages.TYPE_OTHER)) + self._send_message(messages.FixedMessage(f"*#{password}##", messages.TYPE_OTHER)) self.state = 'PASSWORD_SENT' elif self.state == 'PASSWORD_SENT': @@ -66,15 +66,22 @@ def data_received(self, data): _LOGGER.debug("sending messages to event listener %s", msgs) self.event_listener(msgs) - def send_message(self, message): + def _send_message(self, message): now = time.time() if now < self.next_message: time.sleep(self.next_message - now) self.next_message = now + self.write_delay self.transport.write(str(message).encode('utf-8')) + def send_message(self, message): + if self.state != 'EVENT_SESSION_ACTIVE': + print("Not sending message - session not active yet") + # TODO: use an event to indicate when session is active + return + self._send_message(message) + def connection_lost(self, exc): - print("in protocol.connection_lost") + print("in protocol.connection_lost", exc) self.state = 'NOT_CONNECTED' self.transport = None self.on_connection_lost.set_result(False) From 10af92fc0b0e6bb2e9411b94541418c0ba5ee0a3 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sun, 19 Sep 2021 12:22:51 +0200 Subject: [PATCH 35/45] Await session start before sending commands --- examples/command_session.py | 7 +++---- examples/event_session.py | 5 ++++- src/reopenwebnet/protocol.py | 5 ++++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/examples/command_session.py b/examples/command_session.py index a070a4b..c2e59e7 100644 --- a/examples/command_session.py +++ b/examples/command_session.py @@ -21,9 +21,6 @@ def on_event(*args): transport, protocol, on_con_lost = await start_openwebnet(loop, on_event) - # Wait a bit (protocol still initializing) - await asyncio.sleep(1) - # Play with the lights await light_on(protocol) @@ -51,11 +48,13 @@ async def start_openwebnet(loop, on_event): mysock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) mysock.connect(('192.168.0.10', 20000)) on_con_lost = loop.create_future() + on_session_start = loop.create_future() transport, protocol = await loop.create_connection( - lambda: OpenWebNetProtocol(messages.CMD_SESSION, '951753', on_event, on_con_lost), + lambda: OpenWebNetProtocol(messages.CMD_SESSION, '951753', on_session_start, on_event, on_con_lost), sock=mysock) + await on_session_start return transport, protocol, on_con_lost diff --git a/examples/event_session.py b/examples/event_session.py index bd66e4f..be63523 100644 --- a/examples/event_session.py +++ b/examples/event_session.py @@ -36,10 +36,13 @@ async def start_openwebnet(loop, on_event): mysock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) mysock.connect(('192.168.0.10', 20000)) on_con_lost = loop.create_future() + on_session_start = loop.create_future() transport, protocol = await loop.create_connection( - lambda: OpenWebNetProtocol(messages.EVENT_SESSION, '951753', on_event, on_con_lost), + lambda: OpenWebNetProtocol(messages.EVENT_SESSION, '951753', on_session_start, on_event, on_con_lost), sock=mysock) + + await on_session_start return transport, protocol, on_con_lost diff --git a/src/reopenwebnet/protocol.py b/src/reopenwebnet/protocol.py index b88f1cf..0150ee6 100644 --- a/src/reopenwebnet/protocol.py +++ b/src/reopenwebnet/protocol.py @@ -12,10 +12,11 @@ class OpenWebNetProtocol(asyncio.Protocol): - def __init__(self, session_type, password, event_listener, on_connection_lost): + def __init__(self, session_type, password, on_session_start, event_listener, on_connection_lost): self.session_type = session_type self.password = password self.write_delay=0.1 + self.on_session_start = on_session_start self.event_listener = event_listener self.on_connection_lost = on_connection_lost @@ -57,6 +58,8 @@ def data_received(self, data): elif self.state == 'PASSWORD_SENT': if msgs[-1] == messages.ACK: + if self.on_session_start: + self.on_session_start.set_result(True) self.state = 'EVENT_SESSION_ACTIVE' else: _LOGGER.error('Failed to establish event session') From cd267e118d88ab5ffd194a207f845fcf929cf3f8 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sun, 19 Sep 2021 18:40:25 +0200 Subject: [PATCH 36/45] Created a higher level client, simplifying event_session and command_session examples --- examples/command_session.py | 39 ++++++++++-------------------------- examples/event_session.py | 33 +++++++----------------------- src/reopenwebnet/client.py | 34 +++++++++++++++++++++++++++++++ src/reopenwebnet/protocol.py | 3 ++- 4 files changed, 54 insertions(+), 55 deletions(-) create mode 100644 src/reopenwebnet/client.py diff --git a/examples/command_session.py b/examples/command_session.py index c2e59e7..894c6f6 100644 --- a/examples/command_session.py +++ b/examples/command_session.py @@ -1,9 +1,8 @@ import asyncio import logging -import socket from reopenwebnet import messages -from reopenwebnet.protocol import OpenWebNetProtocol +from reopenwebnet.client import OpenWebNetClient logging.basicConfig(level=logging.DEBUG) @@ -13,49 +12,33 @@ async def schedule_stop(delay): async def main(): - loop = asyncio.get_running_loop() - - # Start openwebnet protocol def on_event(*args): print("got event", args) - transport, protocol, on_con_lost = await start_openwebnet(loop, on_event) + client = OpenWebNetClient('192.168.0.10', 20000, '951753', messages.CMD_SESSION) + await client.start() # Play with the lights - await light_on(protocol) + await light_on(client) await asyncio.sleep(1) - await light_off(protocol) + await light_off(client) await asyncio.sleep(1) - await light_on(protocol) + await light_on(client) await asyncio.sleep(1) - await light_off(protocol) + await light_off(client) -async def light_off(protocol): +async def light_off(client): print("Light off") - protocol.send_message(messages.NormalMessage(1, 0, 13)) + client.send_message(messages.NormalMessage(1, 0, 13)) -async def light_on(protocol): +async def light_on(client): print("Light on") - protocol.send_message(messages.NormalMessage(1, 1, 13)) - - -async def start_openwebnet(loop, on_event): - mysock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - mysock.connect(('192.168.0.10', 20000)) - on_con_lost = loop.create_future() - on_session_start = loop.create_future() - - transport, protocol = await loop.create_connection( - lambda: OpenWebNetProtocol(messages.CMD_SESSION, '951753', on_session_start, on_event, on_con_lost), - sock=mysock) - - await on_session_start - return transport, protocol, on_con_lost + client.send_message(messages.NormalMessage(1, 1, 13)) asyncio.run(main()) diff --git a/examples/event_session.py b/examples/event_session.py index be63523..fff07f7 100644 --- a/examples/event_session.py +++ b/examples/event_session.py @@ -1,14 +1,8 @@ import asyncio -import socket from asyncio import FIRST_COMPLETED +from reopenwebnet.client import OpenWebNetClient from reopenwebnet import messages -from reopenwebnet.protocol import OpenWebNetProtocol - -# logging.basicConfig(level=logging.DEBUG) - -async def schedule_stop(delay): - return asyncio.ensure_future(asyncio.sleep(delay)) async def main(): loop = asyncio.get_running_loop() @@ -17,33 +11,20 @@ async def main(): def on_event(*args): print("got event", args) - transport, protocol, on_con_lost = await start_openwebnet(loop, on_event) + client = OpenWebNetClient('192.168.0.10', 20000, '951753', session_type=messages.EVENT_SESSION) + await client.start(on_event) # Schedule stop - delay = 10 + delay = 60 print("Listening for openwebnet events for %d seconds. Try switching a light on and off" % delay) - on_stop = await schedule_stop(delay) + on_stop = asyncio.ensure_future(asyncio.sleep(delay)) # Wait until scheduled stop or connection loss - done, pending = await asyncio.wait([on_con_lost, on_stop], return_when=FIRST_COMPLETED) - if on_con_lost in done: + done, pending = await asyncio.wait([client.on_con_lost, on_stop], return_when=FIRST_COMPLETED) + if client.on_con_lost in done: print("Connection lost") if on_stop in done: print("Scheduled stop") -async def start_openwebnet(loop, on_event): - mysock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - mysock.connect(('192.168.0.10', 20000)) - on_con_lost = loop.create_future() - on_session_start = loop.create_future() - - transport, protocol = await loop.create_connection( - lambda: OpenWebNetProtocol(messages.EVENT_SESSION, '951753', on_session_start, on_event, on_con_lost), - sock=mysock) - - await on_session_start - return transport, protocol, on_con_lost - - asyncio.run(main()) diff --git a/src/reopenwebnet/client.py b/src/reopenwebnet/client.py new file mode 100644 index 0000000..ce79c9a --- /dev/null +++ b/src/reopenwebnet/client.py @@ -0,0 +1,34 @@ +import asyncio +import socket + +from reopenwebnet.protocol import OpenWebNetProtocol + + +class OpenWebNetClient: + def __init__(self, host, port, password, session_type): + self.host = host + self.port = port + self.password = password + self.transport = None + self.protocol = None + self.on_con_lost = None + self.session_type = session_type + + async def start(self, event_callback=None): + loop = asyncio.get_running_loop() + mysock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + mysock.connect((self.host, self.port)) + on_con_lost = loop.create_future() + on_session_start = loop.create_future() + + transport, protocol = await loop.create_connection( + lambda: OpenWebNetProtocol(self.session_type, self.password, on_session_start, event_callback, on_con_lost), + sock=mysock) + + await on_session_start + self.transport = transport + self.protocol = protocol + self.on_con_lost = on_con_lost + + def send_message(self, msg): + self.protocol.send_message(msg) diff --git a/src/reopenwebnet/protocol.py b/src/reopenwebnet/protocol.py index 0150ee6..b223f38 100644 --- a/src/reopenwebnet/protocol.py +++ b/src/reopenwebnet/protocol.py @@ -67,7 +67,8 @@ def data_received(self, data): elif self.state == 'EVENT_SESSION_ACTIVE': _LOGGER.debug("sending messages to event listener %s", msgs) - self.event_listener(msgs) + if self.event_listener is not None: + self.event_listener(msgs) def _send_message(self, message): now = time.time() From 76b18916c5ef3dd438b75a14c5080fcded5bb707 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sun, 19 Sep 2021 20:49:57 +0200 Subject: [PATCH 37/45] Fixed mqtt bridge --- bin/openwebnet-mqtt-bridge | 2 ++ src/reopenwebnet/client.py | 5 +++ src/reopenwebnet/config.py | 11 ++++-- src/reopenwebnet/mqtt.py | 74 +++++++++++++++++++------------------- 4 files changed, 53 insertions(+), 39 deletions(-) diff --git a/bin/openwebnet-mqtt-bridge b/bin/openwebnet-mqtt-bridge index 20888b9..0f03feb 100755 --- a/bin/openwebnet-mqtt-bridge +++ b/bin/openwebnet-mqtt-bridge @@ -9,6 +9,8 @@ async def main(): bridge = MqttBridge(read_environment_config()) print("starting bridge") await bridge.start() + while True: + await asyncio.sleep(60) if __name__ == "__main__": asyncio.run(main()) diff --git a/src/reopenwebnet/client.py b/src/reopenwebnet/client.py index ce79c9a..188af1a 100644 --- a/src/reopenwebnet/client.py +++ b/src/reopenwebnet/client.py @@ -1,8 +1,10 @@ import asyncio +import logging import socket from reopenwebnet.protocol import OpenWebNetProtocol +_LOGGER = logging.getLogger(__name__) class OpenWebNetClient: def __init__(self, host, port, password, session_type): @@ -31,4 +33,7 @@ async def start(self, event_callback=None): self.on_con_lost = on_con_lost def send_message(self, msg): + if self.protocol is None: + _LOGGER.error("Could not send message; Did you call client.start()?") + return self.protocol.send_message(msg) diff --git a/src/reopenwebnet/config.py b/src/reopenwebnet/config.py index 88678eb..f989ed8 100644 --- a/src/reopenwebnet/config.py +++ b/src/reopenwebnet/config.py @@ -5,12 +5,17 @@ class Config: + def __init__(self, config_dict): + self.openwebnet = OpenWebNetConfig(config_dict['openwebnet']) if 'openwebnet' in config_dict else None + + self.mqtt = MqttConfig(config_dict['mqtt']) if 'mqtt' in config_dict else None + + +class OpenWebNetConfig: def __init__(self, config_dict): self.host = config_dict['host'] self.port = config_dict.get('port', 20000) - self.password = config_dict['password'] - - self.mqtt = MqttConfig(config_dict['mqtt']) + self.password = config_dict.get('password', None) class MqttConfig: diff --git a/src/reopenwebnet/mqtt.py b/src/reopenwebnet/mqtt.py index 946eaec..ff8619c 100644 --- a/src/reopenwebnet/mqtt.py +++ b/src/reopenwebnet/mqtt.py @@ -3,10 +3,8 @@ import re import paho.mqtt.client as mqtt - from reopenwebnet import messages -from reopenwebnet.commandclient import CommandClient -from reopenwebnet.eventclient import EventClient +from reopenwebnet.client import OpenWebNetClient logging.basicConfig(level=logging.DEBUG) @@ -15,46 +13,50 @@ class MqttBridge: def __init__(self, config): + if config.openwebnet is None: + raise Exception('openwebnet configuration is required') + if config.mqtt is None: raise Exception('mqtt configuration required') - def on_command_session(): - logging.debug('openwebnet command session started') - - def on_event_session(): - logging.debug('openwebnet event session started') - - def on_event(msgs): - logging.debug('openwebnet messages received %s', msgs) - for msg in msgs: - # TODO: handle other 'who' types, allow registering transformations (to allow configuring different topic and payload) - if isinstance(msg, messages.NormalMessage): - if msg.who == '1': - self.mqtt.publish(f"/openwebnet/{msg.who}/{msg.where}/state", msg.what) - - self.command_client = CommandClient(config, on_command_session) - self.event_client = EventClient(config, on_event_session, on_event) - - self.queue = asyncio.Queue() - - def on_mqtt_message(client, dummy, message): - logging.debug('received mqtt message: %s / %s', message.topic, message.payload) - match = MQTT_LIGHT_COMMAND_PATTERN.match(message.topic) - if match is not None: - payload = message.payload.decode('ASCII') - - async def send(): - await self.command_client.send_command(messages.NormalMessage(1, payload, match.group(1))) - - asyncio.run(send()) - + self.event_client = OpenWebNetClient(config.openwebnet.host, config.openwebnet.port, config.openwebnet.password, messages.EVENT_SESSION) + self.command_client = OpenWebNetClient(config.openwebnet.host, config.openwebnet.port, config.openwebnet.password, messages.CMD_SESSION) self.mqtt = _create_mqtt_client(config.mqtt) - self.mqtt.on_message = on_mqtt_message + + self.mqtt.on_message = self.send_mqtt_command_to_openwebnet async def start(self): - logging.debug('starting mqtt bridge') + logging.debug('starting mqtt loop') self.mqtt.loop_start() - await asyncio.wait([self.command_client.start(), self.event_client.start()]) + + logging.debug('starting event client') + await self.event_client.start(self.send_openwebnet_event_to_mqtt) + + logging.debug('starting command client') + await self.command_client.start(self.send_openwebnet_event_to_mqtt) + + def send_mqtt_command_to_openwebnet(self, client, dummy, message): + logging.debug('received mqtt message: %s / %s', message.topic, message.payload) + match = MQTT_LIGHT_COMMAND_PATTERN.match(message.topic) + if match is not None: + what = message.payload.decode('ASCII') + where = match.group(1) + try: + self.command_client.send_message(messages.NormalMessage(1, what, where)) + except Exception as ex: + logging.error("Failed to send message", ex) + + + def send_openwebnet_event_to_mqtt(self, msgs): + logging.debug('openwebnet messages received %s', msgs) + for msg in msgs: + # TODO: handle other 'who' types, allow registering transformations (to allow configuring different topic and payload) + if isinstance(msg, messages.NormalMessage): + if msg.who == '1': + topic = f"/openwebnet/{msg.who}/{msg.where}/state" + logging.debug('publishing to %s: %s'%(topic, msg)) + self.mqtt.publish(topic, msg.what) + def _create_mqtt_client(mqtt_config): client = mqtt.Client(mqtt_config.client_id) From e178cd5e97d14d664150e4982c8936ea6a622c11 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sun, 19 Sep 2021 21:16:07 +0200 Subject: [PATCH 38/45] Don't keep a command client open; It will get disconnected after 30 seconds of non-usage. # Future improvement: reuse command client if it's been recently used; that could speed things up --- bin/openwebnet-mqtt-bridge | 3 +++ examples/command_session.py | 14 +++++--------- src/reopenwebnet/client.py | 5 +++-- src/reopenwebnet/mqtt.py | 21 ++++++++++++--------- src/reopenwebnet/protocol.py | 9 ++++----- 5 files changed, 27 insertions(+), 25 deletions(-) diff --git a/bin/openwebnet-mqtt-bridge b/bin/openwebnet-mqtt-bridge index 0f03feb..094b544 100755 --- a/bin/openwebnet-mqtt-bridge +++ b/bin/openwebnet-mqtt-bridge @@ -4,6 +4,9 @@ import asyncio from reopenwebnet.config import read_environment_config from reopenwebnet.mqtt import MqttBridge +# import logging +# logging.basicConfig(level=logging.DEBUG) + async def main(): bridge = MqttBridge(read_environment_config()) diff --git a/examples/command_session.py b/examples/command_session.py index 894c6f6..4fa25f3 100644 --- a/examples/command_session.py +++ b/examples/command_session.py @@ -19,16 +19,12 @@ def on_event(*args): await client.start() # Play with the lights - await light_on(client) + while True: + await light_on(client) + await asyncio.sleep(1) + await light_off(client) + await asyncio.sleep(40) - await asyncio.sleep(1) - await light_off(client) - - await asyncio.sleep(1) - await light_on(client) - - await asyncio.sleep(1) - await light_off(client) async def light_off(client): diff --git a/src/reopenwebnet/client.py b/src/reopenwebnet/client.py index 188af1a..a053f32 100644 --- a/src/reopenwebnet/client.py +++ b/src/reopenwebnet/client.py @@ -7,7 +7,7 @@ _LOGGER = logging.getLogger(__name__) class OpenWebNetClient: - def __init__(self, host, port, password, session_type): + def __init__(self, host, port, password, session_type, name = "openwebnet"): self.host = host self.port = port self.password = password @@ -15,6 +15,7 @@ def __init__(self, host, port, password, session_type): self.protocol = None self.on_con_lost = None self.session_type = session_type + self.name = name async def start(self, event_callback=None): loop = asyncio.get_running_loop() @@ -24,7 +25,7 @@ async def start(self, event_callback=None): on_session_start = loop.create_future() transport, protocol = await loop.create_connection( - lambda: OpenWebNetProtocol(self.session_type, self.password, on_session_start, event_callback, on_con_lost), + lambda: OpenWebNetProtocol(self.session_type, self.password, on_session_start, event_callback, on_con_lost, name=self.name), sock=mysock) await on_session_start diff --git a/src/reopenwebnet/mqtt.py b/src/reopenwebnet/mqtt.py index ff8619c..3ea992c 100644 --- a/src/reopenwebnet/mqtt.py +++ b/src/reopenwebnet/mqtt.py @@ -6,11 +6,8 @@ from reopenwebnet import messages from reopenwebnet.client import OpenWebNetClient -logging.basicConfig(level=logging.DEBUG) - MQTT_LIGHT_COMMAND_PATTERN = re.compile('/openwebnet/1/(\\d+)/cmd') - class MqttBridge: def __init__(self, config): if config.openwebnet is None: @@ -19,8 +16,9 @@ def __init__(self, config): if config.mqtt is None: raise Exception('mqtt configuration required') - self.event_client = OpenWebNetClient(config.openwebnet.host, config.openwebnet.port, config.openwebnet.password, messages.EVENT_SESSION) - self.command_client = OpenWebNetClient(config.openwebnet.host, config.openwebnet.port, config.openwebnet.password, messages.CMD_SESSION) + self.config = config + + self.event_client = OpenWebNetClient(config.openwebnet.host, config.openwebnet.port, config.openwebnet.password, messages.EVENT_SESSION, name="eventclient") self.mqtt = _create_mqtt_client(config.mqtt) self.mqtt.on_message = self.send_mqtt_command_to_openwebnet @@ -32,9 +30,6 @@ async def start(self): logging.debug('starting event client') await self.event_client.start(self.send_openwebnet_event_to_mqtt) - logging.debug('starting command client') - await self.command_client.start(self.send_openwebnet_event_to_mqtt) - def send_mqtt_command_to_openwebnet(self, client, dummy, message): logging.debug('received mqtt message: %s / %s', message.topic, message.payload) match = MQTT_LIGHT_COMMAND_PATTERN.match(message.topic) @@ -42,10 +37,18 @@ def send_mqtt_command_to_openwebnet(self, client, dummy, message): what = message.payload.decode('ASCII') where = match.group(1) try: - self.command_client.send_message(messages.NormalMessage(1, what, where)) + openwebnet_message = messages.NormalMessage(1, what, where) + asyncio.run(self.send_openwebnet_message(openwebnet_message)) except Exception as ex: logging.error("Failed to send message", ex) + async def send_openwebnet_message(self, message): + command_client = OpenWebNetClient(self.config.openwebnet.host, self.config.openwebnet.port, + self.config.openwebnet.password, messages.CMD_SESSION, + name="commandclient") + await command_client.start(self.send_openwebnet_event_to_mqtt) + command_client.send_message(message) + command_client.transport.close() def send_openwebnet_event_to_mqtt(self, msgs): logging.debug('openwebnet messages received %s', msgs) diff --git a/src/reopenwebnet/protocol.py b/src/reopenwebnet/protocol.py index b223f38..0c7e01a 100644 --- a/src/reopenwebnet/protocol.py +++ b/src/reopenwebnet/protocol.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- import asyncio -import socket -import struct import time from logging import getLogger @@ -12,13 +10,14 @@ class OpenWebNetProtocol(asyncio.Protocol): - def __init__(self, session_type, password, on_session_start, event_listener, on_connection_lost): + def __init__(self, session_type, password, on_session_start, event_listener, on_connection_lost, name = "opwenwebnet"): self.session_type = session_type self.password = password self.write_delay=0.1 self.on_session_start = on_session_start self.event_listener = event_listener self.on_connection_lost = on_connection_lost + self.name = name self.state = 'NOT_CONNECTED' self.buffer = "" @@ -79,13 +78,13 @@ def _send_message(self, message): def send_message(self, message): if self.state != 'EVENT_SESSION_ACTIVE': - print("Not sending message - session not active yet") + _LOGGER.error("Not sending message - session not active yet") # TODO: use an event to indicate when session is active return self._send_message(message) def connection_lost(self, exc): - print("in protocol.connection_lost", exc) + _LOGGER.debug("[%s] in protocol.connection_lost: {}"%(self.name), exc) self.state = 'NOT_CONNECTED' self.transport = None self.on_connection_lost.set_result(False) From 67bcc9c74761f42808842ec2e57efd46e2a74bc8 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sun, 19 Sep 2021 21:35:15 +0200 Subject: [PATCH 39/45] Updated readme and script to make things easier to get started. --- README.md | 37 +++++++++++++--------------------- examples/command_session.py | 22 +++++++++++--------- examples/event_session.py | 9 +++++++-- reopenwebnet_config.yml.sample | 14 ++++++++++--- src/reopenwebnet/config.py | 4 ++-- src/reopenwebnet/mqtt.py | 2 +- 6 files changed, 48 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 1057d8f..52df365 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,33 @@ # ReOpenWebNet -ReOpenWebNet is a client library for communicating with an OpenWebNet gateway. - +ReOpenWebNet is a library communicating with an OpenWebNet gateway. It supports event sessions and command sessions. OpenWebNet is a communication protocol developed by Bticino, to enable communication between devices of its home automation product suite 'MyHome'. For more information about OpenWebNet, see https://www.myopen-legrandgroup.com/developers/ This project started as a fork from https://github.com/pippocla/openwebnet ## Features -Asynchronous components for interacting with the gateway. - -### Python component - -### MQTT Bridge - -See bin/openwebnet-mqtt-bridge -This bridge communicates with an openwebnet service over http and and mqtt service. -This should make it easier to interact with openwebnet in various tools (OpenHAB, Homeassistant, Node-Red) +- Asynchronous components for interacting with the gateway. +- A bridge between openwebnet and mqtt; At the moment only light switches/actuators ('who=1') are supported. If you want to see support for other things, please reach out via GitHub. -#### Low level components: - - CommandClient: establishes command sessions and lets you send commands and read the gateway's responses. The CommandClient does simple rate limiting because the OpenWebNet gateway seems to misbehave if you send too many commands in a short time. - - EventClient: establishes event sessions. +## Example scripts -Both components should automatically reconnect in case of connection loss. +Note: before running these examples, change the constants declared at the top of these script. -### Higher level components: - - ClientFactory: reads configuration from file and can create CommandClient and EventClient instances - - GatewayProxy: Creates a CommandClient and an EventClient and keeps state in sync. +- `examples/event_session.py`: When running this script you should see openwebnet events being logged to the command line as they happen. +- `examples/command_session.py`: Running the script should toggle a light on and off 5 times with 1 second intervals. -## Examples +## MQTT Bridge -See examples/ for programming examples for the various components +See `bin/openwebnet-mqtt-bridge`. -## Testing +This bridge communicates with an openwebnet service over http and and mqtt service. +This should make it easier to interact with openwebnet in various tools (OpenHAB, Homeassistant, Node-Red) -To run the test suite: +### Configuration - python setup.py test +The MQTT bridge is configured via $HOME/.reopenwebnet/config.yaml +See reopenwebnet_config.yml.sample for an example ## Releasing diff --git a/examples/command_session.py b/examples/command_session.py index 4fa25f3..bdd4d6c 100644 --- a/examples/command_session.py +++ b/examples/command_session.py @@ -6,6 +6,11 @@ logging.basicConfig(level=logging.DEBUG) +HOST = '192.168.0.1' +PORT = 20000 +PASSWORD = '951753' +LIGHT_WHERE = 13 + async def schedule_stop(delay): return asyncio.ensure_future(asyncio.sleep(delay)) @@ -15,26 +20,25 @@ async def main(): def on_event(*args): print("got event", args) - client = OpenWebNetClient('192.168.0.10', 20000, '951753', messages.CMD_SESSION) + client = OpenWebNetClient(HOST, PORT, PASSWORD, messages.CMD_SESSION) await client.start() # Play with the lights - while True: - await light_on(client) - await asyncio.sleep(1) - await light_off(client) - await asyncio.sleep(40) - + for i in range(5): + await light_on(client) + await asyncio.sleep(1) + await light_off(client) + await asyncio.sleep(1) async def light_off(client): print("Light off") - client.send_message(messages.NormalMessage(1, 0, 13)) + client.send_message(messages.NormalMessage(1, 0, LIGHT_WHERE)) async def light_on(client): print("Light on") - client.send_message(messages.NormalMessage(1, 1, 13)) + client.send_message(messages.NormalMessage(1, 1, LIGHT_WHERE)) asyncio.run(main()) diff --git a/examples/event_session.py b/examples/event_session.py index fff07f7..02856a7 100644 --- a/examples/event_session.py +++ b/examples/event_session.py @@ -1,8 +1,13 @@ import asyncio from asyncio import FIRST_COMPLETED -from reopenwebnet.client import OpenWebNetClient from reopenwebnet import messages +from reopenwebnet.client import OpenWebNetClient + +HOST = '192.168.0.1' +PORT = 20000 +PASSWORD = '951753' + async def main(): loop = asyncio.get_running_loop() @@ -11,7 +16,7 @@ async def main(): def on_event(*args): print("got event", args) - client = OpenWebNetClient('192.168.0.10', 20000, '951753', session_type=messages.EVENT_SESSION) + client = OpenWebNetClient(HOST, PORT, PASSWORD, session_type=messages.EVENT_SESSION) await client.start(on_event) # Schedule stop diff --git a/reopenwebnet_config.yml.sample b/reopenwebnet_config.yml.sample index 57fd242..2260bc9 100644 --- a/reopenwebnet_config.yml.sample +++ b/reopenwebnet_config.yml.sample @@ -1,4 +1,12 @@ -host: 192.168.1.10 -port: 20000 -password: '951753' +openwebnet: + host: 192.168.1.10 + # port: 20000 + password: '951753' + +mqtt: + host: localhost + # port: 1883 + # client_id: reopenwebnet + # user: + # password: diff --git a/src/reopenwebnet/config.py b/src/reopenwebnet/config.py index f989ed8..062d3af 100644 --- a/src/reopenwebnet/config.py +++ b/src/reopenwebnet/config.py @@ -22,8 +22,8 @@ class MqttConfig: def __init__(self, config_dict): self.host = config_dict['host'] self.port = config_dict.get('port', 1883) - self.user = config_dict.get('user') - self.password = config_dict.get('password') + self.user = config_dict.get('user', None) + self.password = config_dict.get('password', None) self.client_id = config_dict.get('client_id') diff --git a/src/reopenwebnet/mqtt.py b/src/reopenwebnet/mqtt.py index 3ea992c..c9a503d 100644 --- a/src/reopenwebnet/mqtt.py +++ b/src/reopenwebnet/mqtt.py @@ -63,7 +63,7 @@ def send_openwebnet_event_to_mqtt(self, msgs): def _create_mqtt_client(mqtt_config): client = mqtt.Client(mqtt_config.client_id) - if mqtt_config.user is not None: + if mqtt_config.user is not None and mqtt_config.user != '': client.username_pw_set(mqtt_config.user, mqtt_config.password) def on_connect(client, b, c, d): From 49716d8792ce876fc2b937ef7ec7192f1145fa63 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sun, 19 Sep 2021 21:45:39 +0200 Subject: [PATCH 40/45] Touchup setup cfg --- setup.cfg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index e169954..39be3f6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,10 +6,10 @@ name = reopenwebnet description = An OpenWebNet client author = karel1980 -author-email = karel@vervaeke.info +author_email = karel@vervaeke.info license = mit url = https://github.com/karel1980/reopenwebnet -long-description = file: README.rst +long_description = file: README.rst # Change if running only on Windows, Mac or Linux (comma-separated) platforms = any # Add here all kinds of additional classifiers as defined under From ff63e1cf0143b32c9da2b5656db3deb67dfcb810 Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Wed, 22 Sep 2021 11:48:27 +0200 Subject: [PATCH 41/45] Removed leading slash from mqtt topic name. Leading slashes aren't recommended --- src/reopenwebnet/mqtt.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/reopenwebnet/mqtt.py b/src/reopenwebnet/mqtt.py index c9a503d..9cc7a19 100644 --- a/src/reopenwebnet/mqtt.py +++ b/src/reopenwebnet/mqtt.py @@ -6,7 +6,7 @@ from reopenwebnet import messages from reopenwebnet.client import OpenWebNetClient -MQTT_LIGHT_COMMAND_PATTERN = re.compile('/openwebnet/1/(\\d+)/cmd') +MQTT_LIGHT_COMMAND_PATTERN = re.compile('openwebnet/1/(\\d+)/cmd') class MqttBridge: def __init__(self, config): @@ -56,7 +56,7 @@ def send_openwebnet_event_to_mqtt(self, msgs): # TODO: handle other 'who' types, allow registering transformations (to allow configuring different topic and payload) if isinstance(msg, messages.NormalMessage): if msg.who == '1': - topic = f"/openwebnet/{msg.who}/{msg.where}/state" + topic = f"openwebnet/{msg.who}/{msg.where}/state" logging.debug('publishing to %s: %s'%(topic, msg)) self.mqtt.publish(topic, msg.what) @@ -68,7 +68,7 @@ def _create_mqtt_client(mqtt_config): def on_connect(client, b, c, d): logging.debug('mqtt connected %s/%s/%s/%s', client, b, c, d) - client.subscribe('/openwebnet/1/+/cmd') + client.subscribe('openwebnet/1/+/cmd') client.on_connect = on_connect client.connect(mqtt_config.host, port=mqtt_config.port) From 144c37c73438105041a3206788bda92f63f60b5f Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Wed, 22 Sep 2021 11:58:54 +0200 Subject: [PATCH 42/45] changed ip address in example scripts --- examples/command_session.py | 2 +- examples/event_session.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/command_session.py b/examples/command_session.py index bdd4d6c..a7be8e2 100644 --- a/examples/command_session.py +++ b/examples/command_session.py @@ -6,7 +6,7 @@ logging.basicConfig(level=logging.DEBUG) -HOST = '192.168.0.1' +HOST = '192.168.0.10' PORT = 20000 PASSWORD = '951753' LIGHT_WHERE = 13 diff --git a/examples/event_session.py b/examples/event_session.py index 02856a7..deb8b80 100644 --- a/examples/event_session.py +++ b/examples/event_session.py @@ -4,7 +4,7 @@ from reopenwebnet import messages from reopenwebnet.client import OpenWebNetClient -HOST = '192.168.0.1' +HOST = '192.168.0.10' PORT = 20000 PASSWORD = '951753' From b1f6d8f17abd4ae6c405e3d566aff315170e9cce Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Wed, 22 Sep 2021 13:34:22 +0200 Subject: [PATCH 43/45] Fixed logging (use %s, not {}) --- src/reopenwebnet/protocol.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/reopenwebnet/protocol.py b/src/reopenwebnet/protocol.py index 0c7e01a..a910191 100644 --- a/src/reopenwebnet/protocol.py +++ b/src/reopenwebnet/protocol.py @@ -10,10 +10,11 @@ class OpenWebNetProtocol(asyncio.Protocol): - def __init__(self, session_type, password, on_session_start, event_listener, on_connection_lost, name = "opwenwebnet"): + def __init__(self, session_type, password, on_session_start, event_listener, on_connection_lost, + name="opwenwebnet"): self.session_type = session_type self.password = password - self.write_delay=0.1 + self.write_delay = 0.1 self.on_session_start = on_session_start self.event_listener = event_listener self.on_connection_lost = on_connection_lost @@ -84,7 +85,7 @@ def send_message(self, message): self._send_message(message) def connection_lost(self, exc): - _LOGGER.debug("[%s] in protocol.connection_lost: {}"%(self.name), exc) + _LOGGER.debug("[%s] in protocol.connection_lost: %s", self.name, exc) self.state = 'NOT_CONNECTED' self.transport = None self.on_connection_lost.set_result(False) From 4ffa70b3999f59ea0e4e720d181547046a3e45ad Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sat, 25 Sep 2021 21:37:37 +0200 Subject: [PATCH 44/45] Converted mqtt package into an executable module --- bin/openwebnet-mqtt-bridge | 20 ++----------------- .../{mqtt.py => mqtt/__init__.py} | 0 src/reopenwebnet/mqtt/__main__.py | 14 +++++++++++++ 3 files changed, 16 insertions(+), 18 deletions(-) rename src/reopenwebnet/{mqtt.py => mqtt/__init__.py} (100%) create mode 100755 src/reopenwebnet/mqtt/__main__.py diff --git a/bin/openwebnet-mqtt-bridge b/bin/openwebnet-mqtt-bridge index 094b544..b2eb10e 100755 --- a/bin/openwebnet-mqtt-bridge +++ b/bin/openwebnet-mqtt-bridge @@ -1,19 +1,3 @@ -#!/usr/bin/env python -import asyncio +#!/bin/sh -from reopenwebnet.config import read_environment_config -from reopenwebnet.mqtt import MqttBridge - -# import logging -# logging.basicConfig(level=logging.DEBUG) - - -async def main(): - bridge = MqttBridge(read_environment_config()) - print("starting bridge") - await bridge.start() - while True: - await asyncio.sleep(60) - -if __name__ == "__main__": - asyncio.run(main()) +python -mreopenwebnet.mqtt diff --git a/src/reopenwebnet/mqtt.py b/src/reopenwebnet/mqtt/__init__.py similarity index 100% rename from src/reopenwebnet/mqtt.py rename to src/reopenwebnet/mqtt/__init__.py diff --git a/src/reopenwebnet/mqtt/__main__.py b/src/reopenwebnet/mqtt/__main__.py new file mode 100755 index 0000000..5ceaa45 --- /dev/null +++ b/src/reopenwebnet/mqtt/__main__.py @@ -0,0 +1,14 @@ +import asyncio + +from reopenwebnet.config import read_environment_config +from reopenwebnet.mqtt import MqttBridge + +async def main(): + bridge = MqttBridge(read_environment_config()) + print("starting bridge") + await bridge.start() + while True: + await asyncio.sleep(60) + +if __name__ == "__main__": + asyncio.run(main()) From abb027cdb6c708e85b8f1fe710e5f4cc867be02b Mon Sep 17 00:00:00 2001 From: Karel Vervaeke Date: Sun, 30 Oct 2022 08:10:36 +0100 Subject: [PATCH 45/45] Changed README from rst to md --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 39be3f6..514327b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,7 +9,7 @@ author = karel1980 author_email = karel@vervaeke.info license = mit url = https://github.com/karel1980/reopenwebnet -long_description = file: README.rst +long_description = file: README.md # Change if running only on Windows, Mac or Linux (comma-separated) platforms = any # Add here all kinds of additional classifiers as defined under