From 3842f0b784db685f47a71cf79faab077495a4690 Mon Sep 17 00:00:00 2001 From: majamassarini Date: Thu, 11 Nov 2021 23:19:02 +0100 Subject: [PATCH] Kickoff --- .coveragerc | 6 + .flake8 | 16 ++ .gitignore | 10 + .travis.yml | 18 ++ LICENSE | 21 ++ README.md | 6 + docs/Makefile | 20 ++ docs/make.bat | 35 +++ docs/source/clients.rst | 25 ++ docs/source/command/dpt_switch.rst | 11 + docs/source/command/dpt_updown.rst | 11 + docs/source/commands.rst | 27 ++ docs/source/conf.py | 67 +++++ docs/source/gateways.rst | 23 ++ docs/source/index.rst | 101 +++++++ docs/source/messages.rst | 14 + docs/source/trigger/always.rst | 35 +++ docs/source/trigger/equal.rst | 77 ++++++ docs/source/trigger/greater_than.rst | 25 ++ docs/source/trigger/in_between.rst | 10 + docs/source/trigger/lesser_than.rst | 15 ++ docs/source/trigger/mean/greater_than.rst | 11 + docs/source/trigger/mean/in_between.rst | 18 ++ docs/source/trigger/mean/lesser_than.rst | 16 ++ docs/source/triggers.rst | 26 ++ knx_plugin/__init__.py | 3 + knx_plugin/client/__init__.py | 120 +++++++++ knx_plugin/client/knxnet_ip.py | 212 +++++++++++++++ knx_plugin/client/usbhid.py | 112 ++++++++ knx_plugin/command/__init__.py | 21 ++ knx_plugin/command/custom_clima.py | 127 +++++++++ knx_plugin/command/dpt_brightness.py | 57 ++++ knx_plugin/command/dpt_switch.py | 68 +++++ knx_plugin/command/dpt_updown.py | 61 +++++ knx_plugin/gateway/__init__.py | 110 ++++++++ knx_plugin/gateway/knxnet_ip.py | 44 ++++ knx_plugin/gateway/usbhid.py | 12 + knx_plugin/message.py | 169 ++++++++++++ knx_plugin/tests/__init__.py | 0 knx_plugin/tests/test_courtesy.py | 217 +++++++++++++++ knx_plugin/tests/test_curtain_stop.py | 135 ++++++++++ knx_plugin/tests/test_doctests.py | 36 +++ knx_plugin/tests/test_gateway.py | 72 +++++ .../tests/test_multiple_protocol_triggers.py | 148 +++++++++++ .../tests/test_performer_brightness_update.py | 142 ++++++++++ .../tests/test_performer_power_update.py | 99 +++++++ .../tests/test_performer_switch_update.py | 106 ++++++++ .../test_performer_temperature_update.py | 99 +++++++ knx_plugin/tests/test_performer_thermostat.py | 148 +++++++++++ knx_plugin/tests/test_postpone_switchoff.py | 160 +++++++++++ knx_plugin/tests/test_presence.py | 185 +++++++++++++ knx_plugin/tests/test_scene_timer.py | 138 ++++++++++ knx_plugin/tests/test_windy.py | 150 +++++++++++ knx_plugin/tests/testcase.py | 26 ++ knx_plugin/trigger/__init__.py | 249 ++++++++++++++++++ knx_plugin/trigger/custom_clima.py | 235 +++++++++++++++++ knx_plugin/trigger/custom_scene.py | 50 ++++ knx_plugin/trigger/dpt_brightness.py | 26 ++ .../trigger/dpt_control_dimming/__init__.py | 81 ++++++ .../dpt_control_dimming/step/__init__.py | 1 + .../trigger/dpt_control_dimming/step/down.py | 18 ++ .../trigger/dpt_control_dimming/step/up.py | 11 + knx_plugin/trigger/dpt_scene_control.py | 46 ++++ knx_plugin/trigger/dpt_start.py | 21 ++ knx_plugin/trigger/dpt_switch.py | 21 ++ knx_plugin/trigger/dpt_updown.py | 21 ++ knx_plugin/trigger/dpt_value_lux/__init__.py | 181 +++++++++++++ knx_plugin/trigger/dpt_value_lux/balance.py | 188 +++++++++++++ .../trigger/dpt_value_power/__init__.py | 15 ++ .../trigger/dpt_value_power/consumption.py | 204 ++++++++++++++ .../trigger/dpt_value_power/production.py | 39 +++ knx_plugin/trigger/dpt_value_temp.py | 145 ++++++++++ knx_plugin/trigger/dpt_value_wsp.py | 120 +++++++++ knx_plugin/trigger/mean/__init__.py | 157 +++++++++++ requirements.txt | 2 + setup.py | 27 ++ 76 files changed, 5479 insertions(+) create mode 100644 .coveragerc create mode 100644 .flake8 create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/Makefile create mode 100644 docs/make.bat create mode 100644 docs/source/clients.rst create mode 100644 docs/source/command/dpt_switch.rst create mode 100644 docs/source/command/dpt_updown.rst create mode 100644 docs/source/commands.rst create mode 100644 docs/source/conf.py create mode 100644 docs/source/gateways.rst create mode 100644 docs/source/index.rst create mode 100644 docs/source/messages.rst create mode 100644 docs/source/trigger/always.rst create mode 100644 docs/source/trigger/equal.rst create mode 100644 docs/source/trigger/greater_than.rst create mode 100644 docs/source/trigger/in_between.rst create mode 100644 docs/source/trigger/lesser_than.rst create mode 100644 docs/source/trigger/mean/greater_than.rst create mode 100644 docs/source/trigger/mean/in_between.rst create mode 100644 docs/source/trigger/mean/lesser_than.rst create mode 100644 docs/source/triggers.rst create mode 100644 knx_plugin/__init__.py create mode 100644 knx_plugin/client/__init__.py create mode 100644 knx_plugin/client/knxnet_ip.py create mode 100644 knx_plugin/client/usbhid.py create mode 100644 knx_plugin/command/__init__.py create mode 100644 knx_plugin/command/custom_clima.py create mode 100644 knx_plugin/command/dpt_brightness.py create mode 100644 knx_plugin/command/dpt_switch.py create mode 100644 knx_plugin/command/dpt_updown.py create mode 100644 knx_plugin/gateway/__init__.py create mode 100644 knx_plugin/gateway/knxnet_ip.py create mode 100644 knx_plugin/gateway/usbhid.py create mode 100644 knx_plugin/message.py create mode 100644 knx_plugin/tests/__init__.py create mode 100644 knx_plugin/tests/test_courtesy.py create mode 100644 knx_plugin/tests/test_curtain_stop.py create mode 100644 knx_plugin/tests/test_doctests.py create mode 100644 knx_plugin/tests/test_gateway.py create mode 100644 knx_plugin/tests/test_multiple_protocol_triggers.py create mode 100644 knx_plugin/tests/test_performer_brightness_update.py create mode 100644 knx_plugin/tests/test_performer_power_update.py create mode 100644 knx_plugin/tests/test_performer_switch_update.py create mode 100644 knx_plugin/tests/test_performer_temperature_update.py create mode 100644 knx_plugin/tests/test_performer_thermostat.py create mode 100644 knx_plugin/tests/test_postpone_switchoff.py create mode 100644 knx_plugin/tests/test_presence.py create mode 100644 knx_plugin/tests/test_scene_timer.py create mode 100644 knx_plugin/tests/test_windy.py create mode 100644 knx_plugin/tests/testcase.py create mode 100644 knx_plugin/trigger/__init__.py create mode 100644 knx_plugin/trigger/custom_clima.py create mode 100644 knx_plugin/trigger/custom_scene.py create mode 100644 knx_plugin/trigger/dpt_brightness.py create mode 100644 knx_plugin/trigger/dpt_control_dimming/__init__.py create mode 100644 knx_plugin/trigger/dpt_control_dimming/step/__init__.py create mode 100644 knx_plugin/trigger/dpt_control_dimming/step/down.py create mode 100644 knx_plugin/trigger/dpt_control_dimming/step/up.py create mode 100644 knx_plugin/trigger/dpt_scene_control.py create mode 100644 knx_plugin/trigger/dpt_start.py create mode 100644 knx_plugin/trigger/dpt_switch.py create mode 100644 knx_plugin/trigger/dpt_updown.py create mode 100644 knx_plugin/trigger/dpt_value_lux/__init__.py create mode 100644 knx_plugin/trigger/dpt_value_lux/balance.py create mode 100644 knx_plugin/trigger/dpt_value_power/__init__.py create mode 100644 knx_plugin/trigger/dpt_value_power/consumption.py create mode 100644 knx_plugin/trigger/dpt_value_power/production.py create mode 100644 knx_plugin/trigger/dpt_value_temp.py create mode 100644 knx_plugin/trigger/dpt_value_wsp.py create mode 100644 knx_plugin/trigger/mean/__init__.py create mode 100644 requirements.txt create mode 100644 setup.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..7850040 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,6 @@ +[run] +command_line = -m unittest discover +omit = *tests*,*__init__.py + +[report] +omit = *tests*,*__init__.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..752825f --- /dev/null +++ b/.flake8 @@ -0,0 +1,16 @@ +[flake8] +# postponed evaluation of annotations seems not being working +ignore = F821 + +# line break before binary operator - introduced by back (W503) +# doctests are not well formatted, lines are too long and are not fixed by black, ignore them (W501) +extend-ignore = W503,E501 + +# black setting is 88 +max-line-length = 90 +max-doc-length = 120 + +per-file-ignores = + __init__.py:F401,E402 + knx_plugin/trigger/custom_clima.py:W505 + knx_plugin/trigger/dpt_value_lux/balance.py:W505 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..503b036 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.pyc +*.bak +.settings +.project +.pydevproject +.idea +.workspace.xml +build/ +dist/ +*egg-info diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..edb5a55 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +env: + - CODECOV_TOKEN='aaa' +language: python +python: + - "3.8" +before_install: + - python --version + - pip install -U pip + - pip install -U pytest + - pip install codecov + - pip install -r requirements.txt +install: + - python setup.py install +# command to run tests +script: + - python -m coverage run +after_success: + - codecov diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4b878d6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020-present Maja Massarini + +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.md b/README.md new file mode 100644 index 0000000..09ab71c --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# automate-knx-plugin +[![Build Status](https://app.travis-ci.com/majamassarini/automate-knx-plugin.svg?branch=main)](https://app.travis-ci.com/majamassarini/automate-knx-plugin) +[![codecov](https://codecov.io/gh/majamassarini/automate-knx-plugin/branch/main/graph/badge.svg?token=...)](https://codecov.io/gh/majamassarini/automate-knx-plugin) +[![Documentation Status](https://readthedocs.org/projects/automate-knx-plugin/badge/?version=latest)](https://automate-knx-plugin.readthedocs.io/en/latest/?badge=latest) + +The **KNX** plugin for the [automate-home project](https://maja-massarini-automate-home.readthedocs-hosted.com/en/latest/?). diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..6247f7e --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/clients.rst b/docs/source/clients.rst new file mode 100644 index 0000000..97aa6cc --- /dev/null +++ b/docs/source/clients.rst @@ -0,0 +1,25 @@ +Clients +======= + +A client is used by a :meth:`knx_plugin.gateway.definition.Gateway` to connect the **KNX bus** through a *KNX gateway device*. + +Abstract +-------- + +.. autoclass:: knx_plugin.client.Client + +USB HID +------- + +The connected *knx gateway device* is a **USB HID device**. + +.. autoclass:: knx_plugin.client.usbhid.Client + :no-members: + +KNXNET IP +--------- + +The connected *knx gateway device* is a **KNXNET IP device**. + +.. autoclass:: knx_plugin.client.knxnet_ip.Client + :no-members: diff --git a/docs/source/command/dpt_switch.rst b/docs/source/command/dpt_switch.rst new file mode 100644 index 0000000..e2ec372 --- /dev/null +++ b/docs/source/command/dpt_switch.rst @@ -0,0 +1,11 @@ +DPT Switch +========== + + +OnOff +----- +.. autoclass:: knx_plugin.command.dpt_switch.OnOff + +OffOn +----- +.. autoclass:: knx_plugin.command.dpt_switch.OffOn diff --git a/docs/source/command/dpt_updown.rst b/docs/source/command/dpt_updown.rst new file mode 100644 index 0000000..bbddc8e --- /dev/null +++ b/docs/source/command/dpt_updown.rst @@ -0,0 +1,11 @@ +DPT UpDown +========== + + +UpDown +------ +.. autoclass:: knx_plugin.command.dpt_updown.UpDown + +Up +-- +.. autoclass:: knx_plugin.command.dpt_updown.Up diff --git a/docs/source/commands.rst b/docs/source/commands.rst new file mode 100644 index 0000000..86c838b --- /dev/null +++ b/docs/source/commands.rst @@ -0,0 +1,27 @@ +Commands +******** + +Commands are entities **capable of forge** messages for the KNX bus interpreting changes in *Appliance* state. + +Commands *compare* an *old Appliance state* with a *new one* and create messages to be sent to the Appliance's device. + +Commands are designed to read *Appliance's attributes*: :meth:`home.appliance.attribute.mixin` + +.. autoclass:: knx_plugin.message.Command + +.. toctree:: + :maxdepth: 4 + + command/dpt_switch + command/dpt_updown + + +DPT_Brightness +-------------- + +.. autoclass:: knx_plugin.command.dpt_brightness.Brightness + +Custom Clima +------------ + +.. autoclass:: knx_plugin.command.custom_clima.Setup diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..77be0cf --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,67 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# 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. +# +import os +import sys +sys.path.insert(0, os.path.abspath('../../')) +sys.path.insert(0, os.path.abspath('../../../home/')) +sys.path.insert(0, os.path.abspath('../../../knx-stack/')) +sys.path.insert(0, os.path.abspath('../../../../git/knx-stack/')) +print(sys.path) + + +# -- Project information ----------------------------------------------------- + +project = 'knx-plugin' +copyright = '2021, Maja Massarini' +author = 'Maja Massarini' + +# The full version, including alpha/beta/rc tags +release = '0.9' + + +# -- General configuration --------------------------------------------------- + +# 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.doctest', + 'sphinx.ext.autodoc', +] +autodoc_inherit_docstrings = True +autodoc_default_options = { + 'member-order': 'bysource', + 'members': True, + 'undoc-members': True, +} + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + + +# -- 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 = 'sphinx_rtd_theme' + +# 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'] \ No newline at end of file diff --git a/docs/source/gateways.rst b/docs/source/gateways.rst new file mode 100644 index 0000000..c629761 --- /dev/null +++ b/docs/source/gateways.rst @@ -0,0 +1,23 @@ +Gateway +======= + +It is the **home instance** gateway to the **Knx bus**. +Connects triggers and commands to the **KNX bus**. +It depends on the physical connecting device, it could be both a USB HID device or a KNXNET IP device. + +Abstract +-------- + +.. autoclass:: knx_plugin.gateway.Gateway + +USB HID +^^^^^^^ + +.. autoclass:: knx_plugin.gateway.usbhid.Gateway + +KNXNET IP +^^^^^^^^^ + +.. autoclass:: knx_plugin.gateway.knxnet_ip.Gateway + :no-members: + diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..668c82d --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,101 @@ +.. knx-plugin documentation master file, created by + sphinx-quickstart on Wed Jan 27 10:59:40 2021. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +automate-knx-plugin documentation +======================== + +**knx-plugin** is a library which translates **Appliance's state changes** into *KNX commands* and +**KNX events** into *messages for Appliances*. + +An **Appliance** is a non deterministic state machine, abstract representation of one or more physical devices. + +Examples +======== + +KNX events +---------- + +When the button is pressed the *Light Appliance* is **Forced On**. + +.. doctest:: knx_event + + >>> import json + >>> import home + >>> import knx_stack + >>> import knx_plugin + + >>> # Setup the KNX stack + >>> address_table = knx_stack.AddressTable(knx_stack.Address(4097), [], 255) + >>> association_table = knx_stack.AssociationTable(address_table, []) + >>> groupobject_table = knx_stack.GroupObjectTable() + + >>> # Build a Trigger which triggers the message sent by the button when it is pressed + >>> trigger = knx_plugin.trigger.dpt_switch.On.make(addresses=[knx_stack.GroupAddress(free_style=1234),], + ... events=[home.appliance.light.event.forced.Event.On]) + >>> trigger.associate(association_table, groupobject_table) + + >>> # Simulate reception of a message sent by the button when Off is pressed and the trigger is not triggered + >>> dpt = knx_stack.datapointtypes.DPT_Switch() + >>> dpt.bits.action = dpt.Action.off + >>> asap = association_table.get_asaps_from_address(knx_stack.GroupAddress(free_style=1234))[0] + >>> bus_event = knx_stack.layer.application.a_group_value_write.ind.Msg(asap, dpt) + >>> event = knx_plugin.Description.make_from(bus_event) + >>> trigger.is_triggered(event) + False + + >>> # Simulate reception of a message sent by the button when On is pressed and the trigger is triggered + >>> dpt.bits.action = dpt.Action.on + >>> event = knx_plugin.Description.make_from(bus_event) + >>> trigger.is_triggered(event) + True + + >>> # Since the trigger has been triggered the Light Appliance can be notified with the trigger's event + >>> appliance = home.appliance.light.presence.Appliance("a light", []) + >>> old_state, new_state = appliance.notify(trigger.events[0]) + >>> old_state + Off (computed from events: home.event.presence.Event.Off, home.appliance.light.event.forced.event.Event.Not) and disabled events set() + >>> new_state + Forced On (computed from events: home.event.presence.Event.Off, home.appliance.light.event.forced.event.Event.On) and disabled events set() + + +Adjust *Lux Sensor Appliance* **brightness** when receiving lux messages from lux sensor device + +.. doctest:: knx_event + + >>> import home + >>> import knx_plugin + + >>> # Create a trigger which triggers any lux value + >>> trigger = knx_plugin.trigger.dpt_value_lux.Always.make([1234]) + + >>> # Simulate reception of a new lux value from bus + >>> bus_event = ''' + ... {"name": "DPT_Value_Lux", + ... "addresses": [1234], + ... "fields": {"decoded_value": 1.0}} + ... ''' + >>> description = (knx_plugin.Description(json.loads(bus_event))) + + >>> # Update the Lux Sensor Appliance using data in the knx message + >>> appliance = home.appliance.sensor.luxmeter.Appliance("a lux sensor", [0.0]) + >>> appliance.state + 0.0 lux (computed from events: 0.0) and disabled events set() + >>> trigger.make_new_state_from(description, appliance.state) + 1.0 lux (computed from events: 1.0) and disabled events set() + + +.. toctree:: + :maxdepth: 5 + + messages + clients + gateways + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/source/messages.rst b/docs/source/messages.rst new file mode 100644 index 0000000..1bf8f45 --- /dev/null +++ b/docs/source/messages.rst @@ -0,0 +1,14 @@ +Messages +======== + +Every message from/to the knx bus is represented as a :meth:`knx_plugin.message.Description` entity. + +Both triggers and commands are :meth:`knx_plugin.message.Description` entities. + +.. autoclass:: knx_plugin.message.Description + +.. toctree:: + :maxdepth: 2 + + triggers + commands diff --git a/docs/source/trigger/always.rst b/docs/source/trigger/always.rst new file mode 100644 index 0000000..28511c7 --- /dev/null +++ b/docs/source/trigger/always.rst @@ -0,0 +1,35 @@ +Always +====== + +.. autoclass:: knx_plugin.trigger.Always + :no-members: + +DPT Value Temp +-------------- + +.. autoclass:: knx_plugin.trigger.dpt_value_temp.Always + :no-members: + +DPT Value Wsp +------------- +.. autoclass:: knx_plugin.trigger.dpt_value_wsp.Always + :no-members: + +DPT Value Lux +------------- +.. autoclass:: knx_plugin.trigger.dpt_value_lux.Always + :no-members: + +.. autoclass:: knx_plugin.trigger.dpt_value_lux.Brightness + :no-members: + +DPT Value Power +--------------- +.. autoclass:: knx_plugin.trigger.dpt_value_power.Always + :no-members: + +DPT Brightness +-------------- +.. autoclass:: knx_plugin.trigger.dpt_brightness.Always + :no-members: + diff --git a/docs/source/trigger/equal.rst b/docs/source/trigger/equal.rst new file mode 100644 index 0000000..00f1aeb --- /dev/null +++ b/docs/source/trigger/equal.rst @@ -0,0 +1,77 @@ +Equal +===== +.. autoclass:: knx_plugin.trigger.Equal + :no-members: + +DPT Switch +---------- + +On +^^ +.. autoclass:: knx_plugin.trigger.dpt_switch.On + :no-members: + :members: DPT + +Off +^^^ +.. autoclass:: knx_plugin.trigger.dpt_switch.Off + :no-members: + :members: DPT + +DPT UpDown +---------- + +Up +^^^^^^ +.. autoclass:: knx_plugin.trigger.dpt_updown.Up + :no-members: + :members: DPT + +Down +^^^^^^ +.. autoclass:: knx_plugin.trigger.dpt_updown.Down + :no-members: + :members: DPT + + +DPT Start +---------- + +Start +^^^^^^ +.. autoclass:: knx_plugin.trigger.dpt_start.Start + :no-members: + :members: DPT + +Stop +^^^^^^ +.. autoclass:: knx_plugin.trigger.dpt_start.Stop + :no-members: + :members: DPT + + +DPT Control Dimming +------------------- + +Step Up +^^^^^^^ +.. autoclass:: knx_plugin.trigger.dpt_control_dimming.step.up.Trigger + :no-members: + :members: DPT + +Step Down +^^^^^^^^^ +.. autoclass:: knx_plugin.trigger.dpt_control_dimming.step.down.Trigger + :no-members: + :members: DPT + + +DPT Scene Control +----------------- + +Activate +^^^^^^^^ +.. autoclass:: knx_plugin.trigger.dpt_scene_control.Activate + :no-members: + :members: DPT, make, make_from_yaml + diff --git a/docs/source/trigger/greater_than.rst b/docs/source/trigger/greater_than.rst new file mode 100644 index 0000000..35eea1c --- /dev/null +++ b/docs/source/trigger/greater_than.rst @@ -0,0 +1,25 @@ +Greater Than +============ + +.. autoclass:: knx_plugin.trigger.GreaterThan + :no-members: + +DPT Value Temp +-------------- + +.. autoclass:: knx_plugin.trigger.dpt_value_temp.Hot + +DPT Value Wsp +------------- +.. autoclass:: knx_plugin.trigger.dpt_value_wsp.Strong + +DPT Value Power +--------------- + +.. autoclass:: knx_plugin.trigger.dpt_value_power.consumption.Overhead + +DPT Value Power +--------------- + +.. autoclass:: knx_plugin.trigger.dpt_value_power.production.No + diff --git a/docs/source/trigger/in_between.rst b/docs/source/trigger/in_between.rst new file mode 100644 index 0000000..c90b092 --- /dev/null +++ b/docs/source/trigger/in_between.rst @@ -0,0 +1,10 @@ +In Between +========== + +.. autoclass:: knx_plugin.trigger.InBetween + :no-members: + +DPT Value Temp +-------------- + +.. autoclass:: knx_plugin.trigger.dpt_value_temp.Warm diff --git a/docs/source/trigger/lesser_than.rst b/docs/source/trigger/lesser_than.rst new file mode 100644 index 0000000..2c01ca1 --- /dev/null +++ b/docs/source/trigger/lesser_than.rst @@ -0,0 +1,15 @@ +Lesser Than +=========== + +.. autoclass:: knx_plugin.trigger.LesserThan + :no-members: + +DPT Value Temp +-------------- + +.. autoclass:: knx_plugin.trigger.dpt_value_temp.Cold + +DPT Value Power +--------------- + +.. autoclass:: knx_plugin.trigger.dpt_value_power.consumption.No \ No newline at end of file diff --git a/docs/source/trigger/mean/greater_than.rst b/docs/source/trigger/mean/greater_than.rst new file mode 100644 index 0000000..8c5b88b --- /dev/null +++ b/docs/source/trigger/mean/greater_than.rst @@ -0,0 +1,11 @@ +Greater Than the mean value +=========================== + +.. autoclass:: knx_plugin.trigger.mean.GreaterThan + :no-members: + +DPT Value Lux +-------------- + +.. autoclass:: knx_plugin.trigger.dpt_value_lux.Bright + diff --git a/docs/source/trigger/mean/in_between.rst b/docs/source/trigger/mean/in_between.rst new file mode 100644 index 0000000..19c972e --- /dev/null +++ b/docs/source/trigger/mean/in_between.rst @@ -0,0 +1,18 @@ +In Between the mean value plus a range +====================================== + +.. autoclass:: knx_plugin.trigger.mean.InBetween + :no-members: + +DPT Value Lux +------------- + +.. autoclass:: knx_plugin.trigger.dpt_value_lux.Dark + +DPT Value Power +--------------- + +.. autoclass:: knx_plugin.trigger.dpt_value_power.consumption.Low + +.. autoclass:: knx_plugin.trigger.dpt_value_power.consumption.High + diff --git a/docs/source/trigger/mean/lesser_than.rst b/docs/source/trigger/mean/lesser_than.rst new file mode 100644 index 0000000..2ccfeb2 --- /dev/null +++ b/docs/source/trigger/mean/lesser_than.rst @@ -0,0 +1,16 @@ +Lesser Than the mean value +=========================== + +.. autoclass:: knx_plugin.trigger.mean.LesserThan + :no-members: + +DPT Value Lux +-------------- + +.. autoclass:: knx_plugin.trigger.dpt_value_lux.DeepDark + +DPT Value Wsp +-------------- + +.. autoclass:: knx_plugin.trigger.dpt_value_wsp.Weak + diff --git a/docs/source/triggers.rst b/docs/source/triggers.rst new file mode 100644 index 0000000..883b7ed --- /dev/null +++ b/docs/source/triggers.rst @@ -0,0 +1,26 @@ +Triggers +******** + +Triggers are entities **comparable** with messages received from the knx bus. + +If the comparison, made through the **is_triggered** method, returns True than the entity is considered to be triggered. + +When the entity is triggered + +1) if it is used by a **Scheduler Trigger** than its events are notified to *Appliances* +2) if it is used by a **Performer** its events are used to **directly** update an *Appliance* + +.. autoclass:: knx_plugin.trigger.Trigger + +.. toctree:: + :maxdepth: 4 + + trigger/equal + trigger/always + trigger/greater_than + trigger/mean/greater_than + trigger/lesser_than + trigger/mean/lesser_than + trigger/in_between + trigger/mean/in_between + diff --git a/knx_plugin/__init__.py b/knx_plugin/__init__.py new file mode 100644 index 0000000..ba6e460 --- /dev/null +++ b/knx_plugin/__init__.py @@ -0,0 +1,3 @@ +from knx_plugin import gateway, client +from knx_plugin.message import Description, Command +from knx_plugin import trigger, command diff --git a/knx_plugin/client/__init__.py b/knx_plugin/client/__init__.py new file mode 100644 index 0000000..8c205ab --- /dev/null +++ b/knx_plugin/client/__init__.py @@ -0,0 +1,120 @@ +import asyncio +import logging + +import knx_stack +from typing import Iterable, Callable, Union, Tuple, Any + + +class MsgNotEncoded(Exception): + """Knx msg has not been encoded""" + + +class Client(asyncio.Protocol): + def __init__(self, knx_state: "knx_stack.State", tasks: Iterable["Callable"]): + self._loop = asyncio.get_event_loop() + self._transport = None + self._tasks = set(tasks) + self._state = knx_state + + self.logger = logging.getLogger(__name__) + + def connection_made(self, transport): + self._transport = transport + self.logger.info("Connection made: {}".format(str(self._transport))) + + def connection_lost(self, exc): + self.logger.error("Connection lost: {}".format(str(exc))) + self._transport = None + + def error_received(self, exc): + self.logger.error("Error received: {}".format(str(exc))) + + def encode( + self, + msg: Union[ + "knx_stack.layer.application.a_group_value_write.req.Msg", + "knx_stack.layer.application.a_group_value_write.req.Msg", + "knx_stack.layer.application.a_group_value_write.ind.Msg", + ], + ) -> "knx_stack.Msg": + if isinstance(msg, knx_stack.layer.application.a_group_value_write.req.Msg): + knx_msg = knx_stack.encode_msg(self._state, msg) + elif isinstance(msg, knx_stack.layer.application.a_group_value_read.req.Msg): + knx_msg = knx_stack.encode_msg(self._state, msg) + elif isinstance(msg, knx_stack.layer.application.a_group_value_write.ind.Msg): + knx_msg = knx_stack.encode_msg(self._state, msg) + else: + raise MsgNotEncoded("msg: {} could not be encoded".format(msg)) + + return knx_msg + + def filter( + self, + msgs: Iterable[ + Union[ + "knx_stack.layer.application.a_group_value_read.req.Msg", + "knx_stack.layer.application.a_group_value_write.req.Msg", + "knx_stack.layer.application.a_group_value_read.con.Msg", + "knx_stack.layer.application.a_group_value_write.con.Msg", + "knx_stack.layer.application.a_group_value_read.ind.Msg", + "knx_stack.layer.application.a_group_value_write.ind.Msg", + ] + ], + ) -> Tuple[ + Iterable[ + Union[ + "knx_stack.layer.application.a_group_value_read.req.Msg", + "knx_stack.layer.application.a_group_value_write.req.Msg", + ] + ], + Iterable[ + Union[ + "knx_stack.layer.application.a_group_value_read.con.Msg", + "knx_stack.layer.application.a_group_value_write.con.Msg", + ] + ], + Iterable[ + Union[ + "knx_stack.layer.application.a_group_value_read.ind.Msg", + "knx_stack.layer.application.a_group_value_write.ind.Msg", + ] + ], + Iterable[Any], + ]: + req = list() + con = list() + ind = list() + others = list() + if msgs: + for msg in msgs: + if isinstance( + msg, knx_stack.layer.application.a_group_value_read.req.Msg + ) or isinstance( + msg, knx_stack.layer.application.a_group_value_write.req.Msg + ): + req.append(msg) + elif isinstance( + msg, knx_stack.layer.application.a_group_value_read.con.Msg + ) or isinstance( + msg, knx_stack.layer.application.a_group_value_write.con.Msg + ): + con.append(msg) + elif isinstance( + msg, knx_stack.layer.application.a_group_value_read.ind.Msg + ) or isinstance( + msg, knx_stack.layer.application.a_group_value_write.ind.Msg + ): + ind.append(msg) + else: + others.append(msg) + return req, con, ind, others + + async def _wait_for_transport(self): + while not self._transport: + await asyncio.sleep(0.1) + + async def write(self, msgs, *args): + raise NotImplementedError + + +from knx_plugin.client import knxnet_ip, usbhid diff --git a/knx_plugin/client/knxnet_ip.py b/knx_plugin/client/knxnet_ip.py new file mode 100644 index 0000000..37fd477 --- /dev/null +++ b/knx_plugin/client/knxnet_ip.py @@ -0,0 +1,212 @@ +import datetime +import asyncio + +import knx_stack +from typing import Iterable, Callable +from knx_plugin.client import Client as Parent + + +class KnxnetIPClientException(Exception): + pass + + +class Client(Parent): + + MAX_RETRIES = 3 + + def __init__( + self, + knx_state: "knx_stack.State", + tasks: Iterable["Callable"], + local_addr: str, + local_port: int, + remote_addr: str, + remote_port: int, + ): + super(Client, self).__init__(knx_state, tasks) + self._local_addr = local_addr + self._local_port = local_port + self._remote_addr = remote_addr + self._remote_port = remote_port + self._connect_timeout = None + self._tunneling_request_timeout = None + self._connect_alive_timeout = None + self._retries = 0 + self._got_a_confirmation = False + + def connection_made(self, transport): + super(Client, self).connection_made(transport) + connect_req = knx_stack.knxnet_ip.core.connect.req.Msg( + addr_control_endpoint=self._local_addr, + port_control_endpoint=self._local_port, + addr_data_endpoint=self._local_addr, + port_data_endpoint=self._local_port, + ) + self._loop.create_task(self.manage_connect_timeout()) + msg = knx_stack.encode_msg(self._state, connect_req) + self._transport.sendto(self.encode(msg), (self._remote_addr, self._remote_port)) + self._connect_timeout = datetime.datetime.now() + + def decode(self, data): + msgs = [] + msg = knx_stack.knxnet_ip.Msg.make_from_str(data.hex()) + self.logger.debug("received: {}".format(msg)) + try: + msgs = knx_stack.decode_msg(self._state, msg) + except TypeError as e: + self.logger.error(e) + return msgs + + def encode(self, msg): + self.logger.debug("sent: {}".format(msg)) + final_msg = bytearray.fromhex(str(msg)) + return final_msg + + def datagram_received(self, data, addr): + msgs = self.decode(data) + (reqs, cons, inds, others) = self.filter(msgs) + for task in self._tasks: + for con in cons: + self._loop.create_task(task(con)) + for ind in inds: + self._loop.create_task(task(ind)) + for con in cons: + self.manage_request_confirmation(con) + for other in others: + self.manage_connect(other) + self.manage_server_tunneling_request(other) + + async def write(self, msgs, *args): + await self._wait_for_transport() + for msg in msgs: + if isinstance(msg, knx_stack.layer.application.a_group_value_write.req.Msg): + while self._retries < self.MAX_RETRIES and not self._got_a_confirmation: + self.logger.info("retry {}".format(self._retries)) + self._retries += 1 + self._got_a_confirmation = False + req = knx_stack.encode_msg(self._state, msg) + self._transport.sendto( + self.encode(req), (self._remote_addr, self._remote_port) + ) + self._tunneling_request_timeout = datetime.datetime.now() + await self.manage_tunneling_request_timeout() + self._retries = 0 + self._got_a_confirmation = False + + def manage_connect(self, msg): + if self._connect_timeout: + self.logger.info("{}".format(msg)) + if isinstance(msg, knx_stack.knxnet_ip.core.connect.res.Msg): + self.logger.info("ConnectRes received") + if msg.status == knx_stack.knxnet_ip.ErrorCodes.E_NO_ERROR: + self._connect_alive_timeout = datetime.datetime.now() + self._loop.create_task(self.manage_connect_alive_timeout()) + self.logger.info("Knxnet ip client connected") + else: + raise KnxnetIPClientException( + "Knxnet_ip client connection with server {} has an error {}".format( + (self._remote_addr, self._local_port), + knx_stack.knxnet_ip.ErrorCodes[msg.status], + ) + ) + + def manage_request_confirmation(self, msg): + if self._tunneling_request_timeout: + if isinstance(msg, knx_stack.layer.application.a_group_value_write.con.Msg): + self.logger.info("Got a confirmation {}".format(msg)) + self._got_a_confirmation = True + + def manage_server_tunneling_request(self, msg): + if isinstance(msg, knx_stack.decode.knxnet_ip.tunneling.req.Msg): + if msg.status == knx_stack.knxnet_ip.ErrorCodes.E_NO_ERROR: + ack_msg = knx_stack.knxnet_ip.tunneling.ack.Msg( + sequence_counter=msg.sequence_counter, status=msg.status + ) + ack = knx_stack.encode_msg(self._state, ack_msg) + self._transport.sendto( + self.encode(ack), (self._remote_addr, self._remote_port) + ) + self._connect_alive_timeout = datetime.datetime.now() + else: + self.logger.error( + "Received server tunneling request with error {}".format( + knx_stack.definition.knxnet_ip.ErrorCodes(msg.status) + ) + ) + + async def manage_connect_timeout(self): + while True: + try: + if self._connect_timeout: + if ( + datetime.datetime.now() - self._connect_timeout + ) > datetime.timedelta( + seconds=knx_stack.knxnet_ip.CONNECT_REQUEST_TIMEOUT + ): + self.logger.info("Connect timeout expired") + self._connect_timeout = None + break + await asyncio.sleep(knx_stack.knxnet_ip.CONNECT_REQUEST_TIMEOUT / 3) + except Exception as e: + self.logger.error(e) + + async def manage_tunneling_request_timeout(self): + while True: + try: + if self._tunneling_request_timeout: + if ( + datetime.datetime.now() - self._tunneling_request_timeout + ) > datetime.timedelta( + seconds=knx_stack.knxnet_ip.TUNNELING_REQUEST_TIMEOUT + ): + self.logger.info("Tunneling request timeout expired") + self._tunneling_request_timeout = None + break + elif self._got_a_confirmation: + self._tunneling_request_timeout = None + break + else: + self.logger.info("Tunneling request timeout not expired yet") + await asyncio.sleep( + knx_stack.knxnet_ip.TUNNELING_REQUEST_TIMEOUT / 6 + ) + elif self._got_a_confirmation: + self._tunneling_request_timeout = None + break + else: + await asyncio.sleep( + knx_stack.knxnet_ip.TUNNELING_REQUEST_TIMEOUT / 4 + ) + except Exception as e: + self.logger.error(e) + + async def manage_connect_alive_timeout(self): + while True: + try: + if self._connect_alive_timeout: + if ( + datetime.datetime.now() - self._connect_alive_timeout + ) > datetime.timedelta( + seconds=(knx_stack.knxnet_ip.CONNECTION_ALIVE_TIME / 2) + ): + self.logger.info("Connect alive timeout expired") + req_msg = knx_stack.knxnet_ip.core.connectionstate.req.Msg( + addr_control_endpoint=self._local_addr, + port_control_endpoint=self._local_port, + ) + knx_msg = knx_stack.encode_msg(self._state, req_msg) + self._connect_alive_timeout = datetime.datetime.now() + self._transport.sendto( + self.encode(knx_msg), (self._remote_addr, self._remote_port) + ) + await asyncio.sleep(knx_stack.knxnet_ip.CONNECTION_ALIVE_TIME / 2) + except Exception as e: + self.logger.error(e) + + async def disconnect(self): + disconnect_req = knx_stack.knxnet_ip.core.disconnect.req.Msg( + addr_control_endpoint=self._local_addr, + port_control_endpoint=self._local_port, + ) + msg = knx_stack.encode_msg(self._state, disconnect_req) + self._transport.sendto(self.encode(msg), (self._remote_addr, self._remote_port)) diff --git a/knx_plugin/client/usbhid.py b/knx_plugin/client/usbhid.py new file mode 100644 index 0000000..4858925 --- /dev/null +++ b/knx_plugin/client/usbhid.py @@ -0,0 +1,112 @@ +import asyncio +import logging + +import knx_stack + +from knx_plugin.client import Client as Parent + + +class MsgNotEncoded(Exception): + """Knx msg has not been encoded""" + + +class Client(Parent): + def data_received(self, data): + msgs = self.decode(data) + (reqs, cons, inds, others) = self.filter(msgs) + for task in self._tasks: + for msg in cons: + self._loop.create_task(task(msg)) + for msg in inds: + self._loop.create_task(task(msg)) + + def decode(self, data): + msgs = list() + str_msgs = data.decode() + splitted_data = str_msgs.split("\n") + all_msgs = list() + for msg in splitted_data: + try: + if msg: + octects_msg = knx_stack.Msg.make_from_str(msg) + self.logger.debug("received: {}".format(octects_msg)) + msgs = knx_stack.decode_msg(self._state, octects_msg) + if msgs: + all_msgs.extend(msgs) + except IndexError as e: + self.logger.error(str(e) + " decoding msg: " + str(msg)) + except ValueError as e: + self.logger.error(str(e) + " decoding msg: " + str(msg)) + return all_msgs + + def encode(self, msg): + knx_msg = super(Client, self).encode(msg) + self.logger.debug("sent: {}".format(knx_msg)) + knx_msg = str(knx_msg) + padding = 112 - len(knx_msg) + padded_msg = knx_msg + "0" * padding + padded_msg += "\n" + return padded_msg + + async def write(self, msgs, *args): + await self._wait_for_transport() + for msg in msgs: + if isinstance(msg, knx_stack.layer.application.a_group_value_write.req.Msg): + knx_msg = str(self.encode(msg)) + self.logger.info("written {}".format(knx_msg)) + self._transport.write(knx_msg.encode()) + + +class ClientExample(object): + def __init__(self): + self._transport = None + self._protocol = None + + def send_msg(self, msg, *args): + state = args[0] + new_state = state + final_msg = None + if isinstance(msg, knx_stack.layer.application.a_group_value_write.req.Msg): + final_msg = knx_stack.encode_msg(state, msg) + elif isinstance(msg, knx_stack.layer.application.a_group_value_read.req.Msg): + final_msg = knx_stack.encode_msg(state, msg) + if final_msg: + self._transport.write(str(final_msg).encode()) + return new_state + + async def run(self): + self._transport, self._protocol = await loop.create_connection( + lambda: Client(None, []), "localhost", 5555 + ) + + address_table = knx_stack.AddressTable(0x0000, [], 255) + association_table = knx_stack.AssociationTable(address_table, {}) + + new_association_table = association_table.associate(0x0E89, 1) + from knx_stack.datapointtypes import DPT_Switch + + state = knx_stack.State( + knx_stack.Medium.usb_hid, new_association_table, {1: DPT_Switch} + ) + switch = DPT_Switch() + switch.bits.action = DPT_Switch.Action.on + req_msg = knx_stack.layer.application.a_group_value_write.req.Msg( + asap=1, dpt=switch + ) + + while True: + self.send_msg(req_msg, state) + await asyncio.sleep(3) + + +if __name__ == "__main__": + import sys + + root = logging.getLogger() + root.setLevel(logging.DEBUG) + handler = logging.StreamHandler(sys.stdout) + root.addHandler(handler) + + loop = asyncio.get_event_loop() + loop.create_task(ClientExample().run()) + loop.run_forever() diff --git a/knx_plugin/command/__init__.py b/knx_plugin/command/__init__.py new file mode 100644 index 0000000..4679948 --- /dev/null +++ b/knx_plugin/command/__init__.py @@ -0,0 +1,21 @@ +from typing import TypeVar, Type + +import home + +OnOffAppliance = TypeVar( + "OnOffAppliance", + Type[home.appliance.light.state.State], + Type[home.appliance.socket.presence.state.State], + Type[home.appliance.socket.energy_guard.state.State], + Type[home.appliance.sound.player.state.State], + Type[home.appliance.sprinkler.state.State], +) + +OpenCloseAppliance = TypeVar( + "OpenCloseAppliance", + Type[home.appliance.curtain.indoor.blackout.state.State], + Type[home.appliance.curtain.outdoor.state.State], +) + + +from knx_plugin.command import custom_clima, dpt_brightness, dpt_switch, dpt_updown diff --git a/knx_plugin/command/custom_clima.py b/knx_plugin/command/custom_clima.py new file mode 100644 index 0000000..8edc7b3 --- /dev/null +++ b/knx_plugin/command/custom_clima.py @@ -0,0 +1,127 @@ +import copy + +import home +from knx_plugin.message import Command as Parent + + +class Command(Parent): + def __init__(self, description, low_setpoint, high_setpoint): + super(Command, self).__init__(description) + self._low_setpoint = low_setpoint + self._high_setpoint = high_setpoint + + @classmethod + def make(cls, addresses, low_setpoint, high_setpoint): + description = copy.deepcopy(cls.DPT) + dsc = cls(description, low_setpoint, high_setpoint) + dsc.addresses = addresses + return dsc + + @classmethod + def make_from_yaml(cls, addresses, low_setpoint, high_setpoint): + description = copy.deepcopy(cls.DPT) + description["addresses"] = addresses + return cls(description, low_setpoint, high_setpoint) + + def _encode_winter_setpoint(self, setpoint): + self.dpt.setpoint = int(setpoint * 10 - 50) + + def _encode_summer_setpoint(self, setpoint): + self.dpt.setpoint = int(setpoint * 10 + 50) + + def _encode_setpoint(self, state): + if not state.is_on or state.is_keeping: + setpoint = self._low_setpoint + else: + setpoint = self._high_setpoint + + if home.event.clima.season.Event.Winter in state: + self._encode_winter_setpoint(setpoint) + else: + self._encode_summer_setpoint(setpoint) + + def _encode_season(self, state): + if home.event.clima.season.Event.Winter in state: + self.dpt.stagione = "inverno" + else: + self.dpt.stagione = "estate" + + def _encode_funzionamento(self, state): + if state.is_keeping: + self.dpt.funzionamento = "riduzione_notturna" + elif state.is_on: + self.dpt.funzionamento = "automatico" + else: + self.dpt.funzionamento = "off" + + +class Setup(Command): + """ + >>> import home + >>> import knx_plugin + >>> import knx_stack + + >>> cmd = knx_plugin.command.custom_clima.Setup.make([3202], 19, 20) + >>> cmd._asaps = [1] + >>> state = home.appliance.thermostat.presence.state.off.State() + >>> first_state = home.appliance.thermostat.presence.state.off.State([0.0, home.event.clima.season.Event.Winter, + ... home.event.clima.command.Event.Off]) + >>> msg = cmd.make_msgs_from(state, first_state) + >>> knx_stack.Long(value=msg[0].dpt.value) + 0x50058C00 + >>> "off" in str(msg[0].dpt) + True + >>> "140" in str(msg[0].dpt) + True + >>> second_state = home.appliance.thermostat.presence.state.keep.State([0.0, home.event.clima.season.Event.Winter, + ... home.event.clima.command.Event.Keep]) + >>> msg = cmd.make_msgs_from(first_state, second_state) + >>> knx_stack.Long(value=msg[0].dpt.value) + 0x54058C00 + >>> "riduzione_notturna" in str(msg[0].dpt) + True + >>> "140" in str(msg[0].dpt) + True + >>> third_state = home.appliance.thermostat.presence.state.on.State([0.0, home.event.clima.season.Event.Winter, + ... home.event.presence.Event.On, + ... home.event.clima.command.Event.On]) + >>> msg = cmd.make_msgs_from(second_state, third_state) + >>> knx_stack.Long(value=msg[0].dpt.value) + 0x58059600 + >>> "automatico" in str(msg[0].dpt) + True + >>> "150" in str(msg[0].dpt) + True + """ + + DPT = { + "name": "DPTSetupClima", + "fields": { + "funzionamento": "automatico", + "centralizzato": True, # invio dati dal termostato al sistema + "stagione": "inverno", + "terziario": True, # anti bimbo + "differenziale": 5, # valori da 1 a 10 per esprimere valori da 0.1 a 1 grado centigrado + "variazione_setpoint": 0, # limite alla variazione del set point da parte dell'utente sul termostato + "unita_misura": "celsius", + "setpoint": 155, # 20 gradi + "temporizzazione": 0, + }, + "addresses": [], + } + + def make_msgs_from(self, old_state, new_state): + result = [] + + if ( + (old_state.season != new_state.season) + or (old_state.mode != new_state.mode) + or (old_state.setpoint != new_state.setpoint) + ): + self._encode_season(new_state) + self._encode_setpoint(new_state) + self._encode_funzionamento(new_state) + + result = self.execute() + + return result diff --git a/knx_plugin/command/dpt_brightness.py b/knx_plugin/command/dpt_brightness.py new file mode 100644 index 0000000..d74922f --- /dev/null +++ b/knx_plugin/command/dpt_brightness.py @@ -0,0 +1,57 @@ +from typing import Union, Type + +import home +from knx_plugin.message import Command + + +class Brightness(Command): + """ + >>> import home + >>> import knx_plugin + >>> command = knx_plugin.command.dpt_brightness.Brightness.make([]) + >>> command._asaps = [1] + >>> old_state = home.appliance.light.indoor.dimmerable.state.off.State() + >>> new_state = old_state.next(home.appliance.light.event.brightness.Event(10)) + >>> new_state = new_state.next(home.appliance.light.event.circadian_rhythm.brightness.Event(20)) + >>> new_state = new_state.next(home.appliance.light.event.lux_balancing.brightness.Event(30)) + >>> new_state = new_state.next(home.appliance.light.indoor.dimmerable.event.forced.Event.CircadianRhythm) + >>> msgs = command.make_msgs_from(old_state, new_state) + >>> msgs[0].dpt.value + 20 + >>> old_state = new_state.next(home.appliance.light.indoor.dimmerable.event.forced.Event.Not) + >>> new_state = old_state.next(home.appliance.light.indoor.dimmerable.event.forced.Event.LuxBalance) + >>> msgs = command.make_msgs_from(old_state, new_state) + >>> msgs[0].dpt.value + 30 + >>> old_state = new_state.next(home.appliance.light.indoor.dimmerable.event.forced.Event.Not) + >>> new_state = old_state.next(home.appliance.light.indoor.dimmerable.event.forced.Event.On) + >>> msgs = command.make_msgs_from(old_state, new_state) + >>> msgs[0].dpt.value + 10 + """ + + DPT = { + "type": "knx", + "name": "DPT_Brightness", + "addresses": [], + "fields": {"value": 100}, + } + + def make_msgs_from( + self, + old_state: Union[ + Type[home.appliance.light.indoor.dimmerable.state.State], + Type[home.appliance.light.indoor.hue.state.State], + ], + new_state: Union[ + Type[home.appliance.light.indoor.dimmerable.state.State], + Type[home.appliance.light.indoor.hue.state.State], + ], + ): + result = [] + if ((old_state.is_on != new_state.is_on) and new_state.is_on) or ( + (old_state.brightness != new_state.brightness) and new_state.is_on + ): + self._dpt.value = new_state.brightness + result = self.execute() + return result diff --git a/knx_plugin/command/dpt_switch.py b/knx_plugin/command/dpt_switch.py new file mode 100644 index 0000000..e5fca68 --- /dev/null +++ b/knx_plugin/command/dpt_switch.py @@ -0,0 +1,68 @@ +from knx_plugin.command import OnOffAppliance +from knx_plugin.message import Command + + +class OnOff(Command): + """ + >>> import home + >>> import knx_plugin + >>> old_state = home.appliance.light.indoor.hue.state.off.State() + >>> new_state = old_state.next(home.appliance.light.indoor.dimmerable.event.forced.Event.On) + >>> command = knx_plugin.command.dpt_switch.OnOff.make([0xAAAA]) + >>> command._asaps = [1] + >>> command.make_msgs_from(old_state, new_state) + [GroupValueWriteReq (DPT_Switch {'action': 'on'} for asap 1)] + """ + + DPT = { + "type": "knx", + "name": "DPT_Switch", + "addresses": [], + "fields": {"action": "off"}, + } + + def make_msgs_from( + self, + old_state: OnOffAppliance, + new_state: OnOffAppliance, + ): + """ + - If Appliance has been turned **on** then send a *dpt_switch on* message on bus + - If Appliance has been turned **off** then send a *dpt_switch off* message on bus + """ + result = [] + if old_state.is_on != new_state.is_on: + if new_state.is_on: + self._dpt.action = "on" + else: + self._dpt.action = "off" + result = self.execute() + return result + + +class OffOn(Command): + + DPT = { + "type": "knx", + "name": "DPT_Switch", + "addresses": [], + "fields": {"action": "off"}, + } + + def make_msgs_from( + self, + old_state: OnOffAppliance, + new_state: OnOffAppliance, + ): + """ + - If Appliance has been turned **on** then send a *dpt_switch off* message on bus + - If Appliance has been turned **off** then send a *dpt_switch on* message on bus + """ + result = [] + if old_state.is_on != new_state.is_on: + if new_state.is_on: + self._dpt.action = "off" + else: + self._dpt.action = "on" + result = self.execute() + return result diff --git a/knx_plugin/command/dpt_updown.py b/knx_plugin/command/dpt_updown.py new file mode 100644 index 0000000..6131afd --- /dev/null +++ b/knx_plugin/command/dpt_updown.py @@ -0,0 +1,61 @@ +from knx_plugin.command import OpenCloseAppliance +from knx_plugin.message import Command + + +class UpDown(Command): + + DPT = {"name": "DPT_UpDown", "addresses": [], "fields": {"direction": "up"}} + + def make_msgs_from( + self, + old_state: OpenCloseAppliance, + new_state: OpenCloseAppliance, + ): + """ + - If Appliance has been **closed** then send a *dpt_updown down* message on bus + - If Appliance has been **opened** then send a *dpt_updown up* message on bus + """ + result = [] + if old_state.is_opened != new_state.is_opened: + if new_state.is_opened: + self._dpt.direction = "up" + else: + self._dpt.direction = "down" + result = self.execute() + return result + + +class Up(Command): + + DPT = {"name": "DPT_UpDown", "addresses": [], "fields": {"direction": "up"}} + + def make_msgs_from( + self, old_state: OpenCloseAppliance, new_state: OpenCloseAppliance + ): + """ + - If Appliance has been **closed** do nothing! + - If Appliance has been **opened** then send a *dpt_updown up* message on bus + """ + result = [] + if old_state.is_opened != new_state.is_opened: + if new_state.is_opened: + self._dpt.direction = "up" + result = self.execute() + return result + + +class Stop(Command): + + DPT = {"name": "DPT_Start", "addresses": [], "fields": {"direction": "stop"}} + + def make_msgs_from( + self, old_state: OpenCloseAppliance, new_state: OpenCloseAppliance + ): + result = [] + if ( + old_state.is_opened == new_state.is_opened + and old_state.is_closing != new_state.is_closing + ): + if new_state.is_closing: + result = self.execute() + return result diff --git a/knx_plugin/gateway/__init__.py b/knx_plugin/gateway/__init__.py new file mode 100644 index 0000000..c36732f --- /dev/null +++ b/knx_plugin/gateway/__init__.py @@ -0,0 +1,110 @@ +import asyncio +import logging + +from typing import Iterable, Callable, Union + +import home +import knx_stack + +from knx_plugin.message import Description +from knx_plugin.trigger import Trigger + + +class Gateway(home.protocol.Gateway): + + PROTOCOL = Description.PROTOCOL + + def __init__( + self, client: "knx_plugin.Client", host: str = "0.0.0.0", port: int = 5555 + ): + self._client = client + self._host = host + self._port = port + self._triggers = set() + self._protocol_instance = None + self._transport = None + + address_table = knx_stack.AddressTable(knx_stack.Address(0x0000), [], 1000) + self._association_table = knx_stack.AssociationTable(address_table, []) + self._datapointtypes = knx_stack.GroupObjectTable() + self._knx_state = None + self._init_state() + + self._loop = asyncio.get_event_loop() + self.logger = logging.getLogger(__name__) + + def _init_state(self): + raise NotImplementedError + + @property + def protocol_instance(self): + return self._protocol_instance + + async def disconnect(self) -> None: + if self._transport: + self._transport.close() + + def _associate(self, descriptions): + for description in descriptions: + description.associate(self._association_table, self._datapointtypes) + + self.logger.info(self._association_table) + self.logger.info(self._datapointtypes) + + def associate_commands(self, descriptions: "knx_plugin.message.Command") -> None: + self._associate(descriptions) + + def associate_triggers(self, descriptions: "knx_plugin.message.Trigger") -> None: + self._associate(descriptions) + + async def run(self, other_tasks: Iterable[Callable]) -> None: + self._protocol_instance = self._client( + self._knx_state, self._wrap_tasks(other_tasks) + ) + (self._transport, _) = await self._loop.create_connection( + lambda: self._protocol_instance, self._host, self._port + ) + + @staticmethod + def make_trigger( + msg: Union[ + "knx_stack.layer.application.a_group_value_write.ind.Msg", + "knx_stack.layer.application.a_group_value_read.ind.Msg", + ] + ): + logger = logging.getLogger(__name__) + if ( + isinstance(msg, knx_stack.layer.application.a_group_value_write.ind.Msg) + or isinstance(msg, knx_stack.layer.application.a_group_value_write.ind.Msg) + or isinstance(msg, knx_stack.layer.application.a_group_value_read.req.Msg) + or isinstance(msg, knx_stack.layer.application.a_group_value_write.req.Msg) + or isinstance(msg, knx_stack.layer.application.a_group_value_write.con.Msg) + or isinstance(msg, knx_stack.layer.application.a_group_value_write.con.Msg) + ): + trigger = Trigger.make_from(msg) + logger.debug("{} for asaps {}".format(trigger.dpt, trigger.asaps)) + return trigger + elif isinstance( + msg, knx_stack.decode.knxnet_ip.core.connect.res.Msg + ) or isinstance(msg, knx_stack.decode.knxnet_ip.tunneling.req.Msg): + logger.debug("knxnet ip protocol knx message {}".format(msg)) + pass + else: + logger.error("Not a managed knx message {}".format(msg)) + + async def writer( + self, + msgs: Iterable[ + Union[ + "knx_stack.layer.application.a_group_value_write.req.Msg", + "knx_stack.layer.application.a_group_value_read.req.Msg", + ] + ], + *args + ): + while not self._protocol_instance: + await asyncio.sleep(0.01) + await self._protocol_instance.write(msgs, *args) + + +from knx_plugin.gateway import usbhid, knxnet_ip diff --git a/knx_plugin/gateway/knxnet_ip.py b/knx_plugin/gateway/knxnet_ip.py new file mode 100644 index 0000000..7b4baff --- /dev/null +++ b/knx_plugin/gateway/knxnet_ip.py @@ -0,0 +1,44 @@ +import knx_stack +from knx_plugin.gateway import Gateway as Parent + + +class Gateway(Parent): + def __init__( + self, + client, + remote_host, + remote_port, + local_host, + local_port, + nat_local_host, + nat_local_port, + ): + super(Gateway, self).__init__(client, remote_host, remote_port) + self._local_host = local_host + self._local_port = local_port + self._nat_local_host = nat_local_host + self._nat_local_port = nat_local_port + + def _init_state(self): + self._knx_state = knx_stack.knxnet_ip.State( + knx_stack.Medium.knxnet_ip, self._association_table, self._datapointtypes + ) + + async def run(self, other_tasks): + self._protocol_instance = self._client( + self._knx_state, + self._wrap_tasks(other_tasks), + self._nat_local_host, + self._nat_local_port, + self._host, + self._port, + ) + (self._transport, _) = await self._loop.create_datagram_endpoint( + lambda: self._protocol_instance, + local_addr=(self._local_host, self._local_port), + remote_addr=(self._host, self._port), + ) + + async def disconnect(self): + await self._protocol_instance.disconnect() + await super(Gateway, self).disconnect() diff --git a/knx_plugin/gateway/usbhid.py b/knx_plugin/gateway/usbhid.py new file mode 100644 index 0000000..d90ed04 --- /dev/null +++ b/knx_plugin/gateway/usbhid.py @@ -0,0 +1,12 @@ +import knx_stack +from knx_plugin.gateway import Gateway as Parent + + +class Gateway(Parent): + def __init__(self, client, host="0.0.0.0", port=5555): + super(Gateway, self).__init__(client, host, port) + + def _init_state(self): + self._knx_state = knx_stack.State( + knx_stack.Medium.usb_hid, self._association_table, self._datapointtypes + ) diff --git a/knx_plugin/message.py b/knx_plugin/message.py new file mode 100644 index 0000000..47d7031 --- /dev/null +++ b/knx_plugin/message.py @@ -0,0 +1,169 @@ +import copy +import logging + +from typing import List, Union, Type + +import home +import knx_stack + + +class Description(home.protocol.Description): + + PROTOCOL = "knx" + + DPT = {"name": "Fake", "fields": {}} + + def __init__(self, data: dict): + super(Description, self).__init__(data) + factory = knx_stack.datapointtypes.DPT_Factory() + fields = data["fields"] if "fields" in data else {} + self._dpt: Type[knx_stack.datapointtypes.DPT] = factory.make( + data["name"], fields + ) + self._dpt_class = self._dpt.__class__ + self._addresses: List[knx_stack.Address] = ( + [ + knx_stack.GroupAddress(free_style=address) + for address in data["addresses"] + ] + if "addresses" in data + else [] + ) + self._asaps = [] + self._label = "{} {}".format(self._dpt.__class__.__name__, self._addresses) + + self._logger = logging.getLogger(__name__) + + @property + def dpt(self) -> Type[knx_stack.datapointtypes.DPT]: + return self._dpt + + @property + def asaps(self) -> List[knx_stack.ASAP]: + return self._asaps + + @property + def addresses(self) -> List[knx_stack.Address]: + return self._addresses + + @addresses.setter + def addresses(self, value: List[knx_stack.Address]): + self._addresses = value + + @classmethod + def make( + cls, addresses: List[knx_stack.Address] + ) -> "knx_plugin.message.Description": + description = copy.deepcopy(cls.DPT) + dsc = cls(description) + dsc.addresses = addresses + return dsc + + @classmethod + def make_from_yaml(cls, addresses: List[int]) -> "knx_plugin.message.Description": + description = copy.deepcopy(cls.DPT) + description["addresses"] = addresses + return cls(description) + + @classmethod + def make_from( + cls, + msg: Union[ + knx_stack.layer.application.a_group_value_read.ind.Msg, + knx_stack.layer.application.a_group_value_write.ind.Msg, + ], + ) -> "knx_plugin.message.Description": + dpt_description = knx_stack.datapointtypes.Description_Factory.make(msg.dpt) + description = { + "type": "knx", + "name": dpt_description[0], + "addresses": [], + "fields": dpt_description[1], + } + d = cls(description) + d._asaps = [knx_stack.ASAP(msg.asap.value, "{}".format(d.label))] + return d + + def __eq__(self, other): + if self.PROTOCOL == other.PROTOCOL: + if self.dpt.__class__ == other.dpt.__class__ and set( + self.asaps + ).intersection(set(other.asaps)): + return True + return False + + def __hash__(self): + s = "class: {} asaps: {}".format( + self.dpt.__class__.__name__, [str(asap.value) for asap in self._asaps] + ) + return hash(s) + + def associate_with(self, association_table: knx_stack.AssociationTable) -> None: + for address in self._addresses: + tsap = association_table.get_tsap(address) + asaps = association_table.get_asaps(tsap) + self._asaps.extend(asaps) + + def associate( + self, + association_table: knx_stack.AssociationTable, + groupobject_table: knx_stack.GroupObjectTable, + ): + value = association_table.get_free_asap_value() + asap = knx_stack.ASAP( + value, + "{}: {} {}".format( + self._label, self.dpt.__class__.__name__, self._addresses + ), + ) + self._asaps.append(asap) + association_table.associate(asap, self._addresses) + groupobject_table.associate(asap, self._dpt_class) + + self._logger.info( + "associate %s for %s to asap %s" + % (str(self._addresses), str(self.dpt.__class__.__name__), str(self.asaps)) + ) + + def __str__(self, *args, **kwargs): + return self._label + + +class Command(Description, home.protocol.Command): + """A generic KNX command. + + Example: + >>> import knx_stack + >>> import knx_plugin + + >>> address_table = knx_stack.AddressTable(knx_stack.Address(4097), [], 255) + >>> association_table = knx_stack.AssociationTable(address_table, []) + >>> groupobject_table = knx_stack.GroupObjectTable() + + >>> data = {"name": "DPT_SceneControl", + ... "addresses": [2823], + ... "fields": {"number": 7, "command": "activate"}} + >>> class C(knx_plugin.Command): + ... def make_msgs_from(self, old_state, new_state): + ... pass + >>> command = C(data) + >>> command.associate(association_table, groupobject_table) + >>> msgs = command.execute() + >>> msgs[0].dpt.number + 7 + >>> msgs[0].dpt.command + + """ + + def execute( + self, + ) -> List["knx_stack.layer.application.a_group_value_write.req.Msg"]: + req_msgs = [] + for asap in self._asaps: + req_msgs.append( + knx_stack.layer.application.a_group_value_write.req.Msg( + asap=asap, dpt=self._dpt + ) + ) + self._logger.info("executed %s with msgs %s" % (str(self), str(req_msgs))) + return req_msgs diff --git a/knx_plugin/tests/__init__.py b/knx_plugin/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/knx_plugin/tests/test_courtesy.py b/knx_plugin/tests/test_courtesy.py new file mode 100644 index 0000000..123caff --- /dev/null +++ b/knx_plugin/tests/test_courtesy.py @@ -0,0 +1,217 @@ +import asyncio +import unittest + +import home +import knx_plugin +import knx_stack +from knx_plugin.tests.testcase import TestCase + + +class Stub(home.MyHome): + def _build_appliances(self): + sensore_movimento = home.appliance.sensor.motion.Appliance( + "sensore di movimento", [] + ) + luce = home.appliance.light.Appliance("una luce", []) + lucee = home.appliance.light.Appliance("due luci", []) + collection = home.appliance.Collection() + collection["luci"] = set([luce, lucee]) + collection["sensori"] = set( + [ + sensore_movimento, + ] + ) + return collection + + def _build_performers(self): + performers = list() + appliance = self.appliances.find("una luce") + knx_command = knx_plugin.command.dpt_switch.OnOff.make_from_yaml( + [ + 0xBBBB, + ] + ) + knx_trigger_on = knx_plugin.trigger.dpt_switch.On.make_from_yaml( + [ + 0xBBBB, + ], + [ + home.appliance.light.event.forced.Event.On, + ], + ) + knx_trigger_off = knx_plugin.trigger.dpt_switch.Off.make_from_yaml( + [ + 0xBBBB, + ], + [ + home.appliance.light.event.forced.Event.Off, + ], + ) + luce_performer = home.Performer( + appliance.name, + appliance, + [ + knx_command, + ], + [knx_trigger_on, knx_trigger_off], + ) + luce_performer.notify([home.event.sun.brightness.Event.DeepDark]) + performers.append(luce_performer) + appliance = self.appliances.find("due luci") + knx_command = knx_plugin.command.dpt_switch.OnOff.make_from_yaml( + [ + 0xCCCC, + ] + ) + knx_trigger_on = knx_plugin.trigger.dpt_switch.On.make_from_yaml( + [ + 0xCCCC, + ], + [ + home.appliance.light.event.forced.Event.On, + ], + ) + knx_trigger_off = knx_plugin.trigger.dpt_switch.Off.make_from_yaml( + [ + 0xCCCC, + ], + [ + home.appliance.light.event.forced.Event.Off, + ], + ) + luce_performer = home.Performer( + appliance.name, + appliance, + [ + knx_command, + ], + [knx_trigger_on, knx_trigger_off], + ) + luce_performer.notify([home.event.sun.brightness.Event.DeepDark]) + performers.append(luce_performer) + appliance = self.appliances.find("sensore di movimento") + trigger_missed = knx_plugin.trigger.dpt_switch.Off.make_from_yaml( + [ + 0xEEEE, + ], + [ + home.event.motion.Event.Missed, + ], + ) + trigger_spotted = knx_plugin.trigger.dpt_switch.On.make_from_yaml( + [ + 0xEEEE, + ], + [ + home.event.motion.Event.Spotted, + ], + ) + trigger_courtesy_on = knx_plugin.trigger.dpt_switch.On.make_from_yaml( + [ + 0xEEEE, + ], + [ + home.event.courtesy.Event.On, + ], + ) + trigger_courtesy_off = knx_plugin.trigger.dpt_switch.Off.make_from_yaml( + [ + 0xEEEE, + ], + [ + home.event.courtesy.Event.Off, + ], + ) + sensore_performer = home.Performer( + appliance.name, + appliance, + [], + [ + trigger_missed, + trigger_spotted, + trigger_courtesy_on, + trigger_courtesy_off, + ], + ) + performers.append(sensore_performer) + return performers + + def _build_group_of_performers(self): + return { + "luci": [self._performers[0], self._performers[1]], + "sensori": [self._performers[2]], + } + + def _build_scheduler_triggers(self): + performers = self._group_of_performers["sensori"] + triggers = list() + for trigger in performers.triggers: + t = home.scheduler.trigger.protocol.Trigger( + name="courtesy", events=[], protocol_trigger=trigger + ) + triggers.append(t) + return triggers + + def _build_schedule_infos(self): + return [ + ( + self.find_group_of_performers("luci"), + self.find_scheduler_triggers("courtesy"), + ) + ] + + +class TestLogics(TestCase): + def test_courtesy(tc): + tc.enable_logging() + tc.myhome = Stub() + tc.make_process(tc.myhome) + + class Test(unittest.IsolatedAsyncioTestCase): + + MAX_LOOP = 10 + + async def asyncSetUp(self): + tc.add_knx_gateway(tc.myhome) + self._loop = asyncio.get_event_loop() + tc.create_tasks(self._loop, tc.myhome) + self._loop.create_task(self.emulate_bus_events()) + + async def test_state(self): + i = 0 + is_notified = False + while not is_notified and i < self.MAX_LOOP: + await asyncio.sleep(0.3) + is_notified = tc.myhome.appliances.find("una luce").is_notified( + home.event.courtesy.Event.On + ) + i += 1 + + async def emulate_bus_events(self): + await asyncio.sleep(0.1) + dpt = knx_stack.datapointtypes.DPT_Switch() + dpt.action = "on" + tsap = tc.knx_gateway._association_table.get_tsap( + knx_stack.GroupAddress(free_style=0xEEEE) + ) + asaps = tc.knx_gateway._association_table.get_asaps(tsap) + for asap in asaps: + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=dpt + ) + msg = tc._knx_gateway.protocol_instance.encode(msg) + tc._knx_gateway.protocol_instance.data_received(msg.encode("utf-8")) + await asyncio.sleep(0.1) + + test = Test("test_state") + tc.assertFalse( + tc.myhome.appliances.find("una luce").is_notified( + home.event.courtesy.Event.On + ) + ) + test.run() + tc.assertTrue( + tc.myhome.appliances.find("una luce").is_notified( + home.event.courtesy.Event.On + ) + ) diff --git a/knx_plugin/tests/test_curtain_stop.py b/knx_plugin/tests/test_curtain_stop.py new file mode 100644 index 0000000..eb66b5b --- /dev/null +++ b/knx_plugin/tests/test_curtain_stop.py @@ -0,0 +1,135 @@ +import home +import asyncio +import unittest + +import knx_plugin +from knx_plugin.tests.testcase import TestCase + + +class Stub(home.MyHome): + def _build_appliances(self): + curtain = home.appliance.curtain.positionable.Curtain("curtain") + rainmeter = home.appliance.sensor.rainmeter.Appliance("rainmeter") + collection = home.appliance.Collection() + collection["curtains"] = set( + [ + curtain, + ] + ) + collection["sensors"] = set( + [ + rainmeter, + ] + ) + return collection + + def _build_performers(self): + performers = list() + appliance = self.appliances.find("curtain") + command_down = knx_plugin.command.dpt_updown.UpDown.make_from_yaml( + [ + 0xCCCC, + ] + ) + command_stop = knx_plugin.command.dpt_updown.Stop.make_from_yaml( + [ + 0xDDDD, + ] + ) + performer = home.Performer( + appliance.name, appliance, [command_down, command_stop], [] + ) + performers.append(performer) + appliance = self.appliances.find("rainmeter") + trigger = knx_plugin.trigger.dpt_switch.On.make_from_yaml( + [ + 0xBBBB, + ], + [ + home.event.rain.Event.Gentle, + ], + ) + performer = home.Performer(appliance.name, appliance, [], [trigger]) + performers.append(performer) + return performers + + def _build_group_of_performers(self): + return {"curtains": [self._performers[0]], "sensors": [self._performers[1]]} + + def _build_scheduler_triggers(self): + triggers = list() + stop_timer_performers = list() + stop_timer_performers.extend(self.find_group_of_performers("curtains")) + trigger = home.scheduler.trigger.state.timer.Trigger( + name="stop curtain", + events=[], + state="Closing", + timeout_seconds=0.2, + stop_timer_events=[home.event.curtain.stop.Event.Stop], + stop_timer_performers=stop_timer_performers, + ) + triggers.append(trigger) + for performer in self.find_group_of_performers("sensors"): + for t in performer.triggers: + trigger = home.scheduler.trigger.protocol.Trigger( + name="is raining", events=[], protocol_trigger=t + ) + triggers.append(trigger) + return triggers + + def _build_schedule_infos(self): + return [ + ( + self.find_group_of_performers("curtains"), + self.find_scheduler_triggers("is raining"), + ), + ( + self.find_group_of_performers("curtains"), + self.find_scheduler_triggers("stop curtain"), + ), + ] + + +class TestLogics(TestCase): + @unittest.skip("No more positionable.curtain modeled") + def test_timer_trigger(self): + self.enable_verbose_logging() + description = { + "type": "knx", + "name": "DPT_Switch", + "fields": {"action": "on"}, + "addresses": [0xBBBB], + } + command = knx_plugin.command.dpt_switch.OnOff(description) + knx_curtain_engine = knx_plugin.bus.Emulator( + [ + "0113130008000B01030000110096E00000CCCC010081", + "0113130008000B01030000110096E00000DDDD010080", + ] + ) # request + knx_curtain_engine.WAIT_FOR_MSGS = 2 + + knx_curtain_engine.associate_commands([command]) + knx_curtain_engine.run([]) + msgs = command.execute() + asyncio.get_event_loop().create_task(knx_curtain_engine.writer(msgs)) + + myhome = Stub() + + self.assertFalse( + myhome.appliances.find("curtain").is_notified( + home.event.curtain.stop.Event.Stop + ) + ) + + self.make_process(myhome) + self.add_knx_gateway(myhome) + self.execute(myhome) + knx_curtain_engine.disconnect() + + self.assertTrue( + myhome.appliances.find("curtain").is_notified( + home.event.curtain.stop.Event.Stop + ) + ) + self.assertTrue(knx_curtain_engine.all_messages_received) diff --git a/knx_plugin/tests/test_doctests.py b/knx_plugin/tests/test_doctests.py new file mode 100644 index 0000000..3f54dae --- /dev/null +++ b/knx_plugin/tests/test_doctests.py @@ -0,0 +1,36 @@ +import doctest +import unittest +import knx_plugin + + +tests = list() +tests.append(doctest.DocTestSuite("knx_plugin.gateway")) +tests.append(doctest.DocTestSuite("knx_plugin.message")) +tests.append(doctest.DocTestSuite("knx_plugin.trigger")) +tests.append(doctest.DocTestSuite("knx_plugin.trigger.mean")) +tests.append(doctest.DocTestSuite("knx_plugin.trigger.custom_clima")) +tests.append(doctest.DocTestSuite("knx_plugin.trigger.dpt_value_wsp")) +tests.append(doctest.DocTestSuite("knx_plugin.trigger.dpt_value_temp")) +tests.append(doctest.DocTestSuite("knx_plugin.trigger.dpt_value_power")) +tests.append(doctest.DocTestSuite("knx_plugin.trigger.dpt_value_lux")) +tests.append(doctest.DocTestSuite("knx_plugin.trigger.dpt_value_lux.balance")) +tests.append(doctest.DocTestSuite("knx_plugin.trigger.dpt_value_power.consumption")) +tests.append(doctest.DocTestSuite("knx_plugin.trigger.dpt_value_power.production")) +tests.append(doctest.DocTestSuite("knx_plugin.trigger.dpt_control_dimming")) +tests.append(doctest.DocTestSuite("knx_plugin.command.custom_clima")) +tests.append(doctest.DocTestSuite("knx_plugin.command.dpt_brightness")) +tests.append(doctest.DocTestSuite("knx_plugin.command.dpt_switch")) + +tests.append(doctest.DocFileSuite("../docs/source/index.rst", package=knx_plugin)) + + +def load_tests(loader, suite, ignore): + for test in tests: + suite.addTests(test) + return suite + + +suite = unittest.TestSuite() +[suite.addTests(test) for test in tests] +runner = unittest.TextTestRunner(verbosity=2) +runner.run(suite) diff --git a/knx_plugin/tests/test_gateway.py b/knx_plugin/tests/test_gateway.py new file mode 100644 index 0000000..528e790 --- /dev/null +++ b/knx_plugin/tests/test_gateway.py @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: GPL-3.0-only +# +# automate home devices +# +# Copyright (C) 2021 Maja Massarini + +import asyncio +import unittest +import unittest.mock + +import knx_stack +import knx_plugin + + +class TestGateway(unittest.TestCase): + def test_stopped(tc): + description = { + "name": "DPT_Switch", + "fields": {"action": "on"}, + "addresses": [1111], + } + trigger = knx_plugin.trigger.dpt_switch.On(description) + command = knx_plugin.command.dpt_switch.OnOff(description) + events = [] + + class Test(unittest.IsolatedAsyncioTestCase): + + STATE_CHANGED = "stopped" + MAX_LOOP = 10 + + async def a_task(self, msgs): + print(msgs) + events.append(self.STATE_CHANGED) + + async def postpone_gw_running(self): + await asyncio.sleep(0.1) + await self._gateway.run([self.a_task]) + + async def asyncSetUp(self): + self._gateway = knx_plugin.gateway.usbhid.Gateway( + knx_plugin.client.usbhid.Client + ) + self._gateway.associate_triggers([trigger]) + self._gateway._loop.create_connection = unittest.mock.AsyncMock( + return_value=(1, None) + ) + + loop = asyncio.get_event_loop() + loop.create_task(self.postpone_gw_running()) + loop.create_task(self.emulate_bus_event()) + + msgs = command.execute() + loop.create_task(self._gateway.writer([msgs])) + + async def emulate_bus_event(self): + for asap in trigger.asaps: + await asyncio.sleep(0.2) + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=trigger.dpt + ) + msg = self._gateway.protocol_instance.encode(msg) + self._gateway.protocol_instance.data_received(msg.encode("utf-8")) + + async def test_stopped(self): + i = 0 + while self.STATE_CHANGED not in events and i < self.MAX_LOOP: + await asyncio.sleep(1) + i += 1 + + test = Test("test_stopped") + test.run() + tc.assertIn(Test.STATE_CHANGED, events) diff --git a/knx_plugin/tests/test_multiple_protocol_triggers.py b/knx_plugin/tests/test_multiple_protocol_triggers.py new file mode 100644 index 0000000..d900139 --- /dev/null +++ b/knx_plugin/tests/test_multiple_protocol_triggers.py @@ -0,0 +1,148 @@ +import asyncio +import unittest + +import home +import knx_stack +import knx_plugin +from knx_plugin.tests.testcase import TestCase + + +class Stub(home.MyHome): + def _build_appliances(self): + sensore_movimento_a = home.appliance.sensor.motion.Appliance( + "sensore di movimento a", [] + ) + sensore_movimento_b = home.appliance.sensor.motion.Appliance( + "sensore di movimento b", [] + ) + luce = home.appliance.light.Appliance("una luce", []) + collection = home.appliance.Collection() + collection["luci"] = set([luce]) + collection["sensori"] = set([sensore_movimento_a, sensore_movimento_b]) + return collection + + def _build_performers(self): + self.positive_triggers = list() + self.negative_triggers = list() + performers = list() + appliance = self.appliances.find("una luce") + knx_command = knx_plugin.command.dpt_switch.OnOff.make_from_yaml( + [ + 0xBBBB, + ] + ) + luce_performer = home.Performer( + appliance.name, + appliance, + [ + knx_command, + ], + [], + ) + performers.append(luce_performer) + for name, address in [ + ("sensore di movimento a", 0xCCCC), + ("sensore di movimento b", 0xDDDD), + ]: + appliance = self.appliances.find(name) + trigger_spotted = knx_plugin.trigger.dpt_switch.On.make_from_yaml( + [ + address, + ], + [ + home.event.motion.Event.Spotted, + ], + ) + trigger_missed = knx_plugin.trigger.dpt_switch.On.make_from_yaml( + [ + address, + ], + [ + home.event.motion.Event.Missed, + ], + ) + self.positive_triggers.append(trigger_spotted) + self.negative_triggers.append(trigger_missed) + performer = home.Performer(appliance.name, appliance, [], [trigger_spotted]) + performers.append(performer) + return performers + + def _build_group_of_performers(self): + return { + "luci": [self._performers[0]], + "sensori": [self._performers[1], self._performers[2]], + } + + def _build_scheduler_triggers(self): + t = home.scheduler.trigger.protocol.multi.Trigger( + name="and", + events=[home.appliance.light.event.forced.Event.On], + positive_a=self.positive_triggers[0], + negative_a=self.negative_triggers[0], + positive_b=self.positive_triggers[1], + negative_b=self.negative_triggers[1], + ) + return [t] + + def _build_schedule_infos(self): + return [ + (self.find_group_of_performers("luci"), self.find_scheduler_triggers("and")) + ] + + +class TestLogics(TestCase): + def test_multiple_protocol_trigger(tc): + tc.enable_logging() + tc.myhome = Stub() + tc.make_process(tc.myhome) + + class Test(unittest.IsolatedAsyncioTestCase): + + MAX_LOOP = 10 + + async def asyncSetUp(self): + tc.add_knx_gateway(tc.myhome) + self._loop = asyncio.get_event_loop() + tc.create_tasks(self._loop, tc.myhome) + self._loop.create_task(self.emulate_bus_events()) + + async def test_state(self): + i = 0 + is_notified = False + while not is_notified and i < self.MAX_LOOP: + await asyncio.sleep(0.3) + is_notified = tc.myhome.appliances.find("una luce").is_notified( + home.appliance.light.event.forced.Event.On + ) + i += 1 + + async def emulate_bus_events(self): + await asyncio.sleep(0.1) + dpt = knx_stack.datapointtypes.DPT_Switch() + dpt.action = "on" + for address in [0xCCCC, 0xDDDD, 0xEEEE]: + address = knx_stack.GroupAddress(free_style=address) + tsap = tc.knx_gateway._association_table.get_tsap(address) + asaps = tc.knx_gateway._association_table.get_asaps(tsap) + for asap in asaps: + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=dpt + ) + msg = tc._knx_gateway.protocol_instance.encode(msg) + tc._knx_gateway.protocol_instance.data_received( + msg.encode("utf-8") + ) + await asyncio.sleep(0.1) + + test = Test("test_state") + tc.assertFalse( + tc.myhome.appliances.find("una luce").is_notified( + home.appliance.light.event.forced.Event.On + ) + ) + test.run() + tc.assertTrue( + tc.myhome.appliances.find("una luce").is_notified( + home.appliance.light.event.forced.Event.On + ) + ) diff --git a/knx_plugin/tests/test_performer_brightness_update.py b/knx_plugin/tests/test_performer_brightness_update.py new file mode 100644 index 0000000..282491f --- /dev/null +++ b/knx_plugin/tests/test_performer_brightness_update.py @@ -0,0 +1,142 @@ +import asyncio +import unittest + +import home +import knx_stack +import knx_plugin +from knx_plugin.tests.testcase import TestCase + + +class Stub(home.MyHome): + class Dimmerrr(home.appliance.light.indoor.dimmerable.Appliance): + def __init__(self, name, events): + super(Stub.Dimmerrr, self).__init__(name, events) + self._wait_for_n_updates = 0 + + def update_by(self, trigger, description): + self._wait_for_n_updates += 1 + if self._wait_for_n_updates >= 8: + asyncio.get_event_loop().stop() + return super(Stub.Dimmerrr, self).update_by(trigger, description) + + def _build_appliances(self): + luce = Stub.Dimmerrr("una luce", []) + collection = home.appliance.Collection() + collection["luci"] = set( + [ + luce, + ] + ) + return collection + + def _build_performers(self): + appliance = self.appliances.find("una luce") + knx_command = knx_plugin.command.dpt_brightness.Brightness.make_from_yaml( + [ + 0xBBBB, + ] + ) + knx_trigger_on = knx_plugin.trigger.dpt_switch.On.make_from_yaml( + [0xCCCC, 0xDDDD], [home.appliance.light.event.forced.Event.On] + ) + knx_trigger_off = knx_plugin.trigger.dpt_switch.Off.make_from_yaml( + [0xCCCC, 0xDDDD], [home.appliance.light.event.forced.Event.Off] + ) + knx_trigger = knx_plugin.trigger.dpt_brightness.Always.make_from_yaml( + [ + 0xBBBB, + ] + ) + luce_performer = home.Performer( + appliance.name, + appliance, + [ + knx_command, + ], + [ + knx_trigger_on, + knx_trigger_off, + knx_trigger, + ], + ) + return [luce_performer] + + def _build_group_of_performers(self): + return {"luci": self._performers} + + def _build_scheduler_triggers(self): + return [] + + def _build_schedule_infos(self): + return [] + + +class TestLogics(TestCase): + def test_performer_update(tc): + tc.enable_logging() + tc.myhome = Stub() + tc.make_process(tc.myhome) + + class Test(unittest.IsolatedAsyncioTestCase): + + MAX_LOOP = 10 + + async def asyncSetUp(self): + tc.add_knx_gateway(tc.myhome) + self._loop = asyncio.get_event_loop() + tc.create_tasks(self._loop, tc.myhome) + self._loop.create_task(self.emulate_bus_events()) + + async def test_state(self): + i = 0 + is_notified = False + while not is_notified and i < self.MAX_LOOP: + await asyncio.sleep(0.3) + is_notified = tc.myhome.appliances.find("una luce").is_notified( + home.appliance.light.event.brightness.Event(44) + ) + i += 1 + + async def emulate_bus_events(self): + await asyncio.sleep(0.1) + dpt_switch = knx_stack.datapointtypes.DPT_Switch() + dpt_switch.action = "on" + dpt_brightness = knx_stack.datapointtypes.DPT_Brightness() + dpt_brightness.value = 44 + + tsap = tc.knx_gateway._association_table.get_tsap( + knx_stack.GroupAddress(free_style=0xDDDD) + ) + asaps = tc.knx_gateway._association_table.get_asaps(tsap) + for asap in asaps: + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=dpt_switch + ) + msg = tc._knx_gateway.protocol_instance.encode(msg) + tc._knx_gateway.protocol_instance.data_received(msg.encode("utf-8")) + await asyncio.sleep(0.1) + + tsap = tc.knx_gateway._association_table.get_tsap( + knx_stack.GroupAddress(free_style=0xBBBB) + ) + asaps = tc.knx_gateway._association_table.get_asaps(tsap) + for asap in asaps: + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=dpt_brightness + ) + msg = tc._knx_gateway.protocol_instance.encode(msg) + tc._knx_gateway.protocol_instance.data_received(msg.encode("utf-8")) + await asyncio.sleep(0.1) + + test = Test("test_state") + tc.assertFalse( + tc.myhome.appliances.find("una luce").is_notified( + home.appliance.light.event.brightness.Event(44) + ) + ) + test.run() + tc.assertTrue( + tc.myhome.appliances.find("una luce").is_notified( + home.appliance.light.event.brightness.Event(44) + ) + ) diff --git a/knx_plugin/tests/test_performer_power_update.py b/knx_plugin/tests/test_performer_power_update.py new file mode 100644 index 0000000..4551fff --- /dev/null +++ b/knx_plugin/tests/test_performer_power_update.py @@ -0,0 +1,99 @@ +import asyncio +import unittest + +import home +import knx_stack +import knx_plugin +from knx_plugin.tests.testcase import TestCase + + +class Stub(home.MyHome): + class Powerrr(home.appliance.sensor.powermeter.Appliance): + def update_by(self, trigger, description): + asyncio.get_event_loop().stop() + return super(Stub.Powerrr, self).update_by(trigger, description) + + def _build_appliances(self): + sensore = Stub.Powerrr("un sensore", []) + collection = home.appliance.Collection() + collection["sensori"] = set( + [ + sensore, + ] + ) + return collection + + def _build_performers(self): + appliance = self.appliances.find("un sensore") + knx_trigger = knx_plugin.trigger.dpt_value_power.Always.make_from_yaml( + [ + 0x178D, + ] + ) + sensore_performer = home.Performer( + appliance.name, + appliance, + [], + [ + knx_trigger, + ], + ) + return [sensore_performer] + + def _build_group_of_performers(self): + return {"sensori": self.performers} + + def _build_scheduler_triggers(self): + return [] + + def _build_schedule_infos(self): + return [] + + +class TestLogics(TestCase): + def test_performer_update(tc): + tc.enable_logging() + tc.myhome = Stub() + tc.make_process(tc.myhome) + + class Test(unittest.IsolatedAsyncioTestCase): + + MAX_LOOP = 10 + + async def asyncSetUp(self): + tc.add_knx_gateway(tc.myhome) + self._loop = asyncio.get_event_loop() + tc.create_tasks(self._loop, tc.myhome) + self._loop.create_task(self.emulate_bus_events()) + + async def test_state(self): + i = 0 + is_notified = False + while not is_notified and i < self.MAX_LOOP: + await asyncio.sleep(0.3) + is_notified = tc.myhome.appliances.find("un sensore").is_notified( + 496.0 + ) + i += 1 + + async def emulate_bus_events(self): + await asyncio.sleep(0.1) + dpt = knx_stack.datapointtypes.DPT_Value_Power() + dpt.encode(496) + + tsap = tc.knx_gateway._association_table.get_tsap( + knx_stack.GroupAddress(0x178D) + ) + asaps = tc.knx_gateway._association_table.get_asaps(tsap) + for asap in asaps: + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=dpt + ) + msg = tc._knx_gateway.protocol_instance.encode(msg) + tc._knx_gateway.protocol_instance.data_received(msg.encode("utf-8")) + await asyncio.sleep(0.1) + + test = Test("test_state") + tc.assertFalse(tc.myhome.appliances.find("un sensore").is_notified(496.0)) + test.run() + tc.assertTrue(tc.myhome.appliances.find("un sensore").is_notified(496.0)) diff --git a/knx_plugin/tests/test_performer_switch_update.py b/knx_plugin/tests/test_performer_switch_update.py new file mode 100644 index 0000000..4002098 --- /dev/null +++ b/knx_plugin/tests/test_performer_switch_update.py @@ -0,0 +1,106 @@ +import asyncio +import unittest + +import home +import knx_stack +import knx_plugin +from knx_plugin.tests.testcase import TestCase + + +class Stub(home.MyHome): + class Switchhh(home.appliance.light.Appliance): + def update_by(self, trigger, description): + asyncio.get_event_loop().stop() + return super(Stub.Switchhh, self).update_by(trigger, description) + + def _build_appliances(self): + luce = Stub.Switchhh("una luce", []) + collection = home.appliance.Collection() + collection["luci"] = set( + [ + luce, + ] + ) + return collection + + def _build_performers(self): + appliance = self.appliances.find("una luce") + knx_command = knx_plugin.command.dpt_switch.OnOff.make_from_yaml( + [ + 0xBBBB, + ] + ) + knx_trigger_on = knx_plugin.trigger.dpt_switch.On.make_from_yaml( + [ + 0xBBBB, + ], + [ + home.appliance.light.event.forced.Event.On, + ], + ) + knx_trigger_off = knx_plugin.trigger.dpt_switch.Off.make_from_yaml( + [ + 0xBBBB, + ], + [ + home.appliance.light.event.forced.Event.Off, + ], + ) + luce_performer = home.Performer( + appliance.name, + appliance, + [ + knx_command, + ], + [knx_trigger_on, knx_trigger_off], + ) + return [luce_performer] + + def _build_group_of_performers(self): + return {"luci": self._performers} + + def _build_scheduler_triggers(self): + return [] + + def _build_schedule_infos(self): + return [] + + +class TestLogics(TestCase): + async def emulate_bus_events(self): + await asyncio.sleep(0.1) + dpt = knx_stack.datapointtypes.DPT_Switch() + dpt.action = "on" + + tsap = self.knx_gateway._association_table.get_tsap( + knx_stack.GroupAddress(0xBBBB) + ) + asaps = self.knx_gateway._association_table.get_asaps(tsap) + for asap in asaps: + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=dpt + ) + msg = self._knx_gateway.protocol_instance.encode(msg) + self._knx_gateway.protocol_instance.data_received(msg.encode("utf-8")) + await asyncio.sleep(0.1) + + @unittest.skip("to be improved") + def test_performer_update(self): + self.enable_verbose_logging() + + myhome = Stub() + self.make_process(myhome) + self.add_knx_gateway(myhome, 10) + + self.assertFalse( + myhome.appliances.find("una luce").is_notified( + home.appliance.light.event.forced.Event.On + ) + ) + asyncio.get_event_loop().create_task(self.emulate_bus_events()) + self.execute(myhome) + self.assertTrue( + myhome.appliances.find("una luce").is_notified( + home.appliance.light.event.forced.Event.On + ) + ) diff --git a/knx_plugin/tests/test_performer_temperature_update.py b/knx_plugin/tests/test_performer_temperature_update.py new file mode 100644 index 0000000..afe9bcb --- /dev/null +++ b/knx_plugin/tests/test_performer_temperature_update.py @@ -0,0 +1,99 @@ +import asyncio +import unittest + +import home +import knx_stack +import knx_plugin +from knx_plugin.tests.testcase import TestCase + + +class Stub(home.MyHome): + class Thermometerrr(home.appliance.sensor.thermometer.Appliance): + def update_by(self, trigger, description): + asyncio.get_event_loop().stop() + return super(Stub.Thermometerrr, self).update_by(trigger, description) + + def _build_appliances(self): + sensore = Stub.Thermometerrr("un termometro", []) + collection = home.appliance.Collection() + collection["sensori"] = set( + [ + sensore, + ] + ) + return collection + + def _build_performers(self): + appliance = self.appliances.find("un termometro") + knx_trigger = knx_plugin.trigger.dpt_value_temp.Always.make_from_yaml( + [ + 0xBBBB, + ] + ) + sensore_performer = home.Performer( + appliance.name, + appliance, + [], + [ + knx_trigger, + ], + ) + return [sensore_performer] + + def _build_group_of_performers(self): + return {"sensori": self.performers} + + def _build_scheduler_triggers(self): + return [] + + def _build_schedule_infos(self): + return [] + + +class TestLogics(TestCase): + def test_performer_update(tc): + tc.enable_logging() + tc.myhome = Stub() + tc.make_process(tc.myhome) + + class Test(unittest.IsolatedAsyncioTestCase): + + MAX_LOOP = 10 + + async def asyncSetUp(self): + tc.add_knx_gateway(tc.myhome) + self._loop = asyncio.get_event_loop() + tc.create_tasks(self._loop, tc.myhome) + self._loop.create_task(self.emulate_bus_events()) + + async def test_state(self): + i = 0 + is_notified = False + while not is_notified and i < self.MAX_LOOP: + await asyncio.sleep(0.3) + is_notified = tc.myhome.appliances.find( + "un termometro" + ).is_notified(0.1) + i += 1 + + async def emulate_bus_events(self): + await asyncio.sleep(0.1) + dpt = knx_stack.datapointtypes.DPT_Value_Temp() + dpt.encode(0.1) + + tsap = tc.knx_gateway._association_table.get_tsap( + knx_stack.GroupAddress(0xBBBB) + ) + asaps = tc.knx_gateway._association_table.get_asaps(tsap) + for asap in asaps: + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=dpt + ) + msg = tc._knx_gateway.protocol_instance.encode(msg) + tc._knx_gateway.protocol_instance.data_received(msg.encode("utf-8")) + await asyncio.sleep(0.1) + + test = Test("test_state") + tc.assertFalse(tc.myhome.appliances.find("un termometro").is_notified(0.1)) + test.run() + tc.assertTrue(tc.myhome.appliances.find("un termometro").is_notified(0.1)) diff --git a/knx_plugin/tests/test_performer_thermostat.py b/knx_plugin/tests/test_performer_thermostat.py new file mode 100644 index 0000000..544a0c1 --- /dev/null +++ b/knx_plugin/tests/test_performer_thermostat.py @@ -0,0 +1,148 @@ +import asyncio +import unittest + +import home +import knx_stack +import knx_plugin +from knx_plugin.tests.testcase import TestCase + + +class Stub(home.MyHome): + class Thermostatt(home.appliance.thermostat.presence.Appliance): + def __init__(self, name, events): + super(Stub.Thermostatt, self).__init__(name, events) + self.was_forced_off = False + self.was_forced_on = False + self.was_forced_keep = False + + def update_by(self, trigger, description): + t = super(Stub.Thermostatt, self).update_by(trigger, description) + if "Forced Off" in self.state.compute(): + self.was_forced_off = True + elif "Forced On" in self.state.compute(): + self.was_forced_on = True + elif "Forced Keep" in self.state.compute(): + self.was_forced_keep = True + return t + + def _build_appliances(self): + termostato = Stub.Thermostatt("un termostato", []) + termostato.notify(home.event.clima.season.Event.Summer) + termostato.notify(home.event.clima.command.Event.Off) + collection = home.appliance.Collection() + collection["termostati"] = set( + [ + termostato, + ] + ) + return collection + + def _build_performers(self): + appliance = self.appliances.find("un termostato") + command = knx_plugin.command.custom_clima.Setup.make_from_yaml([0xBBBB], 19, 20) + trigger_on = knx_plugin.trigger.custom_clima.OnAutomatico.make_from_yaml( + [0xBBBB], [], 19, 20 + ) + trigger_off = knx_plugin.trigger.custom_clima.Off.make_from_yaml( + [0xBBBB], [], 19, 20 + ) + trigger_keep = knx_plugin.trigger.custom_clima.Keep.make_from_yaml( + [0xBBBB], [], 19, 20 + ) + trigger_report = knx_plugin.trigger.custom_clima.Report.make_from_yaml( + [0xBBBB], [], 19, 20 + ) + termostato_performer = home.Performer( + appliance.name, + appliance, + [ + command, + ], + [trigger_on, trigger_off, trigger_keep, trigger_report], + ) + return [termostato_performer] + + def _build_group_of_performers(self): + return {"termostati": self._performers} + + def _build_scheduler_triggers(self): + triggers = list() + trigger = home.scheduler.trigger.interval.Trigger( + name="force off", events=[], seconds=0.5 + ) + triggers.append(trigger) + trigger = home.scheduler.trigger.interval.Trigger( + name="force heat", events=[], seconds=0.6 + ) + triggers.append(trigger) + trigger = home.scheduler.trigger.interval.Trigger( + name="force keep", events=[], seconds=0.7 + ) + triggers.append(trigger) + return triggers + + def _build_schedule_infos(self): + return [ + ( + self.find_group_of_performers("termostati"), + self.find_scheduler_triggers("force off"), + ), + ( + self.find_group_of_performers("termostati"), + self.find_scheduler_triggers("force heat"), + ), + ( + self.find_group_of_performers("termostati"), + self.find_scheduler_triggers("force keep"), + ), + ] + + +class TestLogics(TestCase): + async def emulate_bus_events(self): + await asyncio.sleep(0.1) + dpt_off = knx_stack.datapointtypes.DPTSetupClima() + dpt_off.funzionamento = "off" + dpt_on = knx_stack.datapointtypes.DPTSetupClima() + dpt_on.funzionamento = "automatico" + dpt_keep = knx_stack.datapointtypes.DPTSetupClima() + dpt_keep.funzionamento = "riduzione_notturna" + + tsap = self.knx_gateway._association_table.get_tsap( + knx_stack.GroupAddress(0xBBBB) + ) + asaps = self.knx_gateway._association_table.get_asaps(tsap) + for asap in asaps: + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=dpt_on + ) + msg = self._knx_gateway.protocol_instance.encode(msg) + self._knx_gateway.protocol_instance.data_received(msg.encode("utf-8")) + await asyncio.sleep(0.1) + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=dpt_off + ) + msg = self._knx_gateway.protocol_instance.encode(msg) + self._knx_gateway.protocol_instance.data_received(msg.encode("utf-8")) + await asyncio.sleep(0.1) + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=dpt_keep + ) + msg = self._knx_gateway.protocol_instance.encode(msg) + self._knx_gateway.protocol_instance.data_received(msg.encode("utf-8")) + await asyncio.sleep(0.1) + + @unittest.skip("to be improved") + def test_performer_update(self): + self.enable_verbose_logging() + + myhome = Stub() + self.make_process(myhome) + self.add_knx_gateway(myhome, 6) + + asyncio.get_event_loop().create_task(self.emulate_bus_events()) + self.execute(myhome) + termostato = myhome.appliances.find("un termostato") + self.assertTrue(termostato.was_forced_on) + self.assertTrue(termostato.was_forced_off) + self.assertTrue(termostato.was_forced_keep) diff --git a/knx_plugin/tests/test_postpone_switchoff.py b/knx_plugin/tests/test_postpone_switchoff.py new file mode 100644 index 0000000..98d20d4 --- /dev/null +++ b/knx_plugin/tests/test_postpone_switchoff.py @@ -0,0 +1,160 @@ +import asyncio +import unittest + +import home +import knx_stack +import knx_plugin +from knx_plugin.tests.testcase import TestCase + + +class Stub(home.MyHome): + def _build_appliances(self): + light = home.appliance.light.Appliance("a light", []) + light.notify(home.event.sun.brightness.Event.DeepDark) + another_light = home.appliance.light.Appliance("another light", []) + another_light.notify(home.event.sun.brightness.Event.DeepDark) + motion_sensor = home.appliance.sensor.motion.Appliance("motion sensor", []) + collection = home.appliance.Collection() + collection["lights"] = set([light, another_light]) + collection["sensors"] = set( + [ + motion_sensor, + ] + ) + return collection + + def _build_performers(self): + performers = list() + appliance = self.appliances.find("a light") + command = knx_plugin.command.dpt_switch.OnOff.make_from_yaml( + [ + 0xCCCC, + ] + ) + performer = home.Performer(appliance.name, appliance, [command], []) + performers.append(performer) + appliance = self.appliances.find("another light") + command = knx_plugin.command.dpt_switch.OnOff.make_from_yaml( + [ + 0xDDDD, + ] + ) + performer = home.Performer(appliance.name, appliance, [command], []) + performers.append(performer) + appliance = self.appliances.find("motion sensor") + trigger = knx_plugin.trigger.dpt_switch.On.make_from_yaml( + [ + 0xBBBB, + ], + [home.event.courtesy.Event.On], + ) + performer = home.Performer(appliance.name, appliance, [], [trigger]) + performers.append(performer) + trigger = knx_plugin.trigger.dpt_switch.Off.make_from_yaml( + [ + 0xBBBB, + ], + [home.event.courtesy.Event.Off], + ) + performer = home.Performer(appliance.name, appliance, [], [trigger]) + performers.append(performer) + return performers + + def _build_group_of_performers(self): + return { + "lights": [self._performers[0], self._performers[1]], + "motion spotted": [self._performers[2]], + "motion missed": [self._performers[3]], + } + + def _build_scheduler_triggers(self): + triggers = list() + for performer in self.find_group_of_performers("motion spotted"): + for t in performer.triggers: + trigger = home.scheduler.trigger.protocol.Trigger( + name="motion spotted trigger", events=[], protocol_trigger=t + ) + triggers.append(trigger) + for performer in self.find_group_of_performers("motion missed"): + for t in performer.triggers: + trigger = home.scheduler.trigger.protocol.delay.Trigger( + name="motion missed delay trigger", + events=[], + protocol_trigger=t, + timeout_seconds=1, + ) + triggers.append(trigger) + return triggers + + def _build_schedule_infos(self): + return [ + ( + self.find_group_of_performers("lights"), + self.find_scheduler_triggers("motion spotted trigger"), + ), + ( + self.find_group_of_performers("lights"), + self.find_scheduler_triggers("motion missed delay trigger"), + ), + ] + + +class TestLogics(TestCase): + async def emulate_bus_events(self): + await asyncio.sleep(0.1) + dpt_on = knx_stack.datapointtypes.DPT_Switch() + dpt_on.action = "on" + dpt_off = knx_stack.datapointtypes.DPT_Switch() + dpt_off.action = "off" + + tsap = self.knx_gateway._association_table.get_tsap( + knx_stack.GroupAddress(0xBBBB) + ) + asaps = self.knx_gateway._association_table.get_asaps(tsap) + for asap in asaps: + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=dpt_on + ) + msg = self._knx_gateway.protocol_instance.encode(msg) + self._knx_gateway.protocol_instance.data_received(msg.encode("utf-8")) + await asyncio.sleep(0.1) + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=dpt_off + ) + msg = self._knx_gateway.protocol_instance.encode(msg) + self._knx_gateway.protocol_instance.data_received(msg.encode("utf-8")) + await asyncio.sleep(0.1) + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=dpt_on + ) + msg = self._knx_gateway.protocol_instance.encode(msg) + self._knx_gateway.protocol_instance.data_received(msg.encode("utf-8")) + await asyncio.sleep(0.1) + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=dpt_off + ) + msg = self._knx_gateway.protocol_instance.encode(msg) + self._knx_gateway.protocol_instance.data_received(msg.encode("utf-8")) + await asyncio.sleep(0.1) + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=dpt_off + ) + msg = self._knx_gateway.protocol_instance.encode(msg) + self._knx_gateway.protocol_instance.data_received(msg.encode("utf-8")) + await asyncio.sleep(0.1) + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=dpt_on + ) + msg = self._knx_gateway.protocol_instance.encode(msg) + self._knx_gateway.protocol_instance.data_received(msg.encode("utf-8")) + await asyncio.sleep(0.1) + + @unittest.skip("to be improved") + def testt_delay_trigger(self): + self.enable_verbose_logging() + + myhome = Stub() + self.make_process(myhome) + self.add_knx_gateway(myhome, 2) + asyncio.get_event_loop().create_task(self.emulate_bus_events()) + self.execute(myhome) diff --git a/knx_plugin/tests/test_presence.py b/knx_plugin/tests/test_presence.py new file mode 100644 index 0000000..813c6f3 --- /dev/null +++ b/knx_plugin/tests/test_presence.py @@ -0,0 +1,185 @@ +import asyncio +import unittest + +import home +import knx_plugin +import knx_stack +from knx_plugin.tests.testcase import TestCase + + +class Stub(home.MyHome): + def _build_appliances(self): + sensore_movimento = home.appliance.sensor.motion.Appliance( + "sensore di movimento", [] + ) + luce = home.appliance.light.zone.Appliance("una luce", []) + collection = home.appliance.Collection() + collection["luci"] = set( + [ + luce, + ] + ) + collection["sensori"] = set( + [ + sensore_movimento, + ] + ) + return collection + + def _build_performers(self): + performers = list() + appliance = self.appliances.find("una luce") + knx_command = knx_plugin.command.dpt_switch.OnOff.make_from_yaml( + [ + 0xBBBB, + ] + ) + knx_trigger_on = knx_plugin.trigger.dpt_switch.On.make_from_yaml( + [ + 0xBBBB, + ], + [ + home.appliance.light.event.forced.Event.On, + ], + ) + knx_trigger_off = knx_plugin.trigger.dpt_switch.Off.make_from_yaml( + [ + 0xBBBB, + ], + [ + home.appliance.light.event.forced.Event.Off, + ], + ) + luce_performer = home.Performer( + appliance.name, + appliance, + [ + knx_command, + ], + [knx_trigger_on, knx_trigger_off], + ) + luce_performer.notify([home.event.sun.brightness.Event.DeepDark]) + performers.append(luce_performer) + appliance = self.appliances.find("sensore di movimento") + trigger_missed = knx_plugin.trigger.dpt_switch.Off.make_from_yaml( + [ + 0xEEEE, + ], + [ + home.event.motion.Event.Missed, + ], + ) + trigger_spotted = knx_plugin.trigger.dpt_switch.On.make_from_yaml( + [ + 0xEEEE, + ], + [ + home.event.motion.Event.Spotted, + ], + ) + trigger_courtesy_on = knx_plugin.trigger.dpt_switch.On.make_from_yaml( + [ + 0xEEEE, + ], + [ + home.event.presence.Event.On, + ], + ) + trigger_courtesy_off = knx_plugin.trigger.dpt_switch.Off.make_from_yaml( + [ + 0xEEEE, + ], + [ + home.event.presence.Event.Off, + ], + ) + sensore_performer = home.Performer( + appliance.name, + appliance, + [], + [ + trigger_missed, + trigger_spotted, + trigger_courtesy_on, + trigger_courtesy_off, + ], + ) + performers.append(sensore_performer) + return performers + + def _build_group_of_performers(self): + return {"luci": [self._performers[0]], "sensori": [self._performers[1]]} + + def _build_scheduler_triggers(self): + performers = self._group_of_performers["sensori"] + triggers = list() + for trigger in performers.triggers: + t = home.scheduler.trigger.protocol.Trigger( + name="presence", events=[], protocol_trigger=trigger + ) + triggers.append(t) + return triggers + + def _build_schedule_infos(self): + return [ + ( + self.find_group_of_performers("luci"), + self.find_scheduler_triggers("presence"), + ) + ] + + +class TestLogics(TestCase): + def test_presence(tc): + tc.enable_logging() + tc.myhome = Stub() + tc.make_process(tc.myhome) + + class Test(unittest.IsolatedAsyncioTestCase): + + MAX_LOOP = 10 + + async def asyncSetUp(self): + tc.add_knx_gateway(tc.myhome) + self._loop = asyncio.get_event_loop() + tc.create_tasks(self._loop, tc.myhome) + self._loop.create_task(self.emulate_bus_events()) + + async def test_state(self): + i = 0 + is_notified = False + while not is_notified and i < self.MAX_LOOP: + await asyncio.sleep(0.3) + is_notified = tc.myhome.appliances.find("una luce").is_notified( + home.event.courtesy.Event.On + ) + i += 1 + + async def emulate_bus_events(self): + await asyncio.sleep(0.1) + dpt = knx_stack.datapointtypes.DPT_Switch() + dpt.action = "on" + tsap = tc.knx_gateway._association_table.get_tsap( + knx_stack.GroupAddress(free_style=0xEEEE) + ) + asaps = tc.knx_gateway._association_table.get_asaps(tsap) + for asap in asaps: + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=dpt + ) + msg = tc._knx_gateway.protocol_instance.encode(msg) + tc._knx_gateway.protocol_instance.data_received(msg.encode("utf-8")) + await asyncio.sleep(0.1) + + test = Test("test_state") + tc.assertFalse( + tc.myhome.appliances.find("una luce").is_notified( + home.event.presence.Event.On + ) + ) + test.run() + tc.assertTrue( + tc.myhome.appliances.find("una luce").is_notified( + home.event.presence.Event.On + ) + ) diff --git a/knx_plugin/tests/test_scene_timer.py b/knx_plugin/tests/test_scene_timer.py new file mode 100644 index 0000000..d66843b --- /dev/null +++ b/knx_plugin/tests/test_scene_timer.py @@ -0,0 +1,138 @@ +import asyncio +import unittest + +import home +import knx_stack +import knx_plugin +from knx_plugin.tests.testcase import TestCase + + +class Stub(home.MyHome): + def _build_appliances(self): + scene = home.appliance.sensor.scene.Appliance("scene", []) + light = home.appliance.light.Appliance("light", []) + collection = home.appliance.Collection() + collection["scene"] = set( + [ + scene, + ] + ) + collection["light"] = set( + [ + light, + ] + ) + return collection + + def _build_performers(self): + performers = list() + appliance = self.appliances.find("scene") + knx_trigger_scene = ( + knx_plugin.trigger.dpt_scene_control.Activate.make_from_yaml( + [ + 0xBBBB, + ], + [home.event.scene.Event.Triggered], + number=16, + ) + ) + performer = home.Performer( + appliance.name, + appliance, + [], + [ + knx_trigger_scene, + ], + ) + performers.append(performer) + appliance = self.appliances.find("light") + command = knx_plugin.command.dpt_switch.OnOff.make_from_yaml( + [ + 0xEEEE, + ] + ) + performer = home.Performer(appliance.name, appliance, [command], []) + performers.append(performer) + return performers + + def _build_group_of_performers(self): + return {"scene": [self._performers[0]], "light": [self._performers[1]]} + + def _build_scheduler_triggers(self): + performers = self.find_group_of_performers("scene") + triggers = list() + for t in performers.triggers: + stop_timer_performers = list() + stop_timer_performers.extend(self.find_group_of_performers("scene")) + stop_timer_performers.extend(self.find_group_of_performers("light")) + trigger = home.scheduler.trigger.protocol.timer.Trigger( + name="scene", + events=[home.appliance.light.event.forced.Event.On], + protocol_trigger=t, + timeout_seconds=0.01, + stop_timer_events=[ + home.event.scene.Event.Untriggered, + ], + stop_timer_performers=stop_timer_performers, + ) + triggers.append(trigger) + return triggers + + def _build_schedule_infos(self): + return [ + ( + self.find_group_of_performers("light"), + self.find_scheduler_triggers("scene"), + ) + ] + + +class TestLogics(TestCase): + async def emulate_bus_events(self): + await asyncio.sleep(0.1) + dpt = knx_stack.datapointtypes.DPTVimarScene() + dpt.command = "attiva" + dpt.index = 16 + + tsap = self.knx_gateway._association_table.get_tsap( + knx_stack.GroupAddress(0xBBBB) + ) + asaps = self.knx_gateway._association_table.get_asaps(tsap) + for asap in asaps: + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=dpt + ) + msg = self._knx_gateway.protocol_instance.encode(msg) + self._knx_gateway.protocol_instance.data_received(msg.encode("utf-8")) + await asyncio.sleep(0.1) + + @unittest.skip("to be improved") + def test_timer_trigger(self): + self.enable_verbose_logging() + + myhome = Stub() + self.make_process(myhome) + self.add_knx_gateway(myhome, 1) + + self.assertTrue( + myhome.appliances.find("scene").is_notified( + home.event.scene.Event.Untriggered + ) + ) + self.assertTrue( + myhome.appliances.find("light").is_notified( + home.appliance.light.event.forced.Event.Not + ) + ) + asyncio.get_event_loop().create_task(self.emulate_bus_events()) + self.execute(myhome) + self.assertTrue( + myhome.appliances.find("scene").is_notified( + home.event.scene.Event.Triggered + ) + ) + self.assertTrue( + myhome.appliances.find("light").is_notified( + home.appliance.light.event.forced.Event.On + ) + ) diff --git a/knx_plugin/tests/test_windy.py b/knx_plugin/tests/test_windy.py new file mode 100644 index 0000000..6146d41 --- /dev/null +++ b/knx_plugin/tests/test_windy.py @@ -0,0 +1,150 @@ +import asyncio +import unittest + +import home +import knx_stack +import knx_plugin +from knx_plugin.tests.testcase import TestCase + + +class Stub(home.MyHome): + def _build_appliances(self): + anemometro = home.appliance.sensor.anemometer.Appliance("anemometro", []) + tapparella = home.appliance.curtain.outdoor.Appliance("tapparella", []) + collection = home.appliance.Collection() + collection["tapparelle"] = set( + [ + tapparella, + ] + ) + collection["sensori"] = set( + [ + anemometro, + ] + ) + return collection + + def _build_performers(self): + performers = list() + appliance = self.appliances.find("anemometro") + knx_trigger_windy = knx_plugin.trigger.dpt_value_wsp.Strong.make_from_yaml( + [ + 0xBBBB, + ] + ) + knx_trigger_quiet = knx_plugin.trigger.dpt_value_wsp.Weak.make_from_yaml( + [ + 0xBBBB, + ] + ) + performer = home.Performer( + appliance.name, appliance, [], [knx_trigger_windy, knx_trigger_quiet] + ) + performers.append(performer) + appliance = self.appliances.find("tapparella") + command = knx_plugin.command.dpt_updown.UpDown.make_from_yaml( + [ + 0xEEEE, + ] + ) + trigger_opened = knx_plugin.trigger.dpt_updown.Up.make_from_yaml( + [ + 0xEEEE, + ], + [home.appliance.curtain.event.forced.Event.Opened], + ) + trigger_closed = knx_plugin.trigger.dpt_updown.Down.make_from_yaml( + [ + 0xEEEE, + ], + [home.appliance.curtain.event.forced.Event.Closed], + ) + performer = home.Performer( + appliance.name, appliance, [command], [trigger_opened, trigger_closed] + ) + performer.notify( + [ + home.event.sun.phase.Event.Sunset, + home.event.sun.twilight.civil.Event.Sunset, + ] + ) + performers.append(performer) + return performers + + def _build_group_of_performers(self): + return {"tapparelle": [self._performers[1]], "sensori": [self._performers[0]]} + + def _build_scheduler_triggers(self): + performers = self.find_group_of_performers("sensori") + triggers = list() + for t in performers.triggers: + trigger = home.scheduler.trigger.protocol.Trigger( + name="anemometro", events=[], protocol_trigger=t + ) + triggers.append(trigger) + return triggers + + def _build_schedule_infos(self): + return [ + ( + self.find_group_of_performers("tapparelle"), + self.find_scheduler_triggers("anemometro"), + ) + ] + + +class TestLogics(TestCase): + def test_windy(tc): + tc.enable_logging() + tc.myhome = Stub() + tc.make_process(tc.myhome) + + class Test(unittest.IsolatedAsyncioTestCase): + + MAX_LOOP = 10 + + async def asyncSetUp(self): + tc.add_knx_gateway(tc.myhome) + self._loop = asyncio.get_event_loop() + tc.create_tasks(self._loop, tc.myhome) + self._loop.create_task(self.emulate_bus_events()) + + async def test_state(self): + i = 0 + is_notified = False + while not is_notified and i < self.MAX_LOOP: + await asyncio.sleep(0.3) + is_notified = tc.myhome.appliances.find("tapparella").is_notified( + home.event.wind.Event.Strong + ) + i += 1 + + async def emulate_bus_events(self): + await asyncio.sleep(0.1) + dpt = knx_stack.datapointtypes.DPT_Value_Wsp() + dpt.encode(8.0) + + tsap = tc.knx_gateway._association_table.get_tsap( + knx_stack.GroupAddress(0xBBBB) + ) + asaps = tc.knx_gateway._association_table.get_asaps(tsap) + for asap in asaps: + msg = knx_stack.layer.application.a_group_value_write.ind.Msg( + asap=asap, dpt=dpt + ) + msg = tc._knx_gateway.protocol_instance.encode(msg) + tc._knx_gateway.protocol_instance.data_received(msg.encode("utf-8")) + await asyncio.sleep(0.1) + + test = Test("test_state") + tc.assertFalse( + tc.myhome.appliances.find("tapparella").is_notified( + home.event.wind.Event.Strong + ) + ) + test.run() + tc.assertTrue( + tc.myhome.appliances.find("tapparella").is_notified( + home.event.wind.Event.Strong + ) + ) diff --git a/knx_plugin/tests/testcase.py b/knx_plugin/tests/testcase.py new file mode 100644 index 0000000..37344ac --- /dev/null +++ b/knx_plugin/tests/testcase.py @@ -0,0 +1,26 @@ +from unittest.mock import AsyncMock + +import knx_plugin +from home.tests.testcase import TestCase as Parent + + +class TestCase(Parent): + def setUp(self): + super(TestCase, self).setUp() + + async def write_side_effect(self, msgs, *args): + pass + + def add_knx_gateway(self, myhome): + client = knx_plugin.client.usbhid.Client + self._old_knx_client_write = client.write + client.write = AsyncMock(side_effect=self.write_side_effect) + self._knx_gateway = knx_plugin.gateway.usbhid.Gateway(client) + self._knx_gateway._loop.create_connection = AsyncMock(return_value=(None, None)) + self._knx_gateway.associate_commands(myhome.commands_by("knx")) + self._knx_gateway.associate_triggers(myhome.triggers_by("knx")) + self.process.add(self._knx_gateway) + + @property + def knx_gateway(self): + return self._knx_gateway diff --git a/knx_plugin/trigger/__init__.py b/knx_plugin/trigger/__init__.py new file mode 100644 index 0000000..6532d2b --- /dev/null +++ b/knx_plugin/trigger/__init__.py @@ -0,0 +1,249 @@ +import home +import copy +import knx_stack + +from typing import List +from knx_plugin.message import Description + + +class Trigger(home.protocol.Trigger, Description): + """A generic KNX trigger triggered when it has some ASAPs in common with the compared Description""" + + @classmethod + def make( + cls, addresses: List[knx_stack.Address], events: "home.Event" = None + ) -> "knx_plugin.Trigger": + description = copy.deepcopy(cls.DPT) + dsc = cls(description, events) + dsc.addresses = addresses + return dsc + + @classmethod + def make_from_yaml( + cls, addresses: List[int], events: "home.Event" = None + ) -> "knx_plugin.Trigger": + description = copy.deepcopy(cls.DPT) + description["addresses"] = addresses + return cls(description, events) + + def is_triggered( + self, another_description: "knx_plugin.message.Description" + ) -> bool: + if super(Trigger, self).is_triggered(another_description): + if set(self.asaps).intersection(set(another_description.asaps)): + return True + return False + + +class Equal(Trigger, home.protocol.Trigger): + """A trigger triggered when + + 1) it has some ASAPs in common with the compared Description and + 2) the knx_stack.datapointtypes.DPT described in the compared Description received from bus is equal + to the knx_stack.datapointtypes.DPT described by the trigger + + Example:: + + >>> import knx_stack + >>> import knx_plugin + >>> from knx_stack.datapointtypes import DPT_SceneControl + + >>> trigger = knx_plugin.trigger.dpt_scene_control.Activate.make( + ... addresses=[knx_stack.GroupAddress(free_style=1234)], + ... number=7) + >>> trigger.dpt.number + 7 + >>> trigger.dpt.command == DPT_SceneControl.Command.activate + True + + >>> address_table = knx_stack.AddressTable(knx_stack.Address(4097), [], 255) + >>> association_table = knx_stack.AssociationTable(address_table, []) + >>> groupobject_table = knx_stack.GroupObjectTable() + >>> trigger.associate(association_table, groupobject_table) + + >>> from_bus = {"name": "DPT_SceneControl", + ... "addresses": [1234], + ... "fields": {"number": 1, "command": "activate"}} + >>> another_description = knx_plugin.Description(from_bus) + >>> another_description._asaps = [knx_stack.ASAP(1)] + >>> trigger.is_triggered(another_description) + False + """ + + def is_triggered( + self, another_description: "knx_plugin.message.Description" + ) -> bool: + if super(Equal, self).is_triggered(another_description): + triggered = self.dpt.value == another_description.dpt.value + return triggered + + def __str__(self): + s = super(Equal, self).__str__() + return "{} equals {}".format(s, self.dpt) + + +class Always(Trigger, home.protocol.Trigger, home.protocol.mean.Mixin): + """A trigger triggered when + + 1) it has some ASAPs in common with the compared Description and + 2) the knx_stack.datapointtypes.DPT **type** described in the compared Description received from bus is equal + to the knx_stack.datapointtypes.DPT **type** described by the trigger + """ + + def is_triggered( + self, another_description: "knx_plugin.message.Description" + ) -> bool: + if super(Always, self).is_triggered(another_description): + if set(self.asaps) & set(another_description.asaps): + return True + + def make_new_state_from( + self, + another_description: "knx_plugin.message.Description", + old_state: "home.appliance.State", + ) -> "home.appliance.State": + new_state = super(Always, self).make_new_state_from( + another_description, old_state + ) + new_state = new_state.next(another_description.dpt.decode()) + return new_state + + def get_value(self, description: "knx_plugin.message.Description") -> float: + return description.dpt.decode() + + +class ComparisonMixin: + @staticmethod + def override_value(description, value=None): + if value: + description["fields"]["decoded_value"] = int(value) + return description + + @classmethod + def make_from_yaml( + cls, + addresses: List[int], + events: "home.Event" = None, + value: int = None, + ) -> "knx_plugin.Trigger": + description = copy.deepcopy(cls.DPT) + description["addresses"] = addresses + return cls(description, events, value) + + +class GreaterThan(ComparisonMixin, Trigger, home.protocol.Trigger): + """A trigger triggered when + + 1) it has some ASAPs in common with the compared Description and + 2) the knx_stack.datapointtypes.DPT **int value** contained in the compared Description + received from bus is lesser than the knx_stack.datapointtypes.DPT **int value** + contained into the trigger + + Use it when the comparison result has to be immediate otherwise use + :meth:`knx_plugin.trigger.mean.definition.GreaterThan` + """ + + def __init__( + self, + description: "knx_plugin.message.Description", + events: "home.Event" = None, + value: int = None, + ): + description = self.override_value(description, value) + super(GreaterThan, self).__init__(description, events) + + def is_triggered( + self, another_description: "knx_plugin.message.Description" + ) -> bool: + if super(GreaterThan, self).is_triggered(another_description): + if set(self.asaps).intersection(set(another_description.asaps)): + triggered = self.dpt.decode() < another_description.dpt.decode() + return triggered + + def __str__(self): + s = super(GreaterThan, self).__str__() + return "{} greater than {}".format(s, self.dpt.decode()) + + +class LesserThan(Trigger, home.protocol.Trigger, ComparisonMixin): + """A trigger triggered when + + 1) it has some ASAPs in common with the compared Description and + 2) the knx_stack.datapointtypes.DPT **int value** contained in the compared Description received from bus is greater + than the knx_stack.datapointtypes.DPT **int value** contained into the trigger + """ + + def __init__( + self, + description: "knx_plugin.message.Description", + events: "home.Event" = None, + value: int = None, + ): + description = self.override_value(description, value) + super(LesserThan, self).__init__(description, events) + + def is_triggered(self, another_description) -> bool: + if super(LesserThan, self).is_triggered(another_description): + if set(self.asaps).intersection(set(another_description.asaps)): + triggered = self.dpt.decode() > another_description.dpt.decode() + return triggered + + def __str__(self): + s = super(LesserThan, self).__str__() + return "{} lesser than {}".format(s, self.dpt.decode()) + + +class InBetween(Trigger, home.protocol.Trigger, ComparisonMixin): + """A trigger triggered when + + 1) it has some ASAPs in common with the compared Description and + 2) the knx_stack.datapointtypes.DPT **int value** contained in the compared Description received from bus is greater + than the knx_stack.datapointtypes.DPT **int value** contained into the trigger and lesser than this last value + plus a **range** + """ + + def __init__( + self, + description: "knx_plugin.message.Description", + events: "home.Event" = None, + value: int = None, + range: int = None, + ): + description = self.override_value(description, value) + super(InBetween, self).__init__(description, events) + self._range = range if range else 1 + + def is_triggered( + self, another_description: "knx_plugin.message.Description" + ) -> bool: + if super(InBetween, self).is_triggered(another_description): + triggered = ( + self.dpt.decode() + < another_description.dpt.decode() + < (self.dpt.decode() + self._range) + ) + return triggered + return False + + def __str__(self): + s = super(InBetween, self).__str__() + return "{} in between [{}:{}]".format( + s, self.dpt.decode(), (self.dpt.decode() + self._range) + ) + + +from knx_plugin.trigger import ( + mean, + custom_clima, + custom_scene, + dpt_value_lux, + dpt_value_power, + dpt_brightness, + dpt_switch, + dpt_updown, + dpt_value_temp, + dpt_value_wsp, + dpt_control_dimming, + dpt_scene_control, + dpt_start, +) diff --git a/knx_plugin/trigger/custom_clima.py b/knx_plugin/trigger/custom_clima.py new file mode 100644 index 0000000..897d02d --- /dev/null +++ b/knx_plugin/trigger/custom_clima.py @@ -0,0 +1,235 @@ +import copy + +import home +import knx_stack + +from knx_plugin.trigger import Always as Parent + + +class Trigger(Parent): + def __init__(self, description, events, low_setpoint, high_setpoint): + super(Trigger, self).__init__(description, events) + self._low_setpoint = low_setpoint if low_setpoint else 20 + self._high_setpoint = high_setpoint if high_setpoint else 22 + + @classmethod + def make(cls, addresses, events=None, low_setpoint=None, high_setpoint=None): + description = copy.deepcopy(cls.DPT) + dsc = cls(description, events, low_setpoint, high_setpoint) + dsc.addresses = addresses + return dsc + + @classmethod + def make_from_yaml( + cls, addresses, events=None, low_setpoint=None, high_setpoint=None + ): + description = copy.deepcopy(cls.DPT) + description["addresses"] = addresses + return cls(description, events, low_setpoint, high_setpoint) + + @staticmethod + def _decode_winter_setpoint(command): + return (command.dpt.setpoint + 50) / 10 + + @staticmethod + def _decode_summer_setpoint(command): + return (command.dpt.setpoint - 50) / 10 + + def _decode_setpoint(self, command): + if ( + command.dpt.stagione + == knx_stack.datapointtypes.DPTSetupClima.Stagione.inverno + ): + setpoint = self._decode_winter_setpoint(command) + else: + setpoint = self._decode_summer_setpoint(command) + + return setpoint + + @staticmethod + def _decode_season(command): + if ( + command.dpt.stagione + == knx_stack.datapointtypes.DPTSetupClima.Stagione.inverno + ): + season = home.event.clima.season.Event.Winter + else: + season = home.event.clima.season.Event.Summer + return season + + @staticmethod + def _decode_mode(command): + if command.dpt.funzionamento in ( + knx_stack.datapointtypes.DPTSetupClima.Funzionamento.automatico, + knx_stack.datapointtypes.DPTSetupClima.Funzionamento.manuale, + ): + mode = home.event.clima.command.Event.On + elif ( + command.dpt.funzionamento + == knx_stack.datapointtypes.DPTSetupClima.Funzionamento.riduzione_notturna + ): + mode = home.event.clima.command.Event.Keep + elif ( + command.dpt.funzionamento + == knx_stack.datapointtypes.DPTSetupClima.Funzionamento.off + ): + mode = home.event.clima.command.Event.Off + else: + mode = None + return mode + + @staticmethod + def _decode_temperatura(command): + return command.dpt.temperatura + 15 + + +class Force(Trigger): + @property + def forced_event(self): + raise NotImplementedError + + @property + def events(self): + """ + Messages are sent continuously, not only when the user manually set the thermostat. + Thus the thermostat should be made forced only if the sent message is different from + the system state... make_new_state_from should be used instead of sending an event to the + appliance every time the trigger is triggered + """ + return [] + + def make_new_state_from(self, another_description, old_state): + mode = self._decode_mode(another_description) + if mode not in old_state: + new_state = old_state.next(self.forced_event) + else: + new_state = old_state + return new_state + + +class Off(Force): + + DPT = {"name": "DPTSetupClima", "fields": {"funzionamento": "off"}, "addresses": []} + + @property + def forced_event(self): + return home.appliance.thermostat.presence.event.forced.Event.Off + + def is_triggered(self, another_description): + if super(Off, self).is_triggered(another_description): + return home.event.clima.command.Event.Off == self._decode_mode( + another_description + ) + else: + return False + + +class Keep(Force): + + DPT = { + "name": "DPTSetupClima", + "fields": {"funzionamento": "riduzione_notturna"}, + "addresses": [], + } + + @property + def forced_event(self): + return home.appliance.thermostat.presence.event.forced.Event.Keep + + def is_triggered(self, another_description): + if super(Keep, self).is_triggered(another_description): + return home.event.clima.command.Event.Keep == self._decode_mode( + another_description + ) + else: + return False + + +class OnManuale(Force): + + DPT = { + "name": "DPTSetupClima", + "fields": {"funzionamento": "manuale", "stagione": "inverno"}, + "addresses": [], + } + + @property + def forced_event(self): + return home.appliance.thermostat.presence.event.forced.Event.On + + def is_triggered(self, another_description): + if super(OnManuale, self).is_triggered(another_description): + return home.event.clima.command.Event.On == self._decode_mode( + another_description + ) + else: + return False + + +class OnAutomatico(Force): + + DPT = { + "name": "DPTSetupClima", + "fields": {"funzionamento": "automatico", "stagione": "inverno"}, + "addresses": [], + } + + @property + def forced_event(self): + return home.appliance.thermostat.presence.event.forced.Event.On + + def is_triggered(self, another_description): + if super(OnAutomatico, self).is_triggered(another_description): + return home.event.clima.command.Event.On == self._decode_mode( + another_description + ) + else: + return False + + +class Report(Trigger): + """ + >>> import home + >>> import knx_plugin + >>> import copy + >>> cmd = knx_plugin.trigger.custom_clima.Report.make_from_yaml([3202], [], 19, 20) + >>> cmd._asaps = [1] + >>> cmd.dpt.value = 0x00E3E369 + >>> description = copy.copy(knx_plugin.trigger.custom_clima.Report.DPT) + >>> description["addresses"] = [3202] + >>> description["fields"]["funzionamento"] = "off" + >>> another_cmd = knx_plugin.trigger.custom_clima.Off(description, [], 12, 12) + >>> cmd.make_new_state_from(another_cmd, + ... home.appliance.thermostat.presence.state.off.State([0.0, + ... home.event.clima.season.Event.Winter, + ... home.event.clima.command.Event.On])) + Off (computed from events: 43.0, Setpoint: 20.0°, Keeping mode setpoint: 21.5°, home.event.clima.season.Event.Winter, home.event.clima.command.Event.Off, home.event.presence.Event.Off, home.appliance.thermostat.presence.event.forced.event.Event.Not) and disabled events set() + """ + + DPT = { + "name": "DPTInfoClimaReport", + "fields": { + "funzionamento": "automatico_invio_temperatura_abilitato", + "centralizzato": True, + "stagione": "inverno", + "terziario": True, + "stato_rele": False, + "setpoint": 150, # 20 gradi + "temporizzazione": 0, + "temperatura": 28, + }, + "addresses": [], + } + + def make_new_state_from(self, another_description, old_state): + setpoint = self._decode_setpoint(another_description) + temperature = self._decode_temperatura(another_description) + season = self._decode_season(another_description) + mode = self._decode_mode(another_description) + + new_state = old_state.next(season) + new_state = new_state.next(mode) + new_state = new_state.next(temperature) + new_state.setpoint = setpoint + + return new_state diff --git a/knx_plugin/trigger/custom_scene.py b/knx_plugin/trigger/custom_scene.py new file mode 100644 index 0000000..2ebd701 --- /dev/null +++ b/knx_plugin/trigger/custom_scene.py @@ -0,0 +1,50 @@ +import copy + +from typing import List + +import home +import knx_stack +from knx_plugin.trigger import Equal as Parent + + +class Equal(Parent): + + DPT = { + "type": "knx", + "name": "DPTVimarScene", + "addresses": [], + "fields": {"index": 0, "command": "attiva"}, + } + + DEFAULT_EVENTS = [home.event.scene.Event.Triggered] + + def __init__( + self, description: dict, events: List[home.Event] = None, index: int = None + ): + description["fields"]["index"] = index if index else 0 + super(Equal, self).__init__(description, events) + + @classmethod + def make( + cls, + addresses: List[knx_stack.Address], + events: List[home.Event] = None, + index: int = None, + ): + description = copy.deepcopy(cls.DPT) + dsc = cls(description, events, index) + dsc.addresses = addresses + return dsc + + @classmethod + def make_from_yaml( + cls, addresses: List[int], events: List[home.Event] = None, index: int = None + ): + description = copy.deepcopy(cls.DPT) + description["addresses"] = addresses + return cls(description, events, index) + + +class EqualNoDefaultEvents(Equal): + + DEFAULT_EVENTS = [] diff --git a/knx_plugin/trigger/dpt_brightness.py b/knx_plugin/trigger/dpt_brightness.py new file mode 100644 index 0000000..1f55e79 --- /dev/null +++ b/knx_plugin/trigger/dpt_brightness.py @@ -0,0 +1,26 @@ +import home +from knx_plugin.message import Description +from knx_plugin.trigger import Trigger + + +class Always(Trigger, home.protocol.Trigger): + """ + Update a State, which holds a brightness attribute, + with the new measured brightness value + """ + + DPT = { + "type": "knx", + "name": "DPT_Brightness", + "addresses": [], + "fields": {"value": 100}, + } + + def make_new_state_from( + self, another_description: Description, old_state: home.appliance.State + ) -> home.appliance.State: + new_state = super(Always, self).make_new_state_from( + another_description, old_state + ) + new_state.brightness = another_description.dpt.value + return new_state diff --git a/knx_plugin/trigger/dpt_control_dimming/__init__.py b/knx_plugin/trigger/dpt_control_dimming/__init__.py new file mode 100644 index 0000000..f5c190d --- /dev/null +++ b/knx_plugin/trigger/dpt_control_dimming/__init__.py @@ -0,0 +1,81 @@ +import knx_stack +import home + +from knx_plugin.message import Description +from knx_plugin.trigger import Trigger + + +class Step(Trigger): + + DPT = { + "type": "knx", + "name": "DPT_Control_Dimming", + "addresses": [], + "fields": {"step": 0, "direction": "down"}, + } + + def is_triggered(self, another_description: Description) -> bool: + if super(Step, self).is_triggered(another_description): + if set(self.asaps).intersection(set(another_description.asaps)): + triggered = another_description.dpt.step > 0 + self._logger.info( + "{} ({} triggered={}):" + " dpt.step={}" + " dpt.direction={}" + " for asaps={}".format( + self._dpt_class.__name__, + self.__class__.__name__, + triggered, + self.dpt.step, + self.dpt.direction, + self.asaps, + ) + ) + return triggered + + +class BrightnessStep(Step): + """ + >>> import json + >>> import home + >>> import knx_plugin + >>> trigger = knx_plugin.trigger.dpt_control_dimming.BrightnessStep.make([1234]) + >>> bus_event = ''' + ... {"name": "DPT_Control_Dimming", + ... "addresses": [1234], + ... "fields": {"direction": "down", "step": 7}} + ... ''' + >>> description = (knx_plugin.Description(json.loads(bus_event))) + >>> old_state = home.appliance.light.indoor.dimmerable.state.on.State() + >>> new_state = trigger.make_new_state_from(description, old_state) + >>> new_state.brightness + 10 + """ + + DEFAULT_EVENTS = [home.appliance.light.indoor.dimmerable.event.forced.Event.On] + + def make_new_state_from( + self, another_description: Description, old_state: home.appliance.State + ) -> home.appliance.State: + step_ = ( + another_description.dpt.step + if another_description.dpt.direction + == knx_stack.datapointtypes.DPT_Control_Dimming.Direction.up + else -another_description.dpt.step + ) + + new_state = super(BrightnessStep, self).make_new_state_from( + another_description, old_state + ) + if new_state.brightness + (step_ * 20) > 100: + new_brightness = 100 + elif new_state.brightness + (step_ * 20) < 10: + new_brightness = 10 + else: + new_brightness = new_state.brightness + (step_ * 20) + new_state.brightness = new_brightness + + return new_state + + +from knx_plugin.trigger.dpt_control_dimming import step diff --git a/knx_plugin/trigger/dpt_control_dimming/step/__init__.py b/knx_plugin/trigger/dpt_control_dimming/step/__init__.py new file mode 100644 index 0000000..fe1ccb7 --- /dev/null +++ b/knx_plugin/trigger/dpt_control_dimming/step/__init__.py @@ -0,0 +1 @@ +from knx_plugin.trigger.dpt_control_dimming.step import down, up diff --git a/knx_plugin/trigger/dpt_control_dimming/step/down.py b/knx_plugin/trigger/dpt_control_dimming/step/down.py new file mode 100644 index 0000000..5176ae9 --- /dev/null +++ b/knx_plugin/trigger/dpt_control_dimming/step/down.py @@ -0,0 +1,18 @@ +from knx_plugin.message import Description +from knx_plugin.trigger import Trigger as Parent + + +class Trigger(Parent): + + DPT = { + "type": "knx", + "name": "DPT_Control_Dimming", + "addresses": [], + "fields": {"direction": "down"}, + } + + def is_triggered(self, another_description: Description) -> bool: + triggered = False + if super(Trigger, self).is_triggered(another_description): + triggered = another_description.dpt.direction == self.dpt.direction + return triggered diff --git a/knx_plugin/trigger/dpt_control_dimming/step/up.py b/knx_plugin/trigger/dpt_control_dimming/step/up.py new file mode 100644 index 0000000..29f9ae8 --- /dev/null +++ b/knx_plugin/trigger/dpt_control_dimming/step/up.py @@ -0,0 +1,11 @@ +from knx_plugin.trigger.dpt_control_dimming.step.down import Trigger as Parent + + +class Trigger(Parent): + + DPT = { + "type": "knx", + "name": "DPT_Control_Dimming", + "addresses": [], + "fields": {"direction": "up"}, + } diff --git a/knx_plugin/trigger/dpt_scene_control.py b/knx_plugin/trigger/dpt_scene_control.py new file mode 100644 index 0000000..0cebea4 --- /dev/null +++ b/knx_plugin/trigger/dpt_scene_control.py @@ -0,0 +1,46 @@ +import copy + +from typing import List + +import home +import knx_stack + +from knx_plugin.trigger import Equal as Parent + + +class Activate(Parent): + + DPT = { + "type": "knx", + "name": "DPT_SceneControl", + "addresses": [], + "fields": {"number": 0, "command": "activate"}, + } + + DEFAULT_EVENTS = [] + + def __init__( + self, description: dict, events: List[home.Event] = None, number: int = None + ): + description["fields"]["number"] = number if number else 0 + super(Activate, self).__init__(description, events) + + @classmethod + def make( + cls, + addresses: List[knx_stack.Address], + events: List[home.Event] = None, + number: int = None, + ): + description = copy.deepcopy(cls.DPT) + dsc = cls(description, events, number) + dsc.addresses = addresses + return dsc + + @classmethod + def make_from_yaml( + cls, addresses: List[int], events: List[home.Event] = None, number: int = None + ): + description = copy.deepcopy(cls.DPT) + description["addresses"] = addresses + return cls(description, events, number) diff --git a/knx_plugin/trigger/dpt_start.py b/knx_plugin/trigger/dpt_start.py new file mode 100644 index 0000000..84c07a6 --- /dev/null +++ b/knx_plugin/trigger/dpt_start.py @@ -0,0 +1,21 @@ +from knx_plugin.trigger import Equal + + +class Start(Equal): + + DPT = { + "type": "knx", + "name": "DPT_Start", + "addresses": [], + "fields": {"action": "start"}, + } + + +class Stop(Equal): + + DPT = { + "type": "knx", + "name": "DPT_Start", + "addresses": [], + "fields": {"action": "stop"}, + } diff --git a/knx_plugin/trigger/dpt_switch.py b/knx_plugin/trigger/dpt_switch.py new file mode 100644 index 0000000..9aa3adc --- /dev/null +++ b/knx_plugin/trigger/dpt_switch.py @@ -0,0 +1,21 @@ +from knx_plugin.trigger import Equal + + +class On(Equal): + + DPT = { + "type": "knx", + "name": "DPT_Switch", + "addresses": [], + "fields": {"action": "on"}, + } + + +class Off(Equal): + + DPT = { + "type": "knx", + "name": "DPT_Switch", + "addresses": [], + "fields": {"action": "off"}, + } diff --git a/knx_plugin/trigger/dpt_updown.py b/knx_plugin/trigger/dpt_updown.py new file mode 100644 index 0000000..44eb3b4 --- /dev/null +++ b/knx_plugin/trigger/dpt_updown.py @@ -0,0 +1,21 @@ +from knx_plugin.trigger import Equal + + +class Up(Equal): + + DPT = { + "type": "knx", + "name": "DPT_UpDown", + "addresses": [], + "fields": {"direction": "up"}, + } + + +class Down(Equal): + + DPT = { + "type": "knx", + "name": "DPT_UpDown", + "addresses": [], + "fields": {"direction": "down"}, + } diff --git a/knx_plugin/trigger/dpt_value_lux/__init__.py b/knx_plugin/trigger/dpt_value_lux/__init__.py new file mode 100644 index 0000000..c7d8e80 --- /dev/null +++ b/knx_plugin/trigger/dpt_value_lux/__init__.py @@ -0,0 +1,181 @@ +from typing import List + +import home + +from knx_plugin.trigger import Always as Parent +from knx_plugin.trigger import mean + + +class Always(Parent): + + DPT = { + "type": "knx", + "name": "DPT_Value_Lux", + "addresses": [], + "fields": {"decoded_value": 0}, + } + + +class Brightness(Always): + """ + Update a State, which holds a brightness attribute, + with the new measured lux value + """ + + def make_new_state_from( + self, + another_description: "knx_plugin.message.Description", + old_state: home.appliance.State, + ) -> home.appliance.State: + new_state = super(Always, self).make_new_state_from( + another_description, old_state + ) + new_state.brightness = another_description.dpt.decode() + return new_state + + +class Bright(mean.GreaterThan): + + DEFAULT_EVENTS = [home.event.sun.brightness.Event.Bright] + NUM_OF_SAMPLES = 50 + + DPT = { + "type": "knx", + "name": "DPT_Value_Lux", + "addresses": [], + "fields": {"decoded_value": 45000}, + } + + def __init__( + self, + description: dict, + events: List[home.Event] = None, + samples: int = None, + value: float = None, + ): + super(Bright, self).__init__( + description, events, samples if samples else self.NUM_OF_SAMPLES, value + ) + + +class DeepDark(mean.LesserThan): + """ + >>> import io + >>> import json + >>> import knx_stack + >>> import knx_plugin + + >>> deepdark = knx_plugin.trigger.dpt_value_lux.DeepDark.make_from_yaml(addresses=[1234]) + + >>> address_table = knx_stack.layer.AddressTable(knx_stack.Address(4098), [], 255) + >>> association_table = knx_stack.layer.AssociationTable(address_table, []) + >>> groupobject_table = knx_stack.GroupObjectTable() + >>> deepdark.associate(association_table, groupobject_table) + + >>> bus_event = ''' + ... {"name": "DPT_Value_Lux", + ... "addresses": [1234], + ... "fields": {"decoded_value": 4001}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> deepdark.is_triggered(another_description) + False + + >>> bus_event = ''' + ... {"name": "DPT_Value_Lux", + ... "addresses": [1234], + ... "fields": {"decoded_value": 1000}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> deepdark.is_triggered(another_description) + True + """ + + DEFAULT_EVENTS = [home.event.sun.brightness.Event.DeepDark] + NUM_OF_SAMPLES = 50 + + DPT = { + "type": "knx", + "name": "DPT_Value_Lux", + "addresses": [], + "fields": {"decoded_value": 4000}, + } + + def __init__( + self, + description: dict, + events: List[home.Event] = None, + samples: int = None, + value: float = None, + ): + super(DeepDark, self).__init__( + description, events, samples if samples else self.NUM_OF_SAMPLES, value + ) + + +class Dark(mean.InBetween): + """ + >>> import io + >>> import json + >>> import knx_stack + >>> import knx_plugin + + >>> dark = knx_plugin.trigger.dpt_value_lux.Dark.make_from_yaml(addresses=[1234]) + + >>> address_table = knx_stack.layer.AddressTable(knx_stack.Address(4098), [], 255) + >>> association_table = knx_stack.layer.AssociationTable(address_table, []) + >>> groupobject_table = knx_stack.GroupObjectTable() + >>> dark.associate(association_table, groupobject_table) + + >>> bus_event = ''' + ... {"name": "DPT_Value_Lux", + ... "addresses": [1234], + ... "fields": {"decoded_value": 9000}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> dark.is_triggered(another_description) + True + + >>> bus_event = ''' + ... {"name": "DPT_Value_Lux", + ... "addresses": [1234], + ... "fields": {"decoded_value": 60000}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> dark.is_triggered(another_description) + False + """ + + DPT = { + "type": "knx", + "name": "DPT_Value_Lux", + "addresses": [], + "fields": {"decoded_value": 4000}, + } + + DEFAULT_EVENTS = [home.event.sun.brightness.Event.Dark] + NUM_OF_SAMPLES = 50 + RANGE = 15000 # lux + + def __init__( + self, + description: dict, + events: List[home.Event] = None, + samples: int = None, + value: float = None, + range: int = None, + ): + super(Dark, self).__init__( + description, + events, + samples if samples else self.NUM_OF_SAMPLES, + value, + range if range else self.RANGE, + ) + + +from knx_plugin.trigger.dpt_value_lux import balance diff --git a/knx_plugin/trigger/dpt_value_lux/balance.py b/knx_plugin/trigger/dpt_value_lux/balance.py new file mode 100644 index 0000000..3fb834c --- /dev/null +++ b/knx_plugin/trigger/dpt_value_lux/balance.py @@ -0,0 +1,188 @@ +import copy + +from typing import List + +import home +import knx_stack + +from knx_plugin.message import Description +from knx_plugin.trigger import mean + + +class SunBrightness(mean.Mean): + """ + It triggers **sun brightness**. + The sun brightness **mean** is used to calculate a new light Appliance brightness value. + A *home.appliance.light.event.brightness.Event* has a value in between **[0;100]**. + + If outside sun brightness is high then the new brightness light event value is low + and the other way around. + + The lowest light brightness event value is given by user in *lowest light brightness*. + The highest light brightness event value is given by user in *highest light brightness*. + The *highest light brightness* event is notified when outside *sun brightness value* is lower than *min sun brightness* given by user. + The *lowest light brightness* event is notified when outside *sun brightness value* is higher than *max sun brightness* given by user. + + In between min and max sun brightness values the system will scale the light brightness. + + >>> import io + >>> import json + >>> import knx_stack + >>> import knx_plugin + + >>> balance = knx_plugin.trigger.dpt_value_lux.balance.SunBrightness.make_from_yaml([1234], [], 1, 0, 30, 100, 5000, 30000) + + >>> address_table = knx_stack.layer.AddressTable(knx_stack.Address(4098), [], 255) + >>> association_table = knx_stack.layer.AssociationTable(address_table, []) + >>> groupobject_table = knx_stack.GroupObjectTable() + >>> balance.associate(association_table, groupobject_table) + + >>> bus_event = ''' + ... {"name": "DPT_Value_Lux", + ... "addresses": [1234], + ... "fields": {"decoded_value": 9000}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> balance.is_triggered(another_description) + True + >>> balance.events[0] + Balanced brightness: 89% + + >>> bus_event = ''' + ... {"name": "DPT_Value_Lux", + ... "addresses": [1234], + ... "fields": {"decoded_value": 5000}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> balance.is_triggered(another_description) + True + >>> balance.events[0] + Balanced brightness: 100% + + >>> bus_event = ''' + ... {"name": "DPT_Value_Lux", + ... "addresses": [1234], + ... "fields": {"decoded_value": 30000}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> balance.is_triggered(another_description) + True + >>> balance.events[0] + Balanced brightness: 30% + """ + + DPT = { + "type": "knx", + "name": "DPT_Value_Lux", + "addresses": [], + "fields": {"decoded_value": 0}, + } + NUM_OF_SAMPLES = 30 + + def __init__( + self, + description: dict, + events: List[home.Event] = None, + samples: int = None, + value: float = None, + lowest_light_brightness: int = None, + highest_light_brightness: int = None, + min_sun_brightness: int = None, + max_sun_brightness: int = None, + ): + super(SunBrightness, self).__init__( + description, events, samples if samples else self.NUM_OF_SAMPLES, value + ) + self._lowest_light_brightness = ( + lowest_light_brightness if lowest_light_brightness else 30 + ) + self._highest_light_brightness = ( + highest_light_brightness if highest_light_brightness else 100 + ) + self._min_sun_brightness = min_sun_brightness if min_sun_brightness else 5000 + self._max_sun_brightness = max_sun_brightness if max_sun_brightness else 30000 + self._event = home.appliance.light.event.lux_balancing.brightness.Event( + self._highest_light_brightness + ) + self._coefficient = ( + self._highest_light_brightness - self._lowest_light_brightness + ) / (self._max_sun_brightness - self._min_sun_brightness) + + @classmethod + def make( + cls, + addresses: List[knx_stack.Address], + events: List[home.Event] = None, + samples: int = None, + value: float = None, + lowest_light_brightness: int = None, + highest_light_brightness: int = None, + min_sun_brightness: int = None, + max_sun_brightness: int = None, + ): + description = copy.deepcopy(cls.DPT) + dsc = cls( + description, + events, + samples, + value, + lowest_light_brightness, + highest_light_brightness, + min_sun_brightness, + max_sun_brightness, + ) + dsc.addresses = addresses + return dsc + + @classmethod + def make_from_yaml( + cls, + addresses: List[int], + events: List[home.Event] = None, + samples: int = None, + value: float = None, + lowest_light_brightness: int = None, + highest_light_brightness: int = None, + min_sun_brightness: int = None, + max_sun_brightness: int = None, + ): + description = copy.deepcopy(cls.DPT) + description["addresses"] = addresses + return cls( + description, + events, + samples, + value, + lowest_light_brightness, + highest_light_brightness, + min_sun_brightness, + max_sun_brightness, + ) + + def create_brightness_event( + self, + ) -> home.appliance.light.event.lux_balancing.brightness.Event: + if self._mean < self._min_sun_brightness: + value = self._highest_light_brightness + elif self._mean > self._max_sun_brightness: + value = self._lowest_light_brightness + else: + value = self._highest_light_brightness - ( + (self._mean - self._min_sun_brightness) * self._coefficient + ) + return home.appliance.light.event.lux_balancing.brightness.Event(round(value)) + + def is_triggered(self, another_description: Description) -> bool: + if super(SunBrightness, self).is_triggered(another_description): + self._event = self.create_brightness_event() + return True + return False + + @property + def events(self) -> List[home.Event]: + a_list = self._events.copy() + a_list.append(self._event) + return a_list diff --git a/knx_plugin/trigger/dpt_value_power/__init__.py b/knx_plugin/trigger/dpt_value_power/__init__.py new file mode 100644 index 0000000..00e632e --- /dev/null +++ b/knx_plugin/trigger/dpt_value_power/__init__.py @@ -0,0 +1,15 @@ +from knx_plugin.trigger import Always as Parent + + +class Always(Parent): + + DPT = { + "type": "knx", + "name": "DPT_Value_Power", + "addresses": [], + "fields": {"decoded_value": 0}, + } + + +from knx_plugin.trigger.dpt_value_power import consumption +from knx_plugin.trigger.dpt_value_power import production diff --git a/knx_plugin/trigger/dpt_value_power/consumption.py b/knx_plugin/trigger/dpt_value_power/consumption.py new file mode 100644 index 0000000..db64c95 --- /dev/null +++ b/knx_plugin/trigger/dpt_value_power/consumption.py @@ -0,0 +1,204 @@ +from typing import List + +import home +from knx_plugin.trigger import mean, GreaterThan, LesserThan + + +class No(LesserThan): + """ + >>> import io + >>> import json + >>> import knx_stack + >>> import knx_plugin + + >>> addresses = [knx_stack.GroupAddress(free_style=1234),] + >>> consuming = knx_plugin.trigger.dpt_value_power.consumption.No.make(addresses=addresses) + + >>> address_table = knx_stack.layer.AddressTable(knx_stack.Address(4098), [], 255) + >>> association_table = knx_stack.layer.AssociationTable(address_table, []) + >>> groupobject_table = knx_stack.GroupObjectTable() + >>> consuming.associate(association_table, groupobject_table) + + >>> bus_event = ''' + ... {"name": "DPT_Value_Power", + ... "addresses": [1234], + ... "fields": {"decoded_value": -600}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> consuming.is_triggered(another_description) + True + """ + + DPT = { + "type": "knx", + "name": "DPT_Value_Power", + "addresses": [], + "fields": {"decoded_value": 0}, + } + + DEFAULT_EVENTS = [home.event.power.consumption.Event.No] + + +class Low(mean.InBetween): + """ + >>> import io + >>> import json + >>> import knx_stack + >>> import knx_plugin + + >>> addresses = [knx_stack.GroupAddress(free_style=1234),] + >>> low = knx_plugin.trigger.dpt_value_power.consumption.Low.make(addresses=addresses) + + >>> address_table = knx_stack.layer.AddressTable(knx_stack.Address(4098), [], 255) + >>> association_table = knx_stack.layer.AssociationTable(address_table, []) + >>> groupobject_table = knx_stack.GroupObjectTable() + >>> low.associate(association_table, groupobject_table) + + >>> bus_event = ''' + ... {"name": "DPT_Value_Power", + ... "addresses": [1234], + ... "fields": {"decoded_value": 2}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> low.is_triggered(another_description) + True + + >>> bus_event = ''' + ... {"name": "DPT_Value_Power", + ... "addresses": [1234], + ... "fields": {"decoded_value": 4000}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> low.is_triggered(another_description) + False + """ + + DPT = { + "type": "knx", + "name": "DPT_Value_Power", + "addresses": [], + "fields": {"decoded_value": 1}, + } + + DEFAULT_EVENTS = [home.event.power.consumption.Event.Low] + NUM_OF_SAMPLES = 1 + RANGE = 3300 # W + + def __init__( + self, + description: dict, + events: List[home.Event] = None, + samples: int = None, + range: int = None, + value: float = None, + ): + super(Low, self).__init__( + description, + events, + samples if samples else self.NUM_OF_SAMPLES, + value, + range if range else self.RANGE, + ) + + +class High(mean.InBetween): + """ + >>> import io + >>> import json + >>> import knx_stack + >>> import knx_plugin + + >>> addresses = [knx_stack.GroupAddress(free_style=1234),] + >>> low = knx_plugin.trigger.dpt_value_power.consumption.High.make(addresses=addresses) + + >>> address_table = knx_stack.AddressTable(knx_stack.Address(4098), [], 255) + >>> association_table = knx_stack.AssociationTable(address_table, []) + >>> groupobject_table = knx_stack.GroupObjectTable() + >>> low.associate(association_table, groupobject_table) + + >>> bus_event = ''' + ... {"name": "DPT_Value_Power", + ... "addresses": [1234], + ... "fields": {"decoded_value": 7001}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> low.is_triggered(another_description) + True + + >>> bus_event = ''' + ... {"name": "DPT_Value_Power", + ... "addresses": [1234], + ... "fields": {"decoded_value": 8000}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> low.is_triggered(another_description) + False + """ + + DPT = { + "type": "knx", + "name": "DPT_Value_Power", + "addresses": [], + "fields": {"decoded_value": 7000}, + } + + DEFAULT_EVENTS = [home.event.power.consumption.Event.High] + NUM_OF_SAMPLES = 1 + RANGE = 699 # W + + def __init__( + self, + description: dict, + events: List[home.Event] = None, + samples: int = None, + range: int = None, + value: float = None, + ): + super(High, self).__init__( + description, + events, + samples if samples else self.NUM_OF_SAMPLES, + value, + range if range else self.RANGE, + ) + + +class Overhead(GreaterThan): + """ + >>> import io + >>> import json + >>> import knx_stack + >>> import knx_plugin + + >>> addresses = [knx_stack.GroupAddress(free_style=1234),] + >>> consuming = knx_plugin.trigger.dpt_value_power.consumption.Overhead.make(addresses=addresses) + + >>> address_table = knx_stack.layer.AddressTable(knx_stack.Address(4098), [], 255) + >>> association_table = knx_stack.layer.AssociationTable(address_table, []) + >>> groupobject_table = knx_stack.GroupObjectTable() + >>> consuming.associate(association_table, groupobject_table) + + >>> bus_event = ''' + ... {"name": "DPT_Value_Power", + ... "addresses": [1234], + ... "fields": {"decoded_value": 8100}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> consuming.is_triggered(another_description) + True + """ + + DPT = { + "type": "knx", + "name": "DPT_Value_Power", + "addresses": [], + "fields": {"decoded_value": 8000}, + } + + DEFAULT_EVENTS = [home.event.power.consumption.Event.High] diff --git a/knx_plugin/trigger/dpt_value_power/production.py b/knx_plugin/trigger/dpt_value_power/production.py new file mode 100644 index 0000000..8ccd0f2 --- /dev/null +++ b/knx_plugin/trigger/dpt_value_power/production.py @@ -0,0 +1,39 @@ +import home + +from knx_plugin.trigger import GreaterThan + + +class No(GreaterThan): + """ + >>> import io + >>> import json + >>> import knx_stack + >>> import knx_plugin + + >>> addresses = [knx_stack.GroupAddress(free_style=1234),] + >>> producing = knx_plugin.trigger.dpt_value_power.production.No.make(addresses=addresses) + + >>> address_table = knx_stack.layer.AddressTable(knx_stack.Address(4098), [], 255) + >>> association_table = knx_stack.layer.AssociationTable(address_table, []) + >>> groupobject_table = knx_stack.GroupObjectTable() + >>> producing.associate(association_table, groupobject_table) + + >>> bus_event = ''' + ... {"name": "DPT_Value_Power", + ... "addresses": [1234], + ... "fields": {"decoded_value": 600}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> producing.is_triggered(another_description) + True + """ + + DPT = { + "type": "knx", + "name": "DPT_Value_Power", + "addresses": [], + "fields": {"decoded_value": 0}, + } + + DEFAULT_EVENTS = [home.event.power.production.Event.No] diff --git a/knx_plugin/trigger/dpt_value_temp.py b/knx_plugin/trigger/dpt_value_temp.py new file mode 100644 index 0000000..75e9ef6 --- /dev/null +++ b/knx_plugin/trigger/dpt_value_temp.py @@ -0,0 +1,145 @@ +from typing import List + +import home + +from knx_plugin.message import Description +from knx_plugin.trigger import Always as Parent, GreaterThan, InBetween, LesserThan +from knx_plugin.trigger.custom_clima import Report + + +class Hot(GreaterThan): + """ + >>> import io + >>> import json + >>> import knx_stack + >>> import knx_plugin + + >>> hot = knx_plugin.trigger.dpt_value_temp.Hot.make_from_yaml(addresses=[1234], value=35) + + >>> address_table = knx_stack.AddressTable(knx_stack.Address(4098), [], 255) + >>> association_table = knx_stack.AssociationTable(address_table, []) + >>> groupobject_table = knx_stack.GroupObjectTable() + >>> hot.associate(association_table, groupobject_table) + + >>> bus_event = ''' + ... {"name": "DPT_Value_Temp", + ... "addresses": [1234], + ... "fields": {"decoded_value": 36}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> hot.is_triggered(another_description) + True + """ + + DPT = { + "type": "knx", + "name": "DPT_Value_Temp", + "addresses": [], + "fields": {"decoded_value": 25.0}, + } + + DEFAULT_EVENTS = [home.event.temperature.Event.Hot] + + +class Warm(InBetween): + """ + >>> import io + >>> import json + >>> import knx_stack + >>> import knx_plugin + + >>> warm = knx_plugin.trigger.dpt_value_temp.Warm.make_from_yaml(addresses=[1234]) + + >>> address_table = knx_stack.AddressTable(knx_stack.Address(4098), [], 255) + >>> association_table = knx_stack.AssociationTable(address_table, []) + >>> groupobject_table = knx_stack.GroupObjectTable() + >>> warm.associate(association_table, groupobject_table) + + >>> bus_event = ''' + ... {"name": "DPT_Value_Temp", + ... "addresses": [1234], + ... "fields": {"decoded_value": 15.5}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> warm.is_triggered(another_description) + True + """ + + DPT = { + "type": "knx", + "name": "DPT_Value_Temp", + "addresses": [], + "fields": {"decoded_value": 5.0}, + } + + DEFAULT_EVENTS = [home.event.temperature.Event.Warm] + RANGE = 20.0 + + def __init__( + self, + description: Description, + events: List[home.Event] = None, + value: float = None, + range: int = None, + ): + super(Warm, self).__init__( + description, events, value, range if range else self.RANGE + ) + + +class Cold(LesserThan): + """ + >>> import io + >>> import json + >>> import knx_stack + >>> import knx_plugin + + >>> cold = knx_plugin.trigger.dpt_value_temp.Cold.make_from_yaml(addresses=[1234]) + + >>> address_table = knx_stack.AddressTable(knx_stack.Address(4098), [], 255) + >>> association_table = knx_stack.AssociationTable(address_table, []) + >>> groupobject_table = knx_stack.GroupObjectTable() + >>> cold.associate(association_table, groupobject_table) + + >>> bus_event = ''' + ... {"name": "DPT_Value_Temp", + ... "addresses": [1234], + ... "fields": {"decoded_value": 2.5}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> cold.is_triggered(another_description) + True + """ + + DPT = { + "type": "knx", + "name": "DPT_Value_Temp", + "addresses": [], + "fields": {"decoded_value": 5.0}, + } + + DEFAULT_EVENTS = [home.event.temperature.Event.Cold] + + +class Always(Parent): + + DPT = { + "type": "knx", + "name": "DPT_Value_Temp", + "addresses": [], + "fields": {"decoded_value": 0}, + } + + +class CustomThermostatReport(Report): + def make_new_state_from( + self, another_description: Description, old_state: home.appliance.State + ): + new_state = super(CustomThermostatReport, self).make_new_state_from( + another_description, old_state + ) + new_state = new_state.next(self._decode_temperatura(another_description)) + return new_state diff --git a/knx_plugin/trigger/dpt_value_wsp.py b/knx_plugin/trigger/dpt_value_wsp.py new file mode 100644 index 0000000..20ae39e --- /dev/null +++ b/knx_plugin/trigger/dpt_value_wsp.py @@ -0,0 +1,120 @@ +from typing import List + +import home + +from knx_plugin.message import Description +from knx_plugin.trigger import Always as Parent, GreaterThan +from knx_plugin.trigger.mean import LesserThan + + +class Always(Parent): + + DPT = { + "type": "knx", + "name": "DPT_Value_Wsp", + "addresses": [], + "fields": {"decoded_value": 0}, + } + + +class Strong(GreaterThan): + """ + >>> import io + >>> import json + >>> import knx_stack + >>> import knx_plugin + + >>> strong = knx_plugin.trigger.dpt_value_wsp.Strong.make_from_yaml(addresses=[1234], value=8) + + >>> address_table = knx_stack.AddressTable(knx_stack.Address(4098), [], 255) + >>> association_table = knx_stack.AssociationTable(address_table, []) + >>> groupobject_table = knx_stack.GroupObjectTable() + >>> strong.associate(association_table, groupobject_table) + + >>> bus_event = ''' + ... {"name": "DPT_Value_Wsp", + ... "addresses": [1234], + ... "fields": {"decoded_value": 8.1}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> strong.is_triggered(another_description) + True + + >>> bus_event = ''' + ... {"name": "DPT_Value_Wsp", + ... "addresses": [1234], + ... "fields": {"decoded_value": 7.9}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> strong.is_triggered(another_description) + False + """ + + DPT = { + "type": "knx", + "name": "DPT_Value_Wsp", + "addresses": [], + "fields": {"decoded_value": 4.0}, + } + + DEFAULT_EVENTS = [home.event.wind.Event.Strong] + + +class Weak(LesserThan): + """ + >>> import io + >>> import json + >>> import knx_stack + >>> import knx_plugin + + >>> weak = knx_plugin.trigger.dpt_value_wsp.Weak.make_from_yaml(addresses=[1234]) + + >>> address_table = knx_stack.AddressTable(knx_stack.Address(4098), [], 255) + >>> association_table = knx_stack.AssociationTable(address_table, []) + >>> groupobject_table = knx_stack.GroupObjectTable() + >>> weak.associate(association_table, groupobject_table) + + >>> bus_event = ''' + ... {"name": "DPT_Value_Wsp", + ... "addresses": [1234], + ... "fields": {"decoded_value": 1.2}} + ... ''' + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> weak.is_triggered(another_description) + True + + >>> bus_event = ''' + ... {"name": "DPT_Value_Wsp", + ... "addresses": [1234], + ... "fields": {"decoded_value": 2.1}} + ... ''' + >>> fd = io.StringIO(bus_event) + >>> another_description = knx_plugin.Description(json.loads(bus_event)) + >>> another_description.associate_with(association_table) + >>> weak.is_triggered(another_description) + True + """ + + DPT = { + "type": "knx", + "name": "DPT_Value_Wsp", + "addresses": [], + "fields": {"decoded_value": 2.0}, + } + + DEFAULT_EVENTS = [home.event.wind.Event.Weak] + NUM_OF_SAMPLES = 30 + + def __init__( + self, + description: Description, + events: List[home.Event] = None, + samples: int = None, + value: float = None, + ): + super(Weak, self).__init__( + description, events, samples if samples else self.NUM_OF_SAMPLES, value + ) diff --git a/knx_plugin/trigger/mean/__init__.py b/knx_plugin/trigger/mean/__init__.py new file mode 100644 index 0000000..5a0a96f --- /dev/null +++ b/knx_plugin/trigger/mean/__init__.py @@ -0,0 +1,157 @@ +import copy +import collections +import functools + +from typing import List + +import home +import knx_stack +from knx_plugin.message import Description +from knx_plugin.trigger import Trigger + + +class Mean(Trigger, home.protocol.Trigger): + def __init__( + self, + description: dict, + events: List[home.Event] = None, + samples: int = None, + value: float = None, + ): + if value: + description["fields"]["decoded_value"] = int(value) + super(Mean, self).__init__(description, events) + self._samples = collections.deque(maxlen=(samples if samples else 1)) + self._mean = None + + def is_triggered(self, another_description: Description) -> bool: + if super(Mean, self).is_triggered(another_description): + if set(self.asaps).intersection(set(another_description.asaps)): + self._samples.append(another_description.dpt.decode()) + self._mean = functools.reduce( + lambda a, b: a + b, self._samples, 0 + ) / len(self._samples) + return True + + @classmethod + def make( + cls, + addresses: List[knx_stack.Address], + events: List[home.Event] = None, + samples: int = None, + value: float = None, + ): + description = copy.deepcopy(cls.DPT) + dsc = cls(description, events, samples, value) + dsc.addresses = addresses + return dsc + + def __str__(self): + s = super(Mean, self).__str__() + return "mean value {} (for {})".format(self._mean, s) + + +class GreaterThan(Mean): + """A trigger triggered when + + 1) it has some ASAPs in common with the compared Description and + 2) the knx_stack.datapointtypes.DPT **value** contained into the trigger + is greater than the **mean value** of knx_stack.datapointtypes.DPT **values** received from bus + + The **mean** is calculated using last **num of samples** *Descriptions*. + + Use it when changes has to be slow down otherwise use + :meth:`knx_plugin.trigger.definition.GreaterThan`. + + More *high* is *num of samples* more slow are changes. + """ + + def is_triggered(self, another_description: Description) -> bool: + if super(GreaterThan, self).is_triggered(another_description): + triggered = self.dpt.decode() < self._mean + return triggered + + def __str__(self): + s = super(GreaterThan, self).__str__() + return "{} greater than {}".format(s, self.dpt.decode()) + + +class LesserThan(Mean): + """A trigger triggered when + + 1) it has some ASAPs in common with the compared Description and + 2) the knx_stack.datapointtypes.DPT **value** contained into the trigger + is lesser than the **mean value** of knx_stack.datapointtypes.DPT **values** received from bus + + The **mean** is calculated using last **num of samples** *Descriptions*. + + Use it when changes has to be slow down otherwise use + :meth:`knx_plugin.trigger.definition.LesserThan`. + + More *high* is *num of samples* more slow are changes. + """ + + def is_triggered(self, another_description: Description) -> bool: + if super(LesserThan, self).is_triggered(another_description): + triggered = self.dpt.decode() > self._mean + return triggered + + def __str__(self): + s = super(LesserThan, self).__str__() + return "{} lesser than {}".format(s, self.dpt.decode()) + + +class InBetween(Mean): + """A trigger triggered when + + 1) it has some ASAPs in common with the compared Description and + 2) the knx_stack.datapointtypes.DPT **value** contained into the trigger + is greater than the **mean value** of knx_stack.datapointtypes.DPT **values** received from bus + and lesser than the **mean vlaue** plus a given **range** + + The **mean** is calculated using last **num of samples** *Descriptions*. + + Use it when changes has to be slow down otherwise use + :meth:`knx_plugin.trigger.definition.InBetween`. + + More *high* is *num of samples* more slow are changes. + """ + + def __init__( + self, + description: dict, + events: List[home.Event] = None, + samples: int = None, + value: float = None, + range: int = None, + ): + super(InBetween, self).__init__(description, events, samples, value) + self._range = range if range else 1 + + def is_triggered(self, another_description: Description) -> bool: + if super(InBetween, self).is_triggered(another_description): + triggered = ( + self.dpt.decode() < self._mean < (self.dpt.decode() + self._range) + ) + return triggered + return False + + def __str__(self): + s = super(InBetween, self).__str__() + return "{} in between [{}:{}]".format( + s, self.dpt.decode(), (self.dpt.decode() + self._range) + ) + + @classmethod + def make( + cls, + addresses: List[knx_stack.Address], + events: List[home.Event] = None, + samples: int = None, + value: float = None, + range: int = None, + ): + description = copy.deepcopy(cls.DPT) + dsc = cls(description, events, samples, value, range) + dsc.addresses = addresses + return dsc diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cd4fc5b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +git+git://github.com/majamassarini/knx-stack.git +git+git://github.com/majamassarini/automate-home.git diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..4b4a2e5 --- /dev/null +++ b/setup.py @@ -0,0 +1,27 @@ +from os import path +from setuptools import setup, find_packages + +with open(path.join(".", 'README.md'), encoding='utf-8') as f: + long_description = f.read() + +setup(name="automate-knx-plugin", + version="0.9.0", + url="https://github.com/majamassarini/knx-plugin", + description="A KNX plugin for automate-home", + long_description=long_description, + long_description_content_type='text/markdown', + author="Maja Massarini", + author_email="maja.massarini@gmail.com", + license="MIT", + classifiers=[ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3.8", + "Topic :: Communications", + "Intended Audience :: Developers", + ], + packages=find_packages(exclude=[]), + include_package_data=True, + install_requires=['automate-home', 'knx-stack'] +)